youtube-api-wrappers.cpp 18 KB

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