youtube-api-wrappers.cpp 15 KB

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