obs-studio/UI/youtube-api-wrappers.cpp

606 lines
18 KiB
C++

#include "youtube-api-wrappers.hpp"
#include <QUrl>
#include <QMimeDatabase>
#include <QFile>
#include <string>
#include <iostream>
#include "auth-youtube.hpp"
#include "obs-app.hpp"
#include "qt-wrappers.hpp"
#include "remote-text.hpp"
#include "ui-config.h"
#include "obf.h"
using namespace json11;
/* ------------------------------------------------------------------------- */
#define YOUTUBE_LIVE_API_URL "https://www.googleapis.com/youtube/v3"
#define YOUTUBE_LIVE_STREAM_URL YOUTUBE_LIVE_API_URL "/liveStreams"
#define YOUTUBE_LIVE_BROADCAST_URL YOUTUBE_LIVE_API_URL "/liveBroadcasts"
#define YOUTUBE_LIVE_BROADCAST_TRANSITION_URL \
YOUTUBE_LIVE_BROADCAST_URL "/transition"
#define YOUTUBE_LIVE_BROADCAST_BIND_URL YOUTUBE_LIVE_BROADCAST_URL "/bind"
#define YOUTUBE_LIVE_CHANNEL_URL YOUTUBE_LIVE_API_URL "/channels"
#define YOUTUBE_LIVE_TOKEN_URL "https://oauth2.googleapis.com/token"
#define YOUTUBE_LIVE_VIDEOCATEGORIES_URL YOUTUBE_LIVE_API_URL "/videoCategories"
#define YOUTUBE_LIVE_VIDEOS_URL YOUTUBE_LIVE_API_URL "/videos"
#define YOUTUBE_LIVE_CHAT_MESSAGES_URL YOUTUBE_LIVE_API_URL "/liveChat/messages"
#define YOUTUBE_LIVE_THUMBNAIL_URL \
"https://www.googleapis.com/upload/youtube/v3/thumbnails/set"
#define DEFAULT_BROADCASTS_PER_QUERY \
"50" // acceptable values are 0 to 50, inclusive
/* ------------------------------------------------------------------------- */
bool IsYouTubeService(const std::string &service)
{
auto it = find_if(youtubeServices.begin(), youtubeServices.end(),
[&service](const Auth::Def &yt) {
return service == yt.service;
});
return it != youtubeServices.end();
}
bool YoutubeApiWrappers::GetTranslatedError(QString &error_message)
{
QString translated =
QTStr("YouTube.Errors." + lastErrorReason.toUtf8());
// No translation found
if (translated.startsWith("YouTube.Errors."))
return false;
error_message = translated;
return true;
}
YoutubeApiWrappers::YoutubeApiWrappers(const Def &d) : YoutubeAuth(d) {}
bool YoutubeApiWrappers::TryInsertCommand(const char *url,
const char *content_type,
std::string request_type,
const char *data, Json &json_out,
long *error_code, int data_size)
{
long httpStatusCode = 0;
#ifdef _DEBUG
blog(LOG_DEBUG, "YouTube API command URL: %s", url);
if (data && data[0] == '{') // only log JSON data
blog(LOG_DEBUG, "YouTube API command data: %s", data);
#endif
if (token.empty())
return false;
std::string output;
std::string error;
// Increase timeout by the time it takes to transfer `data_size` at 1 Mbps
int timeout = 5 + data_size / 125000;
bool success = GetRemoteFile(url, output, error, &httpStatusCode,
content_type, request_type, data,
{"Authorization: Bearer " + token},
nullptr, timeout, false, data_size);
if (error_code)
*error_code = httpStatusCode;
if (!success || output.empty()) {
if (!error.empty())
blog(LOG_WARNING, "YouTube API request failed: %s",
error.c_str());
return false;
}
json_out = Json::parse(output, error);
#ifdef _DEBUG
blog(LOG_DEBUG, "YouTube API command answer: %s",
json_out.dump().c_str());
#endif
if (!error.empty()) {
return false;
}
return httpStatusCode < 400;
}
bool YoutubeApiWrappers::UpdateAccessToken()
{
if (refresh_token.empty()) {
return false;
}
std::string clientid = YOUTUBE_CLIENTID;
std::string secret = YOUTUBE_SECRET;
deobfuscate_str(&clientid[0], YOUTUBE_CLIENTID_HASH);
deobfuscate_str(&secret[0], YOUTUBE_SECRET_HASH);
std::string r_token =
QUrl::toPercentEncoding(refresh_token.c_str()).toStdString();
const QString url = YOUTUBE_LIVE_TOKEN_URL;
const QString data_template = "client_id=%1"
"&client_secret=%2"
"&refresh_token=%3"
"&grant_type=refresh_token";
const QString data = data_template.arg(QString(clientid.c_str()),
QString(secret.c_str()),
QString(r_token.c_str()));
Json json_out;
bool success = TryInsertCommand(QT_TO_UTF8(url),
"application/x-www-form-urlencoded", "",
QT_TO_UTF8(data), json_out);
if (!success || json_out.object_items().find("error") !=
json_out.object_items().end())
return false;
token = json_out["access_token"].string_value();
return token.empty() ? false : true;
}
bool YoutubeApiWrappers::InsertCommand(const char *url,
const char *content_type,
std::string request_type,
const char *data, Json &json_out,
int data_size)
{
long error_code;
bool success = TryInsertCommand(url, content_type, request_type, data,
json_out, &error_code, data_size);
if (error_code == 401) {
// Attempt to update access token and try again
if (!UpdateAccessToken())
return false;
success = TryInsertCommand(url, content_type, request_type,
data, json_out, &error_code,
data_size);
}
if (json_out.object_items().find("error") !=
json_out.object_items().end()) {
blog(LOG_ERROR,
"YouTube API error:\n\tHTTP status: %ld\n\tURL: %s\n\tJSON: %s",
error_code, url, json_out.dump().c_str());
lastError = json_out["error"]["code"].int_value();
lastErrorReason =
QString(json_out["error"]["errors"][0]["reason"]
.string_value()
.c_str());
lastErrorMessage = QString(
json_out["error"]["message"].string_value().c_str());
// The existence of an error implies non-success even if the HTTP status code disagrees.
success = false;
}
return success;
}
bool YoutubeApiWrappers::GetChannelDescription(
ChannelDescription &channel_description)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QByteArray url = YOUTUBE_LIVE_CHANNEL_URL
"?part=snippet,contentDetails,statistics"
"&mine=true";
Json json_out;
if (!InsertCommand(url, "application/json", "", nullptr, json_out)) {
return false;
}
if (json_out["pageInfo"]["totalResults"].int_value() == 0) {
lastErrorMessage = QTStr("YouTube.Auth.NoChannels");
return false;
}
channel_description.id =
QString(json_out["items"][0]["id"].string_value().c_str());
channel_description.title = QString(
json_out["items"][0]["snippet"]["title"].string_value().c_str());
return channel_description.id.isEmpty() ? false : true;
}
bool YoutubeApiWrappers::InsertBroadcast(BroadcastDescription &broadcast)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
"?part=snippet,status,contentDetails";
const Json data = Json::object{
{"snippet",
Json::object{
{"title", QT_TO_UTF8(broadcast.title)},
{"description", QT_TO_UTF8(broadcast.description)},
{"scheduledStartTime",
QT_TO_UTF8(broadcast.schedul_date_time)},
}},
{"status",
Json::object{
{"privacyStatus", QT_TO_UTF8(broadcast.privacy)},
{"selfDeclaredMadeForKids", broadcast.made_for_kids},
}},
{"contentDetails",
Json::object{
{"latencyPreference", QT_TO_UTF8(broadcast.latency)},
{"enableAutoStart", broadcast.auto_start},
{"enableAutoStop", broadcast.auto_stop},
{"enableDvr", broadcast.dvr},
{"projection", QT_TO_UTF8(broadcast.projection)},
{
"monitorStream",
Json::object{
{"enableMonitorStream", false},
},
},
}},
};
Json json_out;
if (!InsertCommand(url, "application/json", "", data.dump().c_str(),
json_out)) {
return false;
}
broadcast.id = QString(json_out["id"].string_value().c_str());
return broadcast.id.isEmpty() ? false : true;
}
bool YoutubeApiWrappers::InsertStream(StreamDescription &stream)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QByteArray url = YOUTUBE_LIVE_STREAM_URL
"?part=snippet,cdn,status,contentDetails";
const Json data = Json::object{
{"snippet",
Json::object{
{"title", QT_TO_UTF8(stream.title)},
}},
{"cdn",
Json::object{
{"frameRate", "variable"},
{"ingestionType", "rtmp"},
{"resolution", "variable"},
}},
{"contentDetails", Json::object{{"isReusable", false}}},
};
Json json_out;
if (!InsertCommand(url, "application/json", "", data.dump().c_str(),
json_out)) {
return false;
}
stream.id = QString(json_out["id"].string_value().c_str());
stream.name = QString(json_out["cdn"]["ingestionInfo"]["streamName"]
.string_value()
.c_str());
return stream.id.isEmpty() ? false : true;
}
bool YoutubeApiWrappers::BindStream(const QString broadcast_id,
const QString stream_id,
json11::Json &json_out)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QString url_template = YOUTUBE_LIVE_BROADCAST_BIND_URL
"?id=%1"
"&streamId=%2"
"&part=id,snippet,contentDetails,status";
const QString url = url_template.arg(broadcast_id, stream_id);
const Json data = Json::object{};
this->broadcast_id = broadcast_id;
return InsertCommand(QT_TO_UTF8(url), "application/json", "",
data.dump().c_str(), json_out);
}
bool YoutubeApiWrappers::GetBroadcastsList(Json &json_out, const QString &page,
const QString &status)
{
lastErrorMessage.clear();
lastErrorReason.clear();
QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
"?part=snippet,contentDetails,status"
"&broadcastType=all&maxResults=" DEFAULT_BROADCASTS_PER_QUERY;
if (status.isEmpty())
url += "&mine=true";
else
url += "&broadcastStatus=" + status.toUtf8();
if (!page.isEmpty())
url += "&pageToken=" + page.toUtf8();
return InsertCommand(url, "application/json", "", nullptr, json_out);
}
bool YoutubeApiWrappers::GetVideoCategoriesList(
QVector<CategoryDescription> &category_list_out)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QString url_template = YOUTUBE_LIVE_VIDEOCATEGORIES_URL
"?part=snippet"
"&regionCode=%1"
"&hl=%2";
/*
* All OBS locale regions aside from "US" are missing category id 29
* ("Nonprofits & Activism"), but it is still available to channels
* set to those regions via the YouTube Studio website.
* To work around this inconsistency with the API all locales will
* use the "US" region and only set the language part for localisation.
* It is worth noting that none of the regions available on YouTube
* feature any category not also available to the "US" region.
*/
QString url = url_template.arg("US", QLocale().name());
Json json_out;
if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr,
json_out)) {
if (lastErrorReason != "unsupportedLanguageCode" &&
lastErrorReason != "invalidLanguage")
return false;
// Try again with en-US if YouTube error indicates an unsupported locale
url = url_template.arg("US", "en_US");
if (!InsertCommand(QT_TO_UTF8(url), "application/json", "",
nullptr, json_out))
return false;
}
category_list_out = {};
for (auto &j : json_out["items"].array_items()) {
// Assignable only.
if (j["snippet"]["assignable"].bool_value()) {
category_list_out.push_back(
{j["id"].string_value().c_str(),
j["snippet"]["title"].string_value().c_str()});
}
}
return category_list_out.isEmpty() ? false : true;
}
bool YoutubeApiWrappers::SetVideoCategory(const QString &video_id,
const QString &video_title,
const QString &video_description,
const QString &categorie_id)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QByteArray url = YOUTUBE_LIVE_VIDEOS_URL "?part=snippet";
const Json data = Json::object{
{"id", QT_TO_UTF8(video_id)},
{"snippet",
Json::object{
{"title", QT_TO_UTF8(video_title)},
{"description", QT_TO_UTF8(video_description)},
{"categoryId", QT_TO_UTF8(categorie_id)},
}},
};
Json json_out;
return InsertCommand(url, "application/json", "PUT",
data.dump().c_str(), json_out);
}
bool YoutubeApiWrappers::SetVideoThumbnail(const QString &video_id,
const QString &thumbnail_file)
{
lastErrorMessage.clear();
lastErrorReason.clear();
// Make sure the file hasn't been deleted since originally selecting it
if (!QFile::exists(thumbnail_file)) {
lastErrorMessage = QTStr("YouTube.Actions.Error.FileMissing");
return false;
}
QFile thumbFile(thumbnail_file);
if (!thumbFile.open(QFile::ReadOnly)) {
lastErrorMessage =
QTStr("YouTube.Actions.Error.FileOpeningFailed");
return false;
}
const QByteArray fileContents = thumbFile.readAll();
const QString mime =
QMimeDatabase().mimeTypeForData(fileContents).name();
const QString url = YOUTUBE_LIVE_THUMBNAIL_URL "?videoId=" + video_id;
Json json_out;
return InsertCommand(QT_TO_UTF8(url), QT_TO_UTF8(mime), "POST",
fileContents.constData(), json_out,
fileContents.size());
}
bool YoutubeApiWrappers::StartBroadcast(const QString &broadcast_id)
{
lastErrorMessage.clear();
lastErrorReason.clear();
Json json_out;
if (!FindBroadcast(broadcast_id, json_out))
return false;
auto lifeCycleStatus =
json_out["items"][0]["status"]["lifeCycleStatus"].string_value();
if (lifeCycleStatus == "live" || lifeCycleStatus == "liveStarting")
// Broadcast is already (going to be) live
return true;
else if (lifeCycleStatus == "testStarting") {
// User will need to wait a few seconds before attempting to start broadcast
lastErrorMessage =
QTStr("YouTube.Actions.Error.BroadcastTestStarting");
lastErrorReason.clear();
return false;
}
// Only reset if broadcast has monitoring enabled and is not already in "testing" mode
auto monitorStreamEnabled =
json_out["items"][0]["contentDetails"]["monitorStream"]
["enableMonitorStream"]
.bool_value();
if (lifeCycleStatus != "testing" && monitorStreamEnabled &&
!ResetBroadcast(broadcast_id, json_out))
return false;
const QString url_template = YOUTUBE_LIVE_BROADCAST_TRANSITION_URL
"?id=%1"
"&broadcastStatus=%2"
"&part=status";
const QString live = url_template.arg(broadcast_id, "live");
bool success = InsertCommand(QT_TO_UTF8(live), "application/json",
"POST", "{}", json_out);
// Return a success if the command failed, but was redundant (broadcast already live)
return success || lastErrorReason == "redundantTransition";
}
bool YoutubeApiWrappers::StartLatestBroadcast()
{
return StartBroadcast(this->broadcast_id);
}
bool YoutubeApiWrappers::StopBroadcast(const QString &broadcast_id)
{
lastErrorMessage.clear();
lastErrorReason.clear();
const QString url_template = YOUTUBE_LIVE_BROADCAST_TRANSITION_URL
"?id=%1"
"&broadcastStatus=complete"
"&part=status";
const QString url = url_template.arg(broadcast_id);
Json json_out;
bool success = InsertCommand(QT_TO_UTF8(url), "application/json",
"POST", "{}", json_out);
// Return a success if the command failed, but was redundant (broadcast already stopped)
return success || lastErrorReason == "redundantTransition";
}
bool YoutubeApiWrappers::StopLatestBroadcast()
{
return StopBroadcast(this->broadcast_id);
}
void YoutubeApiWrappers::SetBroadcastId(QString &broadcast_id)
{
this->broadcast_id = broadcast_id;
}
QString YoutubeApiWrappers::GetBroadcastId()
{
return this->broadcast_id;
}
bool YoutubeApiWrappers::ResetBroadcast(const QString &broadcast_id,
json11::Json &json_out)
{
lastErrorMessage.clear();
lastErrorReason.clear();
auto snippet = json_out["items"][0]["snippet"];
auto status = json_out["items"][0]["status"];
auto contentDetails = json_out["items"][0]["contentDetails"];
auto monitorStream = contentDetails["monitorStream"];
const Json data = Json::object{
{"id", QT_TO_UTF8(broadcast_id)},
{"snippet",
Json::object{
{"title", snippet["title"]},
{"description", snippet["description"]},
{"scheduledStartTime", snippet["scheduledStartTime"]},
{"scheduledEndTime", snippet["scheduledEndTime"]},
}},
{"status",
Json::object{
{"privacyStatus", status["privacyStatus"]},
{"madeForKids", status["madeForKids"]},
{"selfDeclaredMadeForKids",
status["selfDeclaredMadeForKids"]},
}},
{"contentDetails",
Json::object{
{
"monitorStream",
Json::object{
{"enableMonitorStream", false},
{"broadcastStreamDelayMs",
monitorStream["broadcastStreamDelayMs"]},
},
},
{"enableAutoStart", contentDetails["enableAutoStart"]},
{"enableAutoStop", contentDetails["enableAutoStop"]},
{"enableClosedCaptions",
contentDetails["enableClosedCaptions"]},
{"enableDvr", contentDetails["enableDvr"]},
{"enableContentEncryption",
contentDetails["enableContentEncryption"]},
{"enableEmbed", contentDetails["enableEmbed"]},
{"recordFromStart", contentDetails["recordFromStart"]},
{"startWithSlate", contentDetails["startWithSlate"]},
}},
};
const QString put = YOUTUBE_LIVE_BROADCAST_URL
"?part=id,snippet,contentDetails,status";
return InsertCommand(QT_TO_UTF8(put), "application/json", "PUT",
data.dump().c_str(), json_out);
}
bool YoutubeApiWrappers::FindBroadcast(const QString &id,
json11::Json &json_out)
{
lastErrorMessage.clear();
lastErrorReason.clear();
QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
"?part=id,snippet,contentDetails,status"
"&broadcastType=all&maxResults=1";
url += "&id=" + id.toUtf8();
if (!InsertCommand(url, "application/json", "", nullptr, json_out))
return false;
auto items = json_out["items"].array_items();
if (items.size() != 1) {
lastErrorMessage =
QTStr("YouTube.Actions.Error.BroadcastNotFound");
return false;
}
return true;
}
bool YoutubeApiWrappers::FindStream(const QString &id, json11::Json &json_out)
{
lastErrorMessage.clear();
lastErrorReason.clear();
QByteArray url = YOUTUBE_LIVE_STREAM_URL "?part=id,snippet,cdn,status"
"&maxResults=1";
url += "&id=" + id.toUtf8();
if (!InsertCommand(url, "application/json", "", nullptr, json_out))
return false;
auto items = json_out["items"].array_items();
if (items.size() != 1) {
lastErrorMessage = "No active broadcast found.";
return false;
}
return true;
}
bool YoutubeApiWrappers::SendChatMessage(const std::string &chat_id,
const QString &message)
{
QByteArray url = YOUTUBE_LIVE_CHAT_MESSAGES_URL "?part=snippet";
json11::Json json_in = Json::object{
{"snippet",
Json::object{
{"liveChatId", chat_id},
{"type", "textMessageEvent"},
{"textMessageDetails",
Json::object{{"messageText", QT_TO_UTF8(message)}}},
}}};
json11::Json json_out;
return InsertCommand(url, "application/json", "POST",
json_in.dump().c_str(), json_out);
}