Browse Source

UI: Add YouTube integration

Yuriy Chumak 4 years ago
parent
commit
e6f1daab8c

+ 4 - 0
.github/workflows/main.yml

@@ -22,6 +22,10 @@ env:
   TWITCH_HASH: ${{ secrets.TWITCH_HASH }}
   RESTREAM_CLIENTID: ${{ secrets.RESTREAM_CLIENTID }}
   RESTREAM_HASH: ${{ secrets.RESTREAM_HASH }}
+  YOUTUBE_CLIENTID: ${{ secrets.YOUTUBE_CLIENTID }}
+  YOUTUBE_CLIENTID_HASH: ${{ secrets.YOUTUBE_CLIENTID_HASH }}
+  YOUTUBE_SECRET: ${{ secrets.YOUTUBE_SECRET }}
+  YOUTUBE_SECRET_HASH: ${{ secrets.YOUTUBE_SECRET_HASH }}
 
 jobs:
   macos64:

+ 46 - 0
UI/CMakeLists.txt

@@ -39,6 +39,28 @@ else()
 	set(RESTREAM_ENABLED TRUE)
 endif()
 
+if(DEFINED ENV{YOUTUBE_CLIENTID} AND NOT DEFINED YOUTUBE_CLIENTID)
+	set(YOUTUBE_CLIENTID "$ENV{YOUTUBE_CLIENTID}")
+endif()
+if(DEFINED ENV{YOUTUBE_CLIENTID_HASH} AND NOT DEFINED YOUTUBE_CLIENTID_HASH)
+	set(YOUTUBE_CLIENTID_HASH "$ENV{YOUTUBE_CLIENTID_HASH}")
+endif()
+if(DEFINED ENV{YOUTUBE_SECRET} AND NOT DEFINED YOUTUBE_SECRET)
+	set(YOUTUBE_SECRET "$ENV{YOUTUBE_SECRET}")
+endif()
+if(DEFINED ENV{YOUTUBE_SECRET_HASH} AND NOT DEFINED YOUTUBE_SECRET_HASH)
+	set(YOUTUBE_SECRET_HASH "$ENV{YOUTUBE_SECRET_HASH}")
+endif()
+
+if(NOT DEFINED YOUTUBE_CLIENTID OR "${YOUTUBE_CLIENTID}" STREQUAL "" OR
+   NOT DEFINED YOUTUBE_SECRET OR "${YOUTUBE_SECRET}" STREQUAL "" OR
+   NOT DEFINED YOUTUBE_CLIENTID_HASH OR "${YOUTUBE_CLIENTID_HASH}" STREQUAL "" OR
+   NOT DEFINED YOUTUBE_SECRET_HASH OR "${YOUTUBE_SECRET_HASH}" STREQUAL "")
+	set(YOUTUBE_ENABLED FALSE)
+else()
+	set(YOUTUBE_ENABLED TRUE)
+endif()
+
 configure_file(
 	"${CMAKE_CURRENT_SOURCE_DIR}/ui-config.h.in"
 	"${CMAKE_CURRENT_BINARY_DIR}/ui-config.h")
@@ -167,6 +189,18 @@ if(BROWSER_AVAILABLE_INTERNAL)
 			auth-restream.hpp
 			)
 	endif()
