youtube-api-wrappers.cpp 17 KB

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