]> git.sur5r.net Git - minitube/commitdiff
New upstream version 3.6
authorJakob Haufe <jakob@haufe.it>
Tue, 29 Sep 2020 08:49:53 +0000 (10:49 +0200)
committerJakob Haufe <jakob@haufe.it>
Tue, 29 Sep 2020 08:49:53 +0000 (10:49 +0200)
29 files changed:
minitube.pro
src/channelaggregator.cpp
src/channelview.cpp
src/mainwindow.cpp
src/mediaview.cpp
src/playlistmodel.cpp
src/refinesearchwidget.cpp
src/searchparams.h
src/standardfeedsview.cpp
src/video.cpp
src/video.h
src/videoapi.h
src/ytchannel.cpp
src/ytjs/ytjs.cpp [new file with mode: 0644]
src/ytjs/ytjs.h [new file with mode: 0644]
src/ytjs/ytjs.pri [new file with mode: 0644]
src/ytjs/ytjschannel.cpp [new file with mode: 0644]
src/ytjs/ytjschannel.h [new file with mode: 0644]
src/ytjs/ytjschannelsource.cpp [new file with mode: 0644]
src/ytjs/ytjschannelsource.h [new file with mode: 0644]
src/ytjs/ytjsnamfactory.cpp [new file with mode: 0644]
src/ytjs/ytjsnamfactory.h [new file with mode: 0644]
src/ytjs/ytjssearch.cpp [new file with mode: 0644]
src/ytjs/ytjssearch.h [new file with mode: 0644]
src/ytjs/ytjssinglevideosource.cpp [new file with mode: 0644]
src/ytjs/ytjssinglevideosource.h [new file with mode: 0644]
src/ytjs/ytjsvideo.cpp [new file with mode: 0644]
src/ytjs/ytjsvideo.h [new file with mode: 0644]
src/ytsearch.cpp

index 5598e04b2fad696a9944219e34a914db5655afd1..c07090cbc32ef308b52c1fbddf9cc74940575d53 100644 (file)
@@ -1,7 +1,7 @@
 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
@@ -42,6 +42,7 @@ include(lib/media/media.pri)
 
 include(src/qtsingleapplication/qtsingleapplication.pri)
 include(src/invidious/invidious.pri)
+include(src/ytjs/ytjs.pri)
 
 INCLUDEPATH += $$PWD/src
 
index 5848fa9e935d47bf908db7e039871fa7c1d452fc..2fcf42b5d0f8fdca1430e819e78535159477fc3a 100644 (file)
@@ -30,8 +30,9 @@ $END_LICENSE */
 #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) {
@@ -100,7 +101,15 @@ void ChannelAggregator::processNextChannel() {
 
 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)));
@@ -153,6 +162,11 @@ void ChannelAggregator::reallyProcessChannel(YTChannel *channel) {
         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();
index 0e93c732261364556b3216cab85236b153dd26c4..b4dc6b796e04d55e5079050e97707327da2b0198 100644 (file)
@@ -34,8 +34,9 @@ $END_LICENSE */
 #endif
 #include "channellistview.h"
 
-#include "videoapi.h"
 #include "ivchannelsource.h"
+#include "videoapi.h"
+#include "ytjschannelsource.h"
 
 namespace {
 const QString sortByKey = "subscriptionsSortBy";
@@ -178,6 +179,8 @@ void ChannelView::itemActivated(const QModelIndex &index) {
             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();
index df79a99443f7aaded1bfe773f47bab9ab7996318..9523ff976bd7cd737f5052e487c4e76cce91d403 100644 (file)
@@ -80,6 +80,7 @@ $END_LICENSE */
 
 #include "invidious.h"
 #include "videoapi.h"
+#include "ytjs.h"
 
 #ifdef MEDIA_QTAV
 #include "mediaqtav.h"
@@ -167,6 +168,9 @@ MainWindow::MainWindow()
         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);
@@ -667,7 +671,7 @@ void MainWindow::createActions() {
     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);
index bf60db1d42aeddf1cdbff337c5aa8b6357f93ac8..c06444f0d65512e5d158fb721969257e293b7d7e 100644 (file)
@@ -58,6 +58,10 @@ $END_LICENSE */
 #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;
@@ -237,6 +241,14 @@ SearchParams *MediaView::getSearchParams() {
         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;
 }
 
@@ -255,6 +267,10 @@ void MediaView::search(SearchParams *searchParams) {
                     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);
 
@@ -277,6 +293,12 @@ void MediaView::search(SearchParams *searchParams) {
         } 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);
 }