+
+endif()
+
+if(YOUTUBE_ENABLED)
+	list(APPEND obs_PLATFORM_SOURCES
+		auth-youtube.cpp
+		youtube-api-wrappers.cpp
+		)
+	list(APPEND obs_PLATFORM_HEADERS
+		auth-youtube.hpp
+		youtube-api-wrappers.hpp
+		)
 endif()
 
 set(obs_libffutil_SOURCES
@@ -377,6 +411,18 @@ set(obs_UI
 set(obs_QRC
 	forms/obs.qrc)
 
+if(YOUTUBE_ENABLED)
+	list(APPEND obs_SOURCES
+		window-youtube-actions.cpp
+		)
+	list(APPEND obs_HEADERS
+		window-youtube-actions.hpp
+		)
+	list(APPEND obs_UI
+		forms/OBSYoutubeActions.ui
+		)
+endif()
+
 qt5_wrap_ui(obs_UI_HEADERS ${obs_UI})
 qt5_add_resources(obs_QRC_SOURCES ${obs_QRC})
 

+ 209 - 0
UI/auth-youtube.cpp

@@ -0,0 +1,209 @@
+#include "auth-youtube.hpp"
+
+#include <iostream>
+#include <QMessageBox>
+#include <QThread>
+#include <vector>
+#include <QDesktopServices>
+#include <QHBoxLayout>
+#include <QUrl>
+
+#ifdef WIN32
+#include <windows.h>
+#include <shellapi.h>
+
+#pragma comment(lib, "shell32")
+#endif
+
+#include "auth-listener.hpp"
+#include "obs-app.hpp"
+#include "qt-wrappers.hpp"
+#include "ui-config.h"
+#include "youtube-api-wrappers.hpp"
+#include "window-basic-main.hpp"
+#include "obf.h"
+
+using namespace json11;
+
+/* ------------------------------------------------------------------------- */
+#define YOUTUBE_AUTH_URL "https://accounts.google.com/o/oauth2/v2/auth"
+#define YOUTUBE_TOKEN_URL "https://www.googleapis.com/oauth2/v4/token"
+#define YOUTUBE_SCOPE_VERSION 1
+#define YOUTUBE_API_STATE_LENGTH 32
+#define SECTION_NAME "YouTube"
+
+static const char allowedChars[] =
+	"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+static const int allowedCount = static_cast<int>(sizeof(allowedChars) - 1);
+/* ------------------------------------------------------------------------- */
+
+static inline void OpenBrowser(const QString auth_uri)
+{
+	QUrl url(auth_uri, QUrl::StrictMode);
+	QDesktopServices::openUrl(url);
+}
+
+void RegisterYoutubeAuth()
+{
+	for (auto &service : youtubeServices) {
+		OAuth::RegisterOAuth(
+			service,
+			[service]() {
+				return std::make_shared<YoutubeApiWrappers>(
+					service);
+			},
+			YoutubeAuth::Login, []() { return; });
+	}
+}
+
+YoutubeAuth::YoutubeAuth(const Def &d)
+	: OAuthStreamKey(d), section(SECTION_NAME)
+{
+}
+
+bool YoutubeAuth::RetryLogin()
+{
+	return true;
+}
+
+void YoutubeAuth::SaveInternal()
+{
+	OBSBasic *main = OBSBasic::Get();
+	config_set_string(main->Config(), service(), "DockState",
+			  main->saveState().toBase64().constData());
+
+	const char *section_name = section.c_str();
+	config_set_string(main->Config(), section_name, "RefreshToken",
+			  refresh_token.c_str());
+	config_set_string(main->Config(), section_name, "Token", token.c_str());
+	config_set_uint(main->Config(), section_name, "ExpireTime",
+			expire_time);
+	config_set_int(main->Config(), section_name, "ScopeVer",
+		       currentScopeVer);
+}
+
+static inline std::string get_config_str(OBSBasic *main, const char *section,
+					 const char *name)
+{
+	const char *val = config_get_string(main->Config(), section, name);
+	return val ? val : "";
+}
+
+bool YoutubeAuth::LoadInternal()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	const char *section_name = section.c_str();
+	refresh_token = get_config_str(main, section_name, "RefreshToken");
+	token = get_config_str(main, section_name, "Token");
+	expire_time =
+		config_get_uint(main->Config(), section_name, "ExpireTime");
+	currentScopeVer =
+		(int)config_get_int(main->Config(), section_name, "ScopeVer");
+	return implicit ? !token.empty() : !refresh_token.empty();
+}
+
+void YoutubeAuth::LoadUI()
+{
+	uiLoaded = true;
+}
+
+QString YoutubeAuth::GenerateState()
+{
+	std::uniform_int_distribution<> distr(0, allowedCount);
+	std::string result;
+	result.reserve(YOUTUBE_API_STATE_LENGTH);
+	std::generate_n(std::back_inserter(result), YOUTUBE_API_STATE_LENGTH,
+			[&] {
+				return static_cast<char>(
+					allowedChars[distr(randomSeed)]);
+			});
+	return result.c_str();
+}
+
+// Static.
+std::shared_ptr<Auth> YoutubeAuth::Login(QWidget *owner,
+					 const std::string &service)
+{
+	QString auth_code;
+	AuthListener server;
+
+	auto it = std::find_if(youtubeServices.begin(), youtubeServices.end(),
+			       [service](auto &item) {
+				       return service == item.service;
+			       });
+	if (it == youtubeServices.end()) {
+		return nullptr;
+	}
+	const auto auth = std::make_shared<YoutubeApiWrappers>(*it);
+
+	QString redirect_uri =
+		QString("http://127.0.0.1:%1").arg(server.GetPort());
+
+	QMessageBox dlg(owner);
+	dlg.setWindowFlags(dlg.windowFlags() & ~Qt::WindowCloseButtonHint);
+	dlg.setWindowTitle(QTStr("YouTube.Auth.WaitingAuth.Title"));
+
+	std::string clientid = YOUTUBE_CLIENTID;
+	std::string secret = YOUTUBE_SECRET;
+	deobfuscate_str(&clientid[0], YOUTUBE_CLIENTID_HASH);
+	deobfuscate_str(&secret[0], YOUTUBE_SECRET_HASH);
+
+	QString url_template;
+	url_template += "%1";
+	url_template += "?response_type=code";
+	url_template += "&client_id=%2";
+	url_template += "&redirect_uri=%3";
+	url_template += "&state=%4";
+	url_template += "&scope=https://www.googleapis.com/auth/youtube";
+	QString url = url_template.arg(YOUTUBE_AUTH_URL, clientid.c_str(),
+				       redirect_uri, auth->GenerateState());
+
+	QString text = QTStr("YouTube.Auth.WaitingAuth.Text");
+	text = text.arg(
+		QString("<a href='%1'>Google OAuth Service</a>").arg(url));
+
+	dlg.setText(text);
+	dlg.setTextFormat(Qt::RichText);
+	dlg.setStandardButtons(QMessageBox::StandardButton::Cancel);
+
+	connect(&dlg, &QMessageBox::buttonClicked, &dlg,
+		[&](QAbstractButton *) {
+#ifdef _DEBUG
+			blog(LOG_DEBUG, "Action Cancelled.");
+#endif
+			// TODO: Stop server.
+			dlg.reject();
+		});
+
+	// Async Login.
+	connect(&server, &AuthListener::ok, &dlg,
+		[&dlg, &auth_code](QString code) {
+#ifdef _DEBUG
+			blog(LOG_DEBUG, "Got youtube redirected answer: %s",
+			     QT_TO_UTF8(code));
+#endif
+			auth_code = code;
+			dlg.accept();
+		});
+	connect(&server, &AuthListener::fail, &dlg, [&dlg]() {
+#ifdef _DEBUG
+		blog(LOG_DEBUG, "No access granted");
+#endif
+		dlg.reject();
+	});
+
+	auto open_external_browser = [url]() { OpenBrowser(url); };
+	QScopedPointer<QThread> thread(CreateQThread(open_external_browser));
+	thread->start();
+
+	dlg.exec();
+
+	if (!auth->GetToken(YOUTUBE_TOKEN_URL, clientid, secret,
+			    QT_TO_UTF8(redirect_uri), YOUTUBE_SCOPE_VERSION,
+			    QT_TO_UTF8(auth_code), true)) {
+		return nullptr;
+	}
+
+	return auth;
+}

+ 34 - 0
UI/auth-youtube.hpp

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <QObject>
+#include <QString>
+#include <random>
+#include <string>
+
+#include "auth-oauth.hpp"
+
+const std::vector<Auth::Def> youtubeServices = {
+	{"YouTube - RTMP", Auth::Type::OAuth_LinkedAccount, true},
+	{"YouTube - RTMPS", Auth::Type::OAuth_LinkedAccount, true},
+	{"YouTube - HLS", Auth::Type::OAuth_LinkedAccount, true}};
+
+class YoutubeAuth : public OAuthStreamKey {
+	Q_OBJECT
+
+	bool uiLoaded = false;
+	std::mt19937 randomSeed;
+	std::string section;
+
+	virtual bool RetryLogin() override;
+	virtual void SaveInternal() override;
+	virtual bool LoadInternal() override;
+	virtual void LoadUI() override;
+
+	QString GenerateState();
+
+public:
+	YoutubeAuth(const Def &d);
+
+	static std::shared_ptr<Auth> Login(QWidget *parent,
+					   const std::string &service);
+};

+ 62 - 0
UI/data/locale/en-US.ini

@@ -128,6 +128,7 @@ Auth.InvalidScope.Title="Authentication Required"
 Auth.InvalidScope.Text="The authentication requirements for %1 have changed. Some features may not be available."
 Auth.LoadingChannel.Title="Loading channel information..."
 Auth.LoadingChannel.Text="Loading channel information for %1, please wait..."
+Auth.LoadingChannel.Error="Couldn't get channel information."
 Auth.ChannelFailure.Title="Failed to load channel"
 Auth.ChannelFailure.Text="Failed to load channel information for %1\n\n%2: %3"
 Auth.Chat="Chat"
@@ -178,6 +179,7 @@ Basic.AutoConfig.StreamPage.DisconnectAccount.Confirm.Text="This change will app
 Basic.AutoConfig.StreamPage.GetStreamKey="Get Stream Key"
 Basic.AutoConfig.StreamPage.MoreInfo="More Info"
 Basic.AutoConfig.StreamPage.UseStreamKey="Use Stream Key"
+Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced="Use Stream Key (advanced)"
 Basic.AutoConfig.StreamPage.Service="Service"
 Basic.AutoConfig.StreamPage.Service.ShowAll="Show All..."
 Basic.AutoConfig.StreamPage.Service.Custom="Custom..."
@@ -185,6 +187,7 @@ Basic.AutoConfig.StreamPage.Server="Server"
 Basic.AutoConfig.StreamPage.StreamKey="Stream Key"
 Basic.AutoConfig.StreamPage.StreamKey.LinkToSite="(Link)"
 Basic.AutoConfig.StreamPage.EncoderKey="Encoder Key"
+Basic.AutoConfig.StreamPage.ConnectedAccount="Connected account"
 Basic.AutoConfig.StreamPage.PerformBandwidthTest="Estimate bitrate with bandwidth test (may take a few minutes)"
 Basic.AutoConfig.StreamPage.PreferHardwareEncoding="Prefer hardware encoding"
 Basic.AutoConfig.StreamPage.PreferHardwareEncoding.ToolTip="Hardware Encoding eliminates most CPU usage, but may require more bitrate to obtain the same level of quality."
@@ -626,6 +629,7 @@ Basic.Main.StartRecording="Start Recording"
 Basic.Main.StartReplayBuffer="Start Replay Buffer"
 Basic.Main.SaveReplay="Save Replay"
 Basic.Main.StartStreaming="Start Streaming"
+Basic.Main.StartBroadcast="GO LIVE"
 Basic.Main.StartVirtualCam="Start Virtual Camera"
 Basic.Main.StopRecording="Stop Recording"
 Basic.Main.PauseRecording="Pause Recording"
@@ -634,6 +638,7 @@ Basic.Main.StoppingRecording="Stopping Recording..."
 Basic.Main.StopReplayBuffer="Stop Replay Buffer"
 Basic.Main.StoppingReplayBuffer="Stopping Replay Buffer..."
 Basic.Main.StopStreaming="Stop Streaming"
+Basic.Main.StopBroadcast="END STREAM"
 Basic.Main.StoppingStreaming="Stopping Stream..."
 Basic.Main.ForceStopStreaming="Stop Streaming (discard delay)"
 Basic.Main.ShowContextBar="Show Source Toolbar"
@@ -1167,3 +1172,60 @@ ContextBar.MediaControls.PlaylistNext="Next in Playlist"
 ContextBar.MediaControls.PlaylistPrevious="Previous in Playlist"
 ContextBar.MediaControls.MediaProperties="Media Properties"
 ContextBar.MediaControls.BlindSeek="Media Seek Widget"
+
+# YouTube Actions and Auth
+YouTube.Auth.Ok="Authorization completed successfully.\nYou can now close this page."
+YouTube.Auth.NoCode="The authorization process was not completed."
+YouTube.Auth.WaitingAuth.Title="YouTube User Authorization"
+YouTube.Auth.WaitingAuth.Text="Please complete the authorization in your external browser.<br>If the external browser does not open, follow this link and complete the authorization:<br>%1"
+
+YouTube.Actions.CreateNewEvent="Create a new event"
+YouTube.Actions.Title="Title*"
+YouTube.Actions.MyBroadcast="My Broadcast"
+YouTube.Actions.Description="Description"
+YouTube.Actions.Privacy="Privacy*"
+YouTube.Actions.Privacy.Private="Private"
+YouTube.Actions.Privacy.Public="Public"
+YouTube.Actions.Privacy.Unlisted="Unlisted"
+YouTube.Actions.Category="Category"
+
+YouTube.Actions.MadeForKids="Is this video made for kids?*"
+YouTube.Actions.MadeForKids.Yes="Yes, it's made for kids"
+YouTube.Actions.MadeForKids.No="No, it's not made for kids"
+YouTube.Actions.MadeForKids.Help="<a href='https://support.google.com/youtube/topic/9689353'>(?)</a>"
+YouTube.Actions.AdditionalSettings="Additional settings:"
+YouTube.Actions.Latency="Latency"
+YouTube.Actions.Latency.Normal="Normal"
+YouTube.Actions.Latency.Low="Low"
+YouTube.Actions.Latency.UltraLow="Ultra low"
+YouTube.Actions.EnableAutoStart="Enable Auto-start"
+YouTube.Actions.EnableAutoStop="Enable Auto-stop"
+YouTube.Actions.AutoStartStop.Help="<a href='https://developers.google.com/youtube/v3/live/docs/liveBroadcasts#contentDetails.enableAutoStart'>(?)</a>"
+YouTube.Actions.EnableDVR="Enable DVR"
+YouTube.Actions.360Video="360 video"
+YouTube.Actions.360Video.Help="<a href='https://vr.youtube.com/create/360/'>(?)</a>"
+YouTube.Actions.ScheduleForLater="Schedule for later"
+YouTube.Actions.Create_GoLive="Create and Go Live"
+YouTube.Actions.Choose_GoLive="Choose and Go Live"
+YouTube.Actions.Create_Save="Create && Save"
+YouTube.Actions.Dashboard="YouTube Studio..."
+
+YouTube.Actions.Error.Title="Live broadcast creation error"
+YouTube.Actions.Error.Text="YouTube access error '%1'.<br/>A detailed error description can be found at <a href='https://developers.google.com/youtube/v3/live/docs/errors'>https://developers.google.com/youtube/v3/live/docs/errors</a>"
+YouTube.Actions.Error.General="YouTube access error. Please check your network connection or your YouTube server access."
+YouTube.Actions.Error.NoBroadcastCreated="Broadcast creation error '%1'.<br/>A detailed error description can be found at <a href='https://developers.google.com/youtube/v3/live/docs/errors'>https://developers.google.com/youtube/v3/live/docs/errors</a>"
+YouTube.Actions.Error.NoStreamCreated="No stream created. Please relink your account."
+YouTube.Actions.Error.YouTubeApi="YouTube API Error. Please see the log file for more information."
+
+YouTube.Actions.EventCreated.Title="Event Created"
+YouTube.Actions.EventCreated.Text="Event successfully created."
+
+YouTube.Actions.ChooseEvent="Choose an Event"
+YouTube.Actions.Stream="Stream"
+YouTube.Actions.Stream.ScheduledFor="scheduled for"
+
+YouTube.Actions.Notify.Title="YouTube"
+YouTube.Actions.Notify.CreatingBroadcast="Creating a new Live Broadcast, please wait..."
+
+YouTube.Actions.AutoStartStreamingWarning="Auto start is disabled for this stream, you should click \"GO LIVE\"."
+YouTube.Actions.AutoStopStreamingWarning="You will not be able to reconnect.<br>Your stream will stop and you will no longer be live."

+ 60 - 7
UI/forms/AutoConfigStreamPage.ui

@@ -418,15 +418,11 @@
        </item>
        <item row="7" column="1">
         <widget class="QPushButton" name="connectAccount2">
-         <property name="text">
-          <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
+         <property name="cursor">
+          <cursorShape>PointingHandCursor</cursorShape>
          </property>
-        </widget>
-       </item>
-       <item row="8" column="1">
-        <widget class="QPushButton" name="disconnectAccount">
          <property name="text">
-          <string>Basic.AutoConfig.StreamPage.DisconnectAccount</string>
+          <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
          </property>
         </widget>
        </item>
@@ -443,6 +439,63 @@
          </property>
         </spacer>
        </item>
+       <item row="8" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_6">
+         <property name="leftMargin">
+          <number>7</number>
+         </property>
+         <property name="rightMargin">
+          <number>7</number>
+         </property>
+         <item>
+          <widget class="QLabel" name="connectedAccountText">
+           <property name="font">
+            <font>
+             <weight>75</weight>
+             <bold>true</bold>
+            </font>
+           </property>
+           <property name="text">
+            <string>Auth.LoadingChannel.Title</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QPushButton" name="disconnectAccount">
+           <property name="text">
+            <string>Basic.AutoConfig.StreamPage.DisconnectAccount</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="8" column="0">
+        <widget class="QLabel" name="connectedAccountLabel">
+         <property name="text">
+          <string>Basic.AutoConfig.StreamPage.ConnectedAccount</string>
+         </property>
+        </widget>
+       </item>
+       <item row="9" column="0">
+        <spacer name="horizontalSpacer">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="9" column="1">
+        <widget class="QPushButton" name="useStreamKeyAdv">
+         <property name="text">
+          <string>Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced</string>
+         </property>
+        </widget>
+       </item>
       </layout>
      </widget>
     </widget>

+ 52 - 23
UI/forms/OBSBasic.ui

@@ -1280,29 +1280,58 @@
       <number>4</number>
      </property>
      <item>
-      <widget class="QPushButton" name="streamButton">
-       <property name="enabled">
-        <bool>true</bool>
-       </property>
-       <property name="sizePolicy">
-        <sizepolicy hsizetype="Ignored" vsizetype="Fixed">
-         <horstretch>0</horstretch>
-         <verstretch>0</verstretch>
-        </sizepolicy>
-       </property>
-       <property name="minimumSize">
-        <size>
-         <width>150</width>
-         <height>0</height>
-        </size>
-       </property>
-       <property name="text">
-        <string>Basic.Main.StartStreaming</string>
-       </property>
-       <property name="checkable">
-        <bool>true</bool>
-       </property>
-      </widget>
+      <layout class="QHBoxLayout" name="horizontalLayout_7">
+       <item>
+        <widget class="QPushButton" name="streamButton">
+         <property name="enabled">
+          <bool>true</bool>
+         </property>
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Ignored" vsizetype="Fixed">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="minimumSize">
+          <size>
+           <width>150</width>
+           <height>0</height>
+          </size>
+         </property>
+         <property name="text">
+          <string>Basic.Main.StartStreaming</string>
+         </property>
+         <property name="checkable">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <widget class="QPushButton" name="broadcastButton">
+         <property name="enabled">
+          <bool>true</bool>
+         </property>
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Ignored" vsizetype="Fixed">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="minimumSize">
+          <size>
+           <width>150</width>
+           <height>0</height>
+          </size>
+         </property>
+         <property name="text">
+          <string>Basic.Main.StartBroadcast</string>
+         </property>
+         <property name="checkable">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+      </layout>
      </item>
      <item>
       <layout class="QHBoxLayout" name="recordingLayout">

+ 84 - 31
UI/forms/OBSBasicSettings.ui

@@ -1115,32 +1115,27 @@
                </property>
               </spacer>
              </item>
-             <item row="3" column="1">
-              <layout class="QHBoxLayout" name="horizontalLayout_15">
+             <item row="4" column="1">
+              <layout class="QHBoxLayout" name="horizontalLayout_23">
+               <property name="spacing">
+                <number>8</number>
+               </property>
+               <property name="leftMargin">
+                <number>7</number>
+               </property>
+               <property name="rightMargin">
+                <number>7</number>
+               </property>
                <item>
-                <widget class="QPushButton" name="connectAccount2">
+                <widget class="QLabel" name="connectedAccountText">
+                 <property name="styleSheet">
+                  <string notr="true">font-weight: bold</string>
+                 </property>
                  <property name="text">
-                  <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
+                  <string>Auth.LoadingChannel.Title</string>
                  </property>
                 </widget>
                </item>
-               <item>
-                <spacer name="horizontalSpacer_19">
-                 <property name="orientation">
-                  <enum>Qt::Horizontal</enum>
-                 </property>
-                 <property name="sizeHint" stdset="0">
-                  <size>
-                   <width>40</width>
-                   <height>20</height>
-                  </size>
-                 </property>
-                </spacer>
-               </item>
-              </layout>
-             </item>
-             <item row="4" column="1">
-              <layout class="QHBoxLayout" name="horizontalLayout_23">
                <item>
                 <widget class="QPushButton" name="disconnectAccount">
                  <property name="text">
@@ -1163,21 +1158,21 @@
                </item>
               </layout>
              </item>
-             <item row="5" column="1">
+             <item row="6" column="1">
               <widget class="QCheckBox" name="bandwidthTestEnable">
                <property name="text">
                 <string>Basic.Settings.Stream.BandwidthTestMode</string>
                </property>
               </widget>
              </item>
-             <item row="6" column="1">
+             <item row="7" column="1">
               <widget class="QCheckBox" name="useAuth">
                <property name="text">
                 <string>Basic.Settings.Stream.Custom.UseAuthentication</string>
                </property>
               </widget>
              </item>
-             <item row="8" column="0">
+             <item row="9" column="0">
               <widget class="QLabel" name="authUsernameLabel">
                <property name="text">
                 <string>Basic.Settings.Stream.Custom.Username</string>
@@ -1187,10 +1182,10 @@
                </property>
               </widget>
              </item>
-             <item row="8" column="1">
+             <item row="9" column="1">
               <widget class="QLineEdit" name="authUsername"/>
              </item>
-             <item row="9" column="0">
+             <item row="10" column="0">
               <widget class="QLabel" name="authPwLabel">
                <property name="text">
                 <string>Basic.Settings.Stream.Custom.Password</string>
@@ -1200,7 +1195,7 @@
                </property>
               </widget>
              </item>
-             <item row="9" column="1">
+             <item row="10" column="1">
               <widget class="QWidget" name="authPwWidget">
                <layout class="QHBoxLayout" name="horizontalLayout_25">
                 <property name="leftMargin">
@@ -1232,10 +1227,10 @@
                </layout>
               </widget>
              </item>
-             <item row="7" column="1">
+             <item row="8" column="1">
               <widget class="QComboBox" name="twitchAddonDropdown"/>
              </item>
-             <item row="7" column="0">
+             <item row="8" column="0">
               <widget class="QLabel" name="twitchAddonLabel">
                <property name="text">
                 <string>Basic.Settings.Stream.TTVAddon</string>
@@ -1245,20 +1240,78 @@
                </property>
               </widget>
              </item>
-             <item row="10" column="1">
+             <item row="11" column="1">
               <widget class="QCheckBox" name="ignoreRecommended">
                <property name="text">
                 <string>Basic.Settings.Stream.IgnoreRecommended</string>
                </property>
               </widget>
              </item>
-             <item row="11" column="1">
+             <item row="12" column="1">
               <widget class="QLabel" name="enforceSettingsLabel">
                <property name="text">
                 <string notr="true"/>
                </property>
               </widget>
              </item>
+             <item row="5" column="1">
+              <layout class="QHBoxLayout" name="horizontalLayout_28">
+               <item>
+                <widget class="QPushButton" name="useStreamKeyAdv">
+                 <property name="text">
+                  <string>Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced</string>
+                 </property>
+                </widget>
+               </item>
+               <item>
+                <spacer name="horizontalSpacer_28">
+                 <property name="orientation">
+                  <enum>Qt::Horizontal</enum>
+                 </property>
+                 <property name="sizeHint" stdset="0">
+                  <size>
+                   <width>40</width>
+                   <height>20</height>
+                  </size>
+                 </property>
+                </spacer>
+               </item>
+              </layout>
+             </item>
+             <item row="4" column="0">
+              <widget class="QLabel" name="connectedAccountLabel">
+               <property name="text">
+                <string>Basic.AutoConfig.StreamPage.ConnectedAccount</string>
+               </property>
+              </widget>
+             </item>
+             <item row="3" column="1">
+              <layout class="QHBoxLayout" name="horizontalLayout_15">
+               <item>
+                <widget class="QPushButton" name="connectAccount2">
+                 <property name="cursor">
+                  <cursorShape>PointingHandCursor</cursorShape>
+                 </property>
+                 <property name="text">
+                  <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
+                 </property>
+                </widget>
+               </item>
+               <item>
+                <spacer name="horizontalSpacer_19">
+                 <property name="orientation">
+                  <enum>Qt::Horizontal</enum>
+                 </property>
+                 <property name="sizeHint" stdset="0">
+                  <size>
+                   <width>40</width>
+                   <height>20</height>
+                  </size>
+                 </property>
+                </spacer>
+               </item>
+              </layout>
+             </item>
             </layout>
            </widget>
           </widget>

+ 601 - 0
UI/forms/OBSYoutubeActions.ui

@@ -0,0 +1,601 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>OBSYoutubeActions</class>
+ <widget class="QDialog" name="OBSYoutubeActions">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>583</width>
+    <height>452</height>
+   </rect>
+  </property>
+  <property name="sizePolicy">
+   <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+    <horstretch>0</horstretch>
+    <verstretch>0</verstretch>
+   </sizepolicy>
+  </property>
+  <property name="minimumSize">
+   <size>
+    <width>465</width>
+    <height>346</height>
+   </size>
+  </property>
+  <property name="windowTitle">
+   <string>YouTube Actions</string>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QTabWidget" name="tabWidget">
+     <property name="currentIndex">
+      <number>0</number>
+     </property>
+     <widget class="QWidget" name="tab_1">
+      <attribute name="title">
+       <string>YouTube.Actions.CreateNewEvent</string>
+      </attribute>
+      <layout class="QFormLayout" name="formLayout">
+       <property name="fieldGrowthPolicy">
+        <enum>QFormLayout::ExpandingFieldsGrow</enum>
+       </property>
+       <property name="labelAlignment">
+        <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+       </property>
+       <item row="0" column="0">
+        <widget class="QLabel" name="label">
+         <property name="text">
+          <string>YouTube.Actions.Title</string>
+         </property>
+        </widget>
+       </item>
+       <item row="0" column="1">
+        <widget class="QLineEdit" name="title">
+         <property name="text">
+          <string>YouTube.Actions.MyBroadcast</string>
+         </property>
+         <property name="maxLength">
+          <number>100</number>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="0">
+        <widget class="QLabel" name="label_2">
+         <property name="text">
+          <string>YouTube.Actions.Description</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="1">
+        <widget class="QLineEdit" name="description">
+         <property name="maxLength">
+          <number>5000</number>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="0">
+        <widget class="QLabel" name="label_4">
+         <property name="text">
+          <string>YouTube.Actions.Privacy</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="1">
+        <widget class="QComboBox" name="privacyBox">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="sizeAdjustPolicy">
+          <enum>QComboBox::AdjustToContentsOnFirstShow</enum>
+         </property>
+        </widget>
+       </item>
+       <item row="3" column="0">
+        <widget class="QLabel" name="label_5">
+         <property name="text">
+          <string>YouTube.Actions.Category</string>
+         </property>
+        </widget>
+       </item>
+       <item row="3" column="1">
+        <widget class="QComboBox" name="categoryBox">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+        </widget>
+       </item>
+       <item row="4" column="0">
+        <widget class="QLabel" name="label_8">
+         <property name="text">
+          <string>YouTube.Actions.MadeForKids</string>
+         </property>
+        </widget>
+       </item>
+       <item row="4" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_5">
+         <item>
+          <widget class="QRadioButton" name="notMakeForKids">
+           <property name="text">
+            <string>YouTube.Actions.MadeForKids.No</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLabel" name="helpMadeForKids">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="text">
+            <string>YouTube.Actions.MadeForKids.Help</string>
+           </property>
+           <property name="textFormat">
+            <enum>Qt::RichText</enum>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="5" column="0">
+        <spacer name="horizontalSpacer_5">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="5" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_8">
+         <item>
+          <widget class="QRadioButton" name="yesMakeForKids">
+           <property name="text">
+            <string>YouTube.Actions.MadeForKids.Yes</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="6" column="0">
+        <widget class="QLabel" name="label_7">
+         <property name="text">
+          <string>YouTube.Actions.AdditionalSettings</string>
+         </property>
+        </widget>
+       </item>
+       <item row="6" column="1">
+        <spacer name="horizontalSpacer_2">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="7" column="0">
+        <spacer name="horizontalSpacer_7">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="7" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout">
+         <item>
+          <widget class="QLabel" name="label_3">
+           <property name="text">
+            <string>YouTube.Actions.Latency</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QComboBox" name="latencyBox">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="11" column="0">
+        <spacer name="horizontalSpacer_6">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="11" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_6">
+         <item>
+          <widget class="QCheckBox" name="checkAutoStart">
+           <property name="enabled">
+            <bool>true</bool>
+           </property>
+           <property name="text">
+            <string>YouTube.Actions.EnableAutoStart</string>
+           </property>
+           <property name="checkable">
+            <bool>true</bool>
+           </property>
+           <property name="checked">
+            <bool>true</bool>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLabel" name="helpAutoStartStop">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="text">
+            <string>YouTube.Actions.AutoStartStop.Help</string>
+           </property>
+           <property name="textFormat">
+            <enum>Qt::RichText</enum>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <spacer name="horizontalSpacer_9">
+           <property name="orientation">
+            <enum>Qt::Horizontal</enum>
+           </property>
+           <property name="sizeHint" stdset="0">
+            <size>
+             <width>40</width>
+             <height>20</height>
+            </size>
+           </property>
+          </spacer>
+         </item>
+        </layout>
+       </item>
+       <item row="12" column="0">
+        <spacer name="horizontalSpacer_3">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="12" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_4">
+         <item>
+          <widget class="QCheckBox" name="checkAutoStop">
+           <property name="enabled">
+            <bool>true</bool>
+           </property>
+           <property name="text">
+            <string>YouTube.Actions.EnableAutoStop</string>
+           </property>
+           <property name="checked">
+            <bool>true</bool>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="10" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_9">
+         <item>
+          <widget class="QCheckBox" name="checkScheduledLater">
+           <property name="enabled">
+            <bool>true</bool>
+           </property>
+           <property name="text">
+            <string>YouTube.Actions.ScheduleForLater</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="10" column="0">
+        <spacer name="horizontalSpacer_12">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="13" column="0">
+        <spacer name="horizontalSpacer_13">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="13" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_10">
+         <item>
+          <widget class="QDateTimeEdit" name="scheduledTime">
+           <property name="enabled">
+            <bool>true</bool>
+           </property>
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="calendarPopup">
+            <bool>true</bool>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="8" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_3">
+         <item>
+          <widget class="QCheckBox" name="checkDVR">
+           <property name="text">
+            <string>YouTube.Actions.EnableDVR</string>
+           </property>
+           <property name="checked">
+            <bool>true</bool>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="8" column="0">
+        <spacer name="horizontalSpacer_14">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="9" column="0">
+        <spacer name="horizontalSpacer_8">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>20</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="9" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_7">
+         <item>
+          <widget class="QCheckBox" name="check360Video">
+           <property name="text">
+            <string>YouTube.Actions.360Video</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLabel" name="help360Video">
+           <property name="sizePolicy">
+            <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
+             <horstretch>0</horstretch>
+             <verstretch>0</verstretch>
+            </sizepolicy>
+           </property>
+           <property name="text">
+            <string>YouTube.Actions.360Video.Help</string>
+           </property>
+           <property name="textFormat">
+            <enum>Qt::RichText</enum>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <spacer name="horizontalSpacer_10">
+           <property name="orientation">
+            <enum>Qt::Horizontal</enum>
+           </property>
+           <property name="sizeHint" stdset="0">
+            <size>
+             <width>40</width>
+             <height>20</height>
+            </size>
+           </property>
+          </spacer>
+         </item>
+        </layout>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="tab_2">
+      <attribute name="title">
+       <string>YouTube.Actions.ChooseEvent</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_3">
+       <item>
+        <widget class="QScrollArea" name="scrollArea">
+         <property name="verticalScrollBarPolicy">
+          <enum>Qt::ScrollBarAlwaysOn</enum>
+         </property>
+         <property name="widgetResizable">
+          <bool>true</bool>
+         </property>
+         <widget class="QWidget" name="scrollAreaWidgetContents">
+          <property name="geometry">
+           <rect>
+            <x>0</x>
+            <y>0</y>
+            <width>179</width>
+            <height>192</height>
+           </rect>
+          </property>
+          <property name="sizePolicy">
+           <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+            <horstretch>0</horstretch>
+            <verstretch>0</verstretch>
+           </sizepolicy>
+          </property>
+          <layout class="QVBoxLayout" name="verticalLayout_4">
+           <item>
+            <widget class="QLabel" name="label_11">
+             <property name="styleSheet">
+              <string notr="true">border: 1px solid black;</string>
+             </property>
+             <property name="text">
+              <string>&lt;big&gt;Friday Fortnite Stream&lt;/big&gt;&lt;br/&gt;scheduled for 11/11/20 2:00pm</string>
+             </property>
+             <property name="textFormat">
+              <enum>Qt::RichText</enum>
+             </property>
+             <property name="alignment">
+              <set>Qt::AlignCenter</set>
+             </property>
+             <property name="margin">
+              <number>4</number>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QLabel" name="label_12">
+             <property name="styleSheet">
+              <string notr="true">border: 1px solid black;</string>
+             </property>
+             <property name="text">
+              <string>&lt;big&gt;Friday Fortnite Stream&lt;/big&gt;&lt;br/&gt;scheduled for 11/11/20 2:00pm</string>
+             </property>
+             <property name="textFormat">
+              <enum>Qt::RichText</enum>
+             </property>
+             <property name="alignment">
+              <set>Qt::AlignCenter</set>
+             </property>
+             <property name="margin">
+              <number>4</number>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QLabel" name="label_13">
+             <property name="styleSheet">
+              <string notr="true">border: 1px solid black;</string>
+             </property>
+             <property name="text">
+              <string>&lt;big&gt;Friday Fortnite Stream&lt;/big&gt;&lt;br/&gt;scheduled for 11/11/20 2:00pm</string>
+             </property>
+             <property name="textFormat">
+              <enum>Qt::RichText</enum>
+             </property>
+             <property name="alignment">
+              <set>Qt::AlignCenter</set>
+             </property>
+             <property name="margin">
+              <number>4</number>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QLabel" name="label_14">
+             <property name="styleSheet">
+              <string notr="true">border: 1px solid black;</string>
+             </property>
+             <property name="text">
+              <string>&lt;big&gt;Friday Fortnite Stream&lt;/big&gt;&lt;br/&gt;scheduled for 11/11/20 2:00pm</string>
+             </property>
+             <property name="textFormat">
+              <enum>Qt::RichText</enum>
+             </property>
+             <property name="alignment">
+              <set>Qt::AlignCenter</set>
+             </property>
+             <property name="margin">
+              <number>4</number>
+             </property>
+            </widget>
+           </item>
+          </layout>
+         </widget>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="1,2,6">
+     <item>
+      <widget class="QPushButton" name="cancelButton">
+       <property name="text">
+        <string>Cancel</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="pushButton">
+       <property name="text">
+        <string>YouTube.Actions.Dashboard</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="okButton">
+       <property name="text">
+        <string>YouTube.Actions.Create_GoLive</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 6 - 0
UI/ui-config.h.in

@@ -24,4 +24,10 @@
 #define RESTREAM_CLIENTID "@RESTREAM_CLIENTID@"
 #define RESTREAM_HASH     0x@RESTREAM_HASH@
 
+#define YOUTUBE_ENABLED       @YOUTUBE_ENABLED@
+#define YOUTUBE_CLIENTID      "@YOUTUBE_CLIENTID@"
+#define YOUTUBE_SECRET        "@YOUTUBE_SECRET@"
+#define YOUTUBE_CLIENTID_HASH 0x@YOUTUBE_CLIENTID_HASH@
+#define YOUTUBE_SECRET_HASH   0x@YOUTUBE_SECRET_HASH@
+
 #define DEFAULT_THEME "Dark"

+ 119 - 41
UI/window-basic-auto-config.cpp

@@ -15,7 +15,12 @@
 
 #ifdef BROWSER_AVAILABLE
 #include <browser-panel.hpp>
+#endif
+
 #include "auth-oauth.hpp"
+#include "ui-config.h"
+#if YOUTUBE_ENABLED
+#include "youtube-api-wrappers.hpp"
 #endif
 
 struct QCef;
@@ -257,6 +262,9 @@ AutoConfigStreamPage::AutoConfigStreamPage(QWidget *parent)
 	ui->connectAccount2->setVisible(false);
 	ui->disconnectAccount->setVisible(false);
 
+	ui->connectedAccountLabel->setVisible(false);
+	ui->connectedAccountText->setVisible(false);
+
 	int vertSpacing = ui->topLayout->verticalSpacing();
 
 	QMargins m = ui->topLayout->contentsMargins();
@@ -295,6 +303,9 @@ AutoConfigStreamPage::AutoConfigStreamPage(QWidget *parent)
 	connect(ui->service, SIGNAL(currentIndexChanged(int)), this,
 		SLOT(UpdateMoreInfoLink()));
 
+	connect(ui->useStreamKeyAdv, &QPushButton::clicked, this,
+		[&]() { ui->streamKeyWidget->setVisible(true); });
+
 	connect(ui->key, SIGNAL(textChanged(const QString &)), this,
 		SLOT(UpdateCompleted()));
 	connect(ui->regionUS, SIGNAL(toggled(bool)), this,
@@ -413,7 +424,6 @@ void AutoConfigStreamPage::on_show_clicked()
 
 void AutoConfigStreamPage::OnOAuthStreamKeyConnected()
 {
-#ifdef BROWSER_AVAILABLE
 	OAuthStreamKey *a = reinterpret_cast<OAuthStreamKey *>(auth.get());
 
 	if (a) {
@@ -422,15 +432,45 @@ void AutoConfigStreamPage::OnOAuthStreamKeyConnected()
 		if (validKey)
 			ui->key->setText(QT_UTF8(a->key().c_str()));
 
-		ui->streamKeyWidget->setVisible(!validKey);
-		ui->streamKeyLabel->setVisible(!validKey);
-		ui->connectAccount2->setVisible(!validKey);
-		ui->disconnectAccount->setVisible(validKey);
+		ui->streamKeyWidget->setVisible(false);
+		ui->streamKeyLabel->setVisible(false);
+		ui->connectAccount2->setVisible(false);
+		ui->disconnectAccount->setVisible(true);
+		ui->useStreamKeyAdv->setVisible(false);
+
+		ui->connectedAccountLabel->setVisible(false);
+		ui->connectedAccountText->setVisible(false);
+
+#if YOUTUBE_ENABLED
+		if (IsYouTubeService(a->service())) {
+			ui->key->clear();
+
+			ui->connectedAccountLabel->setVisible(true);
+			ui->connectedAccountText->setVisible(true);
+
+			ui->connectedAccountText->setText(
+				QTStr("Auth.LoadingChannel.Title"));
+
+			QScopedPointer<QThread> thread(CreateQThread([&]() {
+				std::shared_ptr<YoutubeApiWrappers> ytAuth =
+					std::dynamic_pointer_cast<
+						YoutubeApiWrappers>(auth);
+				if (ytAuth.get()) {
+					ChannelDescription cd;
+					if (ytAuth->GetChannelDescription(cd)) {
+						ui->connectedAccountText
+							->setText(cd.title);
+					}
+				}
+			}));
+			thread->start();
+			thread->wait();
+		}
+#endif
 	}
 
 	ui->stackedWidget->setCurrentIndex((int)Section::StreamKey);
 	UpdateCompleted();
-#endif
 }
 
 void AutoConfigStreamPage::OnAuthConnected()
@@ -446,15 +486,16 @@ void AutoConfigStreamPage::OnAuthConnected()
 
 void AutoConfigStreamPage::on_connectAccount_clicked()
 {
-#ifdef BROWSER_AVAILABLE
 	std::string service = QT_TO_UTF8(ui->service->currentText());
 
 	OAuth::DeleteCookies(service);
 
 	auth = OAuthStreamKey::Login(this, service);
-	if (!!auth)
+	if (!!auth) {
 		OnAuthConnected();
-#endif
+
+		ui->useStreamKeyAdv->setVisible(false);
+	}
 }
 
 #define DISCONNECT_COMFIRM_TITLE \
@@ -484,11 +525,14 @@ void AutoConfigStreamPage::on_disconnectAccount_clicked()
 	OAuth::DeleteCookies(service);
 #endif
 
+	reset_service_ui_fields(service);
+
 	ui->streamKeyWidget->setVisible(true);
 	ui->streamKeyLabel->setVisible(true);
-	ui->connectAccount2->setVisible(true);
-	ui->disconnectAccount->setVisible(false);
 	ui->key->setText("");
+
+	ui->connectedAccountLabel->setVisible(false);
+	ui->connectedAccountText->setVisible(false);
 }
 
 void AutoConfigStreamPage::on_useStreamKey_clicked()
@@ -502,6 +546,55 @@ static inline bool is_auth_service(const std::string &service)
 	return Auth::AuthType(service) != Auth::Type::None;
 }
 
