window-youtube-actions.cpp 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. #include "window-basic-main.hpp"
  2. #include "window-youtube-actions.hpp"
  3. #include "obs-app.hpp"
  4. #include "qt-wrappers.hpp"
  5. #include "youtube-api-wrappers.hpp"
  6. #include <QDateTime>
  7. #include <QDesktopServices>
  8. const QString SchedulDateAndTimeFormat = "yyyy-MM-dd'T'hh:mm:ss'Z'";
  9. const QString RepresentSchedulDateAndTimeFormat = "dddd, MMMM d, yyyy h:m";
  10. const QString NormalStylesheet = "border: 1px solid black; border-radius: 5px;";
  11. const QString SelectedStylesheet =
  12. "border: 2px solid black; border-radius: 5px;";
  13. const QString IndexOfGamingCategory = "20";
  14. OBSYoutubeActions::OBSYoutubeActions(QWidget *parent, Auth *auth)
  15. : QDialog(parent),
  16. ui(new Ui::OBSYoutubeActions),
  17. apiYouTube(dynamic_cast<YoutubeApiWrappers *>(auth)),
  18. workerThread(new WorkerThread(apiYouTube))
  19. {
  20. setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
  21. ui->setupUi(this);
  22. ui->privacyBox->addItem(QTStr("YouTube.Actions.Privacy.Public"),
  23. "public");
  24. ui->privacyBox->addItem(QTStr("YouTube.Actions.Privacy.Unlisted"),
  25. "unlisted");
  26. ui->privacyBox->addItem(QTStr("YouTube.Actions.Privacy.Private"),
  27. "private");
  28. ui->latencyBox->addItem(QTStr("YouTube.Actions.Latency.Normal"),
  29. "normal");
  30. ui->latencyBox->addItem(QTStr("YouTube.Actions.Latency.Low"), "low");
  31. ui->latencyBox->addItem(QTStr("YouTube.Actions.Latency.UltraLow"),
  32. "ultraLow");
  33. ui->checkAutoStart->setEnabled(false);
  34. ui->checkAutoStop->setEnabled(false);
  35. UpdateOkButtonStatus();
  36. connect(ui->title, &QLineEdit::textChanged, this,
  37. [&](const QString &) { this->UpdateOkButtonStatus(); });
  38. connect(ui->privacyBox, &QComboBox::currentTextChanged, this,
  39. [&](const QString &) { this->UpdateOkButtonStatus(); });
  40. connect(ui->yesMakeForKids, &QRadioButton::toggled, this,
  41. [&](bool) { this->UpdateOkButtonStatus(); });
  42. connect(ui->notMakeForKids, &QRadioButton::toggled, this,
  43. [&](bool) { this->UpdateOkButtonStatus(); });
  44. connect(ui->tabWidget, &QTabWidget::currentChanged, this,
  45. [&](int) { this->UpdateOkButtonStatus(); });
  46. connect(ui->pushButton, &QPushButton::clicked, this,
  47. &OBSYoutubeActions::OpenYouTubeDashboard);
  48. connect(ui->helpAutoStartStop, &QLabel::linkActivated, this,
  49. [](const QString &link) { QDesktopServices::openUrl(link); });
  50. connect(ui->help360Video, &QLabel::linkActivated, this,
  51. [](const QString &link) { QDesktopServices::openUrl(link); });
  52. connect(ui->helpMadeForKids, &QLabel::linkActivated, this,
  53. [](const QString &link) { QDesktopServices::openUrl(link); });
  54. ui->scheduledTime->setVisible(false);
  55. connect(ui->checkScheduledLater, &QCheckBox::stateChanged, this,
  56. [&](int state) {
  57. ui->scheduledTime->setVisible(state);
  58. if (state) {
  59. ui->checkAutoStart->setEnabled(true);
  60. ui->checkAutoStop->setEnabled(true);
  61. ui->checkAutoStart->setChecked(false);
  62. ui->checkAutoStop->setChecked(false);
  63. } else {
  64. ui->checkAutoStart->setEnabled(false);
  65. ui->checkAutoStop->setEnabled(false);
  66. ui->checkAutoStart->setChecked(true);
  67. ui->checkAutoStop->setChecked(true);
  68. }
  69. UpdateOkButtonStatus();
  70. });
  71. ui->scheduledTime->setDateTime(QDateTime::currentDateTime());
  72. if (!apiYouTube) {
  73. blog(LOG_DEBUG, "YouTube API auth NOT found.");
  74. Cancel();
  75. return;
  76. }
  77. ChannelDescription channel;
  78. if (!apiYouTube->GetChannelDescription(channel)) {
  79. blog(LOG_DEBUG, "Could not get channel description.");
  80. ShowErrorDialog(
  81. parent,
  82. apiYouTube->GetLastError().isEmpty()
  83. ? QTStr("YouTube.Actions.Error.General")
  84. : QTStr("YouTube.Actions.Error.Text")
  85. .arg(apiYouTube->GetLastError()));
  86. Cancel();
  87. return;
  88. }
  89. this->setWindowTitle(channel.title);
  90. QVector<CategoryDescription> category_list;
  91. if (!apiYouTube->GetVideoCategoriesList(
  92. channel.country, channel.language, category_list)) {
  93. blog(LOG_DEBUG, "Could not get video category for country; %s.",
  94. channel.country.toStdString().c_str());
  95. ShowErrorDialog(
  96. parent,
  97. apiYouTube->GetLastError().isEmpty()
  98. ? QTStr("YouTube.Actions.Error.General")
  99. : QTStr("YouTube.Actions.Error.Text")
  100. .arg(apiYouTube->GetLastError()));
  101. Cancel();
  102. return;
  103. }
  104. for (auto &category : category_list) {
  105. ui->categoryBox->addItem(category.title, category.id);
  106. if (category.id == IndexOfGamingCategory) {
  107. ui->categoryBox->setCurrentText(category.title);
  108. }
  109. }
  110. connect(ui->okButton, &QPushButton::clicked, this,
  111. &OBSYoutubeActions::InitBroadcast);
  112. connect(ui->cancelButton, &QPushButton::clicked, this, [&]() {
  113. blog(LOG_DEBUG, "YouTube live broadcast creation cancelled.");
  114. // Close the dialog.
  115. Cancel();
  116. });
  117. qDeleteAll(ui->scrollAreaWidgetContents->findChildren<QWidget *>(
  118. QString(), Qt::FindDirectChildrenOnly));
  119. // Add label indicating loading state
  120. QLabel *loadingLabel = new QLabel();
  121. loadingLabel->setTextFormat(Qt::RichText);
  122. loadingLabel->setAlignment(Qt::AlignHCenter);
  123. loadingLabel->setText(
  124. QString("<big>%1</big>")
  125. .arg(QTStr("YouTube.Actions.EventsLoading")));
  126. ui->scrollAreaWidgetContents->layout()->addWidget(loadingLabel);
  127. // Delete "loading..." label on completion
  128. connect(workerThread, &WorkerThread::finished, this, [&] {
  129. QLayoutItem *item =
  130. ui->scrollAreaWidgetContents->layout()->takeAt(0);
  131. item->widget()->deleteLater();
  132. });
  133. connect(workerThread, &WorkerThread::failed, this, [&]() {
  134. auto last_error = apiYouTube->GetLastError();
  135. if (last_error.isEmpty())
  136. last_error = QTStr("YouTube.Actions.Error.YouTubeApi");
  137. if (!apiYouTube->GetTranslatedError(last_error))
  138. last_error = QTStr("YouTube.Actions.Error.Text")
  139. .arg(last_error);
  140. ShowErrorDialog(this, last_error);
  141. QDialog::reject();
  142. });
  143. connect(workerThread, &WorkerThread::new_item, this,
  144. [&](const QString &title, const QString &dateTimeString,
  145. const QString &broadcast, const QString &status,
  146. bool astart, bool astop) {
  147. ClickableLabel *label = new ClickableLabel();
  148. label->setStyleSheet(NormalStylesheet);
  149. label->setTextFormat(Qt::RichText);
  150. if (status == "live" || status == "testing") {
  151. // Resumable stream
  152. label->setText(
  153. QString("<big>%1</big><br/>%2")
  154. .arg(title,
  155. QTStr("YouTube.Actions.Stream.Resume")));
  156. } else if (dateTimeString.isEmpty()) {
  157. // The broadcast created by YouTube Studio has no start time.
  158. // Yes this does violate the restrictions set in YouTube's API
  159. // But why would YouTube care about consistency?
  160. label->setText(
  161. QString("<big>%1</big><br/>%2")
  162. .arg(title,
  163. QTStr("YouTube.Actions.Stream.YTStudio")));
  164. } else {
  165. label->setText(
  166. QString("<big>%1</big><br/>%2")
  167. .arg(title,
  168. QTStr("YouTube.Actions.Stream.ScheduledFor")
  169. .arg(dateTimeString)));
  170. }
  171. label->setAlignment(Qt::AlignHCenter);
  172. label->setMargin(4);
  173. connect(label, &ClickableLabel::clicked, this,
  174. [&, label, broadcast, astart, astop]() {
  175. for (QWidget *i :
  176. ui->scrollAreaWidgetContents->findChildren<
  177. QWidget *>(
  178. QString(),
  179. Qt::FindDirectChildrenOnly))
  180. i->setStyleSheet(
  181. NormalStylesheet);
  182. label->setStyleSheet(
  183. SelectedStylesheet);
  184. this->selectedBroadcast = broadcast;
  185. this->autostart = astart;
  186. this->autostop = astop;
  187. UpdateOkButtonStatus();
  188. });
  189. ui->scrollAreaWidgetContents->layout()->addWidget(
  190. label);
  191. });
  192. workerThread->start();
  193. #ifdef __APPLE__
  194. // MacOS theming issues
  195. this->resize(this->width() + 200, this->height() + 120);
  196. #endif
  197. valid = true;
  198. }
  199. OBSYoutubeActions::~OBSYoutubeActions()
  200. {
  201. workerThread->stop();
  202. workerThread->wait();
  203. delete workerThread;
  204. }
  205. void WorkerThread::run()
  206. {
  207. if (!pending)
  208. return;
  209. json11::Json broadcasts;
  210. for (QString broacastStatus : {"active", "upcoming"}) {
  211. if (!apiYouTube->GetBroadcastsList(broadcasts, "",
  212. broacastStatus)) {
  213. emit failed();
  214. return;
  215. }
  216. while (pending) {
  217. auto items = broadcasts["items"].array_items();
  218. for (auto item : items) {
  219. QString status = QString::fromStdString(
  220. item["status"]["lifeCycleStatus"]
  221. .string_value());
  222. if (status == "live" || status == "testing") {
  223. // Check that the attached liveStream is offline (reconnectable)
  224. QString stream_id = QString::fromStdString(
  225. item["contentDetails"]
  226. ["boundStreamId"]
  227. .string_value());
  228. json11::Json stream;
  229. if (!apiYouTube->FindStream(stream_id,
  230. stream))
  231. continue;
  232. if (stream["status"]["streamStatus"] ==
  233. "active")
  234. continue;
  235. }
  236. QString title = QString::fromStdString(
  237. item["snippet"]["title"].string_value());
  238. QString scheduledStartTime =
  239. QString::fromStdString(
  240. item["snippet"]
  241. ["scheduledStartTime"]
  242. .string_value());
  243. QString broadcast = QString::fromStdString(
  244. item["id"].string_value());
  245. // Treat already started streams as autostart for UI purposes
  246. bool astart =
  247. status == "live"
  248. ? true
  249. : item["contentDetails"]
  250. ["enableAutoStart"]
  251. .bool_value();
  252. bool astop =
  253. item["contentDetails"]["enableAutoStop"]
  254. .bool_value();
  255. QDateTime utcDTime = QDateTime::fromString(
  256. scheduledStartTime,
  257. SchedulDateAndTimeFormat);
  258. // DateTime parser means that input datetime is a local, so we need to move it
  259. QDateTime dateTime = utcDTime.addSecs(
  260. utcDTime.offsetFromUtc());
  261. QString dateTimeString = QLocale().toString(
  262. dateTime,
  263. QString("%1 %2").arg(
  264. QLocale().dateFormat(
  265. QLocale::LongFormat),
  266. QLocale().timeFormat(
  267. QLocale::ShortFormat)));
  268. emit new_item(title, dateTimeString, broadcast,
  269. status, astart, astop);
  270. }
  271. auto nextPageToken =
  272. broadcasts["nextPageToken"].string_value();
  273. if (nextPageToken.empty() || items.empty())
  274. break;
  275. else {
  276. if (!pending)
  277. return;
  278. if (!apiYouTube->GetBroadcastsList(
  279. broadcasts,
  280. QString::fromStdString(
  281. nextPageToken),
  282. broacastStatus)) {
  283. emit failed();
  284. return;
  285. }
  286. }
  287. }
  288. }
  289. emit ready();
  290. }
  291. void OBSYoutubeActions::UpdateOkButtonStatus()
  292. {
  293. if (ui->tabWidget->currentIndex() == 0) {
  294. ui->okButton->setEnabled(
  295. !ui->title->text().isEmpty() &&
  296. !ui->privacyBox->currentText().isEmpty() &&
  297. (ui->yesMakeForKids->isChecked() ||
  298. ui->notMakeForKids->isChecked()));
  299. if (ui->checkScheduledLater->checkState() == Qt::Checked) {
  300. ui->okButton->setText(
  301. QTStr("YouTube.Actions.Create_Save"));
  302. } else {
  303. ui->okButton->setText(
  304. QTStr("YouTube.Actions.Create_GoLive"));
  305. }
  306. ui->pushButton->setVisible(false);
  307. } else {
  308. ui->okButton->setEnabled(!selectedBroadcast.isEmpty());
  309. ui->okButton->setText(QTStr("YouTube.Actions.Choose_GoLive"));
  310. ui->pushButton->setVisible(true);
  311. }
  312. }
  313. bool OBSYoutubeActions::StreamNowAction(YoutubeApiWrappers *api,
  314. StreamDescription &stream)
  315. {
  316. YoutubeApiWrappers *apiYouTube = api;
  317. BroadcastDescription broadcast = {};
  318. UiToBroadcast(broadcast);
  319. // stream now is always autostart/autostop
  320. broadcast.auto_start = true;
  321. broadcast.auto_stop = true;
  322. blog(LOG_DEBUG, "Scheduled date and time: %s",
  323. broadcast.schedul_date_time.toStdString().c_str());
  324. if (!apiYouTube->InsertBroadcast(broadcast)) {
  325. blog(LOG_DEBUG, "No broadcast created.");
  326. return false;
  327. }
  328. stream = {"", "", "OBS Studio Video Stream", ""};
  329. if (!apiYouTube->InsertStream(stream)) {
  330. blog(LOG_DEBUG, "No stream created.");
  331. return false;
  332. }
  333. if (!apiYouTube->BindStream(broadcast.id, stream.id)) {
  334. blog(LOG_DEBUG, "No stream binded.");
  335. return false;
  336. }
  337. if (!apiYouTube->SetVideoCategory(broadcast.id, broadcast.title,
  338. broadcast.description,
  339. broadcast.category.id)) {
  340. blog(LOG_DEBUG, "No category set.");
  341. return false;
  342. }
  343. return true;
  344. }
  345. bool OBSYoutubeActions::StreamLaterAction(YoutubeApiWrappers *api)
  346. {
  347. YoutubeApiWrappers *apiYouTube = api;
  348. BroadcastDescription broadcast = {};
  349. UiToBroadcast(broadcast);
  350. // DateTime parser means that input datetime is a local, so we need to move it
  351. auto dateTime = ui->scheduledTime->dateTime();
  352. auto utcDTime = dateTime.addSecs(-dateTime.offsetFromUtc());
  353. broadcast.schedul_date_time =
  354. utcDTime.toString(SchedulDateAndTimeFormat);
  355. blog(LOG_DEBUG, "Scheduled date and time: %s",
  356. broadcast.schedul_date_time.toStdString().c_str());
  357. if (!apiYouTube->InsertBroadcast(broadcast)) {
  358. blog(LOG_DEBUG, "No broadcast created.");
  359. return false;
  360. }
  361. if (!apiYouTube->SetVideoCategory(broadcast.id, broadcast.title,
  362. broadcast.description,
  363. broadcast.category.id)) {
  364. blog(LOG_DEBUG, "No category set.");
  365. return false;
  366. }
  367. return true;
  368. }
  369. bool OBSYoutubeActions::ChooseAnEventAction(YoutubeApiWrappers *api,
  370. StreamDescription &stream)
  371. {
  372. YoutubeApiWrappers *apiYouTube = api;
  373. json11::Json json;
  374. if (!apiYouTube->FindBroadcast(selectedBroadcast, json)) {
  375. blog(LOG_DEBUG, "No broadcast found.");
  376. return false;
  377. }
  378. std::string boundStreamId =
  379. json["items"]
  380. .array_items()[0]["contentDetails"]["boundStreamId"]
  381. .string_value();
  382. stream.id = boundStreamId.c_str();
  383. if (!stream.id.isEmpty() && apiYouTube->FindStream(stream.id, json)) {
  384. auto item = json["items"].array_items()[0];
  385. auto streamName = item["cdn"]["ingestionInfo"]["streamName"]
  386. .string_value();
  387. auto title = item["snippet"]["title"].string_value();
  388. auto description =
  389. item["snippet"]["description"].string_value();
  390. stream.name = streamName.c_str();
  391. stream.title = title.c_str();
  392. stream.description = description.c_str();
  393. api->SetBroadcastId(selectedBroadcast);
  394. } else {
  395. stream = {"", "", "OBS Studio Video Stream", ""};
  396. if (!apiYouTube->InsertStream(stream)) {
  397. blog(LOG_DEBUG, "No stream created.");
  398. return false;
  399. }
  400. if (!apiYouTube->BindStream(selectedBroadcast, stream.id)) {
  401. blog(LOG_DEBUG, "No stream binded.");
  402. return false;
  403. }
  404. }
  405. return true;
  406. }
  407. void OBSYoutubeActions::ShowErrorDialog(QWidget *parent, QString text)
  408. {
  409. QMessageBox dlg(parent);
  410. dlg.setWindowFlags(dlg.windowFlags() & ~Qt::WindowCloseButtonHint);
  411. dlg.setWindowTitle(QTStr("YouTube.Actions.Error.Title"));
  412. dlg.setText(text);
  413. dlg.setTextFormat(Qt::RichText);
  414. dlg.setIcon(QMessageBox::Warning);
  415. dlg.setStandardButtons(QMessageBox::StandardButton::Ok);
  416. dlg.exec();
  417. }
  418. void OBSYoutubeActions::InitBroadcast()
  419. {
  420. StreamDescription stream;
  421. QMessageBox msgBox(this);
  422. msgBox.setWindowFlags(msgBox.windowFlags() &
  423. ~Qt::WindowCloseButtonHint);
  424. msgBox.setWindowTitle(QTStr("YouTube.Actions.Notify.Title"));
  425. msgBox.setText(QTStr("YouTube.Actions.Notify.CreatingBroadcast"));
  426. msgBox.setStandardButtons(QMessageBox::StandardButtons());
  427. bool success = false;
  428. auto action = [&]() {
  429. if (ui->tabWidget->currentIndex() == 0) {
  430. if (ui->checkScheduledLater->isChecked()) {
  431. success = this->StreamLaterAction(apiYouTube);
  432. } else {
  433. success = this->StreamNowAction(apiYouTube,
  434. stream);
  435. }
  436. } else {
  437. success = this->ChooseAnEventAction(apiYouTube, stream);
  438. };
  439. QMetaObject::invokeMethod(&msgBox, "accept",
  440. Qt::QueuedConnection);
  441. };
  442. QScopedPointer<QThread> thread(CreateQThread(action));
  443. thread->start();
  444. msgBox.exec();
  445. thread->wait();
  446. if (success) {
  447. if (ui->tabWidget->currentIndex() == 0) {
  448. // Stream later usecase.
  449. if (ui->checkScheduledLater->isChecked()) {
  450. QMessageBox msg(this);
  451. msg.setWindowTitle(QTStr(
  452. "YouTube.Actions.EventCreated.Title"));
  453. msg.setText(QTStr(
  454. "YouTube.Actions.EventCreated.Text"));
  455. msg.setStandardButtons(QMessageBox::Ok);
  456. msg.exec();
  457. // Close dialog without start streaming.
  458. Cancel();
  459. } else {
  460. // Stream now usecase.
  461. blog(LOG_DEBUG, "New valid stream: %s",
  462. QT_TO_UTF8(stream.name));
  463. emit ok(QT_TO_UTF8(stream.id),
  464. QT_TO_UTF8(stream.name), true, true);
  465. Accept();
  466. }
  467. } else {
  468. // Stream to precreated broadcast usecase.
  469. emit ok(QT_TO_UTF8(stream.id), QT_TO_UTF8(stream.name),
  470. autostart, autostop);
  471. Accept();
  472. }
  473. } else {
  474. // Fail.
  475. auto last_error = apiYouTube->GetLastError();
  476. if (last_error.isEmpty())
  477. last_error = QTStr("YouTube.Actions.Error.YouTubeApi");
  478. if (!apiYouTube->GetTranslatedError(last_error))
  479. last_error =
  480. QTStr("YouTube.Actions.Error.NoBroadcastCreated")
  481. .arg(last_error);
  482. ShowErrorDialog(this, last_error);
  483. }
  484. }
  485. void OBSYoutubeActions::UiToBroadcast(BroadcastDescription &broadcast)
  486. {
  487. broadcast.title = ui->title->text();
  488. broadcast.description = ui->description->text();
  489. broadcast.privacy = ui->privacyBox->currentData().toString();
  490. broadcast.category.title = ui->categoryBox->currentText();
  491. broadcast.category.id = ui->categoryBox->currentData().toString();
  492. broadcast.made_for_kids = ui->yesMakeForKids->isChecked();
  493. broadcast.latency = ui->latencyBox->currentData().toString();
  494. broadcast.auto_start = ui->checkAutoStart->isChecked();
  495. broadcast.auto_stop = ui->checkAutoStop->isChecked();
  496. broadcast.dvr = ui->checkDVR->isChecked();
  497. broadcast.schedul_for_later = ui->checkScheduledLater->isChecked();
  498. broadcast.projection = ui->check360Video->isChecked() ? "360"
  499. : "rectangular";
  500. // Current time by default.
  501. broadcast.schedul_date_time = QDateTime::currentDateTimeUtc().toString(
  502. SchedulDateAndTimeFormat);
  503. }
  504. void OBSYoutubeActions::OpenYouTubeDashboard()
  505. {
  506. ChannelDescription channel;
  507. if (!apiYouTube->GetChannelDescription(channel)) {
  508. blog(LOG_DEBUG, "Could not get channel description.");
  509. ShowErrorDialog(
  510. this,
  511. apiYouTube->GetLastError().isEmpty()
  512. ? QTStr("YouTube.Actions.Error.General")
  513. : QTStr("YouTube.Actions.Error.Text")
  514. .arg(apiYouTube->GetLastError()));
  515. return;
  516. }
  517. //https://studio.youtube.com/channel/UCA9bSfH3KL186kyiUsvi3IA/videos/live?filter=%5B%5D&sort=%7B%22columnType%22%3A%22date%22%2C%22sortOrder%22%3A%22DESCENDING%22%7D
  518. QString uri =
  519. QString("https://studio.youtube.com/channel/%1/videos/live?filter=[]&sort={\"columnType\"%3A\"date\"%2C\"sortOrder\"%3A\"DESCENDING\"}")
  520. .arg(channel.id);
  521. QDesktopServices::openUrl(uri);
  522. }
  523. void OBSYoutubeActions::Cancel()
  524. {
  525. workerThread->stop();
  526. reject();
  527. }
  528. void OBSYoutubeActions::Accept()
  529. {
  530. workerThread->stop();
  531. accept();
  532. }