@@ -943,6 +965,10 @@ void MediaView::relatedVideos() {
         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);
index 99083489b18356919e86058779abcb804ebb66b2..81a16c247eb33429c34389a317aad623afa4ab10 100644 (file)
@@ -29,6 +29,9 @@ $END_LICENSE */
 #include "ivsearch.h"
 #include "ytsearch.h"
 
+#include "ytjschannelsource.h"
+#include "ytjssearch.h"
+
 namespace {
 const QString recentKeywordsKey = "recentKeywords";
 const QString recentChannelsKey = "recentChannels";
@@ -77,11 +80,8 @@ QVariant PlaylistModel::data(const QModelIndex &index, int role) const {
         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
@@ -276,6 +276,12 @@ void PlaylistModel::handleFirstVideo(Video *video) {
         } 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
index 4840d25c304b0885a50e252e6beae82709539eb9..bbbe4938d3c9d90f6acbf840e240a5de79fc179d 100644 (file)
@@ -65,8 +65,8 @@ void RefineSearchWidget::setup() {
     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);
@@ -103,12 +103,11 @@ void RefineSearchWidget::setup() {
     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);
index 1bcdac9b06c734266811a30c320d2da8ddc3c46e..1acf3995db7d40b1004a9d5a7ef05939f6d18b32 100644 (file)
@@ -35,9 +35,9 @@ public:
 
     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 };
 
index 232b4d5f20d2923643f36ad02df41adfd63fbb66..464f6d06330398e82e6680a7cb1be54ea4abd251 100644 (file)
@@ -52,7 +52,7 @@ void StandardFeedsView::load() {
                 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")));
index 05d9761b58eb52ea899706c959587dd49114d466..6df9fd88b14f318fbbba6d6d8962923d6549e490 100644 (file)
@@ -25,11 +25,13 @@ $END_LICENSE */
 #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;
@@ -114,14 +116,38 @@ void Video::setThumbnail(const QByteArray &bytes) {
 
 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;
index 723be191d592764c0f6667d6431254dd4a59a96a..100ce8c102d6f497f2acd2a9a7b262c5c2243694 100644 (file)
@@ -25,6 +25,7 @@ $END_LICENSE */
 #include <QtGui>
 
 class YTVideo;
+class YTJSVideo;
 
 class Video : public QObject {
     Q_OBJECT
@@ -101,6 +102,8 @@ private slots:
     void streamUrlLoaded(const QString &streamUrl, const QString &audioUrl);
 
 private:
+    void loadStreamUrlJS();
+
     QString title;
     QString description;
     QString channelTitle;
@@ -125,6 +128,7 @@ private:
     bool loadingThumbnail;
 
     YTVideo *ytVideo;
+    YTJSVideo *ytjsVideo;
 };
 
 // This is required in order to use QPointer<Video> as a QVariant
index 5a9f482913de2874d72633bc3fe298d9580cd5d2..98cc3752866aa5bf43b1c89addb3c58129c21dff 100644 (file)
@@ -5,8 +5,8 @@
 
 class VideoAPI {
 public:
-    enum Impl { YT3, IV };
-    static Impl impl() { return IV; }
+    enum Impl { YT3, IV, JS };
+    static Impl impl() { return JS; }
 
 private:
     VideoAPI() {}
index 550bdb665c9baadcebd369a9724a6c0cbc000491..1d83b2a7c11aa8dff802f025b3d08ee86c63d7cf 100644 (file)
@@ -28,8 +28,9 @@ $END_LICENSE */
 
 #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),
@@ -105,6 +106,18 @@ void YTChannel::maybeLoadfromAPI() {
             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;
+        });
     }
 }
 
diff --git a/src/ytjs/ytjs.cpp b/src/ytjs/ytjs.cpp
new file mode 100644 (file)
index 0000000..0863741
--- /dev/null
@@ -0,0 +1,94 @@
+#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;
+}
diff --git a/src/ytjs/ytjs.h b/src/ytjs/ytjs.h
new file mode 100644 (file)
index 0000000..30c0141
--- /dev/null
@@ -0,0 +1,115 @@
+#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
diff --git a/src/ytjs/ytjs.pri b/src/ytjs/ytjs.pri
new file mode 100644 (file)
index 0000000..d93b5ea
--- /dev/null
@@ -0,0 +1,18 @@
+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
diff --git a/src/ytjs/ytjschannel.cpp b/src/ytjs/ytjschannel.cpp
new file mode 100644 (file)
index 0000000..da8fc80
--- /dev/null
@@ -0,0 +1,50 @@
+#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);
+}
diff --git a/src/ytjs/ytjschannel.h b/src/ytjs/ytjschannel.h
new file mode 100644 (file)
index 0000000..97450ca
--- /dev/null
@@ -0,0 +1,28 @@
+#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
diff --git a/src/ytjs/ytjschannelsource.cpp b/src/ytjs/ytjschannelsource.cpp
new file mode 100644 (file)
index 0000000..f0862ef
--- /dev/null
@@ -0,0 +1,161 @@
+#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;
+}
diff --git a/src/ytjs/ytjschannelsource.h b/src/ytjs/ytjschannelsource.h
new file mode 100644 (file)
index 0000000..0691c24
--- /dev/null
@@ -0,0 +1,28 @@
+#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
diff --git a/src/ytjs/ytjsnamfactory.cpp b/src/ytjs/ytjsnamfactory.cpp
new file mode 100644 (file)
index 0000000..c3550a7
--- /dev/null
@@ -0,0 +1,57 @@
+#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);
+}
diff --git a/src/ytjs/ytjsnamfactory.h b/src/ytjs/ytjsnamfactory.h
new file mode 100644 (file)
index 0000000..01afda9
--- /dev/null
@@ -0,0 +1,32 @@
+#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
diff --git a/src/ytjs/ytjssearch.cpp b/src/ytjs/ytjssearch.cpp
new file mode 100644 (file)
index 0000000..c5a5031
--- /dev/null
@@ -0,0 +1,205 @@
+#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;
+}
diff --git a/src/ytjs/ytjssearch.h b/src/ytjs/ytjssearch.h
new file mode 100644 (file)
index 0000000..7c0174a
--- /dev/null
@@ -0,0 +1,28 @@
+#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
diff --git a/src/ytjs/ytjssinglevideosource.cpp b/src/ytjs/ytjssinglevideosource.cpp
new file mode 100644 (file)
index 0000000..b63a133
--- /dev/null
@@ -0,0 +1,102 @@
+#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;
+}
diff --git a/src/ytjs/ytjssinglevideosource.h b/src/ytjs/ytjssinglevideosource.h
new file mode 100644 (file)
index 0000000..2e459a1
--- /dev/null
@@ -0,0 +1,28 @@
+#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
diff --git a/src/ytjs/ytjsvideo.cpp b/src/ytjs/ytjsvideo.cpp
new file mode 100644 (file)
index 0000000..7040a50
--- /dev/null
@@ -0,0 +1,85 @@
+#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);
+}
diff --git a/src/ytjs/ytjsvideo.h b/src/ytjs/ytjsvideo.h
new file mode 100644 (file)
index 0000000..a617f51
--- /dev/null
@@ -0,0 +1,22 @@
+#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
index ea07412b50fc1d39a5ac6fe22f824dd27fd0c3db..f52bb8cf5fa82f0448d2499d02fe0467f822208e 100644 (file)
@@ -187,7 +187,7 @@ void YTSearch::requestError(const QString &message) {
 }
 
 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);