File indexing completed on 2025-02-23 04:35:12

0001 // SPDX-FileCopyrightText: 2021 Linus Jahn <lnj@kaidan.im>
0002 // SPDX-License-Identifier: GPL-3.0-or-later
0003 
0004 #include "invidiousapi.h"
0005 
0006 #include <KLocalizedString>
0007 
0008 #include <QJsonDocument>
0009 #include <QNetworkAccessManager>
0010 #include <QNetworkReply>
0011 #include <QStringBuilder>
0012 #include <QUrl>
0013 #include <QUrlQuery>
0014 
0015 const QString API_FEED = QStringLiteral("/api/v1/auth/feed");
0016 const QString API_LOGIN = QStringLiteral("/login");
0017 const QString API_SEARCH = QStringLiteral("/api/v1/search");
0018 const QString API_SUBSCRIPTIONS = QStringLiteral("/api/v1/auth/subscriptions");
0019 const QString API_TOP = QStringLiteral("/api/v1/top");
0020 const QString API_TRENDING = QStringLiteral("/api/v1/trending");
0021 const QString API_VIDEOS = QStringLiteral("/api/v1/videos");
0022 const QString API_CHANNELS = QStringLiteral("/api/v1/channels/videos");
0023 const QString API_HISTORY = QStringLiteral("/api/v1/auth/history");
0024 const QString API_COMMENTS = QStringLiteral("/api/v1/comments");
0025 const QString API_LIST_PLAYLISTS = QStringLiteral("/api/v1/auth/playlists");
0026 const QString API_PREFERENCES = QStringLiteral("/api/v1/auth/preferences");
0027 const QString API_PLAYLISTS = QStringLiteral("/api/v1/playlists");
0028 const QString API_CHANNEL_INFO = QStringLiteral("/api/v1/channels");
0029 
0030 using namespace QInvidious;
0031 using namespace Qt::StringLiterals;
0032 
0033 InvidiousApi::InvidiousApi(QNetworkAccessManager *netManager, QObject *parent)
0034     : AbstractApi(netManager, parent)
0035 {
0036 }
0037 
0038 bool InvidiousApi::supportsFeature(AbstractApi::SupportedFeature feature)
0039 {
0040     switch (feature) {
0041     case PopularPage:
0042         return true;
0043     case TrendingCategories:
0044         return true;
0045     }
0046 
0047     return false;
0048 }
0049 
0050 QFuture<LogInResult> InvidiousApi::logIn(QStringView username, QStringView password)
0051 {
0052     QUrlQuery params;
0053     params.addQueryItem(QStringLiteral("email"), QString::fromUtf8(QUrl::toPercentEncoding(username.toString())));
0054     params.addQueryItem(QStringLiteral("password"), QString::fromUtf8(QUrl::toPercentEncoding(password.toString())));
0055     params.addQueryItem(QStringLiteral("action"), QStringLiteral("signin"));
0056 
0057     QUrl url = apiUrl(API_LOGIN);
0058     auto urlQuery = genericUrlQuery();
0059     urlQuery.addQueryItem(QStringLiteral("referer"), QString::fromUtf8(QUrl::toPercentEncoding(QStringLiteral("/"))));
0060     urlQuery.addQueryItem(QStringLiteral("type"), QStringLiteral("invidious"));
0061     url.setQuery(urlQuery);
0062 
0063     QNetworkRequest request(url);
0064     request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/x-www-form-urlencoded"));
0065     request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::RedirectPolicy::ManualRedirectPolicy);
0066 
0067     return post<LogInResult>(std::move(request), params.toString().toUtf8(), [=](QNetworkReply *reply) -> LogInResult {
0068         const auto cookies = reply->header(QNetworkRequest::SetCookieHeader).value<QList<QNetworkCookie>>();
0069 
0070         if (!cookies.isEmpty()) {
0071             m_credentials.setUsername(username);
0072             m_credentials.setCookie(cookies.first());
0073             Q_EMIT credentialsChanged();
0074             return m_credentials;
0075         }
0076         return std::pair(QNetworkReply::ContentAccessDenied, i18n("Username or password is wrong."));
0077     });
0078 }
0079 
0080 QFuture<VideoResult> InvidiousApi::requestVideo(QStringView videoId)
0081 {
0082     return get<VideoResult>(QNetworkRequest(videoUrl(videoId)), [=](QNetworkReply *reply) -> VideoResult {
0083         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0084             return Video::fromJson(doc);
0085         }
0086         return invalidJsonError();
0087     });
0088 }
0089 
0090 QString InvidiousApi::resolveVideoUrl(QStringView videoId)
0091 {
0092     return QStringLiteral("ytdl://%1").arg(videoId);
0093 }
0094 
0095 QFuture<SearchListResult> InvidiousApi::requestSearchResults(const SearchParameters &parameters)
0096 {
0097     auto url = videoListUrl(Search, QStringLiteral(""), parameters.toQueryParameters());
0098     auto request = QNetworkRequest(url);
0099 
0100     return get<SearchListResult>(std::move(request), [=](QNetworkReply *reply) -> SearchListResult {
0101         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0102             const auto obj = doc.object();
0103 
0104             QList<SearchResult> results;
0105             for (auto value : doc.array()) {
0106                 if (value.isObject()) {
0107                     results << SearchResult::fromJson(value.toObject());
0108                 }
0109             }
0110 
0111             return results;
0112         }
0113         return invalidJsonError();
0114     });
0115 }
0116 
0117 QFuture<VideoListResult> InvidiousApi::requestFeed(qint32 page)
0118 {
0119     QHash<QString, QString> parameters;
0120     parameters.insert(QStringLiteral("page"), QString::number(page));
0121 
0122     return requestVideoList(Feed, QStringLiteral(""), parameters);
0123 }
0124 
0125 QFuture<VideoListResult> InvidiousApi::requestTop()
0126 {
0127     return requestVideoList(Top);
0128 }
0129 
0130 QFuture<VideoListResult> InvidiousApi::requestTrending(TrendingTopic topic)
0131 {
0132     QHash<QString, QString> parameters;
0133     switch (topic) {
0134     case Music:
0135         parameters.insert(QStringLiteral("type"), QStringLiteral("music"));
0136         break;
0137     case Gaming:
0138         parameters.insert(QStringLiteral("type"), QStringLiteral("gaming"));
0139         break;
0140     case Movies:
0141         parameters.insert(QStringLiteral("type"), QStringLiteral("movies"));
0142         break;
0143     case News:
0144         parameters.insert(QStringLiteral("type"), QStringLiteral("news"));
0145         break;
0146     case Main:
0147         break;
0148     }
0149     return requestVideoList(Trending, QStringLiteral(""), parameters);
0150 }
0151 
0152 QFuture<VideoListResult> InvidiousApi::requestChannel(QStringView query, qint32 page)
0153 {
0154     QHash<QString, QString> parameters;
0155     parameters.insert(QStringLiteral("page"), QString::number(page));
0156     return requestVideoList(Channel, query.toString(), parameters);
0157 }
0158 
0159 QFuture<SubscriptionsResult> InvidiousApi::requestSubscriptions()
0160 {
0161     return get<SubscriptionsResult>(authenticatedNetworkRequest(subscriptionsUrl()), [=](QNetworkReply *reply) -> SubscriptionsResult {
0162         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0163             auto array = doc.array();
0164 
0165             QList<QString> subscriptions;
0166             std::transform(array.cbegin(), array.cend(), std::back_inserter(subscriptions), [](const QJsonValue &val) {
0167                 return val.toObject().value(QStringLiteral("authorId")).toString();
0168             });
0169             return subscriptions;
0170         }
0171         return invalidJsonError();
0172     });
0173 }
0174 
0175 QFuture<Result> InvidiousApi::subscribeToChannel(QStringView channel)
0176 {
0177     return post<Result>(authenticatedNetworkRequest(subscribeUrl(channel)), {}, checkIsReplyOk);
0178 }
0179 
0180 QFuture<Result> InvidiousApi::unsubscribeFromChannel(QStringView channel)
0181 {
0182     return deleteResource<Result>(authenticatedNetworkRequest(subscribeUrl(channel)), checkIsReplyOk);
0183 }
0184 
0185 QFuture<HistoryResult> InvidiousApi::requestHistory(qint32 page)
0186 {
0187     QUrlQuery parameters;
0188     parameters.addQueryItem(QStringLiteral("page"), QString::number(page));
0189 
0190     QUrl url = apiUrl(API_HISTORY);
0191     url.setQuery(parameters);
0192 
0193     return get<HistoryResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> HistoryResult {
0194         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0195             const auto array = doc.array();
0196 
0197             QList<QString> history;
0198             std::transform(array.cbegin(), array.cend(), std::back_inserter(history), [](const QJsonValue &val) {
0199                 return val.toString();
0200             });
0201             return history;
0202         }
0203         return invalidJsonError();
0204     });
0205 }
0206 
0207 QFuture<Result> InvidiousApi::markWatched(const QString &videoId)
0208 {
0209     return post<Result>(authenticatedNetworkRequest(apiUrl(API_HISTORY % u'/' % videoId)), {}, checkIsReplyOk);
0210 }
0211 
0212 QFuture<Result> InvidiousApi::markUnwatched(const QString &videoId)
0213 {
0214     return deleteResource<Result>(authenticatedNetworkRequest(apiUrl(API_HISTORY % u'/' % videoId)), checkIsReplyOk);
0215 }
0216 
0217 QFuture<CommentsResult> InvidiousApi::requestComments(const QString &videoId, const QString &continuation)
0218 {
0219     QUrlQuery parameters;
0220     if (!continuation.isEmpty()) {
0221         parameters.addQueryItem(QStringLiteral("continuation"), continuation);
0222     }
0223 
0224     QUrl url = apiUrl(API_COMMENTS % u'/' % videoId);
0225     url.setQuery(parameters);
0226 
0227     return get<CommentsResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> CommentsResult {
0228         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0229             const auto array = doc[u"comments"].toArray();
0230 
0231             QList<Comment> comments;
0232             std::transform(array.cbegin(), array.cend(), std::back_inserter(comments), [](const QJsonValue &val) {
0233                 Comment comment;
0234                 Comment::fromJson(val.toObject(), comment);
0235                 return comment;
0236             });
0237             return Comments{comments, doc[u"continuation"].toString()};
0238         }
0239         return invalidJsonError();
0240     });
0241 }
0242 
0243 QFuture<PlaylistsResult> InvidiousApi::requestPlaylists()
0244 {
0245     QUrl url = apiUrl(API_LIST_PLAYLISTS);
0246 
0247     return get<PlaylistsResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> PlaylistsResult {
0248         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0249             const auto array = doc.array();
0250 
0251             QList<Playlist> playlists;
0252             std::transform(array.cbegin(), array.cend(), std::back_inserter(playlists), [](const QJsonValue &val) {
0253                 Playlist playlist;
0254                 Playlist::fromJson(val.toObject(), playlist);
0255                 return playlist;
0256             });
0257             return playlists;
0258         }
0259         return invalidJsonError();
0260     });
0261 }
0262 
0263 QFuture<PreferencesResult> InvidiousApi::requestPreferences()
0264 {
0265     QUrl url = apiUrl(API_PREFERENCES);
0266 
0267     return get<PreferencesResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> PreferencesResult {
0268         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0269             Preferences preferences;
0270             Preferences::fromJson(doc.object(), preferences);
0271             return preferences;
0272         }
0273         return invalidJsonError();
0274     });
0275 }
0276 
0277 QFuture<Result> InvidiousApi::setPreferences(const QInvidious::Preferences &preferences)
0278 {
0279     QUrl url = apiUrl(API_PREFERENCES);
0280 
0281     return post<Result>(authenticatedNetworkRequest(std::move(url)), QJsonDocument(preferences.toJson()).toJson(), checkIsReplyOk);
0282 }
0283 
0284 QFuture<VideoListResult> InvidiousApi::requestPlaylist(const QString &plid)
0285 {
0286     QUrl url = apiUrl(API_PLAYLISTS % u'/' % plid);
0287 
0288     return get<VideoListResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> VideoListResult {
0289         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0290             const auto obj = doc.object();
0291 
0292             return VideoBasicInfo::fromJson(obj.value("videos"_L1).toArray());
0293         }
0294         return invalidJsonError();
0295     });
0296 }
0297 
0298 QFuture<ChannelResult> InvidiousApi::requestChannelInfo(QStringView queryd)
0299 {
0300     QUrl url = apiUrl(API_CHANNEL_INFO % u'/' % queryd);
0301 
0302     return get<ChannelResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> ChannelResult {
0303         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0304             QInvidious::Channel channel;
0305             Channel::fromJson(doc.object(), channel);
0306             return channel;
0307         }
0308         return invalidJsonError();
0309     });
0310 }
0311 
0312 QFuture<Result> InvidiousApi::addVideoToPlaylist(const QString &plid, const QString &videoId)
0313 {
0314     QUrl url = apiUrl(API_LIST_PLAYLISTS % u'/' % plid % u"/videos");
0315 
0316     QJsonObject requestObj;
0317     requestObj["videoId"_L1] = videoId;
0318 
0319     QNetworkRequest request = authenticatedNetworkRequest(std::move(url));
0320     request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/json"));
0321 
0322     return post<Result>(std::move(request), QJsonDocument(requestObj).toJson(QJsonDocument::Compact), checkIsReplyOk);
0323 }
0324 
0325 QFuture<Result> InvidiousApi::removeVideoFromPlaylist(const QString &plid, const QString &indexId)
0326 {
0327     QUrl url = apiUrl(API_LIST_PLAYLISTS % u'/' % plid % u"/videos/" % indexId);
0328 
0329     return deleteResource<Result>(authenticatedNetworkRequest(std::move(url)), checkIsReplyOk);
0330 }
0331 
0332 Error InvidiousApi::invalidJsonError()
0333 {
0334     return std::pair(QNetworkReply::InternalServerError, i18n("Server returned no valid JSON."));
0335 }
0336 
0337 Result InvidiousApi::checkIsReplyOk(QNetworkReply *reply)
0338 {
0339     auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
0340     if (status >= 200 && status < 300) {
0341         return Success();
0342     }
0343     return std::pair(QNetworkReply::InternalServerError, i18n("Server returned the status code %1", QString::number(status)));
0344 }
0345 
0346 QFuture<VideoListResult> InvidiousApi::requestVideoList(VideoListType queryType, const QString &urlExtension, const QHash<QString, QString> &parameters)
0347 {
0348     auto url = videoListUrl(queryType, urlExtension, parameters);
0349     // Feed requests require to be authenticated
0350     auto request = queryType == Feed ? authenticatedNetworkRequest(std::move(url)) : QNetworkRequest(url);
0351 
0352     return get<VideoListResult>(std::move(request), [=](QNetworkReply *reply) -> VideoListResult {
0353         if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) {
0354             if (queryType == Feed) {
0355                 const auto obj = doc.object();
0356 
0357                 // add videos marked as notification
0358                 auto results = VideoBasicInfo::fromJson(obj.value("notifications"_L1).toArray());
0359 
0360                 // add the rest
0361                 results << VideoBasicInfo::fromJson(obj.value("videos"_L1).toArray());
0362                 return results;
0363             } else if (queryType == Channel) {
0364                 const auto obj = doc.object();
0365 
0366                 auto results = VideoBasicInfo::fromJson(obj.value("videos"_L1).toArray());
0367                 return results;
0368             } else {
0369                 QList<VideoBasicInfo> results;
0370                 for (auto value : doc.array()) {
0371                     if (value.isObject() && value.toObject()["type"_L1] == "video"_L1) {
0372                         results << VideoBasicInfo::fromJson(value.toObject());
0373                     }
0374                 }
0375 
0376                 return results;
0377             }
0378         }
0379         return invalidJsonError();
0380     });
0381 }
0382 
0383 QNetworkRequest InvidiousApi::authenticatedNetworkRequest(QUrl &&url)
0384 {
0385     QNetworkRequest request(url);
0386     if (!m_credentials.isAnonymous()) {
0387         const QList<QNetworkCookie> cookies{m_credentials.cookie().value()};
0388         request.setHeader(QNetworkRequest::CookieHeader, QVariant::fromValue(cookies));
0389     }
0390     // some invidious instances redirect some calls using reverse proxies
0391     request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
0392     return request;
0393 }
0394 
0395 QUrlQuery InvidiousApi::genericUrlQuery() const
0396 {
0397     QUrlQuery query;
0398     if (!m_language.isEmpty()) {
0399         query.addQueryItem(QStringLiteral("hl"), m_language);
0400     }
0401     if (!m_region.isEmpty()) {
0402         query.addQueryItem(QStringLiteral("region"), m_region);
0403     }
0404     return query;
0405 }
0406 
0407 QUrl InvidiousApi::videoUrl(QStringView videoId) const
0408 {
0409     return apiUrl(API_VIDEOS % u'/' % videoId);
0410 }
0411 
0412 QUrl InvidiousApi::videoListUrl(VideoListType queryType, const QString &urlExtension, const QHash<QString, QString> &parameters) const
0413 {
0414     QString urlString;
0415     auto query = genericUrlQuery();
0416 
0417     switch (queryType) {
0418     case Search:
0419         urlString.append(API_SEARCH);
0420         break;
0421     case Trending:
0422         urlString.append(API_TRENDING);
0423         break;
0424     case Top:
0425         urlString.append(API_TOP);
0426         break;
0427     case Feed:
0428         urlString.append(API_FEED);
0429         break;
0430     case Channel:
0431         urlString.append(API_CHANNELS);
0432         break;
0433     }
0434 
0435     if (!urlExtension.isEmpty()) {
0436         urlString.append(QStringLiteral("/"));
0437         urlString.append(urlExtension);
0438     }
0439 
0440     for (QHash<QString, QString>::const_iterator parameter = parameters.begin(); parameter != parameters.end(); ++parameter) {
0441         query.addQueryItem(parameter.key(), parameter.value());
0442     }
0443 
0444     QUrl url = apiUrl(urlString);
0445     url.setQuery(query);
0446     return url;
0447 }
0448 
0449 QUrl InvidiousApi::subscriptionsUrl() const
0450 {
0451     auto url = apiUrl(API_SUBSCRIPTIONS);
0452     QUrlQuery query;
0453     query.addQueryItem(QStringLiteral("fields"), QStringLiteral("authorId"));
0454     url.setQuery(query);
0455     return url;
0456 }
0457 
0458 QUrl InvidiousApi::subscribeUrl(QStringView channelId) const
0459 {
0460     return apiUrl(API_SUBSCRIPTIONS % u'/' % channelId);
0461 }