123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- #include "YoutubeApiWrappers.hpp"
- #include <utility/RemoteTextThread.hpp>
- #include <utility/obf.h>
- #include <widgets/OBSBasic.hpp>
- #include <qt-wrappers.hpp>
- #include <ui-config.h>
- #include <QFile>
- #include <QMimeDatabase>
- #include "moc_YoutubeApiWrappers.cpp"
- 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_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 IsUserSignedIntoYT()
- {
- Auth *auth = OBSBasic::Get()->GetAuth();
- if (auth) {
- YoutubeApiWrappers *apiYouTube(dynamic_cast<YoutubeApiWrappers *>(auth));
- if (apiYouTube) {
- return true;
- }
- }
- return false;
- }
- 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 = 60 + 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)
- {
- 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;
- Json json_out;
- 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"
- "®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;
- }
|