#include "youtube-api-wrappers.hpp" #include #include #include #include #include #include "auth-youtube.hpp" #include "obs-app.hpp" #include "qt-wrappers.hpp" #include "remote-text.hpp" #include "ui-config.h" #include "obf.h" using namespace json11; /* ------------------------------------------------------------------------- */ #define YOUTUBE_LIVE_API_URL "https://www.googleapis.com/youtube/v3" #define YOUTUBE_LIVE_STREAM_URL YOUTUBE_LIVE_API_URL "/liveStreams" #define YOUTUBE_LIVE_BROADCAST_URL YOUTUBE_LIVE_API_URL "/liveBroadcasts" #define YOUTUBE_LIVE_BROADCAST_TRANSITION_URL \ YOUTUBE_LIVE_BROADCAST_URL "/transition" #define YOUTUBE_LIVE_BROADCAST_BIND_URL YOUTUBE_LIVE_BROADCAST_URL "/bind" #define YOUTUBE_LIVE_CHANNEL_URL YOUTUBE_LIVE_API_URL "/channels" #define YOUTUBE_LIVE_TOKEN_URL "https://oauth2.googleapis.com/token" #define YOUTUBE_LIVE_VIDEOCATEGORIES_URL YOUTUBE_LIVE_API_URL "/videoCategories" #define YOUTUBE_LIVE_VIDEOS_URL YOUTUBE_LIVE_API_URL "/videos" #define 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 &category_list_out) { lastErrorMessage.clear(); lastErrorReason.clear(); const QString url_template = YOUTUBE_LIVE_VIDEOCATEGORIES_URL "?part=snippet" "®ionCode=%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); }