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 &parameters)
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> &parameters)
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> &parameters) 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 }