+static inline bool is_external_oauth(const std::string &service)
+{
+	return Auth::External(service);
+}
+
+void AutoConfigStreamPage::reset_service_ui_fields(std::string &service)
+{
+	// when account is already connected:
+	OAuthStreamKey *a = reinterpret_cast<OAuthStreamKey *>(auth.get());
+#if YOUTUBE_ENABLED
+	if (a && service == a->service() && IsYouTubeService(a->service())) {
+		ui->connectedAccountLabel->setVisible(true);
+		ui->connectedAccountText->setVisible(true);
+		ui->connectAccount2->setVisible(false);
+		ui->disconnectAccount->setVisible(true);
+		return;
+	}
+#endif
+
+	bool external_oauth = is_external_oauth(service);
+	if (external_oauth) {
+		ui->streamKeyWidget->setVisible(false);
+		ui->streamKeyLabel->setVisible(false);
+		ui->connectAccount2->setVisible(true);
+		ui->useStreamKeyAdv->setVisible(true);
+
+		ui->stackedWidget->setCurrentIndex((int)Section::StreamKey);
+
+	} else if (cef) {
+		QString key = ui->key->text();
+		bool can_auth = is_auth_service(service);
+		int page = can_auth && key.isEmpty() ? (int)Section::Connect
+						     : (int)Section::StreamKey;
+
+		ui->stackedWidget->setCurrentIndex(page);
+		ui->streamKeyWidget->setVisible(true);
+		ui->streamKeyLabel->setVisible(true);
+		ui->connectAccount2->setVisible(can_auth);
+		ui->useStreamKeyAdv->setVisible(false);
+	} else {
+		ui->connectAccount2->setVisible(false);
+		ui->useStreamKeyAdv->setVisible(false);
+	}
+
+	ui->connectedAccountLabel->setVisible(false);
+	ui->connectedAccountText->setVisible(false);
+	ui->disconnectAccount->setVisible(false);
+}
+
 void AutoConfigStreamPage::ServiceChanged()
 {
 	bool showMore = ui->service->currentData().toInt() ==
@@ -514,30 +607,7 @@ void AutoConfigStreamPage::ServiceChanged()
 	bool testBandwidth = ui->doBandwidthTest->isChecked();
 	bool custom = IsCustomService();
 
-	ui->disconnectAccount->setVisible(false);
-
-#ifdef BROWSER_AVAILABLE
-	if (cef) {
-		if (lastService != service.c_str()) {
-			bool can_auth = is_auth_service(service);
-			int page = can_auth ? (int)Section::Connect
-					    : (int)Section::StreamKey;
-
-			ui->stackedWidget->setCurrentIndex(page);
-			ui->streamKeyWidget->setVisible(true);
-			ui->streamKeyLabel->setVisible(true);
-			ui->connectAccount2->setVisible(can_auth);
-			auth.reset();
-
-			if (lastService.isEmpty())
-				lastService = service.c_str();
-		}
-	} else {
-		ui->connectAccount2->setVisible(false);
-	}
-#else
-	ui->connectAccount2->setVisible(false);
-#endif
+	reset_service_ui_fields(service);
 
 	/* Test three closest servers if "Auto" is available for Twitch */
 	if (service == "Twitch" && wiz->twitchAuto)
@@ -570,15 +640,23 @@ void AutoConfigStreamPage::ServiceChanged()
 	ui->bitrateLabel->setHidden(testBandwidth);
 	ui->bitrate->setHidden(testBandwidth);
 
-#ifdef BROWSER_AVAILABLE
 	OBSBasic *main = OBSBasic::Get();
 
-	if (!!main->auth &&
-	    service.find(main->auth->service()) != std::string::npos) {
-		auth = main->auth;
-		OnAuthConnected();
-	}
+	if (main->auth) {
+		auto system_auth_service = main->auth->service();
+		bool service_check = service == system_auth_service;
+#if YOUTUBE_ENABLED
+		service_check =
+			service_check ? service_check
+				      : IsYouTubeService(system_auth_service) &&
+						IsYouTubeService(service);
 #endif
+		if (service_check) {
+			auth.reset();
+			auth = main->auth;
+			OnAuthConnected();
+		}
+	}
 
 	UpdateCompleted();
 }

