a4d37dba73
All these fixes are interlinked but to explain them further: Event selection would only partially work, the code to re-use an existing liveStream was never hit and so didn't work. It would also break going live because broadcast_id would never be set. Additionally it called StartBroadcast for no reason if autostart was enabled. API usage was unoptimal. Instead of only fetching the events we need (active, ready) it would fetch *every single livestream* on the youtube channel, 7 at a time, and then throw away every single result in the majority of use cases. This commit changes it to only fetch "active" and "ready" broadcasts and then only filters out active ones that cannot be resumed (because they're stil live). Resuming existing streams also didn't work because they were just thrown out by the selection. Now they get included if the attached liveStream is not receiving data. The're distinguished in the UI and are listed first. Simply selecting them and starting the stream will work. These's still some stuff left, like redundant API calls. But thankfully those fail silently and we can simply ignore it for now.
507 lines
14 KiB
C++
507 lines
14 KiB
C++
#include "youtube-api-wrappers.hpp"
|
|
|
|
#include <QUrl>
|
|
|
|
#include <string>
|
|
#include <iostream>
|
|
|
|
#include "auth-youtube.hpp"
|
|
#include "obs-app.hpp"
|
|
#include "qt-wrappers.hpp"
|
|
#include "remote-text.hpp"
|
|
#include "ui-config.h"
|
|
#include "obf.h"
|
|
|
|
using namespace json11;
|
|
|
|
/* ------------------------------------------------------------------------- */
|
|
#define YOUTUBE_LIVE_API_URL "https://www.googleapis.com/youtube/v3"
|
|
|
|
#define YOUTUBE_LIVE_STREAM_URL YOUTUBE_LIVE_API_URL "/liveStreams"
|
|
#define YOUTUBE_LIVE_BROADCAST_URL YOUTUBE_LIVE_API_URL "/liveBroadcasts"
|
|
#define YOUTUBE_LIVE_BROADCAST_TRANSITION_URL \
|
|
YOUTUBE_LIVE_BROADCAST_URL "/transition"
|
|
#define YOUTUBE_LIVE_BROADCAST_BIND_URL YOUTUBE_LIVE_BROADCAST_URL "/bind"
|
|
|
|
#define YOUTUBE_LIVE_CHANNEL_URL YOUTUBE_LIVE_API_URL "/channels"
|
|
#define YOUTUBE_LIVE_TOKEN_URL "https://oauth2.googleapis.com/token"
|
|
#define YOUTUBE_LIVE_VIDEOCATEGORIES_URL YOUTUBE_LIVE_API_URL "/videoCategories"
|
|
#define YOUTUBE_LIVE_VIDEOS_URL YOUTUBE_LIVE_API_URL "/videos"
|
|
|
|
#define DEFAULT_BROADCASTS_PER_QUERY \
|
|
"50" // acceptable values are 0 to 50, inclusive
|
|
/* ------------------------------------------------------------------------- */
|
|
|
|
bool IsYouTubeService(const std::string &service)
|
|
{
|
|
auto it = find_if(youtubeServices.begin(), youtubeServices.end(),
|
|
[&service](const Auth::Def &yt) {
|
|
return service == yt.service;
|
|
});
|
|
return it != youtubeServices.end();
|
|
}
|
|
|
|
bool YoutubeApiWrappers::GetTranslatedError(QString &error_message)
|
|
{
|
|
QString translated =
|
|
QTStr("YouTube.Errors." + lastErrorReason.toUtf8());
|
|
// No translation found
|
|
if (translated.startsWith("YouTube.Errors."))
|
|
return false;
|
|
error_message = translated;
|
|
return true;
|
|
}
|
|
|
|
YoutubeApiWrappers::YoutubeApiWrappers(const Def &d) : YoutubeAuth(d) {}
|
|
|
|
bool YoutubeApiWrappers::TryInsertCommand(const char *url,
|
|
const char *content_type,
|
|
std::string request_type,
|
|
const char *data, Json &json_out,
|
|
long *error_code)
|
|
{
|
|
long httpStatusCode = 0;
|
|
|
|
#ifdef _DEBUG
|
|
blog(LOG_DEBUG, "YouTube API command URL: %s", url);
|
|
blog(LOG_DEBUG, "YouTube API command data: %s", data);
|
|
#endif
|
|
if (token.empty())
|
|
return false;
|
|
std::string output;
|
|
std::string error;
|
|
bool success = GetRemoteFile(url, output, error, &httpStatusCode,
|
|
content_type, request_type, data,
|
|
{"Authorization: Bearer " + token},
|
|
nullptr, 5, false);
|
|
if (error_code)
|
|
*error_code = httpStatusCode;
|
|
|
|
if (!success || output.empty())
|
|
return false;
|
|
json_out = Json::parse(output, error);
|
|
#ifdef _DEBUG
|
|
blog(LOG_DEBUG, "YouTube API command answer: %s",
|
|
json_out.dump().c_str());
|
|
#endif
|
|
if (!error.empty()) {
|
|
return false;
|
|
}
|
|
return httpStatusCode < 400;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::UpdateAccessToken()
|
|
{
|
|
if (refresh_token.empty()) {
|
|
return false;
|
|
}
|
|
|
|
std::string clientid = YOUTUBE_CLIENTID;
|
|
std::string secret = YOUTUBE_SECRET;
|
|
deobfuscate_str(&clientid[0], YOUTUBE_CLIENTID_HASH);
|
|
deobfuscate_str(&secret[0], YOUTUBE_SECRET_HASH);
|
|
|
|
std::string r_token =
|
|
QUrl::toPercentEncoding(refresh_token.c_str()).toStdString();
|
|
const QString url = YOUTUBE_LIVE_TOKEN_URL;
|
|
const QString data_template = "client_id=%1"
|
|
"&client_secret=%2"
|
|
"&refresh_token=%3"
|
|
"&grant_type=refresh_token";
|
|
const QString data = data_template.arg(QString(clientid.c_str()),
|
|
QString(secret.c_str()),
|
|
QString(r_token.c_str()));
|
|
Json json_out;
|
|
bool success = TryInsertCommand(QT_TO_UTF8(url),
|
|
"application/x-www-form-urlencoded", "",
|
|
QT_TO_UTF8(data), json_out);
|
|
|
|
if (!success || json_out.object_items().find("error") !=
|
|
json_out.object_items().end())
|
|
return false;
|
|
token = json_out["access_token"].string_value();
|
|
return token.empty() ? false : true;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::InsertCommand(const char *url,
|
|
const char *content_type,
|
|
std::string request_type,
|
|
const char *data, Json &json_out)
|
|
{
|
|
long error_code;
|
|
bool success = TryInsertCommand(url, content_type, request_type, data,
|
|
json_out, &error_code);
|
|
|
|
if (error_code == 401) {
|
|
// Attempt to update access token and try again
|
|
if (!UpdateAccessToken())
|
|
return false;
|
|
success = TryInsertCommand(url, content_type, request_type,
|
|
data, json_out, &error_code);
|
|
}
|
|
|
|
if (json_out.object_items().find("error") !=
|
|
json_out.object_items().end()) {
|
|
blog(LOG_ERROR,
|
|
"YouTube API error:\n\tHTTP status: %d\n\tURL: %s\n\tJSON: %s",
|
|
error_code, url, json_out.dump().c_str());
|
|
|
|
lastError = json_out["error"]["code"].int_value();
|
|
lastErrorReason =
|
|
QString(json_out["error"]["errors"][0]["reason"]
|
|
.string_value()
|
|
.c_str());
|
|
lastErrorMessage = QString(
|
|
json_out["error"]["message"].string_value().c_str());
|
|
|
|
// The existence of an error implies non-success even if the HTTP status code disagrees.
|
|
success = false;
|
|
}
|
|
return success;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::GetChannelDescription(
|
|
ChannelDescription &channel_description)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
const QByteArray url = YOUTUBE_LIVE_CHANNEL_URL
|
|
"?part=snippet,contentDetails,statistics"
|
|
"&mine=true";
|
|
Json json_out;
|
|
if (!InsertCommand(url, "application/json", "", nullptr, json_out)) {
|
|
return false;
|
|
}
|
|
|
|
if (json_out["pageInfo"]["totalResults"].int_value() == 0) {
|
|
lastErrorMessage = QTStr("YouTube.Auth.NoChannels");
|
|
return false;
|
|
}
|
|
|
|
channel_description.id =
|
|
QString(json_out["items"][0]["id"].string_value().c_str());
|
|
channel_description.country =
|
|
QString(json_out["items"][0]["snippet"]["country"]
|
|
.string_value()
|
|
.c_str());
|
|
channel_description.language =
|
|
QString(json_out["items"][0]["snippet"]["defaultLanguage"]
|
|
.string_value()
|
|
.c_str());
|
|
channel_description.title = QString(
|
|
json_out["items"][0]["snippet"]["title"].string_value().c_str());
|
|
return channel_description.id.isEmpty() ? false : true;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::InsertBroadcast(BroadcastDescription &broadcast)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
const QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
|
|
"?part=snippet,status,contentDetails";
|
|
const Json data = Json::object{
|
|
{"snippet",
|
|
Json::object{
|
|
{"title", QT_TO_UTF8(broadcast.title)},
|
|
{"description", QT_TO_UTF8(broadcast.description)},
|
|
{"scheduledStartTime",
|
|
QT_TO_UTF8(broadcast.schedul_date_time)},
|
|
}},
|
|
{"status",
|
|
Json::object{
|
|
{"privacyStatus", QT_TO_UTF8(broadcast.privacy)},
|
|
{"selfDeclaredMadeForKids", broadcast.made_for_kids},
|
|
}},
|
|
{"contentDetails",
|
|
Json::object{
|
|
{"latencyPreference", QT_TO_UTF8(broadcast.latency)},
|
|
{"enableAutoStart", broadcast.auto_start},
|
|
{"enableAutoStop", broadcast.auto_stop},
|
|
{"enableDvr", broadcast.dvr},
|
|
{"projection", QT_TO_UTF8(broadcast.projection)},
|
|
{
|
|
"monitorStream",
|
|
Json::object{
|
|
{"enableMonitorStream", false},
|
|
},
|
|
},
|
|
}},
|
|
};
|
|
Json json_out;
|
|
if (!InsertCommand(url, "application/json", "", data.dump().c_str(),
|
|
json_out)) {
|
|
return false;
|
|
}
|
|
broadcast.id = QString(json_out["id"].string_value().c_str());
|
|
return broadcast.id.isEmpty() ? false : true;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::InsertStream(StreamDescription &stream)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
const QByteArray url = YOUTUBE_LIVE_STREAM_URL
|
|
"?part=snippet,cdn,status,contentDetails";
|
|
const Json data = Json::object{
|
|
{"snippet",
|
|
Json::object{
|
|
{"title", QT_TO_UTF8(stream.title)},
|
|
}},
|
|
{"cdn",
|
|
Json::object{
|
|
{"frameRate", "variable"},
|
|
{"ingestionType", "rtmp"},
|
|
{"resolution", "variable"},
|
|
}},
|
|
{"contentDetails", Json::object{{"isReusable", false}}},
|
|
};
|
|
Json json_out;
|
|
if (!InsertCommand(url, "application/json", "", data.dump().c_str(),
|
|
json_out)) {
|
|
return false;
|
|
}
|
|
stream.id = QString(json_out["id"].string_value().c_str());
|
|
stream.name = QString(json_out["cdn"]["ingestionInfo"]["streamName"]
|
|
.string_value()
|
|
.c_str());
|
|
return stream.id.isEmpty() ? false : true;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::BindStream(const QString broadcast_id,
|
|
const QString stream_id)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
const QString url_template = YOUTUBE_LIVE_BROADCAST_BIND_URL
|
|
"?id=%1"
|
|
"&streamId=%2"
|
|
"&part=id,snippet,contentDetails,status";
|
|
const QString url = url_template.arg(broadcast_id, stream_id);
|
|
const Json data = Json::object{};
|
|
this->broadcast_id = broadcast_id;
|
|
Json json_out;
|
|
return InsertCommand(QT_TO_UTF8(url), "application/json", "",
|
|
data.dump().c_str(), json_out);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::GetBroadcastsList(Json &json_out, const QString &page,
|
|
const QString &status)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
|
|
"?part=snippet,contentDetails,status"
|
|
"&broadcastType=all&maxResults=" DEFAULT_BROADCASTS_PER_QUERY;
|
|
|
|
if (status.isEmpty())
|
|
url += "&mine=true";
|
|
else
|
|
url += "&broadcastStatus=" + status.toUtf8();
|
|
|
|
if (!page.isEmpty())
|
|
url += "&pageToken=" + page.toUtf8();
|
|
return InsertCommand(url, "application/json", "", nullptr, json_out);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::GetVideoCategoriesList(
|
|
const QString &country, const QString &language,
|
|
QVector<CategoryDescription> &category_list_out)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
const QString url_template = YOUTUBE_LIVE_VIDEOCATEGORIES_URL
|
|
"?part=snippet"
|
|
"®ionCode=%1"
|
|
"&hl=%2";
|
|
const QString url =
|
|
url_template.arg(country.isEmpty() ? "US" : country,
|
|
language.isEmpty() ? "en" : language);
|
|
Json json_out;
|
|
if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr,
|
|
json_out)) {
|
|
return false;
|
|
}
|
|
category_list_out = {};
|
|
for (auto &j : json_out["items"].array_items()) {
|
|
// Assignable only.
|
|
if (j["snippet"]["assignable"].bool_value()) {
|
|
category_list_out.push_back(
|
|
{j["id"].string_value().c_str(),
|
|
j["snippet"]["title"].string_value().c_str()});
|
|
}
|
|
}
|
|
return category_list_out.isEmpty() ? false : true;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::SetVideoCategory(const QString &video_id,
|
|
const QString &video_title,
|
|
const QString &video_description,
|
|
const QString &categorie_id)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
const QByteArray url = YOUTUBE_LIVE_VIDEOS_URL "?part=snippet";
|
|
const Json data = Json::object{
|
|
{"id", QT_TO_UTF8(video_id)},
|
|
{"snippet",
|
|
Json::object{
|
|
{"title", QT_TO_UTF8(video_title)},
|
|
{"description", QT_TO_UTF8(video_description)},
|
|
{"categoryId", QT_TO_UTF8(categorie_id)},
|
|
}},
|
|
};
|
|
Json json_out;
|
|
return InsertCommand(url, "application/json", "PUT",
|
|
data.dump().c_str(), json_out);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::StartBroadcast(const QString &broadcast_id)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
|
|
if (!ResetBroadcast(broadcast_id))
|
|
return false;
|
|
|
|
const QString url_template = YOUTUBE_LIVE_BROADCAST_TRANSITION_URL
|
|
"?id=%1"
|
|
"&broadcastStatus=%2"
|
|
"&part=status";
|
|
const QString live = url_template.arg(broadcast_id, "live");
|
|
Json json_out;
|
|
return InsertCommand(QT_TO_UTF8(live), "application/json", "POST", "{}",
|
|
json_out);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::StartLatestBroadcast()
|
|
{
|
|
return StartBroadcast(this->broadcast_id);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::StopBroadcast(const QString &broadcast_id)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
|
|
const QString url_template = YOUTUBE_LIVE_BROADCAST_TRANSITION_URL
|
|
"?id=%1"
|
|
"&broadcastStatus=complete"
|
|
"&part=status";
|
|
const QString url = url_template.arg(broadcast_id);
|
|
Json json_out;
|
|
return InsertCommand(QT_TO_UTF8(url), "application/json", "POST", "{}",
|
|
json_out);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::StopLatestBroadcast()
|
|
{
|
|
return StopBroadcast(this->broadcast_id);
|
|
}
|
|
|
|
void YoutubeApiWrappers::SetBroadcastId(QString &broadcast_id)
|
|
{
|
|
this->broadcast_id = broadcast_id;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::ResetBroadcast(const QString &broadcast_id)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
|
|
const QString url_template = YOUTUBE_LIVE_BROADCAST_URL
|
|
"?part=id,snippet,contentDetails,status"
|
|
"&id=%1";
|
|
const QString url = url_template.arg(broadcast_id);
|
|
Json json_out;
|
|
|
|
if (!InsertCommand(QT_TO_UTF8(url), "application/json", "", nullptr,
|
|
json_out))
|
|
return false;
|
|
|
|
const QString put = YOUTUBE_LIVE_BROADCAST_URL
|
|
"?part=id,snippet,contentDetails,status";
|
|
|
|
auto snippet = json_out["items"][0]["snippet"];
|
|
auto status = json_out["items"][0]["status"];
|
|
auto contentDetails = json_out["items"][0]["contentDetails"];
|
|
auto monitorStream = contentDetails["monitorStream"];
|
|
|
|
const Json data = Json::object{
|
|
{"id", QT_TO_UTF8(broadcast_id)},
|
|
{"snippet",
|
|
Json::object{
|
|
{"title", snippet["title"]},
|
|
{"scheduledStartTime", snippet["scheduledStartTime"]},
|
|
}},
|
|
{"status",
|
|
Json::object{
|
|
{"privacyStatus", status["privacyStatus"]},
|
|
{"madeForKids", status["madeForKids"]},
|
|
{"selfDeclaredMadeForKids",
|
|
status["selfDeclaredMadeForKids"]},
|
|
}},
|
|
{"contentDetails",
|
|
Json::object{
|
|
{
|
|
"monitorStream",
|
|
Json::object{
|
|
{"enableMonitorStream", false},
|
|
{"broadcastStreamDelayMs",
|
|
monitorStream["broadcastStreamDelayMs"]},
|
|
},
|
|
},
|
|
{"enableDvr", contentDetails["enableDvr"]},
|
|
{"enableContentEncryption",
|
|
contentDetails["enableContentEncryption"]},
|
|
{"enableEmbed", contentDetails["enableEmbed"]},
|
|
{"recordFromStart", contentDetails["recordFromStart"]},
|
|
{"startWithSlate", contentDetails["startWithSlate"]},
|
|
}},
|
|
};
|
|
return InsertCommand(QT_TO_UTF8(put), "application/json", "PUT",
|
|
data.dump().c_str(), json_out);
|
|
}
|
|
|
|
bool YoutubeApiWrappers::FindBroadcast(const QString &id,
|
|
json11::Json &json_out)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
QByteArray url = YOUTUBE_LIVE_BROADCAST_URL
|
|
"?part=id,snippet,contentDetails,status"
|
|
"&broadcastType=all&maxResults=1";
|
|
url += "&id=" + id.toUtf8();
|
|
|
|
if (!InsertCommand(url, "application/json", "", nullptr, json_out))
|
|
return false;
|
|
|
|
auto items = json_out["items"].array_items();
|
|
if (items.size() != 1) {
|
|
lastErrorMessage =
|
|
QTStr("YouTube.Actions.Error.BroadcastNotFound");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool YoutubeApiWrappers::FindStream(const QString &id, json11::Json &json_out)
|
|
{
|
|
lastErrorMessage.clear();
|
|
lastErrorReason.clear();
|
|
QByteArray url = YOUTUBE_LIVE_STREAM_URL "?part=id,snippet,cdn,status"
|
|
"&maxResults=1";
|
|
url += "&id=" + id.toUtf8();
|
|
|
|
if (!InsertCommand(url, "application/json", "", nullptr, json_out))
|
|
return false;
|
|
|
|
auto items = json_out["items"].array_items();
|
|
if (items.size() != 1) {
|
|
lastErrorMessage = "No active broadcast found.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|