From a5050d4810d1cc938a9a80f279225debcb2aef5e Mon Sep 17 00:00:00 2001 From: WarmUpTill Date: Wed, 16 Aug 2023 16:33:13 +0200 Subject: [PATCH] Add Twitch action Allows you to set the stream title and stream category --- .github/scripts/.Wingetfile | 1 + data/locale/en-US.ini | 38 ++ data/res/images/DarkSearch.svg | 8 + data/res/images/LightSearch.svg | 8 + src/macro-external/CMakeLists.txt | 5 + src/macro-external/twitch/CMakeLists.txt | 74 +++ .../twitch/category-selection.cpp | 363 ++++++++++ .../twitch/category-selection.hpp | 129 ++++ .../twitch/macro-action-twitch.cpp | 242 +++++++ .../twitch/macro-action-twitch.hpp | 82 +++ src/macro-external/twitch/token.cpp | 623 ++++++++++++++++++ src/macro-external/twitch/token.hpp | 140 ++++ src/macro-external/twitch/twitch-helpers.cpp | 76 +++ src/macro-external/twitch/twitch-helpers.hpp | 25 + 14 files changed, 1814 insertions(+) create mode 100644 data/res/images/DarkSearch.svg create mode 100644 data/res/images/LightSearch.svg create mode 100644 src/macro-external/twitch/CMakeLists.txt create mode 100644 src/macro-external/twitch/category-selection.cpp create mode 100644 src/macro-external/twitch/category-selection.hpp create mode 100644 src/macro-external/twitch/macro-action-twitch.cpp create mode 100644 src/macro-external/twitch/macro-action-twitch.hpp create mode 100644 src/macro-external/twitch/token.cpp create mode 100644 src/macro-external/twitch/token.hpp create mode 100644 src/macro-external/twitch/twitch-helpers.cpp create mode 100644 src/macro-external/twitch/twitch-helpers.hpp diff --git a/.github/scripts/.Wingetfile b/.github/scripts/.Wingetfile index 4e7c46e4..39ed656c 100644 --- a/.github/scripts/.Wingetfile +++ b/.github/scripts/.Wingetfile @@ -1,3 +1,4 @@ package '7zip.7zip', path: '7-zip', bin: '7z' package 'cmake', path: 'Cmake\bin', bin: 'cmake' package 'innosetup', path: 'Inno Setup 6', bin: 'iscc' +package 'OpenSSL', path: 'OpenSSL', bin: 'openssl' diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index c202101c..c37010ac 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -767,6 +767,11 @@ AdvSceneSwitcher.action.sceneLock.type.lock="lock" AdvSceneSwitcher.action.sceneLock.type.unlock="unlock" AdvSceneSwitcher.action.sceneLock.type.toggle="toggle lock of" AdvSceneSwitcher.action.sceneLock.entry="On{{scenes}}{{actions}}{{sources}}" +AdvSceneSwitcher.action.twitch="Twitch" +AdvSceneSwitcher.action.twitch.type.title="Set stream title" +AdvSceneSwitcher.action.twitch.type.category="Set stream category" +AdvSceneSwitcher.action.twitch.categorySelectionDisabled="Cannot select category without selecting a Twitch account first!" +AdvSceneSwitcher.action.twitch.entry="On{{account}}{{actions}}{{text}}{{category}}{{manualCategorySearch}}" ; Transition Tab AdvSceneSwitcher.transitionTab.title="Transition" @@ -1131,6 +1136,39 @@ AdvSceneSwitcher.osc.message.type.false="False" AdvSceneSwitcher.osc.message.type.infinity="Infinitum" AdvSceneSwitcher.osc.message.type.null="Nil" +AdvSceneSwitcher.twitchToken.name="Account name:" +AdvSceneSwitcher.twitchToken.nameNotAvailable="Account already in use" +AdvSceneSwitcher.twitchToken.select="--select Twitch connection--" +AdvSceneSwitcher.twitchToken.add="Add new connection" +AdvSceneSwitcher.twitchToken.configure="Configure Twitch connection settings" +AdvSceneSwitcher.twitchToken.value="Token:" +AdvSceneSwitcher.twitchToken.invalid="Invalid twitch token" +AdvSceneSwitcher.twitchToken.request="Request token" +AdvSceneSwitcher.twitchToken.request.waiting="Waiting for token approval ..." +AdvSceneSwitcher.twitchToken.request.fail="Failed to get token!" +AdvSceneSwitcher.twitchToken.request.fail.browser="Authentication failed! (%1)\nYou can close this window now." +AdvSceneSwitcher.twitchToken.request.fail.stateMismatch="State mismatch" +AdvSceneSwitcher.twitchToken.request.success="Successfully received token!" +AdvSceneSwitcher.twitchToken.request.success.browser="Authentication successful! You can close this window now." +AdvSceneSwitcher.twitchToken.request.notSet="No token set - Please request new token!" +AdvSceneSwitcher.twitchToken.permissions="Token permissions:" +AdvSceneSwitcher.twitchToken.analytics.readExtensions="View analytics data for the Twitch Extensions owned by the authenticated account." +AdvSceneSwitcher.twitchToken.analytics.readGames="View analytics data for the games owned by the authenticated account." +AdvSceneSwitcher.twitchToken.bits.read="View Bits information for a channel." +AdvSceneSwitcher.twitchToken.channel.manageBroadcast="Manage a channel’s broadcast configuration, including updating channel configuration and managing stream markers and stream tags." + +AdvSceneSwitcher.twitchCategories.fetchStart="Fetching stream categories ..." +AdvSceneSwitcher.twitchCategories.fetchStatus="Got %1 stream categories." +AdvSceneSwitcher.twitchCategories.fetchSkip="Skip fetching more stream categories" +AdvSceneSwitcher.twitchCategories.fetchStop="Stop" +AdvSceneSwitcher.twitchCategories.search="Search for stream category ..." +AdvSceneSwitcher.twitchCategories.name="Category name:" +AdvSceneSwitcher.twitchCategories.manualSearch="Search for additional category and it add to the selection list" +AdvSceneSwitcher.twitchCategories.noViewersCategoriesMissing="Categories without any viewers will have to be searched for manually" +AdvSceneSwitcher.twitchCategories.searchFailed="No new categories were found for \"%1\"." +AdvSceneSwitcher.twitchCategories.searchSuccess="%1 new categories were found for \"%2\" and were added to the list!" +AdvSceneSwitcher.twitchCategories.select="--select category--" + AdvSceneSwitcher.selectScene="--select scene--" AdvSceneSwitcher.selectPreviousScene="Previous Scene" AdvSceneSwitcher.selectCurrentScene="Current Scene" diff --git a/data/res/images/DarkSearch.svg b/data/res/images/DarkSearch.svg new file mode 100644 index 00000000..b0c6905f --- /dev/null +++ b/data/res/images/DarkSearch.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/data/res/images/LightSearch.svg b/data/res/images/LightSearch.svg new file mode 100644 index 00000000..dfcea895 --- /dev/null +++ b/data/res/images/LightSearch.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/macro-external/CMakeLists.txt b/src/macro-external/CMakeLists.txt index cab432f7..54520d40 100644 --- a/src/macro-external/CMakeLists.txt +++ b/src/macro-external/CMakeLists.txt @@ -27,6 +27,11 @@ if(ENABLE_OPENVR_PLUGIN) add_subdirectory(openvr) endif() +option(ENABLE_TWITCH_PLUGIN "Enable the twitch plugin" ON) +if(ENABLE_TWITCH_PLUGIN) + add_subdirectory(twitch) +endif() + option(ENABLE_VIDEO_PLUGIN "Enable the video plugin" ON) if(ENABLE_VIDEO_PLUGIN) add_subdirectory(video) diff --git a/src/macro-external/twitch/CMakeLists.txt b/src/macro-external/twitch/CMakeLists.txt new file mode 100644 index 00000000..37cd39be --- /dev/null +++ b/src/macro-external/twitch/CMakeLists.txt @@ -0,0 +1,74 @@ +cmake_minimum_required(VERSION 3.14) +project(advanced-scene-switcher-twitch) + +# --- Check requirements --- + +if(OS_MACOS) + message( + WARNING "Twitch support disabled on MacOS due to OpenSSL linking issues!") + return() +endif() + +get_target_property(ADVSS_SOURCE_DIR advanced-scene-switcher-lib SOURCE_DIR) +set(CPP_HTTPLIB_DIR "${ADVSS_SOURCE_DIR}/deps/cpp-httplib") +if(NOT EXISTS "${CPP_HTTPLIB_DIR}/CMakeLists.txt") + message(WARNING "cpp-httplib directory \"${CPP_HTTPLIB_DIR}\" not found!\n" + "Twitch support will be disabled!") + return() +endif() +add_subdirectory("${CPP_HTTPLIB_DIR}" "${CPP_HTTPLIB_DIR}/build" + EXCLUDE_FROM_ALL) + +find_package(OpenSSL) +if(NOT OPENSSL_FOUND) + message(WARNING "OpenSSL not found!\n" "Twitch support will be disabled!\n\n") + return() +endif() + +# --- End of section --- + +add_library(${PROJECT_NAME} MODULE) +target_compile_definitions(${PROJECT_NAME} PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT=1) + +target_sources( + ${PROJECT_NAME} + PRIVATE category-selection.cpp + category-selection.hpp + macro-action-twitch.cpp + macro-action-twitch.hpp + token.cpp + token.hpp + twitch-helpers.cpp + twitch-helpers.hpp) + +setup_advss_plugin(${PROJECT_NAME}) +set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "") +target_include_directories(${PROJECT_NAME} PRIVATE "${CPP_HTTPLIB_DIR}/" + "${OPENSSL_INCLUDE_DIR}") +target_link_libraries(${PROJECT_NAME} PRIVATE ${OPENSSL_LIBRARIES}) +install_advss_plugin(${PROJECT_NAME}) +if(OS_WINDOWS) + # Couldn't really find a better way to install runtime dependencies for + # Windows TODO: Clean this up at some point + function(FIND_FILES_WITH_PATTERN result pattern dir) + execute_process( + COMMAND + powershell -Command + "Get-ChildItem -Path '${dir}' -Recurse -Include ${pattern} |" + "Select-Object -First 1 |" + "ForEach-Object { $_.FullName -replace '\\\\', '\\\\' }" + OUTPUT_VARIABLE files + OUTPUT_STRIP_TRAILING_WHITESPACE) + set(${result} + ${files} + PARENT_SCOPE) + endfunction() + + set(OPENSSL_DLL_SEARCH_DIR "${OPENSSL_INCLUDE_DIR}/..") + find_files_with_pattern(CRYPTO_DLL_FILES "libcrypto*.dll" + "${OPENSSL_DLL_SEARCH_DIR}") + find_files_with_pattern(SSL_DLL_FILES "libssl*.dll" + "${OPENSSL_DLL_SEARCH_DIR}") + install_advss_plugin_dependency(TARGET ${PROJECT_NAME} DEPENDENCIES + "${CRYPTO_DLL_FILES}" "${SSL_DLL_FILES}") +endif() diff --git a/src/macro-external/twitch/category-selection.cpp b/src/macro-external/twitch/category-selection.cpp new file mode 100644 index 00000000..41b7b5af --- /dev/null +++ b/src/macro-external/twitch/category-selection.cpp @@ -0,0 +1,363 @@ +#include "category-selection.hpp" +#include "token.hpp" +#include "twitch-helpers.hpp" + +#include +#include +#include +#include +#include + +namespace advss { + +void TwitchCategory::Load(obs_data_t *obj) +{ + OBSDataAutoRelease data = obs_data_get_obj(obj, "category"); + id = obs_data_get_int(data, "id"); + name = obs_data_get_string(data, "name"); +} + +void TwitchCategory::Save(obs_data_t *obj) const +{ + OBSDataAutoRelease data = obs_data_create(); + obs_data_set_int(data, "id", id); + obs_data_set_string(data, "name", name.c_str()); + obs_data_set_obj(obj, "category", data); +} + +bool TwitchCategorySelection::_fetchingCategoriesDone = false; +std::map TwitchCategorySelection::_streamingCategories; + +void TwitchCategorySelection::PopulateCategorySelection() +{ + auto token = _token.lock(); + if (!token && _streamingCategories.empty()) { + return; + } + + if (!_fetchingCategoriesDone && token) { + _categoryGrabber.Start(token); + if (_progressDialog->exec() == QDialog::Accepted) { + _fetchingCategoriesDone = true; + } + _categoryGrabber.Stop(); + _categoryGrabber.wait(); + } + UpdateCategoryList(); +} + +void TwitchCategorySelection::UpdateCategoryList() +{ + _streamingCategories = _categoryGrabber.GetCategories(); + QString currentSelection = currentText(); + + const QSignalBlocker b(this); + clear(); + for (const auto &[name, id] : _streamingCategories) { + addItem(name, id); + } + + setCurrentText(currentSelection); +} + +TwitchCategorySelection::TwitchCategorySelection(QWidget *parent) + : FilterComboBox( + parent, + obs_module_text("AdvSceneSwitcher.twitchCategories.select")), + _progressDialog(new ProgressDialog(this)) +{ + _progressDialog->setWindowModality(Qt::WindowModal); + setSizeAdjustPolicy(QComboBox::AdjustToContents); + + QWidget::connect(this, SIGNAL(currentIndexChanged(int)), this, + SLOT(SelectionChanged(int))); + QWidget::connect(&_categoryGrabber, SIGNAL(CategoryCountUpdated(int)), + _progressDialog, SLOT(CategoryCountUpdated(int))); + QWidget::connect(&_categoryGrabber, SIGNAL(Finished()), this, + SLOT(PopulateFinished())); + QWidget::connect(TwitchCategorySignalManager::Instance(), + SIGNAL(RepopulateRequired()), this, + SLOT(UpdateCategoryList())); +} + +void TwitchCategorySelection::SetToken(const std::weak_ptr &token) +{ + _token = token; + const bool expired = token.expired(); + setDisabled(expired); + if (expired) { + setToolTip(obs_module_text( + "AdvSceneSwitcher.action.twitch.categorySelectionDisabled")); + } else { + setToolTip(""); + } +} + +void TwitchCategorySelection::PopulateFinished() +{ + _progressDialog->accept(); +} + +void TwitchCategorySelection::showPopup() +{ + if (!IsPopulated()) { + PopulateCategorySelection(); + } + adjustSize(); + updateGeometry(); + FilterComboBox::showPopup(); +} + +bool TwitchCategorySelection::IsPopulated() +{ + return count() == _categoryGrabber.GetCategories().size() && + _fetchingCategoriesDone; +} + +void TwitchCategorySelection::SetCategory(const TwitchCategory &id) +{ + // If the list is populated already try to find id ... + int index = findData(id.id); + if (index != -1) { + setCurrentIndex(index); + return; + } + + if (id.id == -1) { + setCurrentIndex(-1); + return; + } + + // ... otherwise just add a dummy entry with the category name + addItem(QString::fromStdString(id.name), id.id); + setCurrentIndex(findData(id.id)); +} + +void TwitchCategorySelection::SelectionChanged(int index) +{ + TwitchCategory category{itemData(index).toInt(), + currentText().toStdString()}; + emit CategoreyChanged(category); +} + +std::map CategoryGrabber::_categoryMap = {}; +std::mutex CategoryGrabber::_mtx = {}; + +void CategoryGrabber::Start(const std::shared_ptr &token, + const std::string search) +{ + _searchString = search; + _token = token; + _stop = false; + start(); +} + +void CategoryGrabber::Stop() +{ + _stop = true; +} + +const std::map &CategoryGrabber::GetCategories() +{ + return _categoryMap; +} + +void CategoryGrabber::run() +{ + if (!_token) { + return; + emit Failed(); + } + + { + std::lock_guard lock(_mtx); + if (_searchString.empty()) { + GetAll(); + } else { + Search(_searchString); + } + } + + emit Finished(); +} + +void CategoryGrabber::Search(const std::string &) +{ + static const std::string uri = "https://api.twitch.tv"; + const std::string path = "/helix/search/categories"; + + int startCount = _categoryMap.size(); + std::string cursor; + httplib::Params params = { + {"first", "100"}, {"after", cursor}, {"query", _searchString}}; + auto response = SendGetRequest(uri, path, *_token, params); + + while (response.status == 200 && !_stop) { + cursor = ParseReply(response.data); + if (cursor.empty()) { + break; // End of category list + } + params = {{"first", "100"}, + {"after", cursor}, + {"query", _searchString}}; + response = SendGetRequest(uri, path, *_token, params); + emit CategoryCountUpdated(_categoryMap.size() - startCount); + } +} + +void CategoryGrabber::GetAll() +{ + static const std::string uri = "https://api.twitch.tv"; + const std::string path = "/helix/games/top"; + + // Declare static to "save" progress in case of cancel + static std::string cursor; + + httplib::Params params = {{"first", "100"}, {"after", cursor}}; + auto response = SendGetRequest(uri, path, *_token, params); + + while (response.status == 200 && !_stop) { + cursor = ParseReply(response.data); + if (cursor.empty()) { + break; // End of category list + } + params = {{"first", "100"}, {"after", cursor}}; + response = SendGetRequest(uri, path, *_token, params); + emit CategoryCountUpdated(_categoryMap.size()); + } +} + +std::string CategoryGrabber::ParseReply(obs_data_t *data) const +{ + OBSDataArrayAutoRelease array = obs_data_get_array(data, "data"); + size_t count = obs_data_array_count(array); + for (size_t i = 0; i < count; i++) { + OBSDataAutoRelease arrayObj = obs_data_array_item(array, i); + int id = std::stoi(obs_data_get_string(arrayObj, "id")); + QString name = obs_data_get_string(arrayObj, "name"); + _categoryMap.emplace(name, id); + } + OBSDataAutoRelease pagination = obs_data_get_obj(data, "pagination"); + return obs_data_get_string(pagination, "cursor"); +} + +ProgressDialog::ProgressDialog(QWidget *parent, bool showSkip) + : QDialog(parent), + _skipFetchCheckBox(new QCheckBox(obs_module_text( + "AdvSceneSwitcher.twitchCategories.fetchSkip"))), + _status(new QLabel(obs_module_text( + "AdvSceneSwitcher.twitchCategories.fetchStart"))) +{ + setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle")); + _skipFetchCheckBox->setVisible(showSkip); + auto layout = new QVBoxLayout(this); + layout->addWidget(_status); + auto cancelButton = new QPushButton( + obs_module_text("AdvSceneSwitcher.twitchCategories.fetchStop"), + this); + layout->addWidget(_skipFetchCheckBox); + layout->addWidget(cancelButton); + setLayout(layout); + + QWidget::connect(_skipFetchCheckBox, &QCheckBox::stateChanged, this, + [this](int value) { _skipFetch = value; }); + QWidget::connect(cancelButton, &QPushButton::clicked, this, + [this]() { _skipFetch ? accept() : reject(); }); + + if (_skipFetch) { + accept(); + } +} + +void ProgressDialog::CategoryCountUpdated(int value) +{ + _status->setText( + QString(obs_module_text( + "AdvSceneSwitcher.twitchCategories.fetchStatus")) + .arg(value)); +} + +TwitchCategorySearchButton::TwitchCategorySearchButton() +{ + setMaximumWidth(22); + const std::string pathPrefix = + GetDataFilePath("res/images/" + GetThemeTypeName()); + SetButtonIcon(this, (pathPrefix + "Search.svg").c_str()); + setToolTip(obs_module_text( + "AdvSceneSwitcher.twitchCategories.manualSearch")); + QWidget::connect(this, SIGNAL(clicked()), this, + SLOT(StartManualCategorySearch())); + QWidget::connect(this, SIGNAL(RequestRepopulate()), + TwitchCategorySignalManager::Instance(), + SIGNAL(RepopulateRequired())); +} + +void TwitchCategorySearchButton::SetToken( + const std::weak_ptr &token) +{ + _token = token; + const bool expired = token.expired(); + setDisabled(expired); + if (expired) { + setToolTip(obs_module_text( + "AdvSceneSwitcher.action.twitch.categorySelectionDisabled")); + } else { + setToolTip("AdvSceneSwitcher.twitchCategories.manualSearch"); + } +} + +void TwitchCategorySearchButton::StartManualCategorySearch() +{ + std::string category; + bool accepted = AdvSSNameDialog::AskForName( + this, + obs_module_text("AdvSceneSwitcher.twitchCategories.search"), + obs_module_text("AdvSceneSwitcher.twitchCategories.name"), + category); + if (!accepted) { + return; + } + + CategoryGrabber categoryGrabber; + auto *progressDialog = new ProgressDialog(this, false); + + QWidget::connect(&categoryGrabber, SIGNAL(CategoryCountUpdated(int)), + progressDialog, SLOT(CategoryCountUpdated(int))); + QWidget::connect(&categoryGrabber, &CategoryGrabber::Finished, this, + [progressDialog]() { progressDialog->accept(); }); + QWidget::connect(&categoryGrabber, &CategoryGrabber::Failed, this, + [progressDialog]() { progressDialog->reject(); }); + + auto previousCategoryCount = categoryGrabber.GetCategories().size(); + + categoryGrabber.Start(_token.lock(), category); + progressDialog->exec(); + categoryGrabber.Stop(); + categoryGrabber.wait(); + + emit RequestRepopulate(); + progressDialog->deleteLater(); + + auto newCategoryCount = + categoryGrabber.GetCategories().size() - previousCategoryCount; + if (newCategoryCount == 0) { + DisplayMessage( + QString(obs_module_text( + "AdvSceneSwitcher.twitchCategories.searchFailed")) + .arg(QString::fromStdString(category))); + } else { + DisplayMessage( + QString(obs_module_text( + "AdvSceneSwitcher.twitchCategories.searchSuccess")) + .arg(QString::number(newCategoryCount), + QString::fromStdString(category))); + } +} + +TwitchCategorySignalManager *TwitchCategorySignalManager::Instance() +{ + static TwitchCategorySignalManager manager; + return &manager; +} + +} // namespace advss diff --git a/src/macro-external/twitch/category-selection.hpp b/src/macro-external/twitch/category-selection.hpp new file mode 100644 index 00000000..821f9f3d --- /dev/null +++ b/src/macro-external/twitch/category-selection.hpp @@ -0,0 +1,129 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace advss { + +class TwitchToken; + +struct TwitchCategory { + void Load(obs_data_t *obj); + void Save(obs_data_t *obj) const; + + int id = -1; + std::string name = "-"; +}; + +class CategoryGrabber : public QThread { + Q_OBJECT +public: + void Start(const std::shared_ptr &token, + const std::string searchString = ""); + void Stop(); + const std::map &GetCategories(); + +private: +signals: + void CategoryCountUpdated(int value); + void Finished(); + void Failed(); + +private: + void run() override; + + void Search(const std::string &); + void GetAll(); + std::string ParseReply(obs_data_t *) const; + + std::shared_ptr _token; + static std::map _categoryMap; + std::string _searchString = ""; + bool _stop = false; + + // Don't allow parallel search requests to not spam Twitch API + static std::mutex _mtx; +}; + +class ProgressDialog : public QDialog { + Q_OBJECT +public: + ProgressDialog(QWidget *parent, bool showSkip = true); + +private slots: + void CategoryCountUpdated(int); + +private: + QCheckBox *_skipFetchCheckBox; + QLabel *_status; + bool _skipFetch = false; +}; + +class TwitchCategorySelection : public FilterComboBox { + Q_OBJECT + +public: + TwitchCategorySelection(QWidget *parent); + + // Will *not* verify if ID is still valid or populate the selection + // list as that would take too long + void SetCategory(const TwitchCategory &); + + // Used for populating the category list + void SetToken(const std::weak_ptr &); + +private slots: + void SelectionChanged(int); + void PopulateFinished(); + void UpdateCategoryList(); + +signals: + void CategoreyChanged(const TwitchCategory &); + +protected: + void showPopup() override; + +private: + void PopulateCategorySelection(); + bool IsPopulated(); + + ProgressDialog *_progressDialog; + CategoryGrabber _categoryGrabber; + std::weak_ptr _token; + static bool _fetchingCategoriesDone; + static std::map _streamingCategories; +}; + +class TwitchCategorySearchButton : public QPushButton { + Q_OBJECT + +public: + TwitchCategorySearchButton(); + void SetToken(const std::weak_ptr &); + +private slots: + void StartManualCategorySearch(); + +signals: + void RequestRepopulate(); + +private: + std::weak_ptr _token; +}; + +// Helper class to ease singal / slot handling +class TwitchCategorySignalManager : public QObject { + Q_OBJECT +public: + static TwitchCategorySignalManager *Instance(); + +signals: + void RepopulateRequired(); +}; + +} // namespace advss diff --git a/src/macro-external/twitch/macro-action-twitch.cpp b/src/macro-external/twitch/macro-action-twitch.cpp new file mode 100644 index 00000000..a63c19fe --- /dev/null +++ b/src/macro-external/twitch/macro-action-twitch.cpp @@ -0,0 +1,242 @@ +#include "macro-action-twitch.hpp" +#include "twitch-helpers.hpp" + +#include +#include + +namespace advss { + +const std::string MacroActionTwitch::id = "twitch"; + +bool MacroActionTwitch::_registered = MacroActionFactory::Register( + MacroActionTwitch::id, + {MacroActionTwitch::Create, MacroActionTwitchEdit::Create, + "AdvSceneSwitcher.action.twitch"}); + +const static std::map actionTypes = { + {MacroActionTwitch::Action::TITLE, + "AdvSceneSwitcher.action.twitch.type.title"}, + {MacroActionTwitch::Action::CATEGORY, + "AdvSceneSwitcher.action.twitch.type.category"}, +}; + +void MacroActionTwitch::SetStreamTitle(const std::shared_ptr &token) +{ + if (std::string(_text).empty()) { + return; + } + + OBSDataAutoRelease data = obs_data_create(); + obs_data_set_string(data, "title", _text.c_str()); + auto result = SendPatchRequest( + "https://api.twitch.tv", + std::string("/helix/channels?broadcaster_id=") + + token->GetUserID(), + *token, data.Get()); + if (result.status != 204) { + blog(LOG_INFO, "Failed to set stream title"); + } +} + +void MacroActionTwitch::SetStreamCategory( + const std::shared_ptr &token) +{ + if (_category.id == -1) { + return; + } + + OBSDataAutoRelease data = obs_data_create(); + obs_data_set_string(data, "game_id", + std::to_string(_category.id).c_str()); + auto result = SendPatchRequest( + "https://api.twitch.tv", + std::string("/helix/channels?broadcaster_id=") + + token->GetUserID(), + *token, data.Get()); + if (result.status != 204) { + blog(LOG_INFO, "Failed to set stream category"); + } +} + +bool MacroActionTwitch::PerformAction() +{ + auto token = _token.lock(); + if (!token) { + return true; + } + + switch (_action) { + case MacroActionTwitch::Action::TITLE: + SetStreamTitle(token); + break; + case MacroActionTwitch::Action::CATEGORY: + SetStreamCategory(token); + break; + default: + break; + } + + return true; +} + +void MacroActionTwitch::LogAction() const +{ + auto it = actionTypes.find(_action); + if (it != actionTypes.end()) { + vblog(LOG_INFO, "performed action \"%s\" with token for \"%s\"", + it->second.c_str(), + GetWeakTwitchTokenName(_token).c_str()); + } else { + blog(LOG_WARNING, "ignored unknown twitch action %d", + static_cast(_action)); + } +} + +bool MacroActionTwitch::Save(obs_data_t *obj) const +{ + MacroAction::Save(obj); + obs_data_set_int(obj, "action", static_cast(_action)); + obs_data_set_string(obj, "token", + GetWeakTwitchTokenName(_token).c_str()); + _text.Save(obj, "text"); + _category.Save(obj); + return true; +} + +bool MacroActionTwitch::Load(obs_data_t *obj) +{ + MacroAction::Load(obj); + _action = static_cast(obs_data_get_int(obj, "action")); + _token = GetWeakTwitchTokenByName(obs_data_get_string(obj, "token")); + _text.Load(obj, "text"); + _category.Load(obj); + return true; +} + +std::string MacroActionTwitch::GetShortDesc() const +{ + return GetWeakTwitchTokenName(_token); +} + +static inline void populateActionSelection(QComboBox *list) +{ + for (const auto &[_, name] : actionTypes) { + list->addItem(obs_module_text(name.c_str())); + } +} + +MacroActionTwitchEdit::MacroActionTwitchEdit( + QWidget *parent, std::shared_ptr entryData) + : QWidget(parent), + _actions(new QComboBox()), + _tokens(new TwitchConnectionSelection()), + _text(new VariableLineEdit(this)), + _category(new TwitchCategorySelection(this)), + _manualCategorySearch(new TwitchCategorySearchButton()), + _layout(new QHBoxLayout()) +{ + _text->setSizePolicy(QSizePolicy::MinimumExpanding, + QSizePolicy::Preferred); + populateActionSelection(_actions); + + QWidget::connect(_actions, SIGNAL(currentIndexChanged(int)), this, + SLOT(ActionChanged(int))); + QWidget::connect(_tokens, SIGNAL(SelectionChanged(const QString &)), + this, SLOT(TwitchTokenChanged(const QString &))); + QWidget::connect(_text, SIGNAL(editingFinished()), this, + SLOT(TextChanged())); + QWidget::connect(_category, + SIGNAL(CategoreyChanged(const TwitchCategory &)), this, + SLOT(CategoreyChanged(const TwitchCategory &))); + + PlaceWidgets(obs_module_text("AdvSceneSwitcher.action.twitch.entry"), + _layout, + {{"{{account}}", _tokens}, + {"{{actions}}", _actions}, + {"{{text}}", _text}, + {"{{category}}", _category}, + {"{{manualCategorySearch}}", _manualCategorySearch}}); + setLayout(_layout); + + _entryData = entryData; + UpdateEntryData(); + _loading = false; +} + +void MacroActionTwitchEdit::TwitchTokenChanged(const QString &token) +{ + if (_loading || !_entryData) { + return; + } + + auto lock = LockContext(); + _entryData->_token = GetWeakTwitchTokenByQString(token); + _category->SetToken(_entryData->_token); + _manualCategorySearch->SetToken(_entryData->_token); + SetupWidgetVisibility(); + emit(HeaderInfoChanged(token)); +} + +void MacroActionTwitchEdit::TextChanged() +{ + if (_loading || !_entryData) { + return; + } + + auto lock = LockContext(); + _entryData->_text = _text->text().toStdString(); +} + +void MacroActionTwitchEdit::CategoreyChanged(const TwitchCategory &category) +{ + if (_loading || !_entryData) { + return; + } + + auto lock = LockContext(); + _entryData->_category = category; +} + +void MacroActionTwitchEdit::SetupWidgetVisibility() +{ + _text->setVisible(_entryData->_action == + MacroActionTwitch::Action::TITLE); + _category->setVisible(_entryData->_action == + MacroActionTwitch::Action::CATEGORY); + if (_entryData->_action == MacroActionTwitch::Action::TITLE) { + RemoveStretchIfPresent(_layout); + } else { + AddStretchIfNecessary(_layout); + } + + adjustSize(); + updateGeometry(); +} + +void MacroActionTwitchEdit::UpdateEntryData() +{ + if (!_entryData) { + return; + } + + _actions->setCurrentIndex(static_cast(_entryData->_action)); + _tokens->SetToken(_entryData->_token); + _text->setText(_entryData->_text); + _category->SetToken(_entryData->_token); + _manualCategorySearch->SetToken(_entryData->_token); + _category->SetCategory(_entryData->_category); + SetupWidgetVisibility(); +} + +void MacroActionTwitchEdit::ActionChanged(int value) +{ + if (_loading || !_entryData) { + return; + } + + auto lock = LockContext(); + _entryData->_action = static_cast(value); + SetupWidgetVisibility(); +} + +} // namespace advss diff --git a/src/macro-external/twitch/macro-action-twitch.hpp b/src/macro-external/twitch/macro-action-twitch.hpp new file mode 100644 index 00000000..85062351 --- /dev/null +++ b/src/macro-external/twitch/macro-action-twitch.hpp @@ -0,0 +1,82 @@ +#pragma once +#include "macro-action-edit.hpp" +#include "token.hpp" +#include "category-selection.hpp" + +#include + +namespace advss { + +class MacroActionTwitch : public MacroAction { +public: + MacroActionTwitch(Macro *m) : MacroAction(m) {} + bool PerformAction(); + void LogAction() const; + bool Save(obs_data_t *obj) const; + bool Load(obs_data_t *obj); + std::string GetShortDesc() const; + std::string GetId() const { return id; }; + static std::shared_ptr Create(Macro *m) + { + return std::make_shared(m); + } + + enum class Action { + TITLE, + CATEGORY, + }; + + Action _action = Action::TITLE; + std::weak_ptr _token; + StringVariable _text = obs_module_text("AdvSceneSwitcher.enterText"); + TwitchCategory _category; + +private: + void SetStreamTitle(const std::shared_ptr &); + void SetStreamCategory(const std::shared_ptr &); + + static bool _registered; + static const std::string id; +}; + +class MacroActionTwitchEdit : public QWidget { + Q_OBJECT + +public: + MacroActionTwitchEdit( + QWidget *parent, + std::shared_ptr entryData = nullptr); + void UpdateEntryData(); + static QWidget *Create(QWidget *parent, + std::shared_ptr action) + { + return new MacroActionTwitchEdit( + parent, + std::dynamic_pointer_cast(action)); + } + +private slots: + void ActionChanged(int); + void TwitchTokenChanged(const QString &); + void TextChanged(); + void CategoreyChanged(const TwitchCategory &); + +signals: + void HeaderInfoChanged(const QString &); + +protected: + std::shared_ptr _entryData; + +private: + void SetupWidgetVisibility(); + + QComboBox *_actions; + TwitchConnectionSelection *_tokens; + VariableLineEdit *_text; + TwitchCategorySelection *_category; + TwitchCategorySearchButton *_manualCategorySearch; + QHBoxLayout *_layout; + bool _loading = true; +}; + +} // namespace advss diff --git a/src/macro-external/twitch/token.cpp b/src/macro-external/twitch/token.cpp new file mode 100644 index 00000000..4538150f --- /dev/null +++ b/src/macro-external/twitch/token.cpp @@ -0,0 +1,623 @@ +#include "token.hpp" +#include "twitch-helpers.hpp" + +#include +#include +#include +#include + +namespace advss { + +static std::deque> twitchTokens; + +const std::unordered_map TokenOption::apiIdToLocale{ + // Add necessary token permissions here + /* + {"analytics:read:extensions", + "AdvSceneSwitcher.twitchToken.analytics.readExtensions"}, + {"analytics:read:games", + "AdvSceneSwitcher.twitchToken.analytics.readGames"}, + {"bits:read", "AdvSceneSwitcher.twitchToken.bits.read"}, + */ + + {"channel:manage:broadcast", + "AdvSceneSwitcher.twitchToken.channel.manageBroadcast"}, +}; + +static void saveConnections(obs_data_t *obj); +static void loadConnections(obs_data_t *obj); + +bool setupTwitchTokenSupport() +{ + GetSwitcher()->AddSaveStep(saveConnections); + GetSwitcher()->AddLoadStep(loadConnections); + return true; +} + +bool TwitchToken::_setup = setupTwitchTokenSupport(); + +static void saveConnections(obs_data_t *obj) +{ + OBSDataArrayAutoRelease connectionArray = obs_data_array_create(); + for (const auto &c : twitchTokens) { + OBSDataAutoRelease arrayObj = obs_data_create(); + c->Save(arrayObj); + obs_data_array_push_back(connectionArray, arrayObj); + } + obs_data_set_array(obj, "twitchConnections", connectionArray); +} + +static void loadConnections(obs_data_t *obj) +{ + twitchTokens.clear(); + + OBSDataArrayAutoRelease connectionArray = + obs_data_get_array(obj, "twitchConnections"); + size_t count = obs_data_array_count(connectionArray); + for (size_t i = 0; i < count; i++) { + OBSDataAutoRelease arrayObj = + obs_data_array_item(connectionArray, i); + auto con = TwitchToken::Create(); + twitchTokens.emplace_back(con); + twitchTokens.back()->Load(arrayObj); + } +} + +void TokenOption::Load(obs_data_t *obj) +{ + apiId = obs_data_get_string(obj, "apiID"); +} + +void TokenOption::Save(obs_data_t *obj) const +{ + obs_data_set_string(obj, "apiID", apiId.c_str()); +} + +std::string TokenOption::GetLocale() const +{ + return apiIdToLocale.at(apiId); +} + +const std::unordered_map &TokenOption::GetTokenMap() +{ + return apiIdToLocale; +} + +bool TokenOption::operator<(const TokenOption &other) const +{ + return apiId < other.apiId; +} + +void TwitchToken::Load(obs_data_t *obj) +{ + Item::Load(obj); + _token = obs_data_get_string(obj, "token"); + _userID = obs_data_get_string(obj, "userID"); + _tokenOptions.clear(); + OBSDataArrayAutoRelease options = obs_data_get_array(obj, "options"); + size_t count = obs_data_array_count(options); + for (size_t i = 0; i < count; i++) { + OBSDataAutoRelease arrayObj = obs_data_array_item(options, i); + TokenOption tokenOption; + tokenOption.Load(arrayObj); + _tokenOptions.insert(tokenOption); + } +} + +void TwitchToken::Save(obs_data_t *obj) const +{ + Item::Save(obj); + obs_data_set_string(obj, "token", _token.c_str()); + obs_data_set_string(obj, "userID", _userID.c_str()); + OBSDataArrayAutoRelease options = obs_data_array_create(); + for (auto &option : _tokenOptions) { + OBSDataAutoRelease arrayObj = obs_data_create(); + option.Save(arrayObj); + obs_data_array_push_back(options, arrayObj); + } + obs_data_set_array(obj, "options", options); +} + +bool TwitchToken::OptionIsEnabled(const TokenOption &option) const +{ + for (const auto &activeOption : _tokenOptions) { + if (activeOption.apiId == option.apiId) { + return true; + } + } + return false; +} + +void TwitchToken::SetToken(const std::string &value) +{ + _token = value; + auto res = + SendGetRequest("https://api.twitch.tv", "/helix/users", *this); + if (res.status != 200) { + blog(LOG_WARNING, "failed to get Twitch user id from token!"); + _userID = -1; + return; + } + + OBSDataArrayAutoRelease array = obs_data_get_array(res.data, "data"); + size_t count = obs_data_array_count(array); + for (size_t i = 0; i < count; i++) { + OBSDataAutoRelease arrayObj = obs_data_array_item(array, i); + _userID = obs_data_get_string(arrayObj, "id"); + _name = obs_data_get_string(arrayObj, "display_name"); + } +} + +TwitchToken *GetTwitchTokenByName(const QString &name) +{ + return GetTwitchTokenByName(name.toStdString()); +} + +TwitchToken *GetTwitchTokenByName(const std::string &name) +{ + for (auto &t : twitchTokens) { + if (t->Name() == name) { + return dynamic_cast(t.get()); + } + } + return nullptr; +} + +std::weak_ptr GetWeakTwitchTokenByName(const std::string &name) +{ + for (const auto &t : twitchTokens) { + if (t->Name() == name) { + std::weak_ptr wp = + std::dynamic_pointer_cast(t); + return wp; + } + } + return std::weak_ptr(); +} + +std::weak_ptr GetWeakTwitchTokenByQString(const QString &name) +{ + return GetWeakTwitchTokenByName(name.toStdString()); +} + +std::string GetWeakTwitchTokenName(std::weak_ptr token) +{ + auto con = token.lock(); + if (!con) { + return obs_module_text("AdvSceneSwitcher.twitchToken.invalid"); + } + return con->Name(); +} + +static bool ConnectionNameAvailable(const QString &name) +{ + return !GetTwitchTokenByName(name); +} + +static bool ConnectionNameAvailable(const std::string &name) +{ + return ConnectionNameAvailable(QString::fromStdString(name)); +} + +static bool AskForSettingsWrapper(QWidget *parent, Item &settings) +{ + TwitchToken &ConnectionSettings = dynamic_cast(settings); + return TwitchTokenSettingsDialog::AskForSettings(parent, + ConnectionSettings); +} + +TwitchConnectionSelection::TwitchConnectionSelection(QWidget *parent) + : ItemSelection(twitchTokens, TwitchToken::Create, + AskForSettingsWrapper, + "AdvSceneSwitcher.twitchToken.select", + "AdvSceneSwitcher.twitchToken.add", + "AdvSceneSwitcher.twitchToken.nameNotAvailable", + "AdvSceneSwitcher.twitchToken.configure", parent) +{ + ShowRenameContextMenu(false); + + // Connect to slots + QWidget::connect(TwitchConnectionSignalManager::Instance(), + SIGNAL(Add(const QString &)), this, + SLOT(AddItem(const QString &))); + QWidget::connect(TwitchConnectionSignalManager::Instance(), + SIGNAL(Remove(const QString &)), this, + SLOT(RemoveItem(const QString &))); + + // Forward signals + QWidget::connect(this, SIGNAL(ItemAdded(const QString &)), + TwitchConnectionSignalManager::Instance(), + SIGNAL(Add(const QString &))); + QWidget::connect(this, SIGNAL(ItemRemoved(const QString &)), + TwitchConnectionSignalManager::Instance(), + SIGNAL(Remove(const QString &))); +} + +void TwitchConnectionSelection::SetToken(const std::string &token) +{ + const QSignalBlocker blocker(_selection); + if (!!GetTwitchTokenByName(token)) { + _selection->setCurrentText(QString::fromStdString(token)); + } else { + _selection->setCurrentIndex(-1); + } +} + +void TwitchConnectionSelection::SetToken( + const std::weak_ptr &token_) +{ + const QSignalBlocker blocker(_selection); + auto token = token_.lock(); + if (token) { + SetToken(token->Name()); + } else { + _selection->setCurrentIndex(-1); + } +} + +static QCheckBox *addOption(const TokenOption &option, const TwitchToken &token, + QGridLayout *layout, int &row) +{ + auto label = new QLabel(obs_module_text(option.GetLocale().c_str())); + label->setWordWrap(true); + layout->addWidget(label, row, 1); + auto checkBox = new QCheckBox(); + checkBox->setChecked(token.OptionIsEnabled(option)); + layout->addWidget(checkBox, row, 0); + row++; + return checkBox; +} + +TwitchTokenSettingsDialog::TwitchTokenSettingsDialog( + QWidget *parent, const TwitchToken &settings) + : ItemSettingsDialog(settings, twitchTokens, + "AdvSceneSwitcher.twitchToken.select", + "AdvSceneSwitcher.twitchToken.add", + "AdvSceneSwitcher.twitchToken.nameNotAvailable", + parent), + _requestToken(new QPushButton( + obs_module_text("AdvSceneSwitcher.twitchToken.request"))), + _showToken(new QPushButton()), + _currentTokenValue(new QLineEdit()), + _tokenStatus(new QLabel()) +{ + _showToken->setMaximumWidth(22); + _showToken->setFlat(true); + _showToken->setStyleSheet( + "QPushButton { background-color: transparent; border: 0px }"); + + _currentTokenValue->setReadOnly(true); + _currentTokenValue->setText(QString::fromStdString(settings._token)); + + _name->setReadOnly(true); + + QWidget::connect(_requestToken, SIGNAL(clicked()), this, + SLOT(RequestToken())); + QWidget::connect(_showToken, SIGNAL(pressed()), this, + SLOT(ShowToken())); + QWidget::connect(_showToken, SIGNAL(released()), this, + SLOT(HideToken())); + connect(&_tokenGrabber, &TokenGrabberThread::GotToken, this, + &TwitchTokenSettingsDialog::GotToken); + + auto generalSettingsGrid = new QGridLayout(); + int row = 0; + generalSettingsGrid->addWidget( + new QLabel( + obs_module_text("AdvSceneSwitcher.twitchToken.name")), + row, 0); + auto nameLayout = new QHBoxLayout; + nameLayout->addWidget(_name); + nameLayout->addWidget(_nameHint); + generalSettingsGrid->addLayout(nameLayout, row, 1); + ++row; + generalSettingsGrid->addWidget( + new QLabel( + obs_module_text("AdvSceneSwitcher.twitchToken.value")), + row, 0); + auto tokenValueLayout = new QHBoxLayout; + tokenValueLayout->addWidget(_currentTokenValue); + tokenValueLayout->addWidget(_showToken); + generalSettingsGrid->addLayout(tokenValueLayout, row, 1); + ++row; + generalSettingsGrid->addWidget(_requestToken, row, 0); + generalSettingsGrid->addWidget(_tokenStatus, row, 1); + + auto optionsGrid = new QGridLayout(); + row = 0; + auto optionsBox = new QGroupBox( + obs_module_text("AdvSceneSwitcher.twitchToken.permissions")); + for (const auto &[id, _] : TokenOption::GetTokenMap()) { + auto checkBox = addOption({id}, settings, optionsGrid, row); + _optionWidgets[id] = checkBox; + } + MinimizeSizeOfColumn(optionsGrid, 0); + optionsBox->setLayout(optionsGrid); + + auto scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto contentWidget = new QWidget(scrollArea); + auto layout = new QVBoxLayout(contentWidget); + layout->addLayout(generalSettingsGrid); + layout->addWidget(optionsBox); + layout->setContentsMargins(0, 0, 0, 0); + scrollArea->setWidget(contentWidget); + + auto dialogLayout = new QVBoxLayout(); + dialogLayout->addWidget(scrollArea); + dialogLayout->addWidget(_buttonbox); + setLayout(dialogLayout); + + _currentTokenValue->setText(QString::fromStdString(settings._token)); + if (settings._token.empty()) { + _tokenStatus->setText(obs_module_text( + "AdvSceneSwitcher.twitchToken.request.notSet")); + } + HideToken(); + + if (_name->text() == "") { + PulseWidget(_requestToken, Qt::green, QColor(0, 0, 0, 0), true); + } + + _currentToken = settings; +} + +void TwitchTokenSettingsDialog::ShowToken() +{ + SetButtonIcon(_showToken, ":res/images/visible.svg"); + _currentTokenValue->setEchoMode(QLineEdit::Normal); +} + +void TwitchTokenSettingsDialog::HideToken() +{ + SetButtonIcon(_showToken, ":res/images/invisible.svg"); + _currentTokenValue->setEchoMode(QLineEdit::PasswordEchoOnEdit); +} + +static std::string generateStateString() +{ + const char *chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const size_t stateStringLen = 32; + + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution dis(0, sizeof(chars) - 2); + + std::string state; + state.reserve(stateStringLen); + + for (size_t i = 0; i < stateStringLen; ++i) { + state += chars[dis(gen)]; + } + + return state; +} + +static std::string generateScopeString(const std::set &options) +{ + if (options.empty()) { + return ""; + } + + std::string scope; + for (const auto &option : options) { + scope += option.apiId + "+"; + } + scope.pop_back(); // Remove trailing + + return scope; +} + +static std::string getHtml(const QString &redirect) +{ + const char *html = R"( + + + Advanced scene switcher + + +
Please click this link to continue if not automatically redirected
+

