Add Twitch action

Allows you to set the stream title and stream category
This commit is contained in:
WarmUpTill 2023-08-16 16:33:13 +02:00 committed by WarmUpTill
parent 9df6963f08
commit a5050d4810
14 changed files with 1814 additions and 0 deletions

View File

@ -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'

View File

@ -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 channels 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"

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve">
<defs>
</defs>
<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" >
<path d="M 87.803 77.194 L 68.212 57.602 c 9.5 -14.422 7.912 -34.054 -4.766 -46.732 c 0 0 -0.001 0 -0.001 0 c -14.495 -14.493 -38.08 -14.494 -52.574 0 c -14.494 14.495 -14.494 38.079 0 52.575 c 7.248 7.247 16.767 10.87 26.287 10.87 c 7.134 0 14.267 -2.035 20.445 -6.104 l 19.591 19.591 C 78.659 89.267 80.579 90 82.498 90 s 3.84 -0.733 5.305 -2.197 C 90.732 84.873 90.732 80.124 87.803 77.194 z M 21.48 52.837 c -8.645 -8.646 -8.645 -22.713 0 -31.358 c 4.323 -4.322 10 -6.483 15.679 -6.483 c 5.678 0 11.356 2.161 15.678 6.483 c 8.644 8.644 8.645 22.707 0.005 31.352 c -0.002 0.002 -0.004 0.003 -0.005 0.005 c -0.002 0.002 -0.003 0.003 -0.004 0.005 C 44.184 61.481 30.123 61.48 21.48 52.837 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: #fefefe; fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve">
<defs>
</defs>
<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" >
<path d="M 87.803 77.194 L 68.212 57.602 c 9.5 -14.422 7.912 -34.054 -4.766 -46.732 c 0 0 -0.001 0 -0.001 0 c -14.495 -14.493 -38.08 -14.494 -52.574 0 c -14.494 14.495 -14.494 38.079 0 52.575 c 7.248 7.247 16.767 10.87 26.287 10.87 c 7.134 0 14.267 -2.035 20.445 -6.104 l 19.591 19.591 C 78.659 89.267 80.579 90 82.498 90 s 3.84 -0.733 5.305 -2.197 C 90.732 84.873 90.732 80.124 87.803 77.194 z M 21.48 52.837 c -8.645 -8.646 -8.645 -22.713 0 -31.358 c 4.323 -4.322 10 -6.483 15.679 -6.483 c 5.678 0 11.356 2.161 15.678 6.483 c 8.644 8.644 8.645 22.707 0.005 31.352 c -0.002 0.002 -0.004 0.003 -0.005 0.005 c -0.002 0.002 -0.003 0.003 -0.004 0.005 C 44.184 61.481 30.123 61.48 21.48 52.837 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: #202020; fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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)

View File

@ -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()

View File

@ -0,0 +1,363 @@
#include "category-selection.hpp"
#include "token.hpp"
#include "twitch-helpers.hpp"
#include <utility.hpp>
#include <name-dialog.hpp>
#include <obs-module-helper.hpp>
#include <obs.hpp>
#include <QVBoxLayout>
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<QString, int> 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<TwitchToken> &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<QString, int> CategoryGrabber::_categoryMap = {};
std::mutex CategoryGrabber::_mtx = {};
void CategoryGrabber::Start(const std::shared_ptr<TwitchToken> &token,
const std::string search)
{
_searchString = search;
_token = token;
_stop = false;
start();
}
void CategoryGrabber::Stop()
{
_stop = true;
}
const std::map<QString, int> &CategoryGrabber::GetCategories()
{
return _categoryMap;
}
void CategoryGrabber::run()
{
if (!_token) {
return;
emit Failed();
}
{
std::lock_guard<std::mutex> 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<TwitchToken> &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

View File

@ -0,0 +1,129 @@
#pragma once
#include <filter-combo-box.hpp>
#include <string>
#include <obs-data.h>
#include <QThread>
#include <QDialog>
#include <QLabel>
#include <QCheckBox>
#include <QPushButton>
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<TwitchToken> &token,
const std::string searchString = "");
void Stop();
const std::map<QString, int> &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<TwitchToken> _token;
static std::map<QString, int> _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<TwitchToken> &);
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<TwitchToken> _token;
static bool _fetchingCategoriesDone;
static std::map<QString, int> _streamingCategories;
};
class TwitchCategorySearchButton : public QPushButton {
Q_OBJECT
public:
TwitchCategorySearchButton();
void SetToken(const std::weak_ptr<TwitchToken> &);
private slots:
void StartManualCategorySearch();
signals:
void RequestRepopulate();
private:
std::weak_ptr<TwitchToken> _token;
};
// Helper class to ease singal / slot handling
class TwitchCategorySignalManager : public QObject {
Q_OBJECT
public:
static TwitchCategorySignalManager *Instance();
signals:
void RepopulateRequired();
};
} // namespace advss

View File

