Browse Source

UI: Add Twitch integration

jp9000 6 years ago
parent
commit
a88b440290
6 changed files with 484 additions and 0 deletions
  1. 19 0
      UI/CMakeLists.txt
  2. 409 0
      UI/auth-twitch.cpp
  3. 46 0
      UI/auth-twitch.hpp
  4. 2 0
      UI/data/locale/en-US.ini
  5. 4 0
      UI/ui-config.h.in
  6. 4 0
      UI/window-basic-main.cpp

+ 19 - 0
UI/CMakeLists.txt

@@ -19,6 +19,16 @@ project(obs)
 
 set(DISABLE_UPDATE_MODULE TRUE CACHE BOOL "Disables building the update module")
 
+if(NOT DEFINED TWITCH_CLIENTID OR "${TWITCH_CLIENTID}" STREQUAL "" OR
+   NOT DEFINED TWITCH_HASH     OR "${TWITCH_HASH}"     STREQUAL "" OR
+   NOT BROWSER_AVAILABLE_INTERNAL)
+	set(TWITCH_ENABLED FALSE)
+	set(TWITCH_CLIENTID "")
+	set(TWITCH_HASH "0")
+else()
+	set(TWITCH_ENABLED TRUE)
+endif()
+
 if(NOT DEFINED MIXER_CLIENTID OR "${MIXER_CLIENTID}" STREQUAL "" OR
    NOT DEFINED MIXER_HASH     OR "${MIXER_HASH}"     STREQUAL "" OR
    NOT BROWSER_AVAILABLE_INTERNAL)
@@ -130,6 +140,15 @@ if(BROWSER_AVAILABLE_INTERNAL)
 		auth-oauth.hpp
 		)
 