Login with Twitch

+ + + )"; + + return QString(html).arg(redirect).toStdString(); +} + +void TwitchTokenSettingsDialog::RequestToken() +{ + // Don't allow parallel RequestToken() calls + _requestToken->setDisabled(true); + + auto scope = QString::fromStdString( + generateScopeString(GetEnabledOptions())); + _tokenGrabber.SetTokenScope(scope); + _tokenGrabber.start(); + _tokenStatus->setText(obs_module_text( + "AdvSceneSwitcher.twitchToken.request.waiting")); +} + +void TwitchTokenSettingsDialog::GotToken(const std::optional &value) +{ + _currentTokenValue->setText(value.value_or("")); + if (value.has_value()) { + _tokenStatus->setText(obs_module_text( + "AdvSceneSwitcher.twitchToken.request.success")); + _currentToken.SetToken(value.value().toStdString()); + auto name = QString::fromStdString(_currentToken._name); + _name->setText(name); + _name->textEdited(name); + } else { + _tokenStatus->setText(obs_module_text( + "AdvSceneSwitcher.twitchToken.request.fail")); + _name->setText(""); + } + _requestToken->setEnabled(true); +} + +std::set TwitchTokenSettingsDialog::GetEnabledOptions() +{ + std::set result; + for (const auto &[id, checkBox] : _optionWidgets) { + if (checkBox->isChecked()) { + TokenOption option; + option.apiId = id; + result.emplace(option); + } + } + return result; +} + +bool TwitchTokenSettingsDialog::AskForSettings(QWidget *parent, + TwitchToken &settings) +{ + TwitchTokenSettingsDialog dialog(parent, settings); + dialog.setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle")); + if (dialog.exec() != DialogCode::Accepted) { + return false; + } + + settings = dialog._currentToken; + settings._tokenOptions = dialog.GetEnabledOptions(); + return true; +} + +int TokenGrabberThread::_timeout = 15; + +TokenGrabberThread::~TokenGrabberThread() +{ + _stopWaiting = true; + _cv.notify_all(); + Stop(); + _server.stop(); +} + +static std::string getAuthErrorString(const char *errDetail) +{ + QString err = obs_module_text( + "AdvSceneSwitcher.twitchToken.request.fail.browser"); + return err.arg(obs_module_text(errDetail)).toStdString(); +} + +void TokenGrabberThread::run() +{ + // Reset + _server.stop(); + _server.~Server(); + new (&_server) httplib::Server(); + if (_serverThread.joinable()) { + _serverThread.join(); + } + _stopWaiting = {false}; + + // Generate URI to request token + auto state = generateStateString(); + auto getTokenURI = "https://id.twitch.tv/oauth2/authorize" + "?response_type=token" + "&client_id=" + + QString(GetClientID()) + + "&redirect_uri=http://localhost:8080/auth" + "&scope=" + + _scope + "&state=" + QString::fromStdString(state); + + // Setup server receiving token string + auto html = getHtml(getTokenURI); + _server.Get("/auth", [html, state](const httplib::Request &req, + httplib::Response &res) { + // Check for errors + if (req.has_param("error")) { + auto recvState = req.get_param_value("state"); + if (recvState != state) { + blog(LOG_WARNING, + "state string does not match in error handling?! " + "Got \"%s\" - expected \"%s\"\n" + "ignoring error ...", + recvState.c_str(), state.c_str()); + return; + } + auto errorStr = + req.get_param_value("error_description"); + res.set_content(getAuthErrorString(errorStr.c_str()), + "text/plain"); + return; + } + + // Parse fragments and redirect to /token with + // corresponding parameters. + res.set_content(html, "text/html"); + }); + _server.Get("/token", [&](const httplib::Request &req, + httplib::Response &res) { + // Check if valid request and grab the token string + std::lock_guard lk(_mutex); + auto recvState = req.get_param_value("state"); + if (recvState != state) { + blog(LOG_WARNING, + "state string does not match! " + "Got \"%s\" - expected \"%s\"", + recvState.c_str(), state.c_str()); + res.set_content( + getAuthErrorString( + "AdvSceneSwitcher.twitchToken.request.fail.stateMismatch"), + "text/plain"); + } else { + _tokenString = QString::fromStdString( + req.get_param_value("access_token")); + res.set_content( + obs_module_text( + "AdvSceneSwitcher.twitchToken.request.success.browser"), + "text/plain"); + } + _stopWaiting = true; + _cv.notify_all(); + }); + + // Request user to grant token + QDesktopServices::openUrl(getTokenURI); + + // Start the server and wait + std::unique_lock lock(_mutex); + _serverThread = + std::thread([this]() { _server.listen("localhost", 8080); }); + auto time = std::chrono::high_resolution_clock::now() + + std::chrono::seconds(_timeout); + while (!_stopWaiting) { + if (_cv.wait_until(lock, time) == std::cv_status::timeout) { + break; + } + } + + emit GotToken(_tokenString); +} + +void TokenGrabberThread::Stop() +{ + if (_server.is_running()) { + _server.stop(); + } + if (_serverThread.joinable()) { + _serverThread.join(); + } + wait(); +} + +TwitchConnectionSignalManager *TwitchConnectionSignalManager::Instance() +{ + static TwitchConnectionSignalManager manager; + return &manager; +} + +} // namespace advss diff --git a/src/macro-external/twitch/token.hpp b/src/macro-external/twitch/token.hpp new file mode 100644 index 00000000..f9014edf --- /dev/null +++ b/src/macro-external/twitch/token.hpp @@ -0,0 +1,140 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace advss { + +class TwitchConnectionSelection; +class TwitchTokenSettingsDialog; + +class TokenOption { +public: + void Load(obs_data_t *obj); + void Save(obs_data_t *obj) const; + std::string GetLocale() const; + + static const std::unordered_map & + GetTokenMap(); + bool operator<(const TokenOption &other) const; + std::string apiId = ""; + +private: + const static std::unordered_map apiIdToLocale; +}; + +class TwitchToken : public Item { +public: + static std::shared_ptr Create() + { + return std::make_shared(); + } + + void Load(obs_data_t *obj); + void Save(obs_data_t *obj) const; + std::string GetName() { return _name; } + bool OptionIsEnabled(const TokenOption &) const; + void SetToken(const std::string &); + bool IsEmpty() const { return _token.empty(); } + std::string GetToken() const { return _token; } + std::string GetUserID() const { return _userID; } + +private: + std::string _token; + std::string _userID; + std::set _tokenOptions = {{"channel:manage:broadcast"}}; + + static bool _setup; + + friend TwitchConnectionSelection; + friend TwitchTokenSettingsDialog; +}; + +class TokenGrabberThread : public QThread { + Q_OBJECT + +public: + ~TokenGrabberThread(); + void SetTokenScope(const QString &value) { _scope = value; } + +signals: + void GotToken(const std::optional &); + +protected: + void run() override; + +private: + void Stop(); + + QString _scope; + std::optional _tokenString; + + static int _timeout; + std::mutex _mutex; + std::atomic_bool _stopWaiting = {false}; + std::condition_variable _cv; + std::thread _serverThread; + httplib::Server _server; +}; + +class TwitchTokenSettingsDialog : public ItemSettingsDialog { + Q_OBJECT + +public: + TwitchTokenSettingsDialog(QWidget *parent, const TwitchToken &); + static bool AskForSettings(QWidget *parent, TwitchToken &settings); + +private slots: + void ShowToken(); + void HideToken(); + void RequestToken(); + void GotToken(const std::optional &); + +private: + std::set GetEnabledOptions(); + + QPushButton *_requestToken; + QPushButton *_showToken; + QLineEdit *_currentTokenValue; + QLabel *_tokenStatus; + TokenGrabberThread _tokenGrabber; + TwitchToken _currentToken; + std::unordered_map _optionWidgets; +}; + +class TwitchConnectionSelection : public ItemSelection { + Q_OBJECT + +public: + TwitchConnectionSelection(QWidget *parent = 0); + void SetToken(const std::string &); + void SetToken(const std::weak_ptr &); +}; + +// Helper class so that it is not required to add signals to the +// AdvSceneSwitcher class for handling adding and removing Twitch connections +class TwitchConnectionSignalManager : public QObject { + Q_OBJECT +public: + static TwitchConnectionSignalManager *Instance(); + +private: +signals: + // Rename signal not required as name is based on Twitch account name + // and item name cannot be manually changed + void Add(const QString &); + void Remove(const QString &); +}; + +TwitchToken *GetTwitchTokenByName(const QString &); +TwitchToken *GetTwitchTokenByName(const std::string &); +std::weak_ptr GetWeakTwitchTokenByName(const std::string &name); +std::weak_ptr GetWeakTwitchTokenByQString(const QString &name); +std::string GetWeakTwitchTokenName(std::weak_ptr); + +} // namespace advss + +Q_DECLARE_METATYPE(advss::TwitchToken *); diff --git a/src/macro-external/twitch/twitch-helpers.cpp b/src/macro-external/twitch/twitch-helpers.cpp new file mode 100644 index 00000000..931ba791 --- /dev/null +++ b/src/macro-external/twitch/twitch-helpers.cpp @@ -0,0 +1,76 @@ +#include "twitch-helpers.hpp" +#include "token.hpp" + +namespace advss { + +static constexpr std::string_view clientID = "ds5tt4ogliifsqc04mz3d3etnck3e5"; + +static httplib::Headers getTokenRequestHeaders(const TwitchToken &token) +{ + return { + {"Authorization", "Bearer " + token.GetToken()}, + {"Client-Id", clientID.data()}, + }; +} + +RequestResult SendGetRequest(const std::string &uri, const std::string &path, + const TwitchToken &token, + const httplib::Params ¶ms) +{ + httplib::Client cli(uri); + auto headers = getTokenRequestHeaders(token); + auto response = cli.Get(path, params, headers); + RequestResult result; + result.status = response->status; + if (response->body.empty()) { + return result; + } + OBSDataAutoRelease replyData = + obs_data_create_from_json(response->body.c_str()); + result.data = replyData; + return result; +} + +RequestResult SendPostRequest(const std::string &uri, const std::string &path, + const TwitchToken &token, + const httplib::Params ¶ms) +{ + httplib::Client cli(uri); + auto headers = getTokenRequestHeaders(token); + auto response = cli.Post(path, headers, params); + RequestResult result; + result.status = response->status; + if (response->body.empty()) { + return result; + } + OBSDataAutoRelease replyData = + obs_data_create_from_json(response->body.c_str()); + result.data = replyData; + return result; +} + +RequestResult SendPatchRequest(const std::string &uri, const std::string &path, + const TwitchToken &token, const OBSData &data) +{ + httplib::Client cli(uri); + auto headers = getTokenRequestHeaders(token); + auto json = obs_data_get_json(data); + std::string body = json ? json : ""; + auto response = cli.Patch(path, headers, body, "application/json"); + RequestResult result; + result.status = response->status; + if (response->body.empty()) { + return result; + } + OBSDataAutoRelease replyData = + obs_data_create_from_json(response->body.c_str()); + result.data = replyData; + return result; +} + +const char *GetClientID() +{ + return clientID.data(); +} + +} // namespace advss diff --git a/src/macro-external/twitch/twitch-helpers.hpp b/src/macro-external/twitch/twitch-helpers.hpp new file mode 100644 index 00000000..d0f61bf4 --- /dev/null +++ b/src/macro-external/twitch/twitch-helpers.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#include + +namespace advss { + +class TwitchToken; + +struct RequestResult { + int status = 0; + OBSData data = nullptr; +}; + +RequestResult SendGetRequest(const std::string &uri, const std::string &path, + const TwitchToken &token, + const httplib::Params & = {}); +RequestResult SendPostRequest(const std::string &uri, const std::string &path, + const TwitchToken &token, + const httplib::Params ¶ms = {}); +RequestResult SendPatchRequest(const std::string &uri, const std::string &path, + const TwitchToken &token, const OBSData &data); +const char *GetClientID(); + +} // namespace advss