+ 2 - 0
UI/window-basic-auto-config.hpp

@@ -197,6 +197,8 @@ public slots:
 	void UpdateMoreInfoLink();
 	void UpdateServerList();
 	void UpdateCompleted();
+
+	void reset_service_ui_fields(std::string &service);
 };
 
 class AutoConfigTestPage : public QWizardPage {

+ 246 - 1
UI/window-basic-main.cpp

@@ -16,6 +16,7 @@
     You should have received a copy of the GNU General Public License
     along with this program.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/
+#include "ui-config.h"
 
 #include <cstddef>
 #include <ctime>
@@ -54,6 +55,11 @@
 #include "window-log-reply.hpp"
 #include "window-projector.hpp"
 #include "window-remux.hpp"
+#if YOUTUBE_ENABLED
+#include "auth-youtube.hpp"
+#include "window-youtube-actions.hpp"
+#include "youtube-api-wrappers.hpp"
+#endif
 #include "qt-wrappers.hpp"
 #include "context-bar-controls.hpp"
 #include "obs-proxy-style.hpp"
@@ -203,6 +209,9 @@ void assignDockToggle(QDockWidget *dock, QAction *action)
 
 extern void RegisterTwitchAuth();
 extern void RegisterRestreamAuth();
+#if YOUTUBE_ENABLED
+extern void RegisterYoutubeAuth();
+#endif
 
 OBSBasic::OBSBasic(QWidget *parent)
 	: OBSMainWindow(parent), undo_s(ui), ui(new Ui::OBSBasic)
@@ -220,6 +229,9 @@ OBSBasic::OBSBasic(QWidget *parent)
 #if RESTREAM_ENABLED
 	RegisterRestreamAuth();
 #endif
+#if YOUTUBE_ENABLED
+	RegisterYoutubeAuth();
+#endif
 
 	setAcceptDrops(true);
 
@@ -232,6 +244,7 @@ OBSBasic::OBSBasic(QWidget *parent)
 	ui->setupUi(this);
 	ui->previewDisabledWidget->setVisible(false);
 	ui->contextContainer->setStyle(new OBSProxyStyle);
+	ui->broadcastButton->setVisible(false);
 
 	/* XXX: Disable drag/drop on Linux until Qt issues are fixed */
 #if !defined(_WIN32) && !defined(__APPLE__)
@@ -447,6 +460,9 @@ OBSBasic::OBSBasic(QWidget *parent)
 
 	connect(ui->scenes, SIGNAL(scenesReordered()), this,
 		SLOT(ScenesReordered()));
+
+	connect(ui->broadcastButton, &QPushButton::clicked, this,
+		&OBSBasic::BroadcastButtonClicked);
 }
 
 static void SaveAudioDevice(const char *name, int channel, obs_data_t *parent,
@@ -4446,6 +4462,15 @@ void OBSBasic::closeEvent(QCloseEvent *event)
 		return;
 	}
 
+#if YOUTUBE_ENABLED
+	/* Also don't close the window if the youtube stream check is active */
+	if (youtubeStreamCheckThread) {
+		QTimer::singleShot(1000, this, SLOT(close()));
+		event->ignore();
+		return;
+	}
+#endif
+
 	if (isVisible())
 		config_set_string(App()->GlobalConfig(), "BasicWindow",
 				  "geometry",
@@ -6019,6 +6044,77 @@ void OBSBasic::DisplayStreamStartError()
 	QMessageBox::critical(this, QTStr("Output.StartStreamFailed"), message);
 }
 
+#if YOUTUBE_ENABLED
+void OBSBasic::YouTubeActionDialogOk(const QString &id, const QString &key,
+				     bool autostart, bool autostop)
+{
+	//blog(LOG_DEBUG, "Stream key: %s", QT_TO_UTF8(key));
+	obs_service_t *service_obj = GetService();
+	obs_data_t *settings = obs_service_get_settings(service_obj);
+
+	const std::string a_key = QT_TO_UTF8(key);
+	obs_data_set_string(settings, "key", a_key.c_str());
+
+	const std::string an_id = QT_TO_UTF8(id);
+	obs_data_set_string(settings, "stream_id", an_id.c_str());
+
+	obs_service_update(service_obj, settings);
+	autoStartBroadcast = autostart;
+	autoStopBroadcast = autostop;
+}
+
+void OBSBasic::YoutubeStreamCheck(const std::string &key)
+{
+	YoutubeApiWrappers *apiYouTube(
+		dynamic_cast<YoutubeApiWrappers *>(GetAuth()));
+	if (!apiYouTube) {
+		/* technically we should never get here -Jim */
+		QMetaObject::invokeMethod(this, "ForceStopStreaming",
+					  Qt::QueuedConnection);
+		youtubeStreamCheckThread->deleteLater();
+		blog(LOG_ERROR, "==========================================");
+		blog(LOG_ERROR, "%s: Uh, hey, we got here", __FUNCTION__);
+		blog(LOG_ERROR, "==========================================");
+		return;
+	}
+
+	int timeout = 0;
+	json11::Json json;
+	QString id = key.c_str();
+
+	for (;;) {
+		if (timeout == 14) {
+			QMetaObject::invokeMethod(this, "ForceStopStreaming",
+						  Qt::QueuedConnection);
+			break;
+		}
+
+		if (!apiYouTube->FindStream(id, json)) {
+			QMetaObject::invokeMethod(this,
+						  "DisplayStreamStartError",
+						  Qt::QueuedConnection);
+			QMetaObject::invokeMethod(this, "StopStreaming",
+						  Qt::QueuedConnection);
+			break;
+		}
+
+		auto item = json["items"][0];
+		auto status = item["status"]["streamStatus"].string_value();
+		if (status == "active") {
+			QMetaObject::invokeMethod(ui->broadcastButton,
+						  "setEnabled",
+						  Q_ARG(bool, true));
+			break;
+		} else {
+			QThread::sleep(1);
+			timeout++;
+		}
+	}
+
+	youtubeStreamCheckThread->deleteLater();
+}
+#endif
+
 void OBSBasic::StartStreaming()
 {
 	if (outputHandler->StreamingActive())
@@ -6026,6 +6122,35 @@ void OBSBasic::StartStreaming()
 	if (disableOutputsRef)
 		return;
 
+	Auth *auth = GetAuth();
+	if (auth) {
+		auth->OnStreamConfig();
+#if YOUTUBE_ENABLED
+		if (!broadcastActive && autoStartBroadcast &&
+		    IsYouTubeService(auth->service())) {
+			OBSYoutubeActions *dialog;
+			dialog = new OBSYoutubeActions(this, auth);
+			connect(dialog, &OBSYoutubeActions::ok, this,
+				&OBSBasic::YouTubeActionDialogOk);
+			int result = dialog->Valid() ? dialog->exec()
+						     : QDialog::Rejected;
+			if (result != QDialog::Accepted) {
+				ui->streamButton->setText(
+					QTStr("Basic.Main.StartStreaming"));
+				ui->streamButton->setEnabled(true);
+				ui->streamButton->setChecked(false);
+
+				if (sysTrayStream) {
+					sysTrayStream->setText(
+						ui->streamButton->text());
+					sysTrayStream->setEnabled(true);
+				}
+				return;
+			}
+		}
+#endif
+	}
+
 	if (!outputHandler->SetupStreaming(service)) {
 		DisplayStreamStartError();
 		return;
@@ -6050,6 +6175,33 @@ void OBSBasic::StartStreaming()
 		return;
 	}
 
+	if (!autoStartBroadcast) {
+		ui->broadcastButton->setVisible(true);
+		ui->broadcastButton->setText(
+			QTStr("Basic.Main.StartBroadcast"));
+		ui->broadcastButton->setStyleSheet("background-color:#6699cc");
+		// well, we need to disable button while stream is not active
+#if YOUTUBE_ENABLED
+		// get a current stream key
+		obs_service_t *service_obj = GetService();
+		obs_data_t *settings = obs_service_get_settings(service_obj);
+		std::string key = obs_data_get_string(settings, "stream_id");
+		if (!key.empty() && !youtubeStreamCheckThread) {
+			ui->broadcastButton->setEnabled(false);
+			youtubeStreamCheckThread = CreateQThread(
+				[this, key] { YoutubeStreamCheck(key); });
+			youtubeStreamCheckThread->setObjectName(
+				"YouTubeStreamCheckThread");
+			youtubeStreamCheckThread->start();
+		}
+#endif
+	} else if (!autoStopBroadcast) {
+		broadcastActive = true;
+		ui->broadcastButton->setVisible(true);
+		ui->broadcastButton->setText(QTStr("Basic.Main.StopBroadcast"));
+		ui->broadcastButton->setStyleSheet("background-color:#ff0000");
+	}
+
 	bool recordWhenStreaming = config_get_bool(
 		GetGlobalConfig(), "BasicWindow", "RecordWhenStreaming");
 	if (recordWhenStreaming)
@@ -6059,6 +6211,65 @@ void OBSBasic::StartStreaming()
 		GetGlobalConfig(), "BasicWindow", "ReplayBufferWhileStreaming");
 	if (replayBufferWhileStreaming)
 		StartReplayBuffer();