+	if(TWITCH_ENABLED)
+		list(APPEND obs_PLATFORM_SOURCES
+			auth-twitch.cpp
+			)
+		list(APPEND obs_PLATFORM_HEADERS
+			auth-twitch.hpp
+			)
+	endif()
+
 	if(MIXER_ENABLED)
 		list(APPEND obs_PLATFORM_SOURCES
 			auth-mixer.cpp

+ 409 - 0
UI/auth-twitch.cpp

@@ -0,0 +1,409 @@
+#include "auth-twitch.hpp"
+
+#include <QPushButton>
+#include <QHBoxLayout>
+#include <QVBoxLayout>
+
+#include <qt-wrappers.hpp>
+#include <obs-app.hpp>
+
+#include "window-basic-main.hpp"
+#include "remote-text.hpp"
+
+#include <json11.hpp>
+
+#include "ui-config.h"
+#include "obf.h"
+
+using namespace json11;
+
+#include <browser-panel.hpp>
+extern QCef *cef;
+extern QCefCookieManager *panel_cookies;
+
+/* ------------------------------------------------------------------------- */
+
+#define TWITCH_AUTH_URL \
+	"https://obsproject.com/app-auth/twitch?action=redirect"
+#define TWITCH_TOKEN_URL \
+	"https://obsproject.com/app-auth/twitch-token"
+#define ACCEPT_HEADER \
+	"Accept: application/vnd.twitchtv.v5+json"
+
+#define TWITCH_SCOPE_VERSION 1
+
+static Auth::Def twitchDef = {
+	"Twitch",
+	Auth::Type::OAuth_StreamKey
+};
+
+/* ------------------------------------------------------------------------- */
+
+TwitchAuth::TwitchAuth(const Def &d)
+	: OAuthStreamKey(d)
+{
+	cef->add_popup_whitelist_url(
+			"https://twitch.tv/popout/frankerfacez/chat?ffz-settings",
+			this);
+	uiLoadTimer.setSingleShot(true);
+	uiLoadTimer.setInterval(500);
+	connect(&uiLoadTimer, &QTimer::timeout,
+			this, &TwitchAuth::TryLoadSecondaryUIPanes);
+}
+
+bool TwitchAuth::GetChannelInfo()
+try {
+	std::string client_id = TWITCH_CLIENTID;
+	deobfuscate_str(&client_id[0], TWITCH_HASH);
+
+	if (!GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION))
+		return false;
+	if (token.empty())
+		return false;
+	if (!key_.empty())
+		return true;
+
+	std::string auth;
+	auth += "Authorization: OAuth ";
+	auth += token;
+
+	std::vector<std::string> headers;
+	headers.push_back(std::string("Client-ID: ") + client_id);
+	headers.push_back(ACCEPT_HEADER);
+	headers.push_back(std::move(auth));
+
+	std::string output;
+	std::string error;
+
+	bool success = false;
+
+	auto func = [&] () {
+		success = GetRemoteFile(
+				"https://api.twitch.tv/kraken/channel",
+				output,
+				error,
+				nullptr,
+				"application/json",
+				nullptr,
+				headers,
+				nullptr,
+				5);
+	};
+
+	ExecuteFuncSafeBlockMsgBox(
+			func,
+			QTStr("Auth.LoadingChannel.Title"),
+			QTStr("Auth.LoadingChannel.Text").arg(service()));
+	if (!success || output.empty())
+		throw ErrorInfo("Failed to get text from remote", error);
+
+	Json json = Json::parse(output, error);
+	if (!error.empty())
+		throw ErrorInfo("Failed to parse json", error);
+
+	error = json["error"].string_value();
+	if (!error.empty())
+		throw ErrorInfo(error, json["error_description"].string_value());
+
+	name = json["name"].string_value();
+	key_ = json["stream_key"].string_value();
+
+	return true;
+} catch (ErrorInfo info) {
+	QString title = QTStr("Auth.ChannelFailure.Title");
+	QString text = QTStr("Auth.ChannelFailure.Text")
+		.arg(service(), info.message.c_str(), info.error.c_str());
+
+	QMessageBox::warning(OBSBasic::Get(), title, text);
+
+	blog(LOG_WARNING, "%s: %s: %s",
+			__FUNCTION__,
+			info.message.c_str(),
+			info.error.c_str());
+	return false;
+}
+
+void TwitchAuth::SaveInternal()
+{
+	OBSBasic *main = OBSBasic::Get();
+	config_set_string(main->Config(), service(), "Name", name.c_str());
+	if (uiLoaded) {
+		config_set_string(main->Config(), service(), "DockState",
+				main->saveState().toBase64().constData());
+	}
+	OAuthStreamKey::SaveInternal();
+}
+
+static inline std::string get_config_str(
+		OBSBasic *main,
+		const char *section,
+		const char *name)
+{
+	const char *val = config_get_string(main->Config(), section, name);
+	return val ? val : "";
+}
+
+bool TwitchAuth::LoadInternal()
+{
+	OBSBasic *main = OBSBasic::Get();
+	name = get_config_str(main, service(), "Name");
+	firstLoad = false;
+	return OAuthStreamKey::LoadInternal();
+}
+
+class TwitchWidget : public QDockWidget {
+public:
+	inline TwitchWidget() : QDockWidget() {}
+
+	QScopedPointer<QCefWidget> widget;
+
+	inline void SetWidget(QCefWidget *widget_)
+	{
+		setWidget(widget_);
+		widget.reset(widget_);
+	}
+};
+
+static const char *ffz_script = "\
+var ffz = document.createElement('script');\
+ffz.setAttribute('src','https://cdn.frankerfacez.com/script/script.min.js');\
+document.head.appendChild(ffz);";
+
+static const char *bttv_script = "\
+localStorage.setItem('bttv_darkenedMode', true);\
+var bttv = document.createElement('script');\
+bttv.setAttribute('src','https://cdn.betterttv.net/betterttv.js');\
+document.head.appendChild(bttv);";
+
+static const char *referrer_script1 = "\
+Object.defineProperty(document, 'referrer', {get : function() { return '";
+static const char *referrer_script2 = "'; }});";
+
+void TwitchAuth::LoadUI()
+{
+	if (uiLoaded)
+		return;
+	if (!GetChannelInfo())
+		return;
+
+	OBSBasic::InitBrowserPanelSafeBlock(true);
+	OBSBasic *main = OBSBasic::Get();
+
+	QCefWidget *browser;
+	std::string url;
+	std::string script;
+
+	/* ----------------------------------- */
+
+	url = "https://www.twitch.tv/popout/";
+	url += name;
+	url += "/chat";
+
+	QSize size = main->frameSize();
+	QPoint pos = main->pos();
+
+	chat.reset(new TwitchWidget());
+	chat->setObjectName("twitchChat");
+	chat->resize(300, 600);
+	chat->setMinimumSize(200, 300);
+	chat->setWindowTitle(QTStr("Auth.Chat"));
+	chat->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	browser = cef->create_widget(nullptr, url, panel_cookies);
+	chat->SetWidget(browser);
+
+	script = bttv_script;
+	script += ffz_script;
+	browser->setStartupScript(script);
+
+	main->addDockWidget(Qt::RightDockWidgetArea, chat.data());
+	chatMenu.reset(main->AddDockWidget(chat.data()));
+
+	/* ----------------------------------- */
+
+	chat->setFloating(true);
+	chat->move(pos.x() + size.width() - chat->width() - 50, pos.y() + 50);
+
+	if (firstLoad) {
+		chat->setVisible(true);
+	} else {
+		const char *dockStateStr = config_get_string(main->Config(),
+				service(), "DockState");
+		QByteArray dockState =
+			QByteArray::fromBase64(QByteArray(dockStateStr));
+		main->restoreState(dockState);
+	}
+
+	TryLoadSecondaryUIPanes();
+
+	uiLoaded = true;
+}
+
+void TwitchAuth::LoadSecondaryUIPanes()
+{
+	OBSBasic *main = OBSBasic::Get();
+
+	QCefWidget *browser;
+	std::string url;
+	std::string script;
+
+	QPoint pos = main->pos();
+
+	script = "localStorage.setItem('twilight.theme', 1);";
+	script += referrer_script1;
+	script += "https://www.twitch.tv/";
+	script += name;
+	script += "/dashboard/live";
+	script += referrer_script2;
+	script += bttv_script;
+	script += ffz_script;
+
+	/* ----------------------------------- */
+
+	url = "https://www.twitch.tv/popout/";
+	url += name;
+	url += "/dashboard/live/stream-info";
+
+	info.reset(new TwitchWidget());
+	info->setObjectName("twitchInfo");
+	info->resize(300, 650);
+	info->setMinimumSize(200, 300);
+	info->setWindowTitle(QTStr("Auth.StreamInfo"));
+	info->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	browser = cef->create_widget(nullptr, url, panel_cookies);
+	info->SetWidget(browser);
+	browser->setStartupScript(script);
+
+	main->addDockWidget(Qt::RightDockWidgetArea, info.data());
+	infoMenu.reset(main->AddDockWidget(info.data()));
+
+	/* ----------------------------------- */
+
+	url = "https://www.twitch.tv/popout/";
+	url += name;
+	url += "/dashboard/live/stats";
+
+	stat.reset(new TwitchWidget());
+	stat->setObjectName("twitchStats");
+	stat->resize(200, 200);
+	stat->setMinimumSize(200, 200);
+	stat->setWindowTitle(QTStr("TwitchAuth.Stats"));
+	stat->setAllowedAreas(Qt::AllDockWidgetAreas);
+
+	browser = cef->create_widget(nullptr, url, panel_cookies);
+	stat->SetWidget(browser);
+	browser->setStartupScript(script);
+
+	main->addDockWidget(Qt::RightDockWidgetArea, stat.data());
+	statMenu.reset(main->AddDockWidget(stat.data()));
+
+	/* ----------------------------------- */
+
+	info->setFloating(true);
+	stat->setFloating(true);
+
+	info->move(pos.x() + 50, pos.y() + 50);
+
+	if (firstLoad) {
+		info->setVisible(true);
+		stat->setVisible(false);
+	} else {
+		const char *dockStateStr = config_get_string(main->Config(),
+				service(), "DockState");
+		QByteArray dockState =
+			QByteArray::fromBase64(QByteArray(dockStateStr));
+		main->restoreState(dockState);
+	}
+}
+
+/* Twitch.tv has an OAuth for itself.  If we try to load multiple panel pages
+ * at once before it's OAuth'ed itself, they will all try to perform the auth
+ * process at the same time, get their own request codes, and only the last
+ * code will be valid -- so one or more panels are guaranteed to fail.
+ *
+ * To solve this, we want to load just one panel first (the chat), and then all
+ * subsequent panels should only be loaded once we know that Twitch has auth'ed
+ * itself (if the cookie "auth-token" exists for twitch.tv).
+ *
+ * This is annoying to deal with. */
+void TwitchAuth::TryLoadSecondaryUIPanes()
+{
+	QPointer<TwitchAuth> this_ = this;
+
+	auto cb = [this_] (bool found)
+	{
+		if (!this_) {
+			return;
+		}
+
+		if (!found) {
+			QMetaObject::invokeMethod(&this_->uiLoadTimer,
+					"start");
+		} else {
+			QMetaObject::invokeMethod(this_, "LoadSecondaryUIPanes");
+		}
+	};
+
+	panel_cookies->CheckForCookie("https://www.twitch.tv", "auth-token", cb);
+}
+
+bool TwitchAuth::RetryLogin()
+{
+	OAuthLogin login(OBSBasic::Get(), TWITCH_AUTH_URL, false);
+	if (login.exec() == QDialog::Rejected) {
+		return false;
+	}
+
+	std::shared_ptr<TwitchAuth> auth = std::make_shared<TwitchAuth>(twitchDef);
+	std::string client_id = TWITCH_CLIENTID;
+	deobfuscate_str(&client_id[0], TWITCH_HASH);
+
+	return GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION,
+			QT_TO_UTF8(login.GetCode()), true);
+}
+
+std::shared_ptr<Auth> TwitchAuth::Login(QWidget *parent)
+{
+	OAuthLogin login(parent, TWITCH_AUTH_URL, false);
+	if (login.exec() == QDialog::Rejected) {
+		return nullptr;
+	}
+
+	std::shared_ptr<TwitchAuth> auth = std::make_shared<TwitchAuth>(twitchDef);
+
+	std::string client_id = TWITCH_CLIENTID;
+	deobfuscate_str(&client_id[0], TWITCH_HASH);
+
+	if (!auth->GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION,
+				QT_TO_UTF8(login.GetCode()))) {
+		return nullptr;
+	}
+
+	std::string error;
+	if (auth->GetChannelInfo()) {
+		return auth;
+	}
+
+	return nullptr;
+}
+
+static std::shared_ptr<Auth> CreateTwitchAuth()
+{
+	return std::make_shared<TwitchAuth>(twitchDef);
+}
+
+static void DeleteCookies()
+{
+	if (panel_cookies)
+		panel_cookies->DeleteCookies("twitch.tv", std::string());
+}
+
+void RegisterTwitchAuth()
+{
+	OAuth::RegisterOAuth(
+			twitchDef,
+			CreateTwitchAuth,
+			TwitchAuth::Login,
+			DeleteCookies);
+}

