youtube-api-wrappers.cpp 18 KB

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