+
+	if (!autoStartBroadcast) {
+		OBSMessageBox::warning(
+			this, "Warning",
+			QTStr("YouTube.Actions.AutoStartStreamingWarning"),
+			false);
+	}
+}
+
+void OBSBasic::BroadcastButtonClicked()
+{
+	if (!autoStartBroadcast) {
+#if YOUTUBE_ENABLED
+		std::shared_ptr<YoutubeApiWrappers> ytAuth =
+			dynamic_pointer_cast<YoutubeApiWrappers>(auth);
+		if (ytAuth.get()) {
+			ytAuth->StartLatestBroadcast();
+		}
+#endif
+		broadcastActive = true;
+
+		autoStartBroadcast = true; // and clear the flag
+		if (!autoStopBroadcast) {
+			ui->broadcastButton->setText(
+				QTStr("Basic.Main.StopBroadcast"));
+			ui->broadcastButton->setStyleSheet(
+				"background-color:#ff0000");
+		} else {
+			ui->broadcastButton->setVisible(false);
+		}
+	} else if (!autoStopBroadcast) {
+#if YOUTUBE_ENABLED
+		bool confirm = config_get_bool(GetGlobalConfig(), "BasicWindow",
+					       "WarnBeforeStoppingStream");
+		if (confirm && isVisible()) {
+			QMessageBox::StandardButton button = OBSMessageBox::question(
+				this, QTStr("ConfirmStop.Title"),
+				QTStr("YouTube.Actions.AutoStopStreamingWarning"),
+				QMessageBox::Yes | QMessageBox::No,
+				QMessageBox::No);
+
+			if (button == QMessageBox::No) {
+				return;
+			}
+		}
+
+		std::shared_ptr<YoutubeApiWrappers> ytAuth =
+			dynamic_pointer_cast<YoutubeApiWrappers>(auth);
+		if (ytAuth.get()) {
+			ytAuth->StopLatestBroadcast();
+		}
+#endif
+		broadcastActive = false;
+
+		autoStopBroadcast = true;
+		ui->broadcastButton->setVisible(false);
+
+		QMetaObject::invokeMethod(this, "StopStreaming");
+	}
 }
 
 #ifdef _WIN32
@@ -6167,6 +6378,18 @@ void OBSBasic::StopStreaming()
 	if (outputHandler->StreamingActive())
 		outputHandler->StopStreaming(streamingStopping);
 