+ 46 - 0
UI/auth-twitch.hpp

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <QDialog>
+#include <QTimer>
+#include <string>
+#include <memory>
+
+#include "auth-oauth.hpp"
+
+class TwitchWidget;
+
+class TwitchAuth : public OAuthStreamKey {
+	Q_OBJECT
+
+	friend class TwitchLogin;
+
+	QSharedPointer<TwitchWidget> chat;
+	QSharedPointer<TwitchWidget> info;
+	QSharedPointer<TwitchWidget> stat;
+	QSharedPointer<QAction> chatMenu;
+	QSharedPointer<QAction> infoMenu;
+	QSharedPointer<QAction> statMenu;
+	bool uiLoaded = false;
+
+	std::string name;
+
+	virtual bool RetryLogin() override;
+
+	virtual void SaveInternal() override;
+	virtual bool LoadInternal() override;
+
+	bool GetChannelInfo();
+
+	virtual void LoadUI() override;
+
+public:
+	TwitchAuth(const Def &d);
+
+	static std::shared_ptr<Auth> Login(QWidget *parent);
+
+	QTimer uiLoadTimer;
+
+public slots:
+	void TryLoadSecondaryUIPanes();
+	void LoadSecondaryUIPanes();
+};

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

@@ -103,6 +103,8 @@ Auth.LoadingChannel.Text="Loading channel information for %1, please wait.."
 Auth.ChannelFailure.Title="Failed to load channel"
 Auth.ChannelFailure.Text="Failed to load channel information for %1\n\n%2: %3"
 Auth.Chat="Chat"
