File indexing completed on 2025-02-23 04:35:13
0001 // SPDX-FileCopyrightText: 2023 Joshua Goins <josh@redstrate.com> 0002 // SPDX-License-Identifier: GPL-3.0-or-later 0003 0004 #include "peertubeapi.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_VIDEOS = QStringLiteral("/api/v1/videos"); 0016 const QString API_CHANNEL = QStringLiteral("/api/v1/video-channels"); 0017 const QString API_SEARCH_VIDEOS = QStringLiteral("/api/v1/search/videos"); 0018 const QString API_SEARCH_VIDEO_CHANNELS = QStringLiteral("/api/v1/search/video-channels"); 0019 const QString API_SEARCH_VIDEO_PLAYLISTS = QStringLiteral("/api/v1/search/video-playlists"); 0020 0021 using namespace QInvidious; 0022 using namespace Qt::StringLiterals; 0023 0024 PeerTubeApi::PeerTubeApi(QNetworkAccessManager *netManager, QObject *parent) 0025 : AbstractApi(netManager, parent) 0026 { 0027 } 0028 0029 bool PeerTubeApi::supportsFeature(AbstractApi::SupportedFeature feature) 0030 { 0031 switch (feature) { 0032 case PopularPage: 0033 case TrendingCategories: 0034 return false; 0035 } 0036 0037 return false; 0038 } 0039 0040 QFuture<LogInResult> PeerTubeApi::logIn(QStringView username, QStringView password) 0041 { 0042 QUrlQuery params; 0043 params.addQueryItem(QStringLiteral("email"), QString::fromUtf8(QUrl::toPercentEncoding(username.toString()))); 0044 params.addQueryItem(QStringLiteral("password"), QString::fromUtf8(QUrl::toPercentEncoding(password.toString()))); 0045 params.addQueryItem(QStringLiteral("action"), QStringLiteral("signin")); 0046 0047 QNetworkRequest request(logInUrl()); 0048 request.setHeader(QNetworkRequest::ContentTypeHeader, QByteArrayLiteral("application/x-www-form-urlencoded")); 0049 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::RedirectPolicy::ManualRedirectPolicy); 0050 0051 return post<LogInResult>(std::move(request), params.toString().toUtf8(), [=](QNetworkReply *reply) -> LogInResult { 0052 const auto cookies = reply->header(QNetworkRequest::SetCookieHeader).value<QList<QNetworkCookie>>(); 0053 0054 if (!cookies.isEmpty()) { 0055 m_credentials.setUsername(username); 0056 m_credentials.setCookie(cookies.first()); 0057 Q_EMIT credentialsChanged(); 0058 return m_credentials; 0059 } 0060 return std::pair(QNetworkReply::ContentAccessDenied, i18n("Username or password is wrong.")); 0061 }); 0062 } 0063 0064 QFuture<VideoResult> PeerTubeApi::requestVideo(QStringView videoId) 0065 { 0066 return get<VideoResult>(QNetworkRequest(videoUrl(videoId)), [=](QNetworkReply *reply) -> VideoResult { 0067 if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) { 0068 return Video::fromJson(doc); 0069 } 0070 return invalidJsonError(); 0071 }); 0072 } 0073 0074 QString PeerTubeApi::resolveVideoUrl(QStringView videoId) 0075 { 0076 return QStringLiteral("https://%1/videos/watch/%2").arg(m_apiHost, videoId); 0077 } 0078 0079 QFuture<SearchListResult> PeerTubeApi::requestSearchResults(const SearchParameters ¶meters) 0080 { 0081 QHash<QString, QString> searchParameters = { 0082 {u"search"_s, parameters.query()}, 0083 {u"start"_s, QString::number(parameters.page())}, 0084 }; 0085 0086 if (parameters.sortBy() == SearchParameters::SortBy::UploadDate) { 0087 searchParameters[u"sort"_s] = u"-createdAt"_s; 0088 } 0089 auto url = videoListUrl(Search, QString(), searchParameters); 0090 0091 auto request = QNetworkRequest(url); 0092 0093 return get<SearchListResult>(std::move(request), [=](QNetworkReply *reply) -> SearchListResult { 0094 if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) { 0095 const auto obj = doc.object(); 0096 0097 QList<SearchResult> results; 0098 const auto resultsJson = obj["data"_L1].toArray(); 0099 for (auto value : resultsJson) { 0100 if (value.isObject()) { 0101 auto result = SearchResult::fromJson(value.toObject()); 0102 0103 auto video = result.video(); 0104 auto newThumbnails = video.videoThumbnails(); 0105 for (auto &thumbnail : newThumbnails) { 0106 thumbnail.setUrl(QUrl(QStringLiteral("https://%1/%2").arg(m_apiHost, thumbnail.url().path()))); 0107 } 0108 video.setVideoThumbnails(newThumbnails); 0109 result.setVideo(video); 0110 results << result; 0111 } 0112 } 0113 0114 return results; 0115 } 0116 return invalidJsonError(); 0117 }); 0118 } 0119 0120 QFuture<VideoListResult> PeerTubeApi::requestFeed(qint32 page) 0121 { 0122 QHash<QString, QString> parameters; 0123 parameters.insert(QStringLiteral("page"), QString::number(page)); 0124 0125 return requestVideoList(Feed, QStringLiteral(""), parameters); 0126 } 0127 0128 QFuture<VideoListResult> PeerTubeApi::requestTop() 0129 { 0130 return requestVideoList(Top); 0131 } 0132 0133 QFuture<VideoListResult> PeerTubeApi::requestTrending(TrendingTopic topic) 0134 { 0135 QHash<QString, QString> parameters; 0136 switch (topic) { 0137 case Music: 0138 parameters.insert(QStringLiteral("type"), QStringLiteral("music")); 0139 break; 0140 case Gaming: 0141 parameters.insert(QStringLiteral("type"), QStringLiteral("gaming")); 0142 break; 0143 case Movies: 0144 parameters.insert(QStringLiteral("type"), QStringLiteral("movies")); 0145 break; 0146 case News: 0147 parameters.insert(QStringLiteral("type"), QStringLiteral("news")); 0148 break; 0149 case Main: 0150 break; 0151 } 0152 return requestVideoList(Trending, QStringLiteral(""), parameters); 0153 } 0154 0155 QFuture<VideoListResult> PeerTubeApi::requestChannel(QStringView query, qint32 page) 0156 { 0157 QHash<QString, QString> parameters; 0158 parameters.insert(QStringLiteral("page"), QString::number(page)); 0159 return requestVideoList(Channel, query.toString(), parameters); 0160 } 0161 0162 QFuture<SubscriptionsResult> PeerTubeApi::requestSubscriptions() 0163 { 0164 return get<SubscriptionsResult>(authenticatedNetworkRequest(subscriptionsUrl()), [=](QNetworkReply *reply) -> SubscriptionsResult { 0165 if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) { 0166 auto array = doc.array(); 0167 0168 QList<QString> subscriptions; 0169 std::transform(array.cbegin(), array.cend(), std::back_inserter(subscriptions), [](const QJsonValue &val) { 0170 return val.toObject().value(QStringLiteral("authorId")).toString(); 0171 }); 0172 return subscriptions; 0173 } 0174 return invalidJsonError(); 0175 }); 0176 } 0177 0178 QFuture<Result> PeerTubeApi::subscribeToChannel(QStringView channel) 0179 { 0180 return post<Result>(authenticatedNetworkRequest(subscribeUrl(channel)), {}, checkIsReplyOk); 0181 } 0182 0183 QFuture<Result> PeerTubeApi::unsubscribeFromChannel(QStringView channel) 0184 { 0185 return deleteResource<Result>(authenticatedNetworkRequest(subscribeUrl(channel)), checkIsReplyOk); 0186 } 0187 0188 QFuture<HistoryResult> PeerTubeApi::requestHistory(qint32 page) 0189 { 0190 Q_UNUSED(page) 0191 return {}; 0192 } 0193 0194 QFuture<Result> PeerTubeApi::markWatched(const QString &videoId) 0195 { 0196 Q_UNUSED(videoId) 0197 return {}; 0198 } 0199 0200 QFuture<Result> PeerTubeApi::markUnwatched(const QString &videoId) 0201 { 0202 Q_UNUSED(videoId) 0203 return {}; 0204 } 0205 0206 QFuture<CommentsResult> PeerTubeApi::requestComments(const QString &videoId, const QString &continuation) 0207 { 0208 Q_UNUSED(continuation) 0209 0210 QUrl url = apiUrl(API_VIDEOS % u'/' % videoId % u"/comment-threads"); 0211 0212 return get<CommentsResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> CommentsResult { 0213 if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) { 0214 const auto array = doc[u"data"].toArray(); 0215 0216 QList<Comment> comments; 0217 std::transform(array.cbegin(), array.cend(), std::back_inserter(comments), [](const QJsonValue &val) { 0218 Comment comment; 0219 Comment::fromJson(val.toObject(), comment); 0220 return comment; 0221 }); 0222 return Comments{comments, {}}; 0223 } 0224 return invalidJsonError(); 0225 }); 0226 } 0227 0228 QFuture<PlaylistsResult> PeerTubeApi::requestPlaylists() 0229 { 0230 return {}; 0231 } 0232 0233 QFuture<PreferencesResult> PeerTubeApi::requestPreferences() 0234 { 0235 return {}; 0236 } 0237 0238 QFuture<Result> PeerTubeApi::setPreferences(const QInvidious::Preferences &preferences) 0239 { 0240 Q_UNUSED(preferences) 0241 return {}; 0242 } 0243 0244 QFuture<VideoListResult> PeerTubeApi::requestPlaylist(const QString &plid) 0245 { 0246 Q_UNUSED(plid) 0247 return {}; 0248 } 0249 0250 QFuture<ChannelResult> PeerTubeApi::requestChannelInfo(QStringView queryd) 0251 { 0252 QUrl url = apiUrl(API_CHANNEL % u'/' % queryd); 0253 0254 return get<ChannelResult>(authenticatedNetworkRequest(std::move(url)), [=](QNetworkReply *reply) -> ChannelResult { 0255 if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) { 0256 QInvidious::Channel channel; 0257 Channel::fromJson(doc.object(), channel); 0258 fixupChannel(channel); 0259 return channel; 0260 } 0261 return invalidJsonError(); 0262 }); 0263 } 0264 0265 QFuture<Result> PeerTubeApi::addVideoToPlaylist(const QString &plid, const QString &videoId) 0266 { 0267 Q_UNUSED(plid) 0268 Q_UNUSED(videoId) 0269 return {}; 0270 } 0271 0272 QFuture<Result> PeerTubeApi::removeVideoFromPlaylist(const QString &plid, const QString &indexId) 0273 { 0274 Q_UNUSED(plid) 0275 Q_UNUSED(indexId) 0276 return {}; 0277 } 0278 0279 Error PeerTubeApi::invalidJsonError() 0280 { 0281 return {QNetworkReply::InternalServerError, i18n("Server returned no valid JSON.")}; 0282 } 0283 0284 Result PeerTubeApi::checkIsReplyOk(QNetworkReply *reply) 0285 { 0286 auto status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); 0287 if (status >= 200 && status < 300) { 0288 return Success(); 0289 } 0290 return std::pair(QNetworkReply::InternalServerError, i18n("Server returned the status code %1", QString::number(status))); 0291 } 0292 0293 QFuture<VideoListResult> PeerTubeApi::requestVideoList(VideoListType queryType, const QString &urlExtension, const QHash<QString, QString> ¶meters) 0294 { 0295 auto url = videoListUrl(queryType, urlExtension, parameters); 0296 // Feed requests require to be authenticated 0297 auto request = queryType == Feed ? authenticatedNetworkRequest(std::move(url)) : QNetworkRequest(url); 0298 0299 return get<VideoListResult>(std::move(request), [=](QNetworkReply *reply) -> VideoListResult { 0300 if (auto doc = QJsonDocument::fromJson(reply->readAll()); !doc.isNull()) { 0301 if (queryType == Feed) { 0302 const auto obj = doc.object(); 0303 0304 auto results = VideoBasicInfo::fromJson(obj.value("data"_L1).toArray()); 0305 fixupVideoThumbnails(results); 0306 return results; 0307 } else if (queryType == Channel) { 0308 const auto obj = doc.object(); 0309 0310 auto results = VideoBasicInfo::fromJson(obj.value("data"_L1).toArray()); 0311 fixupVideoThumbnails(results); 0312 return results; 0313 } else { 0314 QList<VideoBasicInfo> results; 0315 for (auto value : doc["data"_L1].toArray()) { 0316 if (value.isObject()) { 0317 results << VideoBasicInfo::fromJson(value.toObject()); 0318 } 0319 } 0320 0321 fixupVideoThumbnails(results); 0322 return results; 0323 } 0324 } 0325 return invalidJsonError(); 0326 }); 0327 } 0328 0329 QNetworkRequest PeerTubeApi::authenticatedNetworkRequest(QUrl &&url) 0330 { 0331 QNetworkRequest request(url); 0332 if (!m_credentials.isAnonymous()) { 0333 const QList<QNetworkCookie> cookies{m_credentials.cookie().value()}; 0334 request.setHeader(QNetworkRequest::CookieHeader, QVariant::fromValue(cookies)); 0335 } 0336 // some invidious instances redirect some calls using reverse proxies 0337 request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); 0338 return request; 0339 } 0340 0341 QUrlQuery PeerTubeApi::genericUrlQuery() const 0342 { 0343 return {}; 0344 } 0345 0346 QUrl PeerTubeApi::logInUrl() const 0347 { 0348 return {}; 0349 } 0350 0351 QUrl PeerTubeApi::videoUrl(QStringView videoId) const 0352 { 0353 return apiUrl(API_VIDEOS % u'/' % videoId); 0354 } 0355 0356 QUrl PeerTubeApi::videoListUrl(VideoListType queryType, const QString &urlExtension, const QHash<QString, QString> ¶meters) const 0357 { 0358 QString urlString; 0359 switch (queryType) { 0360 case Search: 0361 urlString = API_SEARCH_VIDEOS; 0362 break; 0363 default: 0364 // TODO: implement other query types 0365 urlString = API_VIDEOS; 0366 break; 0367 } 0368 0369 auto query = genericUrlQuery(); 0370 0371 if (!urlExtension.isEmpty()) { 0372 urlString.append(QStringLiteral("/")); 0373 urlString.append(urlExtension); 0374 if (queryType == Channel) { 0375 urlString.append(QStringLiteral("/videos")); 0376 } 0377 } 0378 0379 for (QHash<QString, QString>::const_iterator parameter = parameters.begin(); parameter != parameters.end(); ++parameter) { 0380 query.addQueryItem(parameter.key(), parameter.value()); 0381 } 0382 0383 QUrl url = apiUrl(urlString); 0384 url.setQuery(query); 0385 return url; 0386 } 0387 0388 QUrl PeerTubeApi::subscriptionsUrl() const 0389 { 0390 return {}; 0391 } 0392 0393 QUrl PeerTubeApi::subscribeUrl(QStringView channelId) const 0394 { 0395 Q_UNUSED(channelId) 0396 return {}; 0397 } 0398 0399 void PeerTubeApi::fixupVideoThumbnails(QList<VideoBasicInfo> &list) const 0400 { 0401 // PeerTube gives us relative URLs for thumbnails (why?) so we need to attach the api instance 0402 for (auto &video : list) { 0403 auto newThumbnails = video.videoThumbnails(); 0404 for (auto &thumbnail : newThumbnails) { 0405 thumbnail.setUrl(QUrl(QStringLiteral("https://%1/%2").arg(m_apiHost, thumbnail.url().path()))); 0406 } 0407 video.setVideoThumbnails(newThumbnails); 0408 } 0409 } 0410 0411 void PeerTubeApi::fixupChannel(QInvidious::Channel &channel) 0412 { 0413 // PeerTube gives us relative URLs for avatar/banner 0414 channel.setAvatar(QStringLiteral("https://%1/%2").arg(m_apiHost, channel.avatar())); 0415 channel.setBanner(QStringLiteral("https://%1/%2").arg(m_apiHost, channel.banner())); 0416 }