youtube-api-wrappers.cpp 18 KB

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