YoutubeApiWrappers.cpp 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. #include "YoutubeApiWrappers.hpp"
  2. #include <utility/RemoteTextThread.hpp>
  3. #include <utility/obf.h>
  4. #include <widgets/OBSBasic.hpp>
  5. #include <qt-wrappers.hpp>
  6. #include <ui-config.h>
  7. #include <QFile>
  8. #include <QMimeDatabase>
  9. #include "moc_YoutubeApiWrappers.cpp"
  10. using namespace json11;
  11. namespace {
  12. using std::string_view_literals::operator""sv;
  13. constexpr auto youtubeLiveStreamUrl = "https://www.googleapis.com/youtube/v3/liveStreams"sv;
  14. constexpr auto youtubeLiveBroadcastUrl = "https://www.googleapis.com/youtube/v3/liveBroadcasts"sv;
  15. constexpr auto youtubeLiveBroadcastTransitionUrl = "https://www.googleapis.com/youtube/v3/liveBroadcasts/transition"sv;
  16. constexpr auto youtubeLiveBroadcastBindUrl = "https://www.googleapis.com/youtube/v3/liveBroadcasts/bind"sv;
  17. constexpr auto youtubeLiveChannelUrl = "https://www.googleapis.com/youtube/v3/channels"sv;
  18. constexpr auto youtubeLiveTokenUrl = "https://oauth2.googleapis.com/token"sv;
  19. constexpr auto youtubeLiveVideoCategoriesUrl = "https://www.googleapis.com/youtube/v3/videoCategories"sv;
  20. constexpr auto youtubeLiveVideosUrl = "https://www.googleapis.com/youtube/v3/videos"sv;
  21. constexpr auto youtubeLiveThumbnailUrl = "https://www.googleapis.com/upload/youtube/v3/thumbnails/set"sv;
  22. constexpr auto defaultBroadcastsPerQuery = 50; // acceptable values are 0 to 50, inclusive
  23. } // namespace
  24. bool IsYouTubeService(const std::string &service)
  25. {
  26. auto it = find_if(youtubeServices.begin(), youtubeServices.end(),
  27. [&service](const Auth::Def &yt) { return service == yt.service; });
  28. return it != youtubeServices.end();
  29. }
  30. bool IsUserSignedIntoYT()
  31. {
  32. Auth *auth = OBSBasic::Get()->GetAuth();
  33. if (auth) {
  34. YoutubeApiWrappers *apiYouTube(dynamic_cast<YoutubeApiWrappers *>(auth));
  35. if (apiYouTube) {
  36. return true;
  37. }
  38. }
  39. return false;
  40. }
  41. bool YoutubeApiWrappers::GetTranslatedError(QString &error_message)
  42. {
  43. const QString errorKey = "YouTube.Errors." + lastErrorReason.toUtf8();
  44. const QString translated = QTStr(QT_TO_UTF8(errorKey));
  45. // No translation found
  46. if (translated.startsWith("YouTube.Errors."))
  47. return false;
  48. error_message = translated;
  49. return true;
  50. }
  51. YoutubeApiWrappers::YoutubeApiWrappers(const Def &d) : YoutubeAuth(d) {}
  52. bool YoutubeApiWrappers::TryInsertCommand(const char *url, const char *content_type, std::string request_type,
  53. const char *data, Json &json_out, long *error_code, int data_size)
  54. {
  55. long httpStatusCode = 0;
  56. #ifdef _DEBUG
  57. blog(LOG_DEBUG, "YouTube API command URL: %s", url);
  58. if (data && data[0] == '{') // only log JSON data
  59. blog(LOG_DEBUG, "YouTube API command data: %s", data);
  60. #endif
  61. if (token.empty())
  62. return false;
  63. std::string output;
  64. std::string error;
  65. // Increase timeout by the time it takes to transfer `data_size` at 1 Mbps
  66. int timeout = 60 + data_size / 125000;
  67. bool success = GetRemoteFile(url, output, error, &httpStatusCode, content_type, request_type, data,
  68. {"Authorization: Bearer " + token}, nullptr, timeout, false, data_size);
  69. if (error_code)
  70. *error_code = httpStatusCode;
  71. if (!success || output.empty()) {
  72. if (!error.empty())
  73. blog(LOG_WARNING, "YouTube API request failed: %s", error.c_str());
  74. return false;
  75. }
  76. json_out = Json::parse(output, error);
  77. #ifdef _DEBUG
  78. blog(LOG_DEBUG, "YouTube API command answer: %s", json_out.dump().c_str());
  79. #endif
  80. if (!error.empty()) {
  81. return false;
  82. }
  83. return httpStatusCode < 400;
  84. }
  85. bool YoutubeApiWrappers::UpdateAccessToken()
  86. {
  87. if (refresh_token.empty()) {
  88. return false;
  89. }
  90. std::string clientid = YOUTUBE_CLIENTID;
  91. std::string secret = YOUTUBE_SECRET;
  92. deobfuscate_str(&clientid[0], YOUTUBE_CLIENTID_HASH);
  93. deobfuscate_str(&secret[0], YOUTUBE_SECRET_HASH);
  94. std::string r_token = QUrl::toPercentEncoding(refresh_token.c_str()).toStdString();
  95. const QString data_template = "client_id=%1"
  96. "&client_secret=%2"
  97. "&refresh_token=%3"
  98. "&grant_type=refresh_token";
  99. const QString data =
  100. data_template.arg(QString(clientid.c_str()), QString(secret.c_str()), QString(r_token.c_str()));
  101. Json json_out;
  102. bool success = TryInsertCommand(youtubeLiveTokenUrl.data(), "application/x-www-form-urlencoded", "",
  103. QT_TO_UTF8(data), json_out);
  104. if (!success || json_out.object_items().find("error") != json_out.object_items().end())
  105. return false;
  106. token = json_out["access_token"].string_value();
  107. return token.empty() ? false : true;
  108. }
  109. bool YoutubeApiWrappers::InsertCommand(const char *url, const char *content_type, std::string request_type,
  110. const char *data, Json &json_out, int data_size)
  111. {
  112. long error_code;
  113. bool success = TryInsertCommand(url, content_type, request_type, data, json_out, &error_code, data_size);
  114. if (error_code == 401) {
  115. // Attempt to update access token and try again
  116. if (!UpdateAccessToken())
  117. return false;
  118. success = TryInsertCommand(url, content_type, request_type, data, json_out, &error_code, data_size);
  119. }
  120. if (json_out.object_items().find("error") != json_out.object_items().end()) {
  121. blog(LOG_ERROR, "YouTube API error:\n\tHTTP status: %ld\n\tURL: %s\n\tJSON: %s", error_code, url,
  122. json_out.dump().c_str());
  123. lastError = json_out["error"]["code"].int_value();
  124. lastErrorReason = QString(json_out["error"]["errors"][0]["reason"].string_value().c_str());
  125. lastErrorMessage = QString(json_out["error"]["message"].string_value().c_str());
  126. // The existence of an error implies non-success even if the HTTP status code disagrees.
  127. success = false;
  128. }
  129. return success;
  130. }
  131. bool YoutubeApiWrappers::GetChannelDescription(ChannelDescription &channel_description)
  132. {
  133. lastErrorMessage.clear();
  134. lastErrorReason.clear();
  135. const std::string url =
  136. std::string(youtubeLiveChannelUrl) + "?part=snippet,contentDetails,statistics&mine=true";
  137. Json json_out;
  138. if (!InsertCommand(url.c_str(), "application/json", "", nullptr, json_out)) {
  139. return false;
  140. }
  141. if (json_out["pageInfo"]["totalResults"].int_value() == 0) {
  142. lastErrorMessage = QTStr("YouTube.Auth.NoChannels");
  143. return false;
  144. }
  145. channel_description.id = QString(json_out["items"][0]["id"].string_value().c_str());
  146. channel_description.title = QString(json_out["items"][0]["snippet"]["title"].string_value().c_str());
  147. return channel_description.id.isEmpty() ? false : true;
  148. }
  149. bool YoutubeApiWrappers::InsertBroadcast(BroadcastDescription &broadcast)
  150. {
  151. lastErrorMessage.clear();
  152. lastErrorReason.clear();
  153. const std::string url = std::string(youtubeLiveBroadcastUrl) + "?part=snippet,status,contentDetails";
  154. const Json data = Json::object{
  155. {"snippet",
  156. Json::object{
  157. {"title", QT_TO_UTF8(broadcast.title)},
  158. {"description", QT_TO_UTF8(broadcast.description)},
  159. {"scheduledStartTime", QT_TO_UTF8(broadcast.schedul_date_time)},
  160. }},
  161. {"status",
  162. Json::object{
  163. {"privacyStatus", QT_TO_UTF8(broadcast.privacy)},
  164. {"selfDeclaredMadeForKids", broadcast.made_for_kids},
  165. }},
  166. {"contentDetails",
  167. Json::object{
  168. {"latencyPreference", QT_TO_UTF8(broadcast.latency)},
  169. {"enableAutoStart", broadcast.auto_start},
  170. {"enableAutoStop", broadcast.auto_stop},
  171. {"enableDvr", broadcast.dvr},
  172. {"projection", QT_TO_UTF8(broadcast.projection)},
  173. {
  174. "monitorStream",
  175. Json::object{
  176. {"enableMonitorStream", false},
  177. },
  178. },
  179. }},
  180. };
  181. Json json_out;
  182. if (!InsertCommand(url.c_str(), "application/json", "", data.dump().c_str(), json_out)) {
  183. return false;
  184. }
  185. broadcast.id = QString(json_out["id"].string_value().c_str());
  186. return broadcast.id.isEmpty() ? false : true;
  187. }
  188. bool YoutubeApiWrappers::InsertStream(StreamDescription &stream)
  189. {
  190. lastErrorMessage.clear();
  191. lastErrorReason.clear();
  192. const std::string url = std::string(youtubeLiveStreamUrl) + "?part=snippet,cdn,status,contentDetails";
  193. const Json data = Json::object{
  194. {"snippet",
  195. Json::object{
  196. {"title", QT_TO_UTF8(stream.title)},
  197. }},
  198. {"cdn",
  199. Json::object{
  200. {"frameRate", "variable"},
  201. {"ingestionType", "rtmp"},
  202. {"resolution", "variable"},
  203. }},
  204. {"contentDetails", Json::object{{"isReusable", false}}},
  205. };
  206. Json json_out;
  207. if (!InsertCommand(url.c_str(), "application/json", "", data.dump().c_str(), json_out)) {
  208. return false;
  209. }
  210. stream.id = QString(json_out["id"].string_value().c_str());
  211. stream.name = QString(json_out["cdn"]["ingestionInfo"]["streamName"].string_value().c_str());
  212. return stream.id.isEmpty() ? false : true;
  213. }
  214. bool YoutubeApiWrappers::BindStream(const QString broadcast_id, const QString stream_id)
  215. {
  216. lastErrorMessage.clear();
  217. lastErrorReason.clear();
  218. // TODO: Use std::format with std::string instead of QString::arg with C++20
  219. const QString url_template = QString(youtubeLiveBroadcastBindUrl.data()) +
  220. "?id=%1&streamId=%2&part=id,snippet,contentDetails,status";
  221. const QString url = url_template.arg(broadcast_id, stream_id);
  222. const Json data = Json::object{};
  223. this->broadcast_id = broadcast_id;
  224. Json json_out;
  225. return InsertCommand(QT_TO_UTF8(url), "application/json", "", data.dump().c_str(), json_out);
  226. }
  227. bool YoutubeApiWrappers::GetBroadcastsList(Json &json_out, const QString &page, const QString &status)
  228. {
  229. lastErrorMessage.clear();
  230. lastErrorReason.clear();
  231. std::string url = std::string(youtubeLiveBroadcastUrl) +
  232. "?part=snippet,contentDetails,status&broadcastType=all&maxResults=" +
  233. std::to_string(defaultBroadcastsPerQuery);
  234. if (status.isEmpty())
  235. url += "&mine=true";
  236. else
  237. url += "&broadcastStatus=" + status.toStdString();
  238. if (!page.isEmpty())
  239. url += "&pageToken=" + page.toStdString();
  240. return InsertCommand(url.c_str(), "application/json", "", nullptr, json_out);
  241. }
  242. bool YoutubeApiWrappers::GetVideoCategoriesList(QVector<CategoryDescription> &category_list_out)
  243. {
  244. lastErrorMessage.clear();
  245. lastErrorReason.clear();
  246. // TODO: Use std::format with C++20
  247. const QString url_template =
  248. QString(youtubeLiveVideoCategoriesUrl.data()) + "?part=snippet&regionCode=%1&hl=%2";
  249. /*
  250. * All OBS locale regions aside from "US" are missing category id 29
  251. * ("Nonprofits & Activism"), but it is still available to channels
  252. * set to those regions via the YouTube Studio website.
  253. * To work around this inconsistency with the API all locales will
  254. * use the "US" region and only set the language part for localisation.
  255. * It is worth noting that none of the regions available on YouTube
  256. * feature any category not also available to the "US" region.
  257. */
  258. QString url = url_template.arg("US", QLocale().name());
  259. Json json_out;
  260. if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr, json_out)) {
  261. if (lastErrorReason != "unsupportedLanguageCode" && lastErrorReason != "invalidLanguage")
  262. return false;
  263. // Try again with en-US if YouTube error indicates an unsupported locale
  264. url = url_template.arg("US", "en_US");
  265. if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr, json_out))
  266. return false;
  267. }
  268. category_list_out = {};
  269. for (auto &j : json_out["items"].array_items()) {
  270. // Assignable only.
  271. if (j["snippet"]["assignable"].bool_value()) {
  272. category_list_out.push_back(
  273. {j["id"].string_value().c_str(), j["snippet"]["title"].string_value().c_str()});
  274. }
  275. }
  276. return category_list_out.isEmpty() ? false : true;
  277. }
  278. bool YoutubeApiWrappers::SetVideoCategory(const QString &video_id, const QString &video_title,
  279. const QString &video_description, const QString &categorie_id)
  280. {
  281. lastErrorMessage.clear();
  282. lastErrorReason.clear();
  283. const std::string url = std::string(youtubeLiveVideosUrl) + "?part=snippet";
  284. const Json data = Json::object{
  285. {"id", QT_TO_UTF8(video_id)},
  286. {"snippet",
  287. Json::object{
  288. {"title", QT_TO_UTF8(video_title)},
  289. {"description", QT_TO_UTF8(video_description)},
  290. {"categoryId", QT_TO_UTF8(categorie_id)},
  291. }},
  292. };
  293. Json json_out;
  294. return InsertCommand(url.c_str(), "application/json", "PUT", data.dump().c_str(), json_out);
  295. }
  296. bool YoutubeApiWrappers::SetVideoThumbnail(const QString &video_id, const QString &thumbnail_file)
  297. {
  298. lastErrorMessage.clear();
  299. lastErrorReason.clear();
  300. // Make sure the file hasn't been deleted since originally selecting it
  301. if (!QFile::exists(thumbnail_file)) {
  302. lastErrorMessage = QTStr("YouTube.Actions.Error.FileMissing");
  303. return false;
  304. }
  305. QFile thumbFile(thumbnail_file);
  306. if (!thumbFile.open(QFile::ReadOnly)) {
  307. lastErrorMessage = QTStr("YouTube.Actions.Error.FileOpeningFailed");
  308. return false;
  309. }
  310. const QByteArray fileContents = thumbFile.readAll();
  311. const QString mime = QMimeDatabase().mimeTypeForData(fileContents).name();
  312. const std::string url = std::string(youtubeLiveThumbnailUrl) + "?videoId=" + video_id.toStdString();
  313. Json json_out;
  314. return InsertCommand(url.c_str(), QT_TO_UTF8(mime), "POST", fileContents.constData(), json_out,
  315. fileContents.size());
  316. }
  317. bool YoutubeApiWrappers::StartBroadcast(const QString &broadcast_id)
  318. {
  319. lastErrorMessage.clear();
  320. lastErrorReason.clear();
  321. Json json_out;
  322. if (!FindBroadcast(broadcast_id, json_out))
  323. return false;
  324. auto lifeCycleStatus = json_out["items"][0]["status"]["lifeCycleStatus"].string_value();
  325. if (lifeCycleStatus == "live" || lifeCycleStatus == "liveStarting")
  326. // Broadcast is already (going to be) live
  327. return true;
  328. else if (lifeCycleStatus == "testStarting") {
  329. // User will need to wait a few seconds before attempting to start broadcast
  330. lastErrorMessage = QTStr("YouTube.Actions.Error.BroadcastTestStarting");
  331. lastErrorReason.clear();
  332. return false;
  333. }
  334. // Only reset if broadcast has monitoring enabled and is not already in "testing" mode
  335. auto monitorStreamEnabled =
  336. json_out["items"][0]["contentDetails"]["monitorStream"]["enableMonitorStream"].bool_value();
  337. if (lifeCycleStatus != "testing" && monitorStreamEnabled && !ResetBroadcast(broadcast_id, json_out))
  338. return false;
  339. // TODO: Use std::format with C++20
  340. const QString url_template =
  341. QString(youtubeLiveBroadcastTransitionUrl.data()) + "?id=%1&broadcastStatus=%2&part=status";
  342. const QString live = url_template.arg(broadcast_id, "live");
  343. bool success = InsertCommand(QT_TO_UTF8(live), "application/json", "POST", "{}", json_out);
  344. // Return a success if the command failed, but was redundant (broadcast already live)
  345. return success || lastErrorReason == "redundantTransition";
  346. }
  347. bool YoutubeApiWrappers::StartLatestBroadcast()
  348. {
  349. return StartBroadcast(this->broadcast_id);
  350. }
  351. bool YoutubeApiWrappers::StopBroadcast(const QString &broadcast_id)
  352. {
  353. lastErrorMessage.clear();
  354. lastErrorReason.clear();
  355. const QString url_template =
  356. QString(youtubeLiveBroadcastTransitionUrl.data()) + "?id=%1&broadcastStatus=complete&part=status";
  357. const QString url = url_template.arg(broadcast_id);
  358. Json json_out;
  359. bool success = InsertCommand(QT_TO_UTF8(url), "application/json", "POST", "{}", json_out);
  360. // Return a success if the command failed, but was redundant (broadcast already stopped)
  361. return success || lastErrorReason == "redundantTransition";
  362. }
  363. bool YoutubeApiWrappers::StopLatestBroadcast()
  364. {
  365. return StopBroadcast(this->broadcast_id);
  366. }
  367. void YoutubeApiWrappers::SetBroadcastId(QString &broadcast_id)
  368. {
  369. this->broadcast_id = broadcast_id;
  370. }
  371. QString YoutubeApiWrappers::GetBroadcastId()
  372. {
  373. return this->broadcast_id;
  374. }
  375. bool YoutubeApiWrappers::ResetBroadcast(const QString &broadcast_id, json11::Json &json_out)
  376. {
  377. lastErrorMessage.clear();
  378. lastErrorReason.clear();
  379. auto snippet = json_out["items"][0]["snippet"];
  380. auto status = json_out["items"][0]["status"];
  381. auto contentDetails = json_out["items"][0]["contentDetails"];
  382. auto monitorStream = contentDetails["monitorStream"];
  383. const Json data = Json::object{
  384. {"id", QT_TO_UTF8(broadcast_id)},
  385. {"snippet",
  386. Json::object{
  387. {"title", snippet["title"]},
  388. {"description", snippet["description"]},
  389. {"scheduledStartTime", snippet["scheduledStartTime"]},
  390. {"scheduledEndTime", snippet["scheduledEndTime"]},
  391. }},
  392. {"status",
  393. Json::object{
  394. {"privacyStatus", status["privacyStatus"]},
  395. {"madeForKids", status["madeForKids"]},
  396. {"selfDeclaredMadeForKids", status["selfDeclaredMadeForKids"]},
  397. }},
  398. {"contentDetails",
  399. Json::object{
  400. {
  401. "monitorStream",
  402. Json::object{
  403. {"enableMonitorStream", false},
  404. {"broadcastStreamDelayMs", monitorStream["broadcastStreamDelayMs"]},
  405. },
  406. },
  407. {"enableAutoStart", contentDetails["enableAutoStart"]},
  408. {"enableAutoStop", contentDetails["enableAutoStop"]},
  409. {"enableClosedCaptions", contentDetails["enableClosedCaptions"]},
  410. {"enableDvr", contentDetails["enableDvr"]},
  411. {"enableContentEncryption", contentDetails["enableContentEncryption"]},
  412. {"enableEmbed", contentDetails["enableEmbed"]},
  413. {"recordFromStart", contentDetails["recordFromStart"]},
  414. {"startWithSlate", contentDetails["startWithSlate"]},
  415. }},
  416. };
  417. const std::string put = std::string(youtubeLiveBroadcastUrl) + "?part=id,snippet,contentDetails,status";
  418. return InsertCommand(put.c_str(), "application/json", "PUT", data.dump().c_str(), json_out);
  419. }
  420. bool YoutubeApiWrappers::FindBroadcast(const QString &id, json11::Json &json_out)
  421. {
  422. lastErrorMessage.clear();
  423. lastErrorReason.clear();
  424. std::string url = std::string(youtubeLiveBroadcastUrl) +
  425. "?part=id,snippet,contentDetails,status&broadcastType=all&maxResults=1";
  426. url += "&id=" + id.toStdString();
  427. if (!InsertCommand(url.c_str(), "application/json", "", nullptr, json_out))
  428. return false;
  429. auto items = json_out["items"].array_items();
  430. if (items.size() != 1) {
  431. lastErrorMessage = QTStr("YouTube.Actions.Error.BroadcastNotFound");
  432. return false;
  433. }
  434. return true;
  435. }
  436. bool YoutubeApiWrappers::FindStream(const QString &id, json11::Json &json_out)
  437. {
  438. lastErrorMessage.clear();
  439. lastErrorReason.clear();
  440. std::string url = std::string(youtubeLiveStreamUrl) + "?part=id,snippet,cdn,status&maxResults=1";
  441. url += "&id=" + id.toStdString();
  442. if (!InsertCommand(url.c_str(), "application/json", "", nullptr, json_out))
  443. return false;
  444. auto items = json_out["items"].array_items();
  445. if (items.size() != 1) {
  446. lastErrorMessage = "No active broadcast found.";
  447. return false;
  448. }
  449. return true;
  450. }