@ -0,0 +1,242 @@
#include "macro-action-twitch.hpp"
#include "twitch-helpers.hpp"
#include <log-helper.hpp>
#include <utility.hpp>
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<MacroActionTwitch::Action, std::string> actionTypes = {
{MacroActionTwitch::Action::TITLE,
"AdvSceneSwitcher.action.twitch.type.title"},
{MacroActionTwitch::Action::CATEGORY,
"AdvSceneSwitcher.action.twitch.type.category"},
};
void MacroActionTwitch::SetStreamTitle(const std::shared_ptr<TwitchToken> &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<TwitchToken> &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<int>(_action));
}
}
bool MacroActionTwitch::Save(obs_data_t *obj) const
{
MacroAction::Save(obj);
obs_data_set_int(obj, "action", static_cast<int>(_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<Action>(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<MacroActionTwitch> 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<int>(_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<MacroActionTwitch::Action>(value);
SetupWidgetVisibility();
}
} // namespace advss

View File

@ -0,0 +1,82 @@
#pragma once
#include "macro-action-edit.hpp"
#include "token.hpp"
#include "category-selection.hpp"
#include <variable-line-edit.hpp>
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<MacroAction> Create(Macro *m)
{
return std::make_shared<MacroActionTwitch>(m);
}
enum class Action {
TITLE,
CATEGORY,
};
Action _action = Action::TITLE;
std::weak_ptr<TwitchToken> _token;
StringVariable _text = obs_module_text("AdvSceneSwitcher.enterText");
TwitchCategory _category;
private:
void SetStreamTitle(const std::shared_ptr<TwitchToken> &);
void SetStreamCategory(const std::shared_ptr<TwitchToken> &);
static bool _registered;
static const std::string id;
};
class MacroActionTwitchEdit : public QWidget {
Q_OBJECT
public:
MacroActionTwitchEdit(
QWidget *parent,
std::shared_ptr<MacroActionTwitch> entryData = nullptr);
void UpdateEntryData();
static QWidget *Create(QWidget *parent,
std::shared_ptr<MacroAction> action)
{
return new MacroActionTwitchEdit(
parent,
std::dynamic_pointer_cast<MacroActionTwitch>(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<MacroActionTwitch> _entryData;
private:
void SetupWidgetVisibility();
QComboBox *_actions;
TwitchConnectionSelection *_tokens;
VariableLineEdit *_text;
TwitchCategorySelection *_category;
TwitchCategorySearchButton *_manualCategorySearch;
QHBoxLayout *_layout;
bool _loading = true;
};
} // namespace advss

View File

@ -0,0 +1,623 @@
#include "token.hpp"
#include "twitch-helpers.hpp"
#include <switcher-data.hpp>
#include <utility.hpp>
#include <QScrollArea>
#include <QDesktopServices>
namespace advss {
static std::deque<std::shared_ptr<Item>> twitchTokens;
const std::unordered_map<std::string, std::string> 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<std::string, std::string> &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<TwitchToken *>(t.get());
}
}
return nullptr;
}
std::weak_ptr<TwitchToken> GetWeakTwitchTokenByName(const std::string &name)
{
for (const auto &t : twitchTokens) {
if (t->Name() == name) {
std::weak_ptr<TwitchToken> wp =
std::dynamic_pointer_cast<TwitchToken>(t);
return wp;
}
}
return std::weak_ptr<TwitchToken>();
}
std::weak_ptr<TwitchToken> GetWeakTwitchTokenByQString(const QString &name)
{
return GetWeakTwitchTokenByName(name.toStdString());
}
std::string GetWeakTwitchTokenName(std::weak_ptr<TwitchToken> 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<TwitchToken &>(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<TwitchToken> &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<size_t> 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<TokenOption> &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"(
<html>
<head>
<title>Advanced scene switcher</title>
</head>
<body>
<div id="output">Please click this link to continue if not automatically redirected</div>
<p><a href="%1">Login with Twitch</a></a></p>
<script type="text/javascript">
if (document.location.hash && document.location.hash != '') {
var parsedHash = new URLSearchParams(window.location.hash.slice(1));
if (parsedHash.get('access_token')) {
window.location.replace(`http://localhost:8080/token?access_token=${parsedHash.get('access_token')}&state=${parsedHash.get('state')}`);
output.textContent = 'It is safe to close this window';
}
} else {
window.location.replace('%1');
}
</script>
</body>
</html>)";
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<QString> &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<TokenOption> TwitchTokenSettingsDialog::GetEnabledOptions()
{
std::set<TokenOption> 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<std::mutex> 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<std::mutex> 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

View File

@ -0,0 +1,140 @@
#pragma once
#include <item-selection-helpers.hpp>
#include <httplib.h>
#include <set>
#include <QCheckBox>
#include <QThread>
#include <optional>
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<std::string, std::string> &
GetTokenMap();
bool operator<(const TokenOption &other) const;
std::string apiId = "";
private:
const static std::unordered_map<std::string, std::string> apiIdToLocale;
};
class TwitchToken : public Item {
public:
static std::shared_ptr<Item> Create()
{
return std::make_shared<TwitchToken>();
}
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<TokenOption> _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<QString> &);
protected:
void run() override;
private:
void Stop();
QString _scope;
std::optional<QString> _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<QString> &);
private:
std::set<TokenOption> GetEnabledOptions();
QPushButton *_requestToken;
QPushButton *_showToken;
QLineEdit *_currentTokenValue;
QLabel *_tokenStatus;
TokenGrabberThread _tokenGrabber;
TwitchToken _currentToken;
std::unordered_map<std::string, QCheckBox *> _optionWidgets;
};
class TwitchConnectionSelection : public ItemSelection {
Q_OBJECT
public:
TwitchConnectionSelection(QWidget *parent = 0);
void SetToken(const std::string &);
void SetToken(const std::weak_ptr<TwitchToken> &);
};
// 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<TwitchToken> GetWeakTwitchTokenByName(const std::string &name);
std::weak_ptr<TwitchToken> GetWeakTwitchTokenByQString(const QString &name);
std::string GetWeakTwitchTokenName(std::weak_ptr<TwitchToken>);
} // namespace advss
Q_DECLARE_METATYPE(advss::TwitchToken *);

View File

@ -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 &params)
{
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 &params)
{
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

View File

@ -0,0 +1,25 @@
#pragma once
#include <httplib.h>
#include <obs.hpp>
#include <string>
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 &params = {});
RequestResult SendPatchRequest(const std::string &uri, const std::string &path,
const TwitchToken &token, const OBSData &data);
const char *GetClientID();
} // namespace advss