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

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