CONFIG += c++17 exceptions_off rtti_off optimize_full object_parallel_to_source
TEMPLATE = app
-VERSION = 3.5.1
+VERSION = 3.6
DEFINES += APP_VERSION="$$VERSION"
APP_NAME = Minitube
include(src/qtsingleapplication/qtsingleapplication.pri)
include(src/invidious/invidious.pri)
+include(src/ytjs/ytjs.pri)
INCLUDEPATH += $$PWD/src
#include "http.h"
#include "httputils.h"
-#include "videoapi.h"
#include "ivchannelsource.h"
+#include "videoapi.h"
+#include "ytjschannelsource.h"
ChannelAggregator::ChannelAggregator(QObject *parent)
: QObject(parent), unwatchedCount(-1), running(false), stopped(false), currentChannel(0) {
void ChannelAggregator::checkWebPage(YTChannel *channel) {
currentChannel = channel;
- QString url = "https://www.youtube.com/channel/" + channel->getChannelId() + "/videos";
+
+ QString channelId = channel->getChannelId();
+ QString url;
+ if (channelId.startsWith("UC") && !channelId.contains(' ')) {
+ url = "https://www.youtube.com/channel/" + channelId + "/videos";
+ } else {
+ url = "https://www.youtube.com/user/" + channelId + "/videos";
+ }
+
QObject *reply = HttpUtils::yt().get(url);
connect(reply, SIGNAL(data(QByteArray)), SLOT(parseWebPage(QByteArray)));
connect(videoSource, SIGNAL(gotVideos(QVector<Video *>)),
SLOT(videosLoaded(QVector<Video *>)));
videoSource->loadVideos(50, 1);
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ auto *videoSource = new YTJSChannelSource(params);
+ connect(videoSource, SIGNAL(gotVideos(QVector<Video *>)),
+ SLOT(videosLoaded(QVector<Video *>)));
+ videoSource->loadVideos(50, 1);
}
channel->updateChecked();
#endif
#include "channellistview.h"
-#include "videoapi.h"
#include "ivchannelsource.h"
+#include "videoapi.h"
+#include "ytjschannelsource.h"
namespace {
const QString sortByKey = "subscriptionsSortBy";
vs = videoSource;
} else if (VideoAPI::impl() == VideoAPI::IV) {
vs = new IVChannelSource(params);
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ vs = new YTJSChannelSource(params);
}
emit activated(vs);
channel->updateWatched();
#include "invidious.h"
#include "videoapi.h"
+#include "ytjs.h"
#ifdef MEDIA_QTAV
#include "mediaqtav.h"
Invidious::instance().initServers();
} else if (VideoAPI::impl() == VideoAPI::YT3) {
YT3::instance().initApiKeys();
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ YTJS::instance();
+ Invidious::instance().initServers();
}
QTimer::singleShot(100, this, &MainWindow::lazyInit);
action->setStatusTip(tr("Hide videos that may contain inappropriate content"));
action->setShortcut(QKeySequence(Qt::CTRL + Qt::Key_K));
action->setCheckable(true);
- action->setVisible(VideoAPI::impl() == VideoAPI::YT3);
+ action->setVisible(VideoAPI::impl() != VideoAPI::IV);
actionMap.insert("safeSearch", action);
action = new QAction(tr("Toggle &Menu Bar"), this);
#include "ivsinglevideosource.h"
#include "videoapi.h"
+#include "ytjschannelsource.h"
+#include "ytjssearch.h"
+#include "ytjssinglevideosource.h"
+
MediaView *MediaView::instance() {
static MediaView *i = new MediaView();
return i;
auto search = qobject_cast<IVChannelSource *>(videoSource);
return search->getSearchParams();
}
+ if (clazz == QLatin1String("YTJSSearch")) {
+ auto search = qobject_cast<YTJSSearch *>(videoSource);
+ return search->getSearchParams();
+ }
+ if (clazz == QLatin1String("YTJSChannelSource")) {
+ auto search = qobject_cast<YTJSChannelSource *>(videoSource);
+ return search->getSearchParams();
+ }
return nullptr;
}
auto source = new IVSingleVideoSource(this);
source->setVideoId(videoId);
singleVideoSource = source;
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ auto source = new YTJSSingleVideoSource(this);
+ source->setVideoId(videoId);
+ singleVideoSource = source;
}
setVideoSource(singleVideoSource);
} else {
search = new IVChannelSource(searchParams);
}
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ if (searchParams->channelId().isEmpty()) {
+ search = new YTJSSearch(searchParams);
+ } else {
+ search = new YTJSChannelSource(searchParams);
+ }
}
setVideoSource(search);
}
auto source = new IVSingleVideoSource(this);
source->setVideo(video->clone());
setVideoSource(source);
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ auto source = new YTJSSingleVideoSource(this);
+ source->setVideo(video->clone());
+ setVideoSource(source);
}
MainWindow::instance()->getAction("relatedVideos")->setEnabled(false);
#include "ivsearch.h"
#include "ytsearch.h"
+#include "ytjschannelsource.h"
+#include "ytjssearch.h"
+
namespace {
const QString recentKeywordsKey = "recentKeywords";
const QString recentChannelsKey = "recentChannels";
case Qt::TextAlignmentRole:
return QVariant(int(Qt::AlignHCenter | Qt::AlignVCenter));
case Qt::ForegroundRole:
- if (!errorMessage.isEmpty())
- return palette.color(QPalette::ToolTipText);
- else
- return palette.color(QPalette::Dark);
- case Qt::BackgroundColorRole:
+ return palette.color(QPalette::Dark);
+ case Qt::BackgroundRole:
if (!errorMessage.isEmpty())
return palette.color(QPalette::ToolTipBase);
else
} else if (clazz == QLatin1String("IVChannelSource")) {
auto search = qobject_cast<IVChannelSource *>(videoSource);
searchParams = search->getSearchParams();
+ } else if (clazz == QLatin1String("YTJSSearch")) {
+ auto search = qobject_cast<YTJSSearch *>(videoSource);
+ searchParams = search->getSearchParams();
+ } else if (clazz == QLatin1String("YTJSChannelSource")) {
+ auto search = qobject_cast<YTJSChannelSource *>(videoSource);
+ searchParams = search->getSearchParams();
}
// save keyword
setupLabel(tr("Date"), layout, paramName);
QToolBar *timeBar = setupBar(paramName);
QActionGroup *timeGroup = new QActionGroup(this);
- const QStringList timeSpans = QStringList()
- << tr("Anytime") << tr("Today") << tr("7 Days") << tr("30 Days");
+ const QStringList timeSpans = QStringList() << tr("Anytime") << tr("Today") << tr("7 Days")
+ << tr("30 Days") << tr("This year");
i = 0;
for (const QString &actionName : timeSpans) {
QAction *action = new QAction(actionName, timeBar);
setupLabel(tr("Quality"), layout, paramName);
QToolBar *qualityBar = setupBar(paramName);
QActionGroup *qualityGroup = new QActionGroup(this);
- const QStringList qualityOptions = QStringList() << tr("All") << tr("High Definition");
- tips = QStringList() << "" << tr("720p or higher");
+ const QStringList qualityOptions = QStringList()
+ << tr("All") << tr("HD") << tr("4K") << tr("HDR");
i = 0;
for (const QString &actionName : qualityOptions) {
QAction *action = new QAction(actionName, timeBar);
- action->setStatusTip(tips.at(i));
action->setCheckable(true);
action->setProperty("paramValue", i);
qualityGroup->addAction(action);
enum Duration { DurationAny = 0, DurationShort, DurationMedium, DurationLong };
- enum Quality { QualityAny = 0, QualityHD };
+ enum Quality { QualityAny = 0, QualityHD, Quality4K, QualityHDR };
- enum Time { TimeAny = 0, TimeToday, TimeWeek, TimeMonth };
+ enum Time { TimeAny = 0, TimeToday, TimeWeek, TimeMonth, TimeYear };
enum SafeSearch { None = 0, Moderate, Strict };
SLOT(layoutCategories(const QVector<YTCategory> &)));
youTubeCategories->loadCategories();
addVideoSourceWidget(buildStandardFeed("most_popular", tr("Most Popular")));
- } else if (VideoAPI::impl() == VideoAPI::IV) {
+ } else {
QString regionParam = "region=" + region.id;
addVideoSourceWidget(new IVVideoList("popular?" + regionParam, tr("Most Popular")));
addVideoSourceWidget(new IVVideoList("trending?" + regionParam, tr("Trending")));
#include "jsfunctions.h"
#include "playlistitemdelegate.h"
#include "videodefinition.h"
+
+#include "ytjsvideo.h"
#include "ytvideo.h"
Video::Video()
: duration(0), viewCount(-1), license(LicenseYouTube), definitionCode(0),
- loadingThumbnail(false), ytVideo(nullptr) {}
+ loadingThumbnail(false), ytVideo(nullptr), ytjsVideo(nullptr) {}
Video::~Video() {
qDebug() << "Deleting" << id;
void Video::streamUrlLoaded(const QString &streamUrl, const QString &audioUrl) {
qDebug() << "Streams loaded";
- definitionCode = ytVideo->getDefinitionCode();
this->streamUrl = streamUrl;
emit gotStreamUrl(streamUrl, audioUrl);
- ytVideo->deleteLater();
- ytVideo = nullptr;
+ if (ytVideo) {
+ definitionCode = ytVideo->getDefinitionCode();
+ ytVideo->deleteLater();
+ ytVideo = nullptr;
+ }
+ if (ytjsVideo) {
+ definitionCode = ytjsVideo->getDefinitionCode();
+ ytjsVideo->deleteLater();
+ ytjsVideo = nullptr;
+ }
+}
+
+void Video::loadStreamUrlJS() {
+ if (ytjsVideo) {
+ qDebug() << "Already loading" << id;
+ return;
+ }
+ ytjsVideo = new YTJSVideo(id, this);
+ connect(ytjsVideo, &YTJSVideo::gotStreamUrl, this, &Video::streamUrlLoaded);
+ connect(ytjsVideo, &YTJSVideo::errorStreamUrl, this, [this](const QString &msg) {
+ emit errorStreamUrl(msg);
+ ytjsVideo->deleteLater();
+ ytjsVideo = nullptr;
+ });
+ ytjsVideo->loadStreamUrl();
}
void Video::loadStreamUrl() {
+ loadStreamUrlJS();
+ return;
if (ytVideo) {
qDebug() << "Already loading" << id;
return;
#include <QtGui>
class YTVideo;
+class YTJSVideo;
class Video : public QObject {
Q_OBJECT
void streamUrlLoaded(const QString &streamUrl, const QString &audioUrl);
private:
+ void loadStreamUrlJS();
+
QString title;
QString description;
QString channelTitle;
bool loadingThumbnail;
YTVideo *ytVideo;
+ YTJSVideo *ytjsVideo;
};
// This is required in order to use QPointer<Video> as a QVariant
class VideoAPI {
public:
- enum Impl { YT3, IV };
- static Impl impl() { return IV; }
+ enum Impl { YT3, IV, JS };
+ static Impl impl() { return JS; }
private:
VideoAPI() {}
#include "iconutils.h"
-#include "videoapi.h"
#include "ivchannel.h"
+#include "videoapi.h"
+#include "ytjschannel.h"
YTChannel::YTChannel(const QString &channelId, QObject *parent)
: QObject(parent), id(0), channelId(channelId), loadingThumbnail(false), notifyCount(0),
storeInfo();
loading = false;
});
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ auto ivChannel = new YTJSChannel(channelId);
+ connect(ivChannel, &YTJSChannel::error, this, &YTChannel::requestError);
+ connect(ivChannel, &YTJSChannel::loaded, this, [this, ivChannel] {
+ displayName = ivChannel->getDisplayName();
+ description = ivChannel->getDescription();
+ thumbnailUrl = ivChannel->getThumbnailUrl();
+ ivChannel->deleteLater();
+ emit infoLoaded();
+ storeInfo();
+ loading = false;
+ });
}
}
--- /dev/null
+#include "ytjs.h"
+
+#include "ytjsnamfactory.h"
+
+#include "cachedhttp.h"
+#include "http.h"
+#include "httputils.h"
+
+namespace {
+
+QString wsBase = "https://flavio.tordini.org/minitube-ws/ytjs/";
+QString ytJs = wsBase + "yt.js";
+
+} // namespace
+
+YTJS &YTJS::instance() {
+ static YTJS i;
+ return i;
+}
+
+Http &YTJS::http() {
+ static Http *h = [] {
+ Http *http = new Http;
+ http->addRequestHeader("User-Agent", HttpUtils::userAgent());
+ return http;
+ }();
+ return *h;
+}
+
+Http &YTJS::cachedHttp() {
+ static Http *h = [] {
+ CachedHttp *cachedHttp = new CachedHttp(http(), "ytjs");
+ cachedHttp->setMaxSeconds(3600 * 6);
+ cachedHttp->setIgnoreHostname(true);
+
+ cachedHttp->getValidators().insert("application/javascript", [](const auto &reply) -> bool {
+ return !reply.body().isEmpty();
+ });
+
+ return cachedHttp;
+ }();
+ return *h;
+}
+
+YTJS::YTJS(QObject *parent) : QObject(parent), engine(nullptr) {
+ initialize();
+}
+
+bool YTJS::checkError(const QJSValue &value) {
+ if (value.isError()) {
+ qWarning() << "Error" << value.toString();
+ qDebug() << value.property("stack").toString().splitRef('\n');
+ return true;
+ }
+ return false;
+}
+
+bool YTJS::isInitialized() {
+ if (ready) return true;
+ initialize();
+ return false;
+}
+
+void YTJS::initialize() {
+ if (initializing) return;
+ initializing = true;
+
+ if (engine) engine->deleteLater();
+ engine = new QQmlEngine(this);
+ engine->setNetworkAccessManagerFactory(new YTJSNAMFactory);
+
+ engine->globalObject().setProperty("global", engine->globalObject());
+
+ QJSValue timer = engine->newQObject(new JsTimer(engine));
+ engine->globalObject().setProperty("clearTimeout", timer.property("clearTimeout"));
+ engine->globalObject().setProperty("setTimeout", timer.property("setTimeout"));
+
+ connect(cachedHttp().get(ytJs), &HttpReply::finished, this, [this](auto &reply) {
+ if (!reply.isSuccessful()) {
+ emit initFailed("Cannot load " + ytJs);
+ return;
+ }
+ evaluate(reply.body());
+ ready = true;
+ initializing = false;
+ emit initialized();
+ });
+}
+
+QJSValue YTJS::evaluate(const QString &js) {
+ auto value = engine->evaluate(js);
+ checkError(value);
+ return value;
+}
--- /dev/null
+#ifndef YTJS_H
+#define YTJS_H
+
+#include <QJSEngine>
+#include <QQmlEngine>
+#include <QtCore>
+
+class Http;
+
+class JsTimer : public QTimer {
+ Q_OBJECT
+
+public:
+ static auto &getTimers() {
+ static QHash<QString, JsTimer *> timers;
+ return timers;
+ }
+ // This should be static but cannot bind static functions to QJSEngine
+ Q_INVOKABLE QJSValue clearTimeout(QJSValue id) {
+ // qDebug() << id.toString();
+ auto timer = getTimers().take(id.toString());
+ if (timer) {
+ timer->stop();
+ timer->deleteLater();
+ }
+ return QJSValue();
+ }
+ // This should be static but cannot bind static functions to QJSEngine
+ Q_INVOKABLE QJSValue setTimeout(QJSValue callback, QJSValue delayTime) {
+ // qDebug() << callback.toString() << delayTime.toInt();
+ auto timer = new JsTimer();
+ timer->setInterval(delayTime.toInt());
+ connect(timer, &JsTimer::timeout, this, [callback]() mutable {
+ // qDebug() << "Calling" << callback.toString();
+ auto value = callback.call();
+ if (value.isError()) {
+ qWarning() << "Error" << value.toString();
+ qDebug() << value.property("stack").toString().splitRef('\n');
+ }
+ });
+ timer->start();
+ return timer->hashString();
+ }
+
+ Q_INVOKABLE JsTimer(QObject *parent = nullptr) : QTimer(parent) {
+ setTimerType(Qt::VeryCoarseTimer);
+ setSingleShot(true);
+ connect(this, &JsTimer::destroyed, this, [this] { getTimers().remove(hashString()); });
+ connect(this, &JsTimer::timeout, this, &QTimer::deleteLater);
+ getTimers().insert(hashString(), this);
+ }
+
+ QString hashString() { return QString::number((std::uintptr_t)(this)); }
+
+private:
+};
+
+class ResultHandler : public QObject {
+ Q_OBJECT
+
+public:
+ Q_INVOKABLE QJSValue setData(QJSValue value) {
+ qDebug() << "Success" << value.toString();
+ auto doc = QJsonDocument::fromVariant(value.toVariant());
+ if (doc.isEmpty()) {
+ qDebug() << value.toString();
+ emit error("Cannot parse JSON");
+ return QJSValue();
+ }
+ emit data(doc);
+ return QJSValue();
+ }
+
+ Q_INVOKABLE QJSValue setError(QJSValue value) {
+ QString message = value.toString();
+ qWarning() << "Error" << message;
+ qDebug() << value.property("stack").toString().splitRef('\n');
+ emit error(message);
+ return QJSValue();
+ }
+
+signals:
+ void data(const QJsonDocument &doc);
+ void error(const QString &message);
+};
+
+class YTJS : public QObject {
+ Q_OBJECT
+
+public:
+ static YTJS &instance();
+ static Http &http();
+ static Http &cachedHttp();
+
+ explicit YTJS(QObject *parent = nullptr);
+ bool checkError(const QJSValue &value);
+
+ bool isInitialized();
+ QQmlEngine &getEngine() { return *engine; }
+
+signals:
+ void initialized();
+ void initFailed(QString message);
+
+private:
+ void initialize();
+ QJSValue evaluate(const QString &js);
+
+ // QQmlEngine gives us XMLHttpRequest, console, JSON
+ QQmlEngine *engine;
+ bool initializing = false;
+ bool ready = false;
+};
+
+#endif // YTJS_H
--- /dev/null
+INCLUDEPATH += $$PWD
+DEPENDPATH += $$PWD
+
+HEADERS += $$PWD/ytjs.h \
+ $$PWD/ytjschannel.h \
+ $$PWD/ytjschannelsource.h \
+ $$PWD/ytjsnamfactory.h \
+ $$PWD/ytjssearch.h \
+ $$PWD/ytjssinglevideosource.h \
+ $$PWD/ytjsvideo.h
+
+SOURCES += $$PWD/ytjs.cpp \
+ $$PWD/ytjschannel.cpp \
+ $$PWD/ytjschannelsource.cpp \
+ $$PWD/ytjsnamfactory.cpp \
+ $$PWD/ytjssearch.cpp \
+ $$PWD/ytjssinglevideosource.cpp \
+ $$PWD/ytjsvideo.cpp
--- /dev/null
+#include "ytjschannel.h"
+
+#include "http.h"
+#include "httputils.h"
+#include "ytjs.h"
+
+YTJSChannel::YTJSChannel(const QString &id, QObject *parent) : QObject(parent) {
+ load(id);
+}
+
+void YTJSChannel::load(const QString &channelId) {
+ auto &ytjs = YTJS::instance();
+ if (!ytjs.isInitialized()) {
+ QTimer::singleShot(500, this, [this, channelId] { load(channelId); });
+ return;
+ }
+ auto &engine = ytjs.getEngine();
+
+ auto function = engine.evaluate("channelInfo");
+ if (!function.isCallable()) {
+ qWarning() << function.toString() << " is not callable";
+ emit error(function.toString());
+ return;
+ }
+
+ auto handler = new ResultHandler;
+ connect(handler, &ResultHandler::error, this, &YTJSChannel::error);
+ connect(handler, &ResultHandler::data, this, [this](const QJsonDocument &doc) {
+ auto obj = doc.object();
+
+ displayName = obj["author"].toString();
+ description = obj["description"].toString();
+
+ const auto thumbs = obj["authorThumbnails"].toArray();
+ int maxFoundWidth = 0;
+ for (const auto &thumbObj : thumbs) {
+ QString url = thumbObj["url"].toString();
+ int width = thumbObj["width"].toInt();
+ if (width > maxFoundWidth) {
+ maxFoundWidth = width;
+ thumbnailUrl = url;
+ }
+ }
+
+ emit loaded();
+ });
+ QJSValue h = engine.newQObject(handler);
+ auto value = function.call({h, channelId});
+ ytjs.checkError(value);
+}
--- /dev/null
+#ifndef YTJSCHANNEL_H
+#define YTJSCHANNEL_H
+
+#include <QtCore>
+
+class YTJSChannel : public QObject {
+ Q_OBJECT
+
+public:
+ YTJSChannel(const QString &id, QObject *parent = nullptr);
+
+ QString getDisplayName() const { return displayName; }
+ QString getDescription() const { return description; }
+ QString getThumbnailUrl() const { return thumbnailUrl; }
+
+signals:
+ void loaded();
+ void error(QString message);
+
+private:
+ void load(const QString &channelId);
+
+ QString displayName;
+ QString description;
+ QString thumbnailUrl;
+};
+
+#endif // YTJSCHANNEL_H
--- /dev/null
+#include "ytjschannelsource.h"
+
+#include "mainwindow.h"
+#include "searchparams.h"
+#include "video.h"
+#include "ytjs.h"
+#include "ytsearch.h"
+
+namespace {
+
+int parseDuration(const QString &s) {
+ static const QTime zeroTime(0, 0);
+ QTime time = QTime::fromString(s, QStringLiteral("hh:mm:ss"));
+ return zeroTime.secsTo(time);
+}
+
+QString parseChannelId(const QString &channelUrl) {
+ int pos = channelUrl.lastIndexOf('/');
+ if (pos >= 0) return channelUrl.mid(pos + 1);
+ return QString();
+}
+
+quint64 parsePublishedText(const QString &s) {
+ int pos = s.indexOf(' ');
+ if (pos <= 0) return 0;
+ auto num = s.leftRef(pos);
+ auto now = QDateTime::currentSecsSinceEpoch();
+ if (s.contains("day")) {
+ return now - num.toInt() * 86400;
+ } else if (s.contains("week")) {
+ return now - num.toInt() * 86400 * 7;
+ } else if (s.contains("month")) {
+ return now - num.toInt() * 86400 * 30;
+ } else if (s.contains("year")) {
+ return now - num.toInt() * 86400 * 365;
+ }
+ return 0;
+}
+
+} // namespace
+
+YTJSChannelSource::YTJSChannelSource(SearchParams *searchParams, QObject *parent)
+ : VideoSource(parent), searchParams(searchParams) {}
+
+void YTJSChannelSource::loadVideos(int max, int startIndex) {
+ auto &ytjs = YTJS::instance();
+ if (!ytjs.isInitialized()) {
+ QTimer::singleShot(500, this, [this, max, startIndex] { loadVideos(max, startIndex); });
+ return;
+ }
+ auto &engine = ytjs.getEngine();
+
+ aborted = false;
+
+ auto function = engine.evaluate("channelVideos");
+ if (!function.isCallable()) {
+ qWarning() << function.toString() << " is not callable";
+ emit error(function.toString());
+ return;
+ }
+
+ QString channelId = searchParams->channelId();
+
+ QString sortBy;
+ switch (searchParams->sortBy()) {
+ case SearchParams::SortByNewest:
+ sortBy = "newest";
+ break;
+ case SearchParams::SortByViewCount:
+ sortBy = "oldest";
+ break;
+ case SearchParams::SortByRating:
+ sortBy = "popular";
+ break;
+ }
+
+ if (startIndex <= 1) continuation.clear();
+
+ auto handler = new ResultHandler;
+ connect(handler, &ResultHandler::error, this, &VideoSource::error);
+ connect(handler, &ResultHandler::data, this, [this](const QJsonDocument &doc) {
+ auto obj = doc.object();
+
+ qDebug() << doc.toJson();
+
+ continuation = obj["continuation"].toString();
+
+ const auto items = obj["items"].toArray();
+ QVector<Video *> videos;
+ videos.reserve(items.size());
+
+ for (const auto &i : items) {
+ QString type = i["type"].toString();
+ if (type != "video") continue;
+
+ Video *video = new Video();
+
+ QString id = i["videoId"].toString();
+ video->setId(id);
+
+ QString title = i["title"].toString();
+ video->setTitle(title);
+
+ QString desc = i["description"].toString();
+ if (desc.isEmpty()) desc = i["desc"].toString();
+ video->setDescription(desc);
+
+ const auto thumbs = i["videoThumbnails"].toArray();
+ for (const auto &thumbObj : thumbs) {
+ QString url = thumbObj["url"].toString();
+ int width = thumbObj["width"].toInt();
+ if (width >= 336)
+ video->setLargeThumbnailUrl(url);
+ else if (width >= 246)
+ video->setMediumThumbnailUrl(url);
+ else if (width >= 168)
+ video->setThumbnailUrl(url);
+ }
+
+ int views = i["viewCount"].toInt();
+ video->setViewCount(views);
+
+ int duration = i["lengthSeconds"].toInt();
+ video->setDuration(duration);
+
+ int published = parsePublishedText(i["publishedText"].toString());
+ if (published) video->setPublished(QDateTime::fromSecsSinceEpoch(published));
+
+ QString channelName = i["author"].toString();
+ if (channelName != name) {
+ this->name = channelName;
+ emit nameChanged(name);
+ }
+ video->setChannelTitle(channelName);
+ QString channelId = i["authorId"].toString();
+ video->setChannelId(channelId);
+
+ videos << video;
+ }
+
+ emit gotVideos(videos);
+ emit finished(videos.size());
+ });
+ QJSValue h = engine.newQObject(handler);
+ auto value = function.call({h, channelId, sortBy, continuation});
+ ytjs.checkError(value);
+}
+
+QString YTJSChannelSource::getName() {
+ return name;
+}
+
+const QList<QAction *> &YTJSChannelSource::getActions() {
+ static const QList<QAction *> channelActions = {
+ MainWindow::instance()->getAction("subscribeChannel")};
+ if (searchParams->channelId().isEmpty()) {
+ static const QList<QAction *> noActions;
+ return noActions;
+ }
+ return channelActions;
+}
--- /dev/null
+#ifndef YTJSCHANNELSOURCE_H
+#define YTJSCHANNELSOURCE_H
+
+#include "videosource.h"
+
+class SearchParams;
+class Video;
+
+class YTJSChannelSource : public VideoSource {
+ Q_OBJECT
+
+public:
+ YTJSChannelSource(SearchParams *searchParams, QObject *parent = 0);
+ void loadVideos(int max, int startIndex);
+ void abort() { aborted = true; }
+ QString getName();
+ const QList<QAction *> &getActions();
+ SearchParams *getSearchParams() const { return searchParams; }
+
+private:
+ SearchParams *searchParams;
+ bool aborted = false;
+ QString name;
+
+ QString continuation;
+};
+
+#endif // YTJSCHANNELSOURCE_H
--- /dev/null
+#include "ytjsnamfactory.h"
+
+YTJSDiskCache::YTJSDiskCache(QObject *parent) : QNetworkDiskCache(parent) {}
+
+void YTJSDiskCache::updateMetaData(const QNetworkCacheMetaData &meta) {
+ auto meta2 = fixMetadata(meta);
+ QNetworkDiskCache::updateMetaData(meta2);
+}
+
+QIODevice *YTJSDiskCache::prepare(const QNetworkCacheMetaData &meta) {
+ auto meta2 = fixMetadata(meta);
+ return QNetworkDiskCache::prepare(meta2);
+}
+
+QNetworkCacheMetaData YTJSDiskCache::fixMetadata(const QNetworkCacheMetaData &meta) {
+ auto meta2 = meta;
+
+ auto now = QDateTime::currentDateTimeUtc();
+ if (meta2.expirationDate() < now) {
+ meta2.setExpirationDate(now.addSecs(3600));
+ }
+
+ // Remove caching headers
+ auto headers = meta2.rawHeaders();
+ for (auto i = headers.begin(); i != headers.end(); ++i) {
+ // qDebug() << i->first << i->second;
+ if (i->first == "Cache-Control" || i->first == "Expires") {
+ qDebug() << "Removing" << i->first << i->second;
+ headers.erase(i);
+ }
+ }
+ meta2.setRawHeaders(headers);
+
+ return meta2;
+}
+
+YTJSNAM::YTJSNAM(QObject *parent) : QNetworkAccessManager(parent) {
+ auto cache = new YTJSDiskCache(this);
+ cache->setCacheDirectory(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
+ "/ytjs");
+ setCache(cache);
+ setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
+}
+
+QNetworkReply *YTJSNAM::createRequest(QNetworkAccessManager::Operation op,
+ const QNetworkRequest &request,
+ QIODevice *outgoingData) {
+ auto req2 = request;
+ req2.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache);
+ qDebug() << req2.url();
+ return QNetworkAccessManager::createRequest(op, req2, outgoingData);
+}
+
+QNetworkAccessManager *YTJSNAMFactory::create(QObject *parent) {
+ qDebug() << "Creating NAM";
+ return new YTJSNAM(parent);
+}
--- /dev/null
+#ifndef YTJSNAMFACTORY_H
+#define YTJSNAMFACTORY_H
+
+#include <QtQml>
+
+class YTJSDiskCache : public QNetworkDiskCache {
+public:
+ YTJSDiskCache(QObject *parent);
+ void updateMetaData(const QNetworkCacheMetaData &meta);
+ QIODevice *prepare(const QNetworkCacheMetaData &meta);
+
+private:
+ QNetworkCacheMetaData fixMetadata(const QNetworkCacheMetaData &meta);
+};
+
+class YTJSNAM : public QNetworkAccessManager {
+ Q_OBJECT
+
+public:
+ YTJSNAM(QObject *parent);
+
+protected:
+ QNetworkReply *
+ createRequest(Operation op, const QNetworkRequest &request, QIODevice *outgoingData);
+};
+
+class YTJSNAMFactory : public QQmlNetworkAccessManagerFactory {
+public:
+ QNetworkAccessManager *create(QObject *parent);
+};
+
+#endif // YTJSNAMFACTORY_H
--- /dev/null
+#include "ytjssearch.h"
+
+#include "mainwindow.h"
+#include "searchparams.h"
+#include "video.h"
+#include "ytjs.h"
+#include "ytsearch.h"
+
+namespace {
+
+int parseDuration(const QString &s) {
+ const auto parts = s.splitRef(':');
+ int secs = 0;
+ int p = 0;
+ for (auto i = parts.crbegin(); i != parts.crend(); ++i) {
+ if (p == 0) {
+ secs = i->toInt();
+ } else if (p == 1) {
+ secs += i->toInt() * 60;
+ } else if (p == 2) {
+ secs += i->toInt() * 60 * 60;
+ }
+ p++;
+ }
+ return secs;
+}
+
+QString parseChannelId(const QString &channelUrl) {
+ int pos = channelUrl.lastIndexOf('/');
+ if (pos >= 0) return channelUrl.mid(pos + 1);
+ return QString();
+}
+
+} // namespace
+
+YTJSSearch::YTJSSearch(SearchParams *searchParams, QObject *parent)
+ : VideoSource(parent), searchParams(searchParams) {}
+
+void YTJSSearch::loadVideos(int max, int startIndex) {
+ auto &ytjs = YTJS::instance();
+ if (!ytjs.isInitialized()) {
+ QTimer::singleShot(500, this, [this, max, startIndex] { loadVideos(max, startIndex); });
+ return;
+ }
+ auto &engine = ytjs.getEngine();
+
+ aborted = false;
+
+ auto function = engine.evaluate("search");
+ if (!function.isCallable()) {
+ qWarning() << function.toString() << " is not callable";
+ emit error(function.toString());
+ return;
+ }
+
+ QString q;
+ if (!searchParams->keywords().isEmpty()) {
+ if (searchParams->keywords().startsWith("http://") ||
+ searchParams->keywords().startsWith("https://")) {
+ q = YTSearch::videoIdFromUrl(searchParams->keywords());
+ } else
+ q = searchParams->keywords();
+ }
+
+ // Options
+
+ QJSValue options = engine.newObject();
+
+ if (startIndex > 1 && !nextpageRef.isEmpty()) options.setProperty("nextpageRef", nextpageRef);
+ options.setProperty("limit", max);
+
+ switch (searchParams->safeSearch()) {
+ case SearchParams::None:
+ options.setProperty("safeSearch", false);
+ break;
+ case SearchParams::Strict:
+ options.setProperty("safeSearch", true);
+ break;
+ }
+
+ // Filters
+
+ auto filterMap = engine.evaluate("new Map()");
+ auto jsMapSet = filterMap.property("set");
+ auto addFilter = [&filterMap, &jsMapSet](QString name, QString value) {
+ jsMapSet.callWithInstance(filterMap, {name, value});
+ };
+
+ addFilter("Type", "Video");
+
+ switch (searchParams->sortBy()) {
+ case SearchParams::SortByNewest:
+ addFilter("Sort by", "Upload date");
+ break;
+ case SearchParams::SortByViewCount:
+ addFilter("Sort by", "View count");
+ break;
+ case SearchParams::SortByRating:
+ addFilter("Sort by", "Rating");
+ break;
+ }
+
+ switch (searchParams->duration()) {
+ case SearchParams::DurationShort:
+ addFilter("Duration", "Short");
+ break;
+ case SearchParams::DurationMedium:
+ case SearchParams::DurationLong:
+ addFilter("Duration", "Long");
+ break;
+ }
+
+ switch (searchParams->time()) {
+ case SearchParams::TimeToday:
+ addFilter("Upload date", "Today");
+ break;
+ case SearchParams::TimeWeek:
+ addFilter("Upload date", "This week");
+ break;
+ case SearchParams::TimeMonth:
+ addFilter("Upload date", "This month");
+ break;
+ }
+
+ switch (searchParams->quality()) {
+ case SearchParams::QualityHD:
+ addFilter("Features", "HD");
+ break;
+ case SearchParams::Quality4K:
+ addFilter("Features", "4K");
+ break;
+ case SearchParams::QualityHDR:
+ addFilter("Features", "HDR");
+ break;
+ }
+
+ auto handler = new ResultHandler;
+ connect(handler, &ResultHandler::error, this, &VideoSource::error);
+ connect(handler, &ResultHandler::data, this, [this](const QJsonDocument &doc) {
+ if (aborted) return;
+
+ auto obj = doc.object();
+
+ nextpageRef = obj["nextpageRef"].toString();
+
+ const auto items = obj["items"].toArray();
+ QVector<Video *> videos;
+ videos.reserve(items.size());
+
+ for (const auto &i : items) {
+ QString type = i["type"].toString();
+ if (type != "video") continue;
+
+ Video *video = new Video();
+
+ QString id = YTSearch::videoIdFromUrl(i["link"].toString());
+ video->setId(id);
+
+ QString title = i["title"].toString();
+ video->setTitle(title);
+
+ QString desc = i["description"].toString();
+ video->setDescription(desc);
+
+ QString thumb = i["thumbnail"].toString();
+ video->setThumbnailUrl(thumb);
+
+ int views = i["views"].toInt();
+ video->setViewCount(views);
+
+ int duration = parseDuration(i["duration"].toString());
+ video->setDuration(duration);
+
+ auto authorObj = i["author"];
+ QString channelName = authorObj["name"].toString();
+ video->setChannelTitle(channelName);
+ QString channelId = parseChannelId(authorObj["ref"].toString());
+ video->setChannelId(channelId);
+
+ videos << video;
+ }
+
+ emit gotVideos(videos);
+ emit finished(videos.size());
+ });
+ QJSValue h = engine.newQObject(handler);
+ auto value = function.call({h, q, options, filterMap});
+ if (ytjs.checkError(value)) emit error(value.toString());
+}
+
+QString YTJSSearch::getName() {
+ if (!name.isEmpty()) return name;
+ if (!searchParams->keywords().isEmpty()) return searchParams->keywords();
+ return QString();
+}
+
+const QList<QAction *> &YTJSSearch::getActions() {
+ static const QList<QAction *> channelActions = {
+ MainWindow::instance()->getAction("subscribeChannel")};
+ if (searchParams->channelId().isEmpty()) {
+ static const QList<QAction *> noActions;
+ return noActions;
+ }
+ return channelActions;
+}
--- /dev/null
+#ifndef YTJSSEARCH_H
+#define YTJSSEARCH_H
+
+#include "videosource.h"
+
+class SearchParams;
+class Video;
+
+class YTJSSearch : public VideoSource {
+ Q_OBJECT
+
+public:
+ YTJSSearch(SearchParams *searchParams, QObject *parent = 0);
+ void loadVideos(int max, int startIndex);
+ void abort() { aborted = true; }
+ QString getName();
+ const QList<QAction *> &getActions();
+ SearchParams *getSearchParams() const { return searchParams; }
+
+private:
+ SearchParams *searchParams;
+ bool aborted = false;
+ QString name;
+
+ QString nextpageRef;
+};
+
+#endif // YTJSSEARCH_H
--- /dev/null
+#include "ytjssinglevideosource.h"
+
+#include "video.h"
+#include "ytjs.h"
+
+YTJSSingleVideoSource::YTJSSingleVideoSource(QObject *parent)
+ : VideoSource(parent), video(nullptr) {}
+
+void YTJSSingleVideoSource::loadVideos(int max, int startIndex) {
+ aborted = false;
+
+ auto &ytjs = YTJS::instance();
+ if (!ytjs.isInitialized()) {
+ QTimer::singleShot(500, this, [this, max, startIndex] { loadVideos(max, startIndex); });
+ return;
+ }
+ auto &engine = ytjs.getEngine();
+
+ auto function = engine.evaluate("videoInfo");
+ if (!function.isCallable()) {
+ qWarning() << function.toString() << " is not callable";
+ emit error(function.toString());
+ return;
+ }
+
+ if (startIndex == 1) {
+ if (video) {
+ if (name.isEmpty()) {
+ name = video->getTitle();
+ qDebug() << "Emitting name changed" << name;
+ emit nameChanged(name);
+ }
+ emit gotVideos({video->clone()});
+ }
+ }
+
+ auto handler = new ResultHandler;
+ connect(handler, &ResultHandler::error, this, &VideoSource::error);
+ connect(handler, &ResultHandler::data, this, [this](const QJsonDocument &doc) {
+ if (aborted) return;
+
+ auto obj = doc.object();
+
+ QFile jsonFile("/Users/flavio/test.json");
+ jsonFile.open(QFile::WriteOnly);
+ jsonFile.write(doc.toJson());
+
+ const auto items = obj["related_videos"].toArray();
+ QVector<Video *> videos;
+ videos.reserve(items.size());
+
+ for (const auto &i : items) {
+ Video *video = new Video();
+
+ QString id = i["id"].toString();
+ video->setId(id);
+
+ QString title = i["title"].toString();
+ video->setTitle(title);
+
+ QString desc = i["description"].toString();
+ if (desc.isEmpty()) desc = i["desc"].toString();
+ video->setDescription(desc);
+
+ QString thumb = i["video_thumbnail"].toString();
+ video->setThumbnailUrl(thumb);
+
+ int views = i["view_count"].toInt();
+ video->setViewCount(views);
+
+ int duration = i["length_seconds"].toInt();
+ video->setViewCount(duration);
+
+ QString channelId = i["ucid"].toString();
+ video->setChannelId(channelId);
+
+ QString channelName = i["author"].toString();
+ video->setChannelTitle(channelName);
+
+ videos << video;
+ }
+
+ emit gotVideos(videos);
+ emit finished(videos.size());
+ });
+ QJSValue h = engine.newQObject(handler);
+ auto value = function.call({h, videoId});
+ ytjs.checkError(value);
+}
+
+void YTJSSingleVideoSource::setVideo(Video *video) {
+ this->video = video;
+ videoId = video->getId();
+}
+
+void YTJSSingleVideoSource::abort() {
+ aborted = true;
+}
+
+QString YTJSSingleVideoSource::getName() {
+ return name;
+}
--- /dev/null
+#ifndef YTJSSINGLEVIDEOSOURCE_H
+#define YTJSSINGLEVIDEOSOURCE_H
+
+#include <QtCore>
+
+#include "videosource.h"
+
+class YTJSSingleVideoSource : public VideoSource {
+ Q_OBJECT
+
+public:
+ explicit YTJSSingleVideoSource(QObject *parent = 0);
+
+ void setVideoId(const QString &value) { videoId = value; }
+ void setVideo(Video *video);
+
+ void loadVideos(int max, int startIndex);
+ void abort();
+ QString getName();
+
+private:
+ Video *video;
+ QString videoId;
+ bool aborted = false;
+ QString name;
+};
+
+#endif // YTJSSINGLEVIDEOSOURCE_H
--- /dev/null
+#include "ytjsvideo.h"
+
+#include "videodefinition.h"
+#include "yt3.h"
+#include "ytjs.h"
+
+YTJSVideo::YTJSVideo(const QString &videoId, QObject *parent)
+ : QObject(parent), videoId(videoId), definitionCode(0) {}
+
+void YTJSVideo::loadStreamUrl() {
+ if (loadingStreamUrl) return;
+ loadingStreamUrl = true;
+
+ auto &ytjs = YTJS::instance();
+ if (!ytjs.isInitialized()) {
+ QTimer::singleShot(500, this, [this] { loadStreamUrl(); });
+ return;
+ }
+ auto &engine = ytjs.getEngine();
+
+ auto function = engine.evaluate("videoInfo");
+ if (!function.isCallable()) {
+ qWarning() << function.toString() << " is not callable";
+ loadingStreamUrl = false;
+ emit errorStreamUrl(function.toString());
+ return;
+ }
+
+ auto handler = new ResultHandler;
+ connect(handler, &ResultHandler::error, this, &YTJSVideo::errorStreamUrl);
+ connect(handler, &ResultHandler::data, this, [this](const QJsonDocument &doc) {
+ auto obj = doc.object();
+
+ QMap<int, QString> urlMap;
+ const auto formats = obj["formats"].toArray();
+ for (const auto &format : formats) {
+ bool isDashMpd = format["isDashMPD"].toBool();
+ if (isDashMpd) continue;
+ int itag = format["itag"].toInt();
+ QString url = format["url"].toString();
+ // qDebug() << itag << url;
+ urlMap.insert(itag, url);
+ }
+
+ qDebug() << "available formats" << urlMap.keys();
+ const VideoDefinition &definition = YT3::instance().maxVideoDefinition();
+ const QVector<VideoDefinition> &definitions = VideoDefinition::getDefinitions();
+ int previousIndex = std::max(definitions.indexOf(definition), 0);
+ for (; previousIndex >= 0; previousIndex--) {
+ const VideoDefinition &previousDefinition = definitions.at(previousIndex);
+ qDebug() << "Testing format" << previousDefinition.getCode();
+ if (urlMap.contains(previousDefinition.getCode())) {
+ qDebug() << "Found format" << previousDefinition.getCode();
+
+ QString url = urlMap.value(previousDefinition.getCode());
+ definitionCode = previousDefinition.getCode();
+
+ QString audioUrl;
+ if (!previousDefinition.hasAudio()) {
+ qDebug() << "Finding audio format";
+ static const QVector<int> audioFormats({251, 171, 140});
+ for (int audioFormat : audioFormats) {
+ qDebug() << "Trying audio format" << audioFormat;
+ auto i = urlMap.constFind(audioFormat);
+ if (i != urlMap.constEnd()) {
+ qDebug() << "Found audio format" << i.value();
+ audioUrl = i.value();
+ break;
+ }
+ }
+ }
+
+ loadingStreamUrl = false;
+ emit gotStreamUrl(url, audioUrl);
+ return;
+ }
+ }
+
+ loadingStreamUrl = false;
+ emit errorStreamUrl(tr("Cannot get video stream for %1").arg(videoId));
+ });
+ QJSValue h = engine.newQObject(handler);
+ auto value = function.call({h, videoId});
+ ytjs.checkError(value);
+}
--- /dev/null
+#ifndef YTJSVIDEO_H
+#define YTJSVIDEO_H
+
+#include <QtCore>
+class YTJSVideo : public QObject {
+ Q_OBJECT
+public:
+ explicit YTJSVideo(const QString &videoId, QObject *parent = nullptr);
+ void loadStreamUrl();
+ int getDefinitionCode() const { return definitionCode; }
+
+signals:
+ void gotStreamUrl(const QString &videoUrl, const QString &audioUrl);
+ void errorStreamUrl(const QString &message);
+
+private:
+ QString videoId;
+ bool loadingStreamUrl = false;
+ int definitionCode;
+};
+
+#endif // YTJSVIDEO_H
}
QString YTSearch::videoIdFromUrl(const QString &url) {
- QRegExp re = QRegExp("^.*[\\?&]v=([^&#]+).*$");
+ static QRegExp re = QRegExp("^.*[\\?&]v=([^&#]+).*$");
if (re.exactMatch(url)) return re.cap(1);
re = QRegExp("^.*://.*/([^&#\\?]+).*$");
if (re.exactMatch(url)) return re.cap(1);