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 ¶meters) 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> ¶meters) 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> ¶meters) 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 }