diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3ab225b66..75c10d668 100644 --- a/.github/workflows/main.yml +++ b/.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: diff --git a/UI/CMakeLists.txt b/UI/CMakeLists.txt index 4f2666492..28c5834ff 100644 --- a/UI/CMakeLists.txt +++ b/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}) diff --git a/UI/auth-youtube.cpp b/UI/auth-youtube.cpp new file mode 100644 index 000000000..76c11ed1a --- /dev/null +++ b/UI/auth-youtube.cpp @@ -0,0 +1,209 @@ +#include "auth-youtube.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef WIN32 +#include +#include + +#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(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( + 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( + allowedChars[distr(randomSeed)]); + }); + return result.c_str(); +} + +// Static. +std::shared_ptr 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(*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("Google OAuth Service").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 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; +} diff --git a/UI/auth-youtube.hpp b/UI/auth-youtube.hpp new file mode 100644 index 000000000..db67f77d6 --- /dev/null +++ b/UI/auth-youtube.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +#include "auth-oauth.hpp" + +const std::vector 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 Login(QWidget *parent, + const std::string &service); +}; diff --git a/UI/data/locale/en-US.ini b/UI/data/locale/en-US.ini index 91087263b..75ad31715 100644 --- a/UI/data/locale/en-US.ini +++ b/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.
If the external browser does not open, follow this link and complete the authorization:
%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="(?)" +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="(?)" +YouTube.Actions.EnableDVR="Enable DVR" +YouTube.Actions.360Video="360 video" +YouTube.Actions.360Video.Help="(?)" +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'.
A detailed error description can be found at https://developers.google.com/youtube/v3/live/docs/errors" +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'.
A detailed error description can be found at https://developers.google.com/youtube/v3/live/docs/errors" +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.
Your stream will stop and you will no longer be live." diff --git a/UI/forms/AutoConfigStreamPage.ui b/UI/forms/AutoConfigStreamPage.ui index 18c30c37d..3ea83e9db 100644 --- a/UI/forms/AutoConfigStreamPage.ui +++ b/UI/forms/AutoConfigStreamPage.ui @@ -418,18 +418,14 @@ + + PointingHandCursor + Basic.AutoConfig.StreamPage.ConnectAccount - - - - Basic.AutoConfig.StreamPage.DisconnectAccount - - - @@ -443,6 +439,63 @@ + + + + 7 + + + 7 + + + + + + 75 + true + + + + Auth.LoadingChannel.Title + + + + + + + Basic.AutoConfig.StreamPage.DisconnectAccount + + + + + + + + + Basic.AutoConfig.StreamPage.ConnectedAccount + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced + + + diff --git a/UI/forms/OBSBasic.ui b/UI/forms/OBSBasic.ui index 2dcf159ec..3a63e182c 100644 --- a/UI/forms/OBSBasic.ui +++ b/UI/forms/OBSBasic.ui @@ -1280,29 +1280,58 @@ 4 - - - true - - - - 0 - 0 - - - - - 150 - 0 - - - - Basic.Main.StartStreaming - - - true - - + + + + + true + + + + 0 + 0 + + + + + 150 + 0 + + + + Basic.Main.StartStreaming + + + true + + + + + + + true + + + + 0 + 0 + + + + + 150 + 0 + + + + Basic.Main.StartBroadcast + + + true + + + + diff --git a/UI/forms/OBSBasicSettings.ui b/UI/forms/OBSBasicSettings.ui index 28c07cdb4..7a276d301 100644 --- a/UI/forms/OBSBasicSettings.ui +++ b/UI/forms/OBSBasicSettings.ui @@ -1115,32 +1115,27 @@ - - + + + + 8 + + + 7 + + + 7 + - + + + font-weight: bold + - Basic.AutoConfig.StreamPage.ConnectAccount + Auth.LoadingChannel.Title - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - @@ -1163,21 +1158,21 @@ - + Basic.Settings.Stream.BandwidthTestMode - + Basic.Settings.Stream.Custom.UseAuthentication - + Basic.Settings.Stream.Custom.Username @@ -1187,10 +1182,10 @@ - + - + Basic.Settings.Stream.Custom.Password @@ -1200,7 +1195,7 @@ - + @@ -1232,10 +1227,10 @@ - + - + Basic.Settings.Stream.TTVAddon @@ -1245,20 +1240,78 @@ - + Basic.Settings.Stream.IgnoreRecommended - + + + + + + + Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Basic.AutoConfig.StreamPage.ConnectedAccount + + + + + + + + + PointingHandCursor + + + Basic.AutoConfig.StreamPage.ConnectAccount + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/UI/forms/OBSYoutubeActions.ui b/UI/forms/OBSYoutubeActions.ui new file mode 100644 index 000000000..eb5613225 --- /dev/null +++ b/UI/forms/OBSYoutubeActions.ui @@ -0,0 +1,601 @@ + + + OBSYoutubeActions + + + + 0 + 0 + 583 + 452 + + + + + 0 + 0 + + + + + 465 + 346 + + + + YouTube Actions + + + + + + 0 + + + + YouTube.Actions.CreateNewEvent + + + + QFormLayout::ExpandingFieldsGrow + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + YouTube.Actions.Title + + + + + + + YouTube.Actions.MyBroadcast + + + 100 + + + + + + + YouTube.Actions.Description + + + + + + + 5000 + + + + + + + YouTube.Actions.Privacy + + + + + + + + 0 + 0 + + + + QComboBox::AdjustToContentsOnFirstShow + + + + + + + YouTube.Actions.Category + + + + + + + + 0 + 0 + + + + + + + + YouTube.Actions.MadeForKids + + + + + + + + + YouTube.Actions.MadeForKids.No + + + + + + + + 0 + 0 + + + + YouTube.Actions.MadeForKids.Help + + + Qt::RichText + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + YouTube.Actions.MadeForKids.Yes + + + + + + + + + YouTube.Actions.AdditionalSettings + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + YouTube.Actions.Latency + + + + + + + + 0 + 0 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + YouTube.Actions.EnableAutoStart + + + true + + + true + + + + + + + + 0 + 0 + + + + YouTube.Actions.AutoStartStop.Help + + + Qt::RichText + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + YouTube.Actions.EnableAutoStop + + + true + + + + + + + + + + + true + + + YouTube.Actions.ScheduleForLater + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + true + + + + 0 + 0 + + + + true + + + + + + + + + + + YouTube.Actions.EnableDVR + + + true + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + YouTube.Actions.360Video + + + + + + + + 0 + 0 + + + + YouTube.Actions.360Video.Help + + + Qt::RichText + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + YouTube.Actions.ChooseEvent + + + + + + Qt::ScrollBarAlwaysOn + + + true + + + + + 0 + 0 + 179 + 192 + + + + + 0 + 0 + + + + + + + border: 1px solid black; + + + <big>Friday Fortnite Stream</big><br/>scheduled for 11/11/20 2:00pm + + + Qt::RichText + + + Qt::AlignCenter + + + 4 + + + + + + + border: 1px solid black; + + + <big>Friday Fortnite Stream</big><br/>scheduled for 11/11/20 2:00pm + + + Qt::RichText + + + Qt::AlignCenter + + + 4 + + + + + + + border: 1px solid black; + + + <big>Friday Fortnite Stream</big><br/>scheduled for 11/11/20 2:00pm + + + Qt::RichText + + + Qt::AlignCenter + + + 4 + + + + + + + border: 1px solid black; + + + <big>Friday Fortnite Stream</big><br/>scheduled for 11/11/20 2:00pm + + + Qt::RichText + + + Qt::AlignCenter + + + 4 + + + + + + + + + + + + + + + + + Cancel + + + + + + + YouTube.Actions.Dashboard + + + + + + + YouTube.Actions.Create_GoLive + + + + + + + + + + diff --git a/UI/ui-config.h.in b/UI/ui-config.h.in index 0fcc4845a..0d04fbfd5 100644 --- a/UI/ui-config.h.in +++ b/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" diff --git a/UI/window-basic-auto-config.cpp b/UI/window-basic-auto-config.cpp index 2b1d01a0c..825630424 100644 --- a/UI/window-basic-auto-config.cpp +++ b/UI/window-basic-auto-config.cpp @@ -15,7 +15,12 @@ #ifdef BROWSER_AVAILABLE #include +#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(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 thread(CreateQThread([&]() { + std::shared_ptr 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(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(); } diff --git a/UI/window-basic-auto-config.hpp b/UI/window-basic-auto-config.hpp index e51cd59eb..69b69bc43 100644 --- a/UI/window-basic-auto-config.hpp +++ b/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 { diff --git a/UI/window-basic-main.cpp b/UI/window-basic-main.cpp index 425a19ba1..e5cdb59d5 100644 --- a/UI/window-basic-main.cpp +++ b/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 . ******************************************************************************/ +#include "ui-config.h" #include #include @@ -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(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 ytAuth = + dynamic_pointer_cast(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 ytAuth = + dynamic_pointer_cast(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; diff --git a/UI/window-basic-main.hpp b/UI/window-basic-main.hpp index 28076da4d..2c75043cd 100644 --- a/UI/window-basic-main.hpp +++ b/UI/window-basic-main.hpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -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 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(); diff --git a/UI/window-basic-settings-stream.cpp b/UI/window-basic-settings-stream.cpp index b7e43ee38..44db7e9c2 100644 --- a/UI/window-basic-settings-stream.cpp +++ b/UI/window-basic-settings-stream.cpp @@ -10,7 +10,14 @@ #ifdef BROWSER_AVAILABLE #include +#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(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 ytAuth = + std::dynamic_pointer_cast( + auth); + auto act = [&]() { + get_yt_ch_title(ui.get(), ytAuth.get()); + }; + + QScopedPointer 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() diff --git a/UI/window-basic-settings.cpp b/UI/window-basic-settings.cpp index 5d3ecb680..e8e6d2658 100644 --- a/UI/window-basic-settings.cpp +++ b/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() diff --git a/UI/window-basic-settings.hpp b/UI/window-basic-settings.hpp index 4b5d352f2..a807aed29 100644 --- a/UI/window-basic-settings.hpp +++ b/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; diff --git a/UI/window-youtube-actions.cpp b/UI/window-youtube-actions.cpp new file mode 100644 index 000000000..f17ccc860 --- /dev/null +++ b/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 +#include + +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(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 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( + 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("%1 %2
%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 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(); +} diff --git a/UI/window-youtube-actions.hpp b/UI/window-youtube-actions.hpp new file mode 100644 index 000000000..c48404569 --- /dev/null +++ b/UI/window-youtube-actions.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include + +#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; + +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; +}; diff --git a/UI/youtube-api-wrappers.cpp b/UI/youtube-api-wrappers.cpp new file mode 100644 index 000000000..ac121943a --- /dev/null +++ b/UI/youtube-api-wrappers.cpp @@ -0,0 +1,478 @@ +#include "youtube-api-wrappers.hpp" + +#include + +#include +#include + +#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 &category_list_out) +{ + const QString url_template = YOUTUBE_LIVE_VIDEOCATEGORIES_URL + "?part=snippet" + "®ionCode=%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; +} diff --git a/UI/youtube-api-wrappers.hpp b/UI/youtube-api-wrappers.hpp new file mode 100644 index 000000000..317691f25 --- /dev/null +++ b/UI/youtube-api-wrappers.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include "auth-youtube.hpp" + +#include +#include + +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 &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; +};