+Auth.StreamInfo="Stream Information"
+TwitchAuth.Stats="Twitch Stats"
 
 # copy filters
 Copy.Filters="Copy Filters"

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

@@ -16,6 +16,10 @@
 #define OFF 0
 #endif
 
+#define TWITCH_ENABLED  @TWITCH_ENABLED@
+#define TWITCH_CLIENTID "@TWITCH_CLIENTID@"
+#define TWITCH_HASH     0x@TWITCH_HASH@
+
 #define MIXER_ENABLED  @MIXER_ENABLED@
 #define MIXER_CLIENTID "@MIXER_CLIENTID@"
 #define MIXER_HASH     0x@MIXER_HASH@

+ 4 - 0
UI/window-basic-main.cpp

@@ -191,6 +191,7 @@ void assignDockToggle(QDockWidget *dock, QAction *action)
 			handleMenuToggle);
 }
 
+extern void RegisterTwitchAuth();
 extern void RegisterMixerAuth();
 
 OBSBasic::OBSBasic(QWidget *parent)
@@ -199,6 +200,9 @@ OBSBasic::OBSBasic(QWidget *parent)
 {
 	setAttribute(Qt::WA_NativeWindow);
 
+#if TWITCH_ENABLED
+	RegisterTwitchAuth();
+#endif
 #if MIXER_ENABLED
 	RegisterMixerAuth();
 #endif