+	// special case: force reset broadcast state if
+	// no autostart and no autostop selected
+	if (!autoStartBroadcast && !broadcastActive) {
+		broadcastActive = false;
+		autoStartBroadcast = true;
+		autoStopBroadcast = true;
+		ui->broadcastButton->setVisible(false);
+	}
+
+	if (autoStopBroadcast)
+		broadcastActive = false;
+
 	OnDeactivate();
 
 	bool recordWhenStreaming = config_get_bool(
@@ -6827,6 +7050,23 @@ void OBSBasic::on_streamButton_clicked()
 		bool confirm = config_get_bool(GetGlobalConfig(), "BasicWindow",
 					       "WarnBeforeStoppingStream");
 
+#if YOUTUBE_ENABLED
+		if (isVisible() && auth && IsYouTubeService(auth->service()) &&
+		    autoStopBroadcast) {
+			QMessageBox::StandardButton button = OBSMessageBox::question(
+				this, QTStr("ConfirmStop.Title"),
+				QTStr("YouTube.Actions.AutoStopStreamingWarning"),
+				QMessageBox::Yes | QMessageBox::No,
+				QMessageBox::No);
+
+			if (button == QMessageBox::No) {
+				ui->streamButton->setChecked(true);
+				return;
+			}
+
+			confirm = false;
+		}
+#endif
 		if (confirm && isVisible()) {
 			QMessageBox::StandardButton button =
 				OBSMessageBox::question(
@@ -6848,8 +7088,13 @@ void OBSBasic::on_streamButton_clicked()
 			return;
 		}
 
+		Auth *auth = GetAuth();
+
 		auto action =
-			UIValidation::StreamSettingsConfirmation(this, service);
+			(auth && auth->external())
+				? StreamSettingsAction::ContinueStream
+				: UIValidation::StreamSettingsConfirmation(
+					  this, service);
 		switch (action) {
 		case StreamSettingsAction::ContinueStream:
 			break;

+ 12 - 0
UI/window-basic-main.hpp

@@ -19,6 +19,7 @@
 
 #include <QBuffer>
 #include <QAction>
+#include <QThread>
 #include <QWidgetAction>
 #include <QSystemTrayIcon>
 #include <QStyledItemDelegate>
@@ -556,6 +557,17 @@ private:
 	void MoveSceneItem(enum obs_order_movement movement,
 			   const QString &action_name);
 
+	bool autoStartBroadcast = true;
+	bool autoStopBroadcast = true;
+	bool broadcastActive = false;
+	QPointer<QThread> youtubeStreamCheckThread;
+#if YOUTUBE_ENABLED
+	void YoutubeStreamCheck(const std::string &key);
+	void YouTubeActionDialogOk(const QString &id, const QString &key,
+				   bool autostart, bool autostop);
+#endif
+	void BroadcastButtonClicked();
+
 public slots:
 	void DeferSaveBegin();
 	void DeferSaveEnd();

+ 124 - 35
UI/window-basic-settings-stream.cpp

@@ -10,7 +10,14 @@
 
 #ifdef BROWSER_AVAILABLE
 #include <browser-panel.hpp>
+#endif
+
 #include "auth-oauth.hpp"
+
+#include "ui-config.h"
+
+#if YOUTUBE_ENABLED
+#include "youtube-api-wrappers.hpp"
 #endif
 
 struct QCef;
@@ -39,9 +46,13 @@ void OBSBasicSettings::InitStreamPage()
 	ui->connectAccount2->setVisible(false);
 	ui->disconnectAccount->setVisible(false);
 	ui->bandwidthTestEnable->setVisible(false);
+
 	ui->twitchAddonDropdown->setVisible(false);
 	ui->twitchAddonLabel->setVisible(false);
 
+	ui->connectedAccountLabel->setVisible(false);
+	ui->connectedAccountText->setVisible(false);
+
 	int vertSpacing = ui->topStreamLayout->verticalSpacing();
 
 	QMargins m = ui->topStreamLayout->contentsMargins();
@@ -375,6 +386,68 @@ static inline bool is_auth_service(const std::string &service)
 	return Auth::AuthType(service) != Auth::Type::None;
 }
 
+static inline bool is_external_oauth(const std::string &service)
+{
+	return Auth::External(service);
+}
+
+static void reset_service_ui_fields(Ui::OBSBasicSettings *ui,
+				    std::string &service, bool loading)
+{
+	bool external_oauth = is_external_oauth(service);
+	if (external_oauth) {
+		ui->streamKeyWidget->setVisible(false);
+		ui->streamKeyLabel->setVisible(false);
+		ui->connectAccount2->setVisible(true);
+		ui->useStreamKeyAdv->setVisible(true);
+		ui->streamStackWidget->setCurrentIndex((int)Section::StreamKey);
+	} else if (cef) {
+		QString key = ui->key->text();
+		bool can_auth = is_auth_service(service);
+		int page = can_auth && (!loading || key.isEmpty())
+				   ? (int)Section::Connect
+				   : (int)Section::StreamKey;
+
+		ui->streamStackWidget->setCurrentIndex(page);
+		ui->streamKeyWidget->setVisible(true);
+		ui->streamKeyLabel->setVisible(true);
+		ui->connectAccount2->setVisible(can_auth);
+		ui->useStreamKeyAdv->setVisible(false);
+	} else {
+		ui->connectAccount2->setVisible(false);
+		ui->useStreamKeyAdv->setVisible(false);
+	}
+
+	ui->connectedAccountLabel->setVisible(false);
+	ui->connectedAccountText->setVisible(false);
+	ui->disconnectAccount->setVisible(false);
+}
+
+#if YOUTUBE_ENABLED
+static void get_yt_ch_title(Ui::OBSBasicSettings *ui,
+			    YoutubeApiWrappers *ytAuth)
+{
+	if (ytAuth) {
+		ChannelDescription cd;
+		if (ytAuth->GetChannelDescription(cd)) {
+			ui->connectedAccountText->setText(cd.title);
+		} else {
+			// if we still not changed the service page
+			if (IsYouTubeService(
+				    QT_TO_UTF8(ui->service->currentText()))) {
+				ui->connectedAccountText->setText(
+					QTStr("Auth.LoadingChannel.Error"));
+			}
+		}
+	}
+}
+#endif
+
+void OBSBasicSettings::UseStreamKeyAdvClicked()
+{
+	ui->streamKeyWidget->setVisible(true);
+}
+
 void OBSBasicSettings::on_service_currentIndexChanged(int)
 {
 	bool showMore = ui->service->currentData().toInt() ==
@@ -390,26 +463,9 @@ void OBSBasicSettings::on_service_currentIndexChanged(int)
 	ui->twitchAddonDropdown->setVisible(false);
 	ui->twitchAddonLabel->setVisible(false);
 
-#ifdef BROWSER_AVAILABLE
-	if (cef) {
-		if (lastService != service.c_str()) {
-			QString key = ui->key->text();
-			bool can_auth = is_auth_service(service);
-			int page = can_auth && (!loading || key.isEmpty())
-					   ? (int)Section::Connect
-					   : (int)Section::StreamKey;
-
-			ui->streamStackWidget->setCurrentIndex(page);
-			ui->streamKeyWidget->setVisible(true);
-			ui->streamKeyLabel->setVisible(true);
-			ui->connectAccount2->setVisible(can_auth);
-		}
-	} else {
-		ui->connectAccount2->setVisible(false);
+	if (lastService != service.c_str()) {
+		reset_service_ui_fields(ui.get(), service, loading);
 	}
-#else
-	ui->connectAccount2->setVisible(false);
-#endif
 
 	ui->useAuth->setVisible(custom);
 	ui->authUsernameLabel->setVisible(custom);
@@ -429,15 +485,22 @@ void OBSBasicSettings::on_service_currentIndexChanged(int)
 		ui->serverStackedWidget->setCurrentIndex(0);
 	}
 
-#ifdef BROWSER_AVAILABLE
-	auth.reset();
+	if (!main->auth) {
+		return;
+	}
 
-	if (!!main->auth &&
-	    service.find(main->auth->service()) != std::string::npos) {
+	auto system_auth_service = main->auth->service();
+	bool service_check = service == system_auth_service;
+#if YOUTUBE_ENABLED
+	service_check = service_check ? service_check
+				      : IsYouTubeService(system_auth_service) &&
+						IsYouTubeService(service);
+#endif
+	if (service_check) {
+		auth.reset();
 		auth = main->auth;
 		OnAuthConnected();
 	}
-#endif
 }
 
 void OBSBasicSettings::UpdateServerList()
@@ -528,7 +591,6 @@ OBSService OBSBasicSettings::SpawnTempService()
 
 void OBSBasicSettings::OnOAuthStreamKeyConnected()
 {
-#ifdef BROWSER_AVAILABLE
 	OAuthStreamKey *a = reinterpret_cast<OAuthStreamKey *>(auth.get());
 
 	if (a) {
@@ -541,18 +603,43 @@ void OBSBasicSettings::OnOAuthStreamKeyConnected()
 		ui->streamKeyLabel->setVisible(false);
 		ui->connectAccount2->setVisible(false);
 		ui->disconnectAccount->setVisible(true);
+		ui->useStreamKeyAdv->setVisible(false);
+
+		ui->connectedAccountLabel->setVisible(false);
+		ui->connectedAccountText->setVisible(false);
 
 		if (strcmp(a->service(), "Twitch") == 0) {
 			ui->bandwidthTestEnable->setVisible(true);
 			ui->twitchAddonLabel->setVisible(true);
 			ui->twitchAddonDropdown->setVisible(true);
-		} else {
-			ui->bandwidthTestEnable->setChecked(false);
 		}
+#if YOUTUBE_ENABLED
+		if (IsYouTubeService(a->service())) {
+			ui->key->clear();
+
+			ui->connectedAccountLabel->setVisible(true);
+			ui->connectedAccountText->setVisible(true);
+
+			ui->connectedAccountText->setText(
+				QTStr("Auth.LoadingChannel.Title"));
+
+			std::string a_service = a->service();
+			std::shared_ptr<YoutubeApiWrappers> ytAuth =
+				std::dynamic_pointer_cast<YoutubeApiWrappers>(
+					auth);
+			auto act = [&]() {
+				get_yt_ch_title(ui.get(), ytAuth.get());
+			};
+
+			QScopedPointer<QThread> thread(CreateQThread(act));
+			thread->start();
+			thread->wait();
+		}
+#endif
+		ui->bandwidthTestEnable->setChecked(false);
 	}
 
 	ui->streamStackWidget->setCurrentIndex((int)Section::StreamKey);
-#endif
 }
 
 void OBSBasicSettings::OnAuthConnected()
@@ -573,15 +660,16 @@ void OBSBasicSettings::OnAuthConnected()
 
 void OBSBasicSettings::on_connectAccount_clicked()
 {
-#ifdef BROWSER_AVAILABLE
 	std::string service = QT_TO_UTF8(ui->service->currentText());
 
 	OAuth::DeleteCookies(service);
 
 	auth = OAuthStreamKey::Login(this, service);
-	if (!!auth)
+	if (!!auth) {
 		OnAuthConnected();
-#endif
+
+		ui->useStreamKeyAdv->setVisible(false);
+	}
 }
 
 #define DISCONNECT_COMFIRM_TITLE \
@@ -611,14 +699,15 @@ void OBSBasicSettings::on_disconnectAccount_clicked()
 
 	ui->bandwidthTestEnable->setChecked(false);
 
-	ui->streamKeyWidget->setVisible(true);
-	ui->streamKeyLabel->setVisible(true);
-	ui->connectAccount2->setVisible(true);
-	ui->disconnectAccount->setVisible(false);
+	reset_service_ui_fields(ui.get(), service, loading);
+
 	ui->bandwidthTestEnable->setVisible(false);
 	ui->twitchAddonDropdown->setVisible(false);
 	ui->twitchAddonLabel->setVisible(false);
 	ui->key->setText("");
+
+	ui->connectedAccountLabel->setVisible(false);
+	ui->connectedAccountText->setVisible(false);
 }
 
 void OBSBasicSettings::on_useStreamKey_clicked()

+ 3 - 0
UI/window-basic-settings.cpp

@@ -908,6 +908,9 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	QValidator *validator = new QRegularExpressionValidator(rx, this);
 	ui->baseResolution->lineEdit()->setValidator(validator);
 	ui->outputResolution->lineEdit()->setValidator(validator);
+
+	connect(ui->useStreamKeyAdv, SIGNAL(clicked()), this,
+		SLOT(UseStreamKeyAdvClicked()));
 }
 
 OBSBasicSettings::~OBSBasicSettings()

+ 2 - 0
UI/window-basic-settings.hpp

@@ -399,6 +399,8 @@ private slots:
 	void SetHotkeysIcon(const QIcon &icon);
 	void SetAdvancedIcon(const QIcon &icon);
 
+	void UseStreamKeyAdvClicked();
+
 protected:
 	virtual void closeEvent(QCloseEvent *event) override;
 	void reject() override;

+ 520 - 0
UI/window-youtube-actions.cpp

@@ -0,0 +1,520 @@
+#include "window-basic-main.hpp"
+#include "window-youtube-actions.hpp"
+
+#include "obs-app.hpp"
+#include "qt-wrappers.hpp"
+#include "youtube-api-wrappers.hpp"
+
+#include <QDateTime>
+#include <QDesktopServices>
+
+const QString SchedulDateAndTimeFormat = "yyyy-MM-dd'T'hh:mm:ss'Z'";
+const QString RepresentSchedulDateAndTimeFormat = "dddd, MMMM d, yyyy h:m";
+const QString NormalStylesheet = "border: 1px solid black; border-radius: 5px;";
+const QString SelectedStylesheet =
+	"border: 2px solid black; border-radius: 5px;";
+const QString IndexOfGamingCategory = "20";
+
+OBSYoutubeActions::OBSYoutubeActions(QWidget *parent, Auth *auth)
+	: QDialog(parent),
+	  ui(new Ui::OBSYoutubeActions),
+	  apiYouTube(dynamic_cast<YoutubeApiWrappers *>(auth)),
+	  workerThread(new WorkerThread(apiYouTube))
+{
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+	ui->setupUi(this);
+
+	ui->privacyBox->addItem(QTStr("YouTube.Actions.Privacy.Public"),
+				"public");
+	ui->privacyBox->addItem(QTStr("YouTube.Actions.Privacy.Unlisted"),
+				"unlisted");
+	ui->privacyBox->addItem(QTStr("YouTube.Actions.Privacy.Private"),
+				"private");
+
+	ui->latencyBox->addItem(QTStr("YouTube.Actions.Latency.Normal"),
+				"normal");
+	ui->latencyBox->addItem(QTStr("YouTube.Actions.Latency.Low"), "low");
+	ui->latencyBox->addItem(QTStr("YouTube.Actions.Latency.UltraLow"),
+				"ultraLow");
+
+	ui->checkAutoStart->setEnabled(false);
+	ui->checkAutoStop->setEnabled(false);
+
+	UpdateOkButtonStatus();
+
+	connect(ui->title, &QLineEdit::textChanged, this,
+		[&](const QString &) { this->UpdateOkButtonStatus(); });
+	connect(ui->privacyBox, &QComboBox::currentTextChanged, this,
+		[&](const QString &) { this->UpdateOkButtonStatus(); });
+	connect(ui->yesMakeForKids, &QRadioButton::toggled, this,
+		[&](bool) { this->UpdateOkButtonStatus(); });
+	connect(ui->notMakeForKids, &QRadioButton::toggled, this,
+		[&](bool) { this->UpdateOkButtonStatus(); });
+	connect(ui->tabWidget, &QTabWidget::currentChanged, this,
+		[&](int) { this->UpdateOkButtonStatus(); });
+	connect(ui->pushButton, &QPushButton::clicked, this,
+		&OBSYoutubeActions::OpenYouTubeDashboard);
+
+	connect(ui->helpAutoStartStop, &QLabel::linkActivated, this,
+		[](const QString &link) { QDesktopServices::openUrl(link); });
+	connect(ui->help360Video, &QLabel::linkActivated, this,
+		[](const QString &link) { QDesktopServices::openUrl(link); });
+	connect(ui->helpMadeForKids, &QLabel::linkActivated, this,
+		[](const QString &link) { QDesktopServices::openUrl(link); });
+
+	ui->scheduledTime->setVisible(false);
+	connect(ui->checkScheduledLater, &QCheckBox::stateChanged, this,
+		[&](int state) {
+			ui->scheduledTime->setVisible(state);
+			if (state) {
+				ui->checkAutoStart->setEnabled(true);
+				ui->checkAutoStop->setEnabled(true);
+
+				ui->checkAutoStart->setChecked(false);
+				ui->checkAutoStop->setChecked(false);
+			} else {
+				ui->checkAutoStart->setEnabled(false);
+				ui->checkAutoStop->setEnabled(false);
+
+				ui->checkAutoStart->setChecked(true);
+				ui->checkAutoStop->setChecked(true);
+			}
+			UpdateOkButtonStatus();
+		});
+
+	ui->scheduledTime->setDateTime(QDateTime::currentDateTime());
+
+	if (!apiYouTube) {
+		blog(LOG_DEBUG, "YouTube API auth NOT found.");
+		Cancel();
+		return;
+	}
+	ChannelDescription channel;
+	if (!apiYouTube->GetChannelDescription(channel)) {
+		blog(LOG_DEBUG, "Could not get channel description.");
+		ShowErrorDialog(
+			parent,
+			apiYouTube->GetLastError().isEmpty()
+				? QTStr("YouTube.Actions.Error.General")
+				: QTStr("YouTube.Actions.Error.Text")
+					  .arg(apiYouTube->GetLastError()));
+		Cancel();
+		return;
+	}
+	this->setWindowTitle(channel.title);
+
+	QVector<CategoryDescription> category_list;
+	if (!apiYouTube->GetVideoCategoriesList(
+		    channel.country, channel.language, category_list)) {
+		blog(LOG_DEBUG, "Could not get video category for country; %s.",
+		     channel.country.toStdString().c_str());
+		ShowErrorDialog(
+			parent,
+			apiYouTube->GetLastError().isEmpty()
+				? QTStr("YouTube.Actions.Error.General")
+				: QTStr("YouTube.Actions.Error.Text")
+					  .arg(apiYouTube->GetLastError()));
+		Cancel();
+		return;
+	}
+	for (auto &category : category_list) {
+		ui->categoryBox->addItem(category.title, category.id);
+		if (category.id == IndexOfGamingCategory) {
+			ui->categoryBox->setCurrentText(category.title);
+		}
+	}
+
+	connect(ui->okButton, &QPushButton::clicked, this,
+		&OBSYoutubeActions::InitBroadcast);
+	connect(ui->cancelButton, &QPushButton::clicked, this, [&]() {
+		blog(LOG_DEBUG, "YouTube live broadcast creation cancelled.");
+		// Close the dialog.
+		Cancel();
+	});
+
+	qDeleteAll(ui->scrollAreaWidgetContents->findChildren<QWidget *>(
+		QString(), Qt::FindDirectChildrenOnly));
+
+	connect(workerThread, &WorkerThread::failed, this, &QDialog::reject);
+
+	connect(workerThread, &WorkerThread::new_item, this,
+		[&](const QString &title, const QString &dateTimeString,
+		    const QString &broadcast, bool astart, bool astop) {
+			ClickableLabel *label = new ClickableLabel();
+			label->setStyleSheet(NormalStylesheet);
+			label->setTextFormat(Qt::RichText);
+			label->setText(
+				QString("<big>%1 %2</big><br/>%3 %4")
+					.arg(title,
+					     QTStr("YouTube.Actions.Stream"),
+					     QTStr("YouTube.Actions.Stream.ScheduledFor"),
+					     dateTimeString));
+			label->setAlignment(Qt::AlignHCenter);
+			label->setMargin(4);
+
+			connect(label, &ClickableLabel::clicked, this,
+				[&, label, broadcast, astart, astop]() {
+					for (QWidget *i :
+					     ui->scrollAreaWidgetContents->findChildren<
+						     QWidget *>(
+						     QString(),
+						     Qt::FindDirectChildrenOnly))
+						i->setStyleSheet(
+							NormalStylesheet);
+					label->setStyleSheet(
+						SelectedStylesheet);
+
+					this->selectedBroadcast = broadcast;
+					this->autostart = astart;
+					this->autostop = astop;
+					UpdateOkButtonStatus();
+				});
+			ui->scrollAreaWidgetContents->layout()->addWidget(
+				label);
+		});
+	workerThread->start();
+
+#ifdef __APPLE__
+	// MacOS theming issues
+	this->resize(this->width() + 200, this->height() + 120);
+#endif
+	valid = true;
+}
+
+OBSYoutubeActions::~OBSYoutubeActions()
+{
+	workerThread->stop();
+	workerThread->wait();
+
+	delete workerThread;
+}
+
+void WorkerThread::run()
+{
+	if (!pending)
+		return;
+	json11::Json broadcasts;
+	if (!apiYouTube->GetBroadcastsList(broadcasts, "")) {
+		emit failed();
+		return;
+	}
+
+	while (pending) {
+		auto items = broadcasts["items"].array_items();
+		for (auto item = items.begin(); item != items.end(); item++) {
+			auto status = (*item)["status"]["lifeCycleStatus"]
+					      .string_value();
+			if (status == "created" || status == "ready") {
+				auto title = QString::fromStdString(
+					(*item)["snippet"]["title"]
+						.string_value());
+				auto scheduledStartTime = QString::fromStdString(
+					(*item)["snippet"]["scheduledStartTime"]
+						.string_value());
+				auto broadcast = QString::fromStdString(
+					(*item)["id"].string_value());
+				auto astart = (*item)["contentDetails"]
+						     ["enableAutoStart"]
+							     .bool_value();
+				auto astop = (*item)["contentDetails"]
+						    ["enableAutoStop"]
+							    .bool_value();
+
+				auto utcDTime = QDateTime::fromString(
+					scheduledStartTime,
+					SchedulDateAndTimeFormat);
+				// DateTime parser means that input datetime is a local, so we need to move it
+				auto dateTime = utcDTime.addSecs(
+					utcDTime.offsetFromUtc());
+				auto dateTimeString = QLocale().toString(
+					dateTime,
+					QString("%1  %2").arg(
+						QLocale().dateFormat(
+							QLocale::LongFormat),
+						QLocale().timeFormat(
+							QLocale::ShortFormat)));
+
+				emit new_item(title, dateTimeString, broadcast,
+					      astart, astop);
+			}
+		}
+
+		auto nextPageToken = broadcasts["nextPageToken"].string_value();
+		if (nextPageToken.empty() || items.empty())
+			break;
+		else {
+			if (!pending)
+				return;
+			if (!apiYouTube->GetBroadcastsList(
+				    broadcasts,
+				    QString::fromStdString(nextPageToken))) {
+				emit failed();
+				return;
+			}
+		}
+	}
+
+	emit ready();
+}
+
+void OBSYoutubeActions::UpdateOkButtonStatus()
+{
+	if (ui->tabWidget->currentIndex() == 0) {
+		ui->okButton->setEnabled(
+			!ui->title->text().isEmpty() &&
+			!ui->privacyBox->currentText().isEmpty() &&
+			(ui->yesMakeForKids->isChecked() ||
+			 ui->notMakeForKids->isChecked()));
+		if (ui->checkScheduledLater->checkState() == Qt::Checked) {
+			ui->okButton->setText(
+				QTStr("YouTube.Actions.Create_Save"));
+		} else {
+			ui->okButton->setText(
+				QTStr("YouTube.Actions.Create_GoLive"));
+		}
+
+		ui->pushButton->setVisible(false);
+	} else {
+		ui->okButton->setEnabled(!selectedBroadcast.isEmpty());
+		ui->okButton->setText(QTStr("YouTube.Actions.Choose_GoLive"));
+
+		ui->pushButton->setVisible(true);
+	}
+}
+
+bool OBSYoutubeActions::StreamNowAction(YoutubeApiWrappers *api,
+					StreamDescription &stream)
+{
+	YoutubeApiWrappers *apiYouTube = api;
+	BroadcastDescription broadcast = {};
+	UiToBroadcast(broadcast);
+	// stream now is always autostart/autostop
+	broadcast.auto_start = true;
+	broadcast.auto_stop = true;
+
+	blog(LOG_DEBUG, "Scheduled date and time: %s",
+	     broadcast.schedul_date_time.toStdString().c_str());
+	if (!apiYouTube->InsertBroadcast(broadcast)) {
+		blog(LOG_DEBUG, "No broadcast created.");
+		return false;
+	}
+	stream = {"", "", "OBS Studio Video Stream", ""};
+	if (!apiYouTube->InsertStream(stream)) {
+		blog(LOG_DEBUG, "No stream created.");
+		return false;
+	}
+	if (!apiYouTube->BindStream(broadcast.id, stream.id)) {
+		blog(LOG_DEBUG, "No stream binded.");
+		return false;
+	}
+	if (!apiYouTube->SetVideoCategory(broadcast.id, broadcast.title,
+					  broadcast.description,
+					  broadcast.category.id)) {
+		blog(LOG_DEBUG, "No category set.");
+		return false;
+	}
+	return true;
+}
+
+bool OBSYoutubeActions::StreamLaterAction(YoutubeApiWrappers *api)
+{
+	YoutubeApiWrappers *apiYouTube = api;
+	BroadcastDescription broadcast = {};
+	UiToBroadcast(broadcast);
+
+	// DateTime parser means that input datetime is a local, so we need to move it
+	auto dateTime = ui->scheduledTime->dateTime();
+	auto utcDTime = dateTime.addSecs(-dateTime.offsetFromUtc());
+	broadcast.schedul_date_time =
+		utcDTime.toString(SchedulDateAndTimeFormat);
+
+	blog(LOG_DEBUG, "Scheduled date and time: %s",
+	     broadcast.schedul_date_time.toStdString().c_str());
+	if (!apiYouTube->InsertBroadcast(broadcast)) {
+		blog(LOG_DEBUG, "No broadcast created.");
+		return false;
+	}
+	return true;
+}
+
+bool OBSYoutubeActions::ChooseAnEventAction(YoutubeApiWrappers *api,
+					    StreamDescription &stream,
+					    bool start)
+{
+	YoutubeApiWrappers *apiYouTube = api;
+
+	std::string boundStreamId;
+	{
+		json11::Json json;
+		if (!apiYouTube->FindBroadcast(selectedBroadcast, json)) {
+			blog(LOG_DEBUG, "No broadcast found.");
+			return false;
+		}
+
+		auto item = json["items"].array_items()[0];
+		auto boundStreamId =
+			item["contentDetails"]["boundStreamId"].string_value();
+	}
+
+	stream.id = boundStreamId.c_str();
+	json11::Json json;
+	if (!stream.id.isEmpty() && apiYouTube->FindStream(stream.id, json)) {
+		auto item = json["items"].array_items()[0];
+		auto streamName = item["cdn"]["streamName"].string_value();
+		auto title = item["snippet"]["title"].string_value();
+		auto description =
+			item["snippet"]["description"].string_value();
+
+		stream.name = streamName.c_str();
+		stream.title = title.c_str();
+		stream.description = description.c_str();
+	} else {
+		stream = {"", "", "OBS Studio Video Stream", ""};
+		if (!apiYouTube->InsertStream(stream)) {
+			blog(LOG_DEBUG, "No stream created.");
+			return false;
+		}
+		if (!apiYouTube->BindStream(selectedBroadcast, stream.id)) {
+			blog(LOG_DEBUG, "No stream binded.");
+			return false;
+		}
+	}
+
+	if (start)
+		api->StartBroadcast(selectedBroadcast);
+	return true;
+}
+
+void OBSYoutubeActions::ShowErrorDialog(QWidget *parent, QString text)
+{
+	QMessageBox dlg(parent);
+	dlg.setWindowFlags(dlg.windowFlags() & ~Qt::WindowCloseButtonHint);
+	dlg.setWindowTitle(QTStr("YouTube.Actions.Error.Title"));
+	dlg.setText(text);
+	dlg.setTextFormat(Qt::RichText);
+	dlg.setIcon(QMessageBox::Warning);
+	dlg.setStandardButtons(QMessageBox::StandardButton::Ok);
+	dlg.exec();
+}
+
+void OBSYoutubeActions::InitBroadcast()
+{
+	StreamDescription stream;
+	QMessageBox msgBox(this);
+	msgBox.setWindowFlags(msgBox.windowFlags() &
+			      ~Qt::WindowCloseButtonHint);
+	msgBox.setWindowTitle(QTStr("YouTube.Actions.Notify.Title"));
+	msgBox.setText(QTStr("YouTube.Actions.Notify.CreatingBroadcast"));
+	msgBox.setStandardButtons(QMessageBox::StandardButtons());
+
+	bool success = false;
+	auto action = [&]() {
+		if (ui->tabWidget->currentIndex() == 0) {
+			if (ui->checkScheduledLater->isChecked()) {
+				success = this->StreamLaterAction(apiYouTube);
+			} else {
+				success = this->StreamNowAction(apiYouTube,
+								stream);
+			}
+		} else {
+			success = this->ChooseAnEventAction(apiYouTube, stream,
+							    this->autostart);
+		};
+		QMetaObject::invokeMethod(&msgBox, "accept",
+					  Qt::QueuedConnection);
+	};
+	QScopedPointer<QThread> thread(CreateQThread(action));
+	thread->start();
+	msgBox.exec();
+	thread->wait();
+
+	if (success) {
+		if (ui->tabWidget->currentIndex() == 0) {
+			// Stream later usecase.
+			if (ui->checkScheduledLater->isChecked()) {
+				QMessageBox msg(this);
+				msg.setWindowTitle(QTStr(
+					"YouTube.Actions.EventCreated.Title"));
+				msg.setText(QTStr(
+					"YouTube.Actions.EventCreated.Text"));
+				msg.setStandardButtons(QMessageBox::Ok);
+				msg.exec();
+				// Close dialog without start streaming.
+				Cancel();
+			} else {
+				// Stream now usecase.
+				blog(LOG_DEBUG, "New valid stream: %s",
+				     QT_TO_UTF8(stream.name));
+				emit ok(QT_TO_UTF8(stream.id),
+					QT_TO_UTF8(stream.name), true, true);
+				Accept();
+			}
+		} else {
+			// Stream to precreated broadcast usecase.
+			emit ok(QT_TO_UTF8(stream.id), QT_TO_UTF8(stream.name),
+				autostart, autostop);
+			Accept();
+		}
+	} else {
+		// Fail.
+		auto last_error = apiYouTube->GetLastError();
+		if (last_error.isEmpty()) {
+			last_error = QTStr("YouTube.Actions.Error.YouTubeApi");
+		}
+		ShowErrorDialog(
+			this, QTStr("YouTube.Actions.Error.NoBroadcastCreated")
+				      .arg(last_error));
+	}
+}
+
+void OBSYoutubeActions::UiToBroadcast(BroadcastDescription &broadcast)
+{
+	broadcast.title = ui->title->text();
+	broadcast.description = ui->description->text();
+	broadcast.privacy = ui->privacyBox->currentData().toString();
+	broadcast.category.title = ui->categoryBox->currentText();
+	broadcast.category.id = ui->categoryBox->currentData().toString();
+	broadcast.made_for_kids = ui->yesMakeForKids->isChecked();
+	broadcast.latency = ui->latencyBox->currentData().toString();
+	broadcast.auto_start = ui->checkAutoStart->isChecked();
+	broadcast.auto_stop = ui->checkAutoStop->isChecked();
+	broadcast.dvr = ui->checkDVR->isChecked();
+	broadcast.schedul_for_later = ui->checkScheduledLater->isChecked();
+	broadcast.projection = ui->check360Video->isChecked() ? "360"
+							      : "rectangular";
+	// Current time by default.
+	broadcast.schedul_date_time = QDateTime::currentDateTimeUtc().toString(
+		SchedulDateAndTimeFormat);
+}
+
+void OBSYoutubeActions::OpenYouTubeDashboard()
+{
+	ChannelDescription channel;
+	if (!apiYouTube->GetChannelDescription(channel)) {
+		blog(LOG_DEBUG, "Could not get channel description.");
+		ShowErrorDialog(
+			this,
+			apiYouTube->GetLastError().isEmpty()
+				? QTStr("YouTube.Actions.Error.General")
+				: QTStr("YouTube.Actions.Error.Text")
+					  .arg(apiYouTube->GetLastError()));
+		return;
+	}
+
+	//https://studio.youtube.com/channel/UCA9bSfH3KL186kyiUsvi3IA/videos/live?filter=%5B%5D&sort=%7B%22columnType%22%3A%22date%22%2C%22sortOrder%22%3A%22DESCENDING%22%7D
+	QString uri =
+		QString("https://studio.youtube.com/channel/%1/videos/live?filter=[]&sort={\"columnType\"%3A\"date\"%2C\"sortOrder\"%3A\"DESCENDING\"}")
+			.arg(channel.id);
+	QDesktopServices::openUrl(uri);
+}
+
+void OBSYoutubeActions::Cancel()
+{
+	workerThread->stop();
+	reject();
+}
+void OBSYoutubeActions::Accept()
+{
+	workerThread->stop();
+	accept();
+}

+ 68 - 0
UI/window-youtube-actions.hpp

@@ -0,0 +1,68 @@
+#pragma once
+
+#include <QDialog>
+#include <QString>
+#include <QThread>
+
+#include "ui_OBSYoutubeActions.h"
+#include "youtube-api-wrappers.hpp"
+
+class WorkerThread : public QThread {
+	Q_OBJECT
+public:
+	WorkerThread(YoutubeApiWrappers *api) : QThread(), apiYouTube(api) {}
+
+	void stop() { pending = false; }
+
+protected:
+	YoutubeApiWrappers *apiYouTube;
+	bool pending = true;
+
+public slots:
+	void run() override;
+signals:
+	void ready();
+	void new_item(const QString &title, const QString &dateTimeString,
+		      const QString &broadcast, bool astart, bool astop);
+	void failed();
+};
+
+class OBSYoutubeActions : public QDialog {
+	Q_OBJECT
+
+	std::unique_ptr<Ui::OBSYoutubeActions> ui;
+
+signals:
+	void ok(const QString &id, const QString &key, bool autostart,
+		bool autostop);
+
+protected:
+	void UpdateOkButtonStatus();
+
+	bool StreamNowAction(YoutubeApiWrappers *api,
+			     StreamDescription &stream);
+	bool StreamLaterAction(YoutubeApiWrappers *api);
+	bool ChooseAnEventAction(YoutubeApiWrappers *api,
+				 StreamDescription &stream, bool start);
+
+	void ShowErrorDialog(QWidget *parent, QString text);
+
+public:
+	explicit OBSYoutubeActions(QWidget *parent, Auth *auth);
+	virtual ~OBSYoutubeActions() override;
+
+	bool Valid() { return valid; };
+
+private:
+	void InitBroadcast();
+	void UiToBroadcast(BroadcastDescription &broadcast);
+	void OpenYouTubeDashboard();
+	void Cancel();
+	void Accept();
+
+	QString selectedBroadcast;
+	bool autostart, autostop;
+	bool valid = false;
+	YoutubeApiWrappers *apiYouTube;
+	WorkerThread *workerThread;
+};

+ 478 - 0
UI/youtube-api-wrappers.cpp

@@ -0,0 +1,478 @@
+#include "youtube-api-wrappers.hpp"
+
+#include <QUrl>
+
+#include <string>
+#include <iostream>
+
+#include "auth-youtube.hpp"
+#include "obs-app.hpp"
+#include "qt-wrappers.hpp"
+#include "remote-text.hpp"
+#include "ui-config.h"
+#include "obf.h"
+
+using namespace json11;
+
+/* ------------------------------------------------------------------------- */
+#define YOUTUBE_LIVE_API_URL "https://www.googleapis.com/youtube/v3"
+
+#define YOUTUBE_LIVE_STREAM_URL YOUTUBE_LIVE_API_URL "/liveStreams"
+#define YOUTUBE_LIVE_BROADCAST_URL YOUTUBE_LIVE_API_URL "/liveBroadcasts"
+#define YOUTUBE_LIVE_BROADCAST_TRANSITION_URL \
+	YOUTUBE_LIVE_BROADCAST_URL "/transition"
+#define YOUTUBE_LIVE_BROADCAST_BIND_URL YOUTUBE_LIVE_BROADCAST_URL "/bind"
+
+#define YOUTUBE_LIVE_CHANNEL_URL YOUTUBE_LIVE_API_URL "/channels"
+#define YOUTUBE_LIVE_TOKEN_URL "https://oauth2.googleapis.com/token"
+#define YOUTUBE_LIVE_VIDEOCATEGORIES_URL YOUTUBE_LIVE_API_URL "/videoCategories"
+#define YOUTUBE_LIVE_VIDEOS_URL YOUTUBE_LIVE_API_URL "/videos"
+
+#define DEFAULT_BROADCASTS_PER_QUERY \
+	"7" // acceptable values are 0 to 50, inclusive
+/* ------------------------------------------------------------------------- */
+
+bool IsYouTubeService(const std::string &service)
+{
+	auto it = find_if(youtubeServices.begin(), youtubeServices.end(),
+			  [&service](const Auth::Def &yt) {
+				  return service == yt.service;
+			  });
+	return it != youtubeServices.end();
+}
+
+YoutubeApiWrappers::YoutubeApiWrappers(const Def &d) : YoutubeAuth(d) {}
+
+bool YoutubeApiWrappers::TryInsertCommand(const char *url,
+					  const char *content_type,
+					  std::string request_type,
+					  const char *data, Json &json_out,
+					  long *error_code)
+{
+	if (error_code)
+		*error_code = 0;
+#ifdef _DEBUG
+	blog(LOG_DEBUG, "YouTube API command URL: %s", url);
+	blog(LOG_DEBUG, "YouTube API command data: %s", data);
+#endif
+	if (token.empty())
+		return false;
+	std::string output;
+	std::string error;
+	bool success = GetRemoteFile(url, output, error, error_code,
+				     content_type, request_type, data,
+				     {"Authorization: Bearer " + token},
+				     nullptr, 5);
+
+	if (!success || output.empty())
+		return false;
+	json_out = Json::parse(output, error);
+#ifdef _DEBUG
+	blog(LOG_DEBUG, "YouTube API command answer: %s",
+	     json_out.dump().c_str());
+#endif
+	if (!error.empty()) {
+		return false;
+	}
+	return true;
+}
+
+bool YoutubeApiWrappers::UpdateAccessToken()
+{
+	if (refresh_token.empty()) {
+		return false;
+	}
+
+	std::string clientid = YOUTUBE_CLIENTID;
+	std::string secret = YOUTUBE_SECRET;
+	deobfuscate_str(&clientid[0], YOUTUBE_CLIENTID_HASH);
+	deobfuscate_str(&secret[0], YOUTUBE_SECRET_HASH);
+
+	std::string r_token =
+		QUrl::toPercentEncoding(refresh_token.c_str()).toStdString();
+	const QString url = YOUTUBE_LIVE_TOKEN_URL;
+	const QString data_template = "client_id=%1"
+				      "&client_secret=%2"
+				      "&refresh_token=%3"
+				      "&grant_type=refresh_token";
+	const QString data = data_template.arg(QString(clientid.c_str()),
+					       QString(secret.c_str()),
+					       QString(r_token.c_str()));
+	Json json_out;
+	bool success = TryInsertCommand(QT_TO_UTF8(url),
+					"application/x-www-form-urlencoded", "",
+					QT_TO_UTF8(data), json_out);
+
+	if (!success || json_out.object_items().find("error") !=
+				json_out.object_items().end())
+		return false;
+	token = json_out["access_token"].string_value();
+	return token.empty() ? false : true;
+}
+
+bool YoutubeApiWrappers::InsertCommand(const char *url,
+				       const char *content_type,
+				       std::string request_type,
+				       const char *data, Json &json_out)
+{
+	long error_code;
+	if (!TryInsertCommand(url, content_type, request_type, data, json_out,
+			      &error_code)) {
+		if (error_code == 401) {
+			if (!UpdateAccessToken()) {
+				return false;
+			}
+			//The second try after update token.
+			return TryInsertCommand(url, content_type, request_type,
+						data, json_out);
+		}
+		return false;
+	}
+	if (json_out.object_items().find("error") !=
+	    json_out.object_items().end()) {
+		lastError = json_out["error"]["code"].int_value();
+		lastErrorMessage = QString(
+			json_out["error"]["message"].string_value().c_str());
+		if (json_out["error"]["code"] == 401) {
+			if (!UpdateAccessToken()) {
+				return false;
+			}
+			//The second try after update token.
+			return TryInsertCommand(url, content_type, request_type,
+						data, json_out);
+		}
+		return false;
+	}
+	return true;
+}
+
+bool YoutubeApiWrappers::GetChannelDescription(
+	ChannelDescription &channel_description)
+{
+	lastErrorMessage.clear();
+	const QByteArray url = YOUTUBE_LIVE_CHANNEL_URL
+		"?part=snippet,contentDetails,statistics"
+		"&mine=true";
+	Json json_out;
+	if (!InsertCommand(url, "application/json", "", nullptr, json_out)) {
+		return false;
+	}
+	channel_description.id =
+		QString(json_out["items"][0]["id"].string_value().c_str());
+	channel_description.country =
+		QString(json_out["items"][0]["snippet"]["country"]
+				.string_value()
+				.c_str());
+	channel_description.language =
+		QString(json_out["items"][0]["snippet"]["defaultLanguage"]
+				.string_value()
+				.c_str());
+	channel_description.title = QString(
+		json_out["items"][0]["snippet"]["title"].string_value().c_str());
+	return channel_description.id.isEmpty() ? false : true;
+}
+
+bool YoutubeApiWrappers::InsertBroadcast(BroadcastDescription &broadcast)
+{
+	// Youtube API: The Title property's value must be between 1 and 100 characters long.
+	if (broadcast.title.isEmpty() || broadcast.title.length() > 100) {
+		blog(LOG_ERROR, "Insert broadcast FAIL: Wrong title.");
+		lastErrorMessage = "Broadcast title too long.";
+		return false;
+	}
+	// Youtube API: The property's value can contain up to 5000 characters.
+	if (broadcast.description.length() > 5000) {
+		blog(LOG_ERROR, "Insert broadcast FAIL: Description too long.");
+		lastErrorMessage = "Broadcast description too long.";
+		return false;
+	}
+	const QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
+		"?part=snippet,status,contentDetails";
+	const Json data = Json::object{
+		{"snippet",
+		 Json::object{
+			 {"title", QT_TO_UTF8(broadcast.title)},
+			 {"description", QT_TO_UTF8(broadcast.description)},
+			 {"scheduledStartTime",
+			  QT_TO_UTF8(broadcast.schedul_date_time)},
+		 }},
+		{"status",
+		 Json::object{
+			 {"privacyStatus", QT_TO_UTF8(broadcast.privacy)},
+			 {"selfDeclaredMadeForKids", broadcast.made_for_kids},
+		 }},
+		{"contentDetails",
+		 Json::object{
+			 {"latencyPreference", QT_TO_UTF8(broadcast.latency)},
+			 {"enableAutoStart", broadcast.auto_start},
+			 {"enableAutoStop", broadcast.auto_stop},
+			 {"enableDvr", broadcast.dvr},
+			 {"projection", QT_TO_UTF8(broadcast.projection)},
+			 {
+				 "monitorStream",
+				 Json::object{
+					 {"enableMonitorStream", false},
+				 },
+			 },
+		 }},
+	};
+	Json json_out;
+	if (!InsertCommand(url, "application/json", "", data.dump().c_str(),
+			   json_out)) {
+		return false;
+	}
+	broadcast.id = QString(json_out["id"].string_value().c_str());
+	return broadcast.id.isEmpty() ? false : true;
+}
+
+bool YoutubeApiWrappers::InsertStream(StreamDescription &stream)
+{
+	// Youtube API documentation: The snippet.title property's value in the liveStream resource must be between 1 and 128 characters long.
+	if (stream.title.isEmpty() || stream.title.length() > 128) {
+		blog(LOG_ERROR, "Insert stream FAIL: wrong argument");
+		return false;
+	}
+	// Youtube API: The snippet.description property's value in the liveStream resource can have up to 10000 characters.
+	if (stream.description.length() > 10000) {
+		blog(LOG_ERROR, "Insert stream FAIL: Description too long.");
+		return false;
+	}
+	const QByteArray url = YOUTUBE_LIVE_STREAM_URL
+		"?part=snippet,cdn,status";
+	const Json data = Json::object{
+		{"snippet",
+		 Json::object{
+			 {"title", QT_TO_UTF8(stream.title)},
+		 }},
+		{"cdn",
+		 Json::object{
+			 {"frameRate", "variable"},
+			 {"ingestionType", "rtmp"},
+			 {"resolution", "variable"},
+		 }},
+	};
+	Json json_out;
+	if (!InsertCommand(url, "application/json", "", data.dump().c_str(),
+			   json_out)) {
+		return false;
+	}
+	stream.id = QString(json_out["id"].string_value().c_str());
+	stream.name = QString(json_out["cdn"]["ingestionInfo"]["streamName"]
+				      .string_value()
+				      .c_str());
+	return stream.id.isEmpty() ? false : true;
+}
+
+bool YoutubeApiWrappers::BindStream(const QString broadcast_id,
+				    const QString stream_id)
+{
+	const QString url_template = YOUTUBE_LIVE_BROADCAST_BIND_URL
+		"?id=%1"
+		"&streamId=%2"
+		"&part=id,snippet,contentDetails,status";
+	const QString url = url_template.arg(broadcast_id, stream_id);
+	const Json data = Json::object{};
+	this->broadcast_id = broadcast_id;
+	Json json_out;
+	return InsertCommand(QT_TO_UTF8(url), "application/json", "",
+			     data.dump().c_str(), json_out);
+}
+
+bool YoutubeApiWrappers::GetBroadcastsList(Json &json_out, QString page)
+{
+	lastErrorMessage.clear();
+	QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
+		"?part=snippet,contentDetails,status"
+		"&broadcastType=all&maxResults=" DEFAULT_BROADCASTS_PER_QUERY
+		"&mine=true";
+	if (!page.isEmpty())
+		url += "&pageToken=" + page.toUtf8();
+	return InsertCommand(url, "application/json", "", nullptr, json_out);
+}
+
+bool YoutubeApiWrappers::GetVideoCategoriesList(
+	const QString &country, const QString &language,
+	QVector<CategoryDescription> &category_list_out)
+{
+	const QString url_template = YOUTUBE_LIVE_VIDEOCATEGORIES_URL
+		"?part=snippet"
+		"&regionCode=%1"
+		"&hl=%2";
+	const QString url =
+		url_template.arg(country.isEmpty() ? "US" : country,
+				 language.isEmpty() ? "en" : language);
+	Json json_out;
+	if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr,
+			   json_out)) {
+		return false;
+	}
+	category_list_out = {};
+	for (auto &j : json_out["items"].array_items()) {
+		// Assignable only.
+		if (j["snippet"]["assignable"].bool_value()) {
+			category_list_out.push_back(
+				{j["id"].string_value().c_str(),
+				 j["snippet"]["title"].string_value().c_str()});
+		}
+	}
+	return category_list_out.isEmpty() ? false : true;
+}
+
+bool YoutubeApiWrappers::SetVideoCategory(const QString &video_id,
+					  const QString &video_title,
+					  const QString &video_description,
+					  const QString &categorie_id)
+{
+	const QByteArray url = YOUTUBE_LIVE_VIDEOS_URL "?part=snippet";
+	const Json data = Json::object{
+		{"id", QT_TO_UTF8(video_id)},
+		{"snippet",
+		 Json::object{
+			 {"title", QT_TO_UTF8(video_title)},
+			 {"description", QT_TO_UTF8(video_description)},
+			 {"categoryId", QT_TO_UTF8(categorie_id)},
+		 }},
+	};
+	Json json_out;
+	return InsertCommand(url, "application/json", "PUT",
+			     data.dump().c_str(), json_out);
+}
+
+bool YoutubeApiWrappers::StartBroadcast(const QString &broadcast_id)
+{
+	lastErrorMessage.clear();
+
+	if (!ResetBroadcast(broadcast_id))
+		return false;
+
+	const QString url_template = YOUTUBE_LIVE_BROADCAST_TRANSITION_URL
+		"?id=%1"
+		"&broadcastStatus=%2"
+		"&part=status";
+	const QString live = url_template.arg(broadcast_id, "live");
+	Json json_out;
+	return InsertCommand(QT_TO_UTF8(live), "application/json", "POST", "{}",
+			     json_out);
+}
+
+bool YoutubeApiWrappers::StartLatestBroadcast()
+{
+	return StartBroadcast(this->broadcast_id);
+}
+
+bool YoutubeApiWrappers::StopBroadcast(const QString &broadcast_id)
+{
+	lastErrorMessage.clear();
+
+	const QString url_template = YOUTUBE_LIVE_BROADCAST_TRANSITION_URL
+		"?id=%1"
+		"&broadcastStatus=complete"
+		"&part=status";
+	const QString url = url_template.arg(broadcast_id);
+	Json json_out;
+	return InsertCommand(QT_TO_UTF8(url), "application/json", "POST", "{}",
+			     json_out);
+}
+
+bool YoutubeApiWrappers::StopLatestBroadcast()
+{
+	return StopBroadcast(this->broadcast_id);
+}
+
+bool YoutubeApiWrappers::ResetBroadcast(const QString &broadcast_id)
+{
+	lastErrorMessage.clear();
+
+	const QString url_template = YOUTUBE_LIVE_BROADCAST_URL
+		"?part=id,snippet,contentDetails,status"
+		"&id=%1";
+	const QString url = url_template.arg(broadcast_id);
+	Json json_out;
+
+	if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr,
+			   json_out))
+		return false;
+
+	const QString put = YOUTUBE_LIVE_BROADCAST_URL
+		"?part=id,snippet,contentDetails,status";
+
+	auto snippet = json_out["items"][0]["snippet"];
+	auto status = json_out["items"][0]["status"];
+	auto contentDetails = json_out["items"][0]["contentDetails"];
+	auto monitorStream = contentDetails["monitorStream"];
+
+	const Json data = Json::object{
+		{"id", QT_TO_UTF8(broadcast_id)},
+		{"snippet",
+		 Json::object{
+			 {"title", snippet["title"]},
+			 {"scheduledStartTime", snippet["scheduledStartTime"]},
+		 }},
+		{"status",
+		 Json::object{
+			 {"privacyStatus", status["privacyStatus"]},
+			 {"madeForKids", status["madeForKids"]},
+			 {"selfDeclaredMadeForKids",
+			  status["selfDeclaredMadeForKids"]},
+		 }},
+		{"contentDetails",
+		 Json::object{
+			 {
+				 "monitorStream",
+				 Json::object{
+					 {"enableMonitorStream", false},
+					 {"broadcastStreamDelayMs",
+					  monitorStream["broadcastStreamDelayMs"]},
+				 },
+			 },
+			 {"enableDvr", contentDetails["enableDvr"]},
+			 {"enableContentEncryption",
+			  contentDetails["enableContentEncryption"]},
+			 {"enableEmbed", contentDetails["enableEmbed"]},
+			 {"recordFromStart", contentDetails["recordFromStart"]},
+			 {"startWithSlate", contentDetails["startWithSlate"]},
+		 }},
+	};
+	return InsertCommand(QT_TO_UTF8(put), "application/json", "PUT",
+			     data.dump().c_str(), json_out);
+}
+
+bool YoutubeApiWrappers::FindBroadcast(const QString &id,
+				       json11::Json &json_out)
+{
+	lastErrorMessage.clear();
+	QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
+		"?part=id,snippet,contentDetails,status"
+		"&broadcastType=all&maxResults=1";
+	url += "&id=" + id.toUtf8();
+
+	if (!InsertCommand(url, "application/json", "", nullptr, json_out))
+		return false;
+
+	auto items = json_out["items"].array_items();
+	if (items.size() != 1) {
+		lastErrorMessage = "No active broadcast found.";
+		return false;
+	}
+
+	return true;
+}
+
+bool YoutubeApiWrappers::FindStream(const QString &id, json11::Json &json_out)
+{
+	lastErrorMessage.clear();
+	QByteArray url = YOUTUBE_LIVE_STREAM_URL "?part=id,snippet,cdn,status"
+						 "&maxResults=1";
+	url += "&id=" + id.toUtf8();
+
+	if (!InsertCommand(url, "application/json", "", nullptr, json_out))
+		return false;
+
+	auto items = json_out["items"].array_items();
+	if (items.size() != 1) {
+		lastErrorMessage = "No active broadcast found.";
+		return false;
+	}
+
+	return true;
+}

+ 92 - 0
UI/youtube-api-wrappers.hpp

@@ -0,0 +1,92 @@
+#pragma once
+
+#include "auth-youtube.hpp"
+
+#include <json11.hpp>
+#include <QString>
+
+struct ChannelDescription {
+	QString id;
+	QString title;
+	QString country;
+	QString language;
+};
+
+struct StreamDescription {
+	QString id;
+	QString name;
+	QString title;
+	QString description;
+};
+
+struct CategoryDescription {
+	QString id;
+	QString title;
+};
+
+struct BroadcastDescription {
+	QString id;
+	QString title;
+	QString description;
+	QString privacy;
+	CategoryDescription category;
+	QString latency;
+	bool made_for_kids;
+	bool auto_start;
+	bool auto_stop;
+	bool dvr;
+	bool schedul_for_later;
+	QString schedul_date_time;
+	QString projection;
+};
+
+struct BindDescription {
+	const QString id;
+	const QString stream_name;
+};
+
+bool IsYouTubeService(const std::string &service);
+
+class YoutubeApiWrappers : public YoutubeAuth {
+	Q_OBJECT
+
+	bool TryInsertCommand(const char *url, const char *content_type,
+			      std::string request_type, const char *data,
+			      json11::Json &ret, long *error_code = nullptr);
+	bool UpdateAccessToken();
+	bool InsertCommand(const char *url, const char *content_type,
+			   std::string request_type, const char *data,
+			   json11::Json &ret);
+
+public:
+	YoutubeApiWrappers(const Def &d);
+
+	bool GetChannelDescription(ChannelDescription &channel_description);
+	bool InsertBroadcast(BroadcastDescription &broadcast);
+	bool InsertStream(StreamDescription &stream);
+	bool BindStream(const QString broadcast_id, const QString stream_id);
+	bool GetBroadcastsList(json11::Json &json_out, QString page);
+	bool
+	GetVideoCategoriesList(const QString &country, const QString &language,
+			       QVector<CategoryDescription> &category_list_out);
+	bool SetVideoCategory(const QString &video_id,
+			      const QString &video_title,
+			      const QString &video_description,
+			      const QString &categorie_id);
+	bool StartBroadcast(const QString &broadcast_id);
+	bool StopBroadcast(const QString &broadcast_id);
+	bool ResetBroadcast(const QString &broadcast_id);
+	bool StartLatestBroadcast();
+	bool StopLatestBroadcast();
+
+	bool FindBroadcast(const QString &id, json11::Json &json_out);
+	bool FindStream(const QString &id, json11::Json &json_out);
+
+	QString GetLastError() { return lastErrorMessage; };
+
+private:
+	QString broadcast_id;
+
+	int lastError;
+	QString lastErrorMessage;
+};