namespace {
QNetworkAccessManager *networkAccessManager() {
- static thread_local QNetworkAccessManager *nam = new QNetworkAccessManager();
+ static thread_local QNetworkAccessManager *nam = [] {
+ auto nam = new QNetworkAccessManager();
+#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0)
+ nam->setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
+#endif
+ return nam;
+ }();
return nam;
}
}
void NetworkHttpReply::setupReply() {
+#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
+ connect(networkReply, &QNetworkReply::errorOccurred, this, &NetworkHttpReply::replyError,
+ Qt::UniqueConnection);
+#else
connect(networkReply, SIGNAL(error(QNetworkReply::NetworkError)),
SLOT(replyError(QNetworkReply::NetworkError)), Qt::UniqueConnection);
+#endif
connect(networkReply, SIGNAL(finished()), SLOT(replyFinished()), Qt::UniqueConnection);
connect(networkReply, SIGNAL(downloadProgress(qint64, qint64)),
SLOT(downloadProgress(qint64, qint64)), Qt::UniqueConnection);
}
void NetworkHttpReply::replyFinished() {
+#if QT_VERSION < QT_VERSION_CHECK(5, 9, 0)
QUrl redirection = networkReply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl();
if (redirection.isValid()) {
HttpRequest redirectReq;
readTimeoutTimer->start();
return;
}
+#endif
if (isSuccessful()) {
bytes = networkReply->readAll();
return *result;
}
+ resetNAM();
+
auto function = engine->evaluate(name);
if (!function.isCallable()) {
qWarning() << function.toString() << " is not callable";
return *result;
}
+void JS::resetNAM() {
+ class MyCookieJar : public QNetworkCookieJar {
+ bool insertCookie(const QNetworkCookie &cookie) {
+ if (cookie.name().contains("CONSENT")) {
+ qDebug() << "Fixing CONSENT cookie" << cookie;
+ auto cookie2 = cookie;
+ cookie2.setValue(cookie.value().replace("PENDING", "YES"));
+ return QNetworkCookieJar::insertCookie(cookie2);
+ }
+ return QNetworkCookieJar::insertCookie(cookie);
+ }
+ };
+
+ auto nam = getEngine().networkAccessManager();
+ nam->clearAccessCache();
+ nam->setCookieJar(new MyCookieJar());
+}
+
void JS::initialize() {
if (url.isEmpty()) {
qDebug() << "No js url set";
auto getId() const { return id; }
// This should be static but cannot bind static functions to QJSEngine
- Q_INVOKABLE QJSValue clearTimeout(QJSValue id) {
+ Q_INVOKABLE void clearTimeout(QJSValue id) {
+ // qDebug() << "Clearing timer" << id.toString();
auto timer = getTimers().take(id.toUInt());
if (timer) {
timer->stop();
timer->deleteLater();
} else
qDebug() << "Unknown id" << id.toUInt();
- return QJSValue();
+ return;
}
// This should be static but cannot bind static functions to QJSEngine
Q_INVOKABLE QJSValue setTimeout(QJSValue callback, QJSValue delayTime, QJSValue args) {
}
Q_INVOKABLE JSTimer(QObject *parent = nullptr) : QTimer(parent) {
- setTimerType(Qt::CoarseTimer);
+ setTimerType(Qt::PreciseTimer);
setSingleShot(true);
// avoid 0
static uint counter = 1;
QQmlEngine &getEngine() { return *engine; }
JSResult &callFunction(JSResult *result, const QString &name, const QJSValueList &args);
+ void resetNAM();
signals:
void initialized();
<< req2.rawHeader(i.key());
}
- qDebug() << req2.url() << req2.rawHeaderList();
- return QNetworkAccessManager::createRequest(op, req2, outgoingData);
+#ifndef QT_NO_DEBUG_OUTPUT
+ qDebug() << req2.url();
+ for (const auto &h : req2.rawHeaderList())
+ qDebug() << h << req2.rawHeader(h);
+#endif
+
+ auto reply = QNetworkAccessManager::createRequest(op, req2, outgoingData);
+
+#ifndef QT_NO_DEBUG_OUTPUT
+ connect(reply, &QNetworkReply::finished, this, [reply] {
+ qDebug() << "finished"
+ << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toUInt()
+ << reply->url() << reply->rawHeaderPairs();
+ });
+ connect(reply, &QNetworkReply::redirectAllowed, this,
+ [reply] { qDebug() << "redirectAllowed" << reply->url(); });
+#endif
+
+ return reply;
}
QNetworkAccessManager *JSNAMFactory::create(QObject *parent) {
--- /dev/null
+MIT License
+
+Copyright (c) 2020 Flavio Tordini
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
--- /dev/null
+#ifndef EMPTYPROMISE_H
+#define EMPTYPROMISE_H
+
+#include <QtCore>
+
+class EmptyPromise : public QObject {
+ Q_OBJECT
+
+public:
+ explicit EmptyPromise(QObject *parent = nullptr) : QObject(parent) {
+ connect(this, &EmptyPromise::resolve, this, &QObject::deleteLater);
+ connect(this, &EmptyPromise::reject, this, &QObject::deleteLater);
+ };
+
+ template <typename Functor> EmptyPromise &then(Functor func) {
+ connect(this, &EmptyPromise::resolve, this, func);
+ return *this;
+ }
+ template <typename Functor> EmptyPromise &onFailed(Functor func) {
+ connect(this, &EmptyPromise::reject, this, func);
+ return *this;
+ }
+ template <typename Functor> EmptyPromise &finally(Functor func) {
+ connect(this, &EmptyPromise::destroyed, this, func);
+ return *this;
+ }
+
+signals:
+ void resolve();
+ void reject(const QString &message);
+};
+
+#endif // EMPTYPROMISE_H
--- /dev/null
+#ifndef PROMISE_H
+#define PROMISE_H
+
+#include <QtCore>
+
+/// private, don't use directly
+class BasePromise : public QObject {
+ Q_OBJECT
+
+public:
+ explicit BasePromise(QObject *parent = nullptr) : QObject(parent) {
+ connect(this, &BasePromise::resolve, this, &QObject::deleteLater);
+ connect(this, &BasePromise::reject, this, &QObject::deleteLater);
+ };
+
+ template <typename Function> BasePromise &then(Function func) {
+ connect(this, &BasePromise::resolve, this, func);
+ return *this;
+ }
+ template <typename Function> BasePromise &onFailed(Function func) {
+ connect(this, &BasePromise::reject, this, func);
+ return *this;
+ }
+ template <typename Function> BasePromise &finally(Function func) {
+ connect(this, &BasePromise::destroyed, this, func);
+ return *this;
+ }
+
+signals:
+ void resolve();
+ void reject(const QString &message);
+};
+
+template <class T> class Promise : public BasePromise {
+public:
+ void resolve(T value) {
+ data = value;
+ resolve();
+ }
+ T result() const { return data; }
+
+private:
+ T data;
+};
+
+#endif // PROMISE_H
--- /dev/null
+INCLUDEPATH += $$PWD
+DEPENDPATH += $$PWD
+
+QT *= core
+
+HEADERS += \
+ $$PWD/emptypromise.h \
+ $$PWD/promise.h \
+ $$PWD/variantpromise.h
--- /dev/null
+#ifndef VARIANTPROMISE_H
+#define VARIANTPROMISE_H
+
+#include <QtCore>
+
+class VariantPromise : public QObject {
+ Q_OBJECT
+
+public:
+ explicit VariantPromise(QObject *parent = nullptr) : QObject(parent) {
+ connect(this, &VariantPromise::resolve, this, &QObject::deleteLater);
+ connect(this, &VariantPromise::reject, this, &QObject::deleteLater);
+ };
+
+ template <typename Function> VariantPromise &then(Function func) {
+ connect(this, &VariantPromise::resolve, this, func);
+ return *this;
+ }
+ template <typename Function> VariantPromise &onFailed(Function func) {
+ connect(this, &VariantPromise::reject, this, func);
+ return *this;
+ }
+ template <typename Function> VariantPromise &finally(Function func) {
+ connect(this, &VariantPromise::destroyed, this, func);
+ return *this;
+ }
+
+signals:
+ void resolve(QVariant result);
+ void reject(const QString &message);
+};
+
+#endif // VARIANTPROMISE_H
}
label->setText(t);
};
- connect(this, &Updater::statusChanged, this, onStatusChange);
+ connect(this, &Updater::statusChanged, label, onStatusChange);
onStatusChange(status);
}
return label;
</message>
<message numerus="yes">
<source>%n year(s) ago</source>
- <translation type="unfinished"><numerusform></numerusform><numerusform></numerusform></translation>
+ <translation><numerusform>%n vuotta sitten</numerusform><numerusform>%n vuotta sitten</numerusform></translation>
</message>
</context>
<context>
</message>
<message>
<source>This year</source>
- <translation type="unfinished"/>
+ <translation>Tänä vuonna</translation>
</message>
<message>
<source>HD</source>
- <translation type="unfinished"/>
+ <translation>HD</translation>
</message>
<message>
<source>4K</source>
- <translation type="unfinished"/>
+ <translation>4K</translation>
</message>
<message>
<source>HDR</source>
- <translation type="unfinished"/>
+ <translation>HDR</translation>
</message>
</context>
<context>
</message>
<message>
<source>An update is ready to be installed. Quit and install update.</source>
- <translation type="unfinished"/>
+ <translation>Päivitys on valmiina asennettavaksi. Poistu sovelluksesta asentaaksesi päivityksen.</translation>
</message>
</context>
<context>
</message>
<message>
<source>Trending</source>
- <translation type="unfinished"/>
+ <translation>Trendaavat</translation>
</message>
<message>
<source>Music</source>
- <translation type="unfinished"/>
+ <translation>Musiikki</translation>
</message>
<message>
<source>News</source>
- <translation type="unfinished"/>
+ <translation>Uutiset</translation>
</message>
<message>
<source>Movies</source>
- <translation type="unfinished"/>
+ <translation>Elokuvat</translation>
</message>
<message>
<source>Gaming</source>
- <translation type="unfinished"/>
+ <translation>Pelaaminen</translation>
</message>
</context>
<context>
<name>Updater</name>
<message>
<source>Check for Updates...</source>
- <translation type="unfinished"/>
+ <translation>Tarkista onko päivityksiä saatavilla...</translation>
</message>
<message>
<source>Version %1 is available...</source>
- <translation type="unfinished"/>
+ <translation>Versio %1 on saatavilla...</translation>
</message>
<message>
<source>Downloading version %1...</source>
- <translation type="unfinished"/>
+ <translation>Ladataan versiota %1...</translation>
</message>
<message>
<source>Restart to Update</source>
- <translation type="unfinished"/>
+ <translation>Uudelleenkäynnistä päivittääksesi</translation>
</message>
<message>
<source>Version %1 download failed</source>
- <translation type="unfinished"/>
+ <translation>Version %1 lataaminen epäonnistui</translation>
</message>
<message>
<source>Check for Updates</source>
- <translation type="unfinished"/>
+ <translation>Tarkista onko päivityksiä saatavilla</translation>
</message>
<message>
<source>Download Update</source>
- <translation type="unfinished"/>
+ <translation>Lataa päivitys</translation>
</message>
<message>
<source>Downloading update...</source>
</message>
<message>
<source>Retry Update Download</source>
- <translation type="unfinished"/>
+ <translation>Yritä ladata päivitystä uudelleen</translation>
</message>
<message>
<source>You have the latest version.</source>
- <translation type="unfinished"/>
+ <translation>Käytössä on jo viimeisin versio.</translation>
</message>
<message>
<source>Version %1 is available.</source>
- <translation type="unfinished"/>
+ <translation>Versio %1 on saatavissa.</translation>
</message>
<message>
<source>An update has been downloaded and is ready to be installed.</source>
- <translation type="unfinished"/>
+ <translation>Päivitys ladattiin ja on nyt valmis asennettavaksi.</translation>
</message>
</context>
<context>
<name>updater::DefaultUpdater</name>
<message>
<source>There are currently no updates available.</source>
- <translation type="unfinished"/>
+ <translation>Päivityksiä ei ole tällä hetkellä saatavilla.</translation>
</message>
</context>
<context>
<name>updater::Dialog</name>
<message>
<source>You already have the latest version</source>
- <translation type="unfinished"/>
+ <translation>Käytössä on jo viimeisin versio</translation>
</message>
<message>
<source>Downloading %1 %2...</source>
- <translation type="unfinished"/>
+ <translation>Ladataan %1 %2...</translation>
</message>
<message>
<source>A new version of %1 is available!</source>
</message>
<message>
<source>Download Update</source>
- <translation type="unfinished"/>
+ <translation>Lataa päivitys</translation>
</message>
</context>
</TS>
\ No newline at end of file
</message>
<message numerus="yes">
<source>%n year(s) ago</source>
- <translation type="unfinished"><numerusform></numerusform></translation>
+ <translation><numerusform>%n年前</numerusform></translation>
</message>
</context>
<context>
</message>
<message>
<source>Powered by %1</source>
- <translation type="unfinished"/>
+ <translation>Alimentat de %1</translation>
</message>
<message>
<source>Open-source software</source>
- <translation type="unfinished"/>
+ <translation>Software Sursă-Deschisă</translation>
</message>
<message>
<source>Icon designed by %1.</source>
</message>
<message numerus="yes">
<source>You have %n new video(s)</source>
- <translation type="unfinished"><numerusform></numerusform><numerusform></numerusform><numerusform></numerusform></translation>
+ <translation><numerusform>Aveți un videoclip nou</numerusform><numerusform>Aveți %n videoclipuri noi</numerusform><numerusform>Aveți %n de videoclipuri noi</numerusform></translation>
</message>
</context>
<context>
CONFIG += c++17 exceptions_off rtti_off object_parallel_to_source
TEMPLATE = app
-VERSION = 3.8.1
+VERSION = 3.9.1
DEFINES += APP_VERSION="$$VERSION"
APP_NAME = Minitube
include(lib/http/http.pri)
include(lib/idle/idle.pri)
include(lib/js/js.pri)
+include(lib/promises/promises.pri)
DEFINES += MEDIA_MPV
include(lib/media/media.pri)
<component type="desktop-application">
<name>Minitube</name>
<id>org.tordini.flavio.minitube</id>
+ <launchable type="desktop-id">minitube.desktop</launchable>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<summary>YouTube app</summary>
<url type="donation">https://flavio.tordini.org/donate</url>
<url type="translate">https://www.transifex.com/flaviotordini/minitube/</url>
<releases>
+ <release version="3.9" date="2021-06-22"/>
+ <release version="3.8.2" date="2021-04-02"/>
+ <release version="3.8.1" date="2021-02-26"/>
+ <release version="3.8" date="2021-02-19"/>
<release version="3.7" date="2020-12-30"/>
</releases>
<content_rating type="oars-1.1">
<file>images/app@2x.png</file>
<file>images/64x64/app.png</file>
<file>images/64x64/app@2x.png</file>
+ <file>flags/us.png</file>
</qresource>
</RCC>
video->setChannelId(query.value(4).toString());
video->setDescription(query.value(5).toString());
video->setWebpage(query.value(6).toString());
- video->setThumbnailUrl(query.value(7).toString());
+
+ QString thumbString = query.value(7).toString();
+ if (thumbString.startsWith('[')) {
+ const auto thumbs = QJsonDocument::fromJson(thumbString.toUtf8()).array();
+ for (const auto &t : thumbs) {
+ video->addThumb(t["width"].toInt(), t["height"].toInt(), t["url"].toString());
+ }
+ } else {
+ // assume it's a URL
+ video->addThumb(0, 0, thumbString);
+ }
+
video->setViewCount(query.value(8).toInt());
video->setDuration(query.value(9).toInt());
videos << video;
} else {
currentChannel->updateChecked();
currentChannel = 0;
- processNextChannel();
+ QTimer::singleShot(5000, this, &ChannelAggregator::processNextChannel);
}
}
query.bindValue(7, video->getChannelId());
query.bindValue(8, video->getDescription());
query.bindValue(9, video->getWebpage());
- query.bindValue(10, video->getThumbnailUrl());
+
+ QJsonDocument thumbsDoc;
+ auto thumbsArray = thumbsDoc.array();
+ for (const auto &t : video->getThumbs()) {
+ thumbsArray.append(QJsonObject{
+ {"url", t.getUrl()},
+ {"width", t.getWidth()},
+ {"height", t.getHeight()},
+ });
+ }
+ thumbsDoc.setArray(thumbsArray);
+ query.bindValue(10, thumbsDoc.toJson(QJsonDocument::Compact));
query.bindValue(11, video->getViewCount());
query.bindValue(12, video->getDuration());
success = query.exec();
if (engine) delete engine;
engine = new QJSEngine(this);
engine->evaluate(js);
- emit ready();
+ QTimer::singleShot(0, this, [this] {
+ qDebug() << "Emitting ready";
+ emit ready();
+ });
}
QString JsFunctions::jsFilename() {
} else if (VideoAPI::impl() == VideoAPI::YT3) {
YT3::instance().initApiKeys();
} else if (VideoAPI::impl() == VideoAPI::JS) {
- JS::instance().getNamFactory().setRequestHeaders(
- {{"User-Agent", HttpUtils::stealthUserAgent()}});
JS::instance().initialize(QUrl(QLatin1String(Constants::WEBSITE) + "-ws/bundle2.js"));
- /// JS::instance().initialize(QUrl("http://localhost:8000/bundle-test.js"));
+ // JS::instance().initialize(QUrl("http://localhost:8000/bundle-test.js"));
Invidious::instance().initServers();
}
+ connect(JsFunctions::instance(), &JsFunctions::ready, this, [] {
+ auto ua = JsFunctions::instance()->string("userAgent()").toUtf8();
+ JS::instance().getNamFactory().setRequestHeaders({{"User-Agent", ua}});
+ });
+
QTimer::singleShot(100, this, &MainWindow::lazyInit);
}
fullscreenTimer->setSingleShot(true);
connect(fullscreenTimer, SIGNAL(timeout()), SLOT(hideFullscreenUI()));
- JsFunctions::instance();
-
// Hack to give focus to searchlineedit
View *view = qobject_cast<View *>(views->currentWidget());
if (view == homeView) {
action->setEnabled(false);
actionMap.insert("refineSearch", action);
- action = new QAction(YTRegions::worldwideRegion().name, this);
+ action = new QAction(YTRegions::defaultRegion().name, this);
actionMap.insert("worldwideRegion", action);
action = new QAction(YTRegions::localRegion().name, this);
VideoSource *vs = history.takeLast();
if (!vs->parent()) {
qDebug() << "Deleting VideoSource" << vs->getName() << vs;
+ vs->abort();
vs->deleteLater();
}
}
void MediaView::pause() {
switch (media->state()) {
case Media::PlayingState:
+ qDebug() << "Pausing";
media->pause();
pauseTimer.start();
break;
default:
- if (pauseTimer.hasExpired(60000)) {
+ if (pauseTimer.isValid() && pauseTimer.hasExpired(60000)) {
+ qDebug() << "Pause timer expired";
pauseTimer.invalidate();
auto activeVideo = playlistModel->activeVideo();
if (activeVideo) {
connect(activeVideo, &Video::gotStreamUrl, this,
&MediaView::resumeWithNewStreamUrl);
activeVideo->loadStreamUrl();
- }
- } else
+ } else
+ qDebug() << "No active video";
+ } else {
+ qDebug() << "Playing" << media->file();
media->play();
+ }
break;
}
}
VideoSource *videoSource = history.takeFirst();
// Don't delete videoSource in the Browse view
if (!videoSource->parent()) {
+ videoSource->abort();
videoSource->deleteLater();
}
}
#include "iconutils.h"
#include "playlistmodel.h"
#include "playlistview.h"
+#include "variantpromise.h"
#include "video.h"
#include "videodefinition.h"
const bool isActive = index.data(ActiveTrackRole).toBool();
// get the video metadata
- const Video *video = index.data(VideoRole).value<VideoPointer>().data();
+ Video *video = index.data(VideoRole).value<VideoPointer>().data();
// draw the "current track" highlight underneath the text
if (isActive && !isSelected) paintActiveOverlay(painter, option, line);
// thumb
- const QPixmap &thumb = video->getThumbnail();
+ qreal pixelRatio = painter->device()->devicePixelRatioF();
+ QByteArray thumbKey = ("t" + QString::number(pixelRatio)).toUtf8();
+ const QPixmap &thumb = video->property(thumbKey).value<QPixmap>();
if (!thumb.isNull()) {
painter->drawPixmap(0, 0, thumb);
if (video->getDuration() > 0) drawTime(painter, video->getFormattedDuration(), line);
- }
+ } else
+ video->loadThumb({thumbWidth, thumbHeight}, pixelRatio)
+ .then([pixelRatio, thumbKey, video](auto variant) {
+ QPixmap pixmap;
+ pixmap.loadFromData(variant.toByteArray());
+ pixmap.setDevicePixelRatio(pixelRatio);
+ const int thumbWidth = PlaylistItemDelegate::thumbWidth * pixelRatio;
+ if (pixmap.width() > thumbWidth)
+ pixmap = pixmap.scaledToWidth(thumbWidth, Qt::SmoothTransformation);
+ video->setProperty(thumbKey, pixmap);
+ video->changed();
+ })
+ .onFailed([](auto msg) { qDebug() << msg; });
const bool thumbsOnly = line.width() <= thumbWidth + 60;
const bool isHovered = index.data(HoveredItemRole).toBool();
#include "playlistmodel.h"
#include "mediaview.h"
+#include "playlistitemdelegate.h"
#include "searchparams.h"
#include "video.h"
#include "videomimedata.h"
videos.append(newVideos);
endInsertRows();
for (Video *video : newVideos) {
- connect(video, SIGNAL(gotThumbnail()), SLOT(updateVideoSender()), Qt::UniqueConnection);
- video->loadThumbnail();
+ connect(video, &Video::changed, this, [video, this] {
+ int row = rowForVideo(video);
+ emit dataChanged(createIndex(row, 0), createIndex(row, columnCount() - 1));
+ });
}
}
}
}
-void PlaylistModel::updateVideoSender() {
- Video *video = static_cast<Video *>(sender());
- if (!video) {
- qDebug() << "Cannot get sender";
- return;
- }
- int row = rowForVideo(video);
- emit dataChanged(createIndex(row, 0), createIndex(row, columnCount() - 1));
-}
-
void PlaylistModel::emitDataChanged() {
QModelIndex index = createIndex(rowCount() - 1, 0);
emit dataChanged(index, index);
void addVideos(const QVector<Video *> &newVideos);
void searchFinished(int total);
void searchError(const QString &message);
- void updateVideoSender();
void emitDataChanged();
void setHoveredRow(int row);
layout->setSpacing(0);
l->addLayout(layout);
- addRegion(YTRegions::worldwideRegion());
+ addRegion(YTRegions::defaultRegion());
foreach (YTRegion region, YTRegions::list())
addRegion(region);
#ifdef APP_ACTIVATION
oneYearUsage = (QDateTime::currentSecsSinceEpoch() -
Activation::instance().getLicenseTimestamp()) > 86400 * 365;
-#elif APP_MAC_STORE
+#elif defined APP_MAC_STORE
oneYearUsage = false;
#endif
if (oneYearUsage) {
#include "ivvideolist.h"
#include "videoapi.h"
+#include "ytjstrending.h"
+
StandardFeedsView::StandardFeedsView(QWidget *parent) : View(parent), layout(0) {
setBackgroundRole(QPalette::Base);
setAutoFillBackground(true);
YTRegion region = YTRegions::currentRegion();
+ // TODO consolidate in YT
if (VideoAPI::impl() == VideoAPI::YT3) {
YTCategories *youTubeCategories = new YTCategories(this);
connect(youTubeCategories, SIGNAL(categoriesLoaded(const QVector<YTCategory> &)),
SLOT(layoutCategories(const QVector<YTCategory> &)));
youTubeCategories->loadCategories();
addVideoSourceWidget(buildStandardFeed("most_popular", tr("Most Popular")));
+ } else if (VideoAPI::impl() == VideoAPI::JS) {
+ const QMap<QString, QString> pages = {{"default", tr("Trending")},
+ {"music", tr("Music")},
+ {"movies", tr("Movies")},
+ {"gaming", tr("Gaming")}};
+ auto i = pages.constBegin();
+ while (i != pages.constEnd()) {
+ addVideoSourceWidget(
+ new YTJSTrending(i.value(), {{"page", i.key()}, {"geoLocation", region.id}}));
+ ++i;
+ }
+
+ setUpdatesEnabled(true);
} else {
QString regionParam = "region=" + region.id;
addVideoSourceWidget(new IVVideoList("popular?" + regionParam, tr("Most Popular")));
connect(w, SIGNAL(unavailable(VideoSourceWidget *)),
SLOT(removeVideoSourceWidget(VideoSourceWidget *)));
int i = layout->count();
- const int cols = VideoAPI::impl() == VideoAPI::YT3 ? 5 : 3;
+ const int cols = VideoAPI::impl() == VideoAPI::YT3 ? 5 : 2;
layout->addWidget(w, i / cols, i % cols);
}
}
const int itemCount = items.size();
- const int cols = 4; // itemCount / 3;
+ const int cols = 2; // itemCount / 3;
for (int i = itemCount - 1; i >= 0; i--) {
QLayoutItem *item = items.at(i);
int index = itemCount - 1 - i;
}
void StandardFeedsView::selectWorldwideRegion() {
- YTRegions::setRegion(YTRegions::worldwideRegion().id);
+ YTRegions::setRegion(YTRegions::defaultRegion().id);
load();
}
tip->setFont(FontUtils::medium());
layout->addWidget(tip);
- auto button = new QPushButton("Open subscriptions.json");
+ auto button = new QPushButton("Open subscriptions.csv");
button->setDefault(true);
connect(this, &View::didAppear, button, [button] { button->setFocus(); });
button->setFocus();
button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
connect(button, &QPushButton::clicked, this, [this] {
auto dir = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
- QString fileName = QFileDialog::getOpenFileName(this, tr("Open subscriptions.json"), dir,
- tr("JSON Files (*.json)"));
+ QString fileName = QFileDialog::getOpenFileName(this, tr("Open subscriptions.csv"), dir,
+ tr("YouTube data (*.csv *.json)"));
if (!fileName.isEmpty()) {
auto w = MainWindow::instance();
QString msg;
QFile file(fileName);
if (file.open(QFile::ReadOnly)) {
int count = 0;
- const auto array = QJsonDocument::fromJson(file.readAll()).array();
- for (const auto &i : array) {
- auto id = i["snippet"]["resourceId"]["channelId"].toString();
- qDebug() << "Subscribing to" << id;
- if (YTChannel::subscribe(id)) count++;
+ if (QFileInfo(fileName).suffix().toLower() == "csv") {
+ int lineNumber = 1;
+ while (!file.atEnd()) {
+ QByteArray line = file.readLine();
+ if (lineNumber > 1) {
+ auto fields = line.split(',');
+ auto id = fields.first();
+ qDebug() << "Subscribing to" << id;
+ if (YTChannel::subscribe(id)) count++;
+ }
+ lineNumber++;
+ }
+ } else {
+ const auto array = QJsonDocument::fromJson(file.readAll()).array();
+ for (const auto &i : array) {
+ auto id = i["snippet"]["resourceId"]["channelId"].toString();
+ qDebug() << "Subscribing to" << id;
+ if (YTChannel::subscribe(id)) count++;
+ }
}
msg = tr("Subscribed to %n channel(s)", "", count);
w->showHome();
#include "ytjsvideo.h"
#include "ytvideo.h"
+#include "variantpromise.h"
+
Video::Video()
- : duration(0), viewCount(-1), license(LicenseYouTube), definitionCode(0),
- loadingThumbnail(false), ytVideo(nullptr), ytjsVideo(nullptr) {}
+ : duration(0), viewCount(-1), license(LicenseYouTube), definitionCode(0), ytVideo(nullptr),
+ ytjsVideo(nullptr) {}
Video::~Video() {
qDebug() << "Deleting" << id;
clone->channelId = channelId;
clone->webpage = webpage;
clone->streamUrl = streamUrl;
- clone->thumbnail = thumbnail;
- clone->thumbnailUrl = thumbnailUrl;
- clone->mediumThumbnailUrl = mediumThumbnailUrl;
+ clone->thumbs = thumbs;
+ clone->thumbsNeedSorting = thumbsNeedSorting;
clone->duration = duration;
clone->formattedDuration = formattedDuration;
clone->published = published;
}
}
-void Video::loadThumbnail() {
- if (thumbnailUrl.isEmpty() || loadingThumbnail) return;
- loadingThumbnail = true;
- auto reply = HttpUtils::yt().get(thumbnailUrl);
- connect(reply, SIGNAL(data(QByteArray)), SLOT(setThumbnail(QByteArray)));
- connect(reply, &HttpReply::error, this, [this](auto &msg) {
- qWarning() << msg;
- loadingThumbnail = false;
- });
-}
-
void Video::setDuration(int value) {
duration = value;
formattedDuration = DataUtils::formatDuration(duration);
formattedPublished = DataUtils::formatDateTime(published);
}
-void Video::setThumbnail(const QByteArray &bytes) {
- qreal ratio = qApp->devicePixelRatio();
- thumbnail.loadFromData(bytes);
- thumbnail.setDevicePixelRatio(ratio);
- const int thumbWidth = PlaylistItemDelegate::thumbWidth * ratio;
- if (thumbnail.width() > thumbWidth)
- thumbnail = thumbnail.scaledToWidth(thumbWidth, Qt::SmoothTransformation);
- emit gotThumbnail();
- loadingThumbnail = false;
-}
-
void Video::streamUrlLoaded(const QString &streamUrl, const QString &audioUrl) {
qDebug() << "Streams loaded";
this->streamUrl = streamUrl;
qDebug() << msg;
ytjsVideo->deleteLater();
ytjsVideo = nullptr;
- loadStreamUrlYT();
+ // loadStreamUrlYT();
+ emit errorStreamUrl(msg);
});
ytjsVideo->loadStreamUrl();
}
loadStreamUrlJS();
}
+bool Video::isLoadingStreamUrl() const {
+ return ytVideo != nullptr || ytjsVideo != nullptr;
+}
+
void Video::abortLoadStreamUrl() {
if (ytVideo) {
ytVideo->disconnect(this);
ytVideo->deleteLater();
ytVideo = nullptr;
}
+ if (ytjsVideo) {
+ ytjsVideo->disconnect(this);
+ ytjsVideo->deleteLater();
+ ytjsVideo = nullptr;
+ }
+}
+
+void Video::addThumb(int width, int height, QString url) {
+ thumbs << YTThumb(width, height, url);
+ thumbsNeedSorting = true;
+}
+
+VariantPromise &Video::loadThumb(QSize size, qreal pixelRatio) {
+ if (thumbsNeedSorting) {
+ std::sort(thumbs.begin(), thumbs.end(),
+ [](auto a, auto b) { return a.getWidth() < b.getWidth(); });
+ thumbsNeedSorting = false;
+ }
+
+ auto promise = new VariantPromise(this);
+ if (thumbs.isEmpty()) {
+ QTimer::singleShot(0, promise, [promise] { promise->reject("Empty thumbs"); });
+ return *promise;
+ }
+
+ auto reallyLoad = [this, promise, size, pixelRatio](auto &&self,
+ YTThumb *previous = nullptr) -> void {
+ YTThumb *selected = nullptr;
+
+ static bool fallback = false;
+ if (fallback) {
+ qDebug() << "Doing fallback loop";
+ bool skip = previous != nullptr;
+ for (int i = thumbs.size() - 1; i >= 0; --i) {
+ auto &thumb = thumbs.at(i);
+ if (!skip) {
+ selected = (YTThumb *)&thumb;
+ qDebug() << "selected" << selected->getUrl();
+ break;
+ }
+ if (&thumb == previous) skip = false;
+ }
+ } else {
+ bool skip = previous != nullptr;
+ for (auto &thumb : qAsConst(thumbs)) {
+ if (!skip && thumb.getWidth() * pixelRatio >= size.width() &&
+ thumb.getHeight() * pixelRatio >= size.height()) {
+ selected = (YTThumb *)&thumb;
+ qDebug() << "selected" << selected->getUrl();
+ break;
+ }
+ if (&thumb == previous) skip = false;
+ }
+ }
+ if (!selected && !fallback) {
+ qDebug() << "Falling back";
+ fallback = true;
+ self(self);
+ return;
+ }
+ if (selected) {
+ qDebug() << "Loading" << selected->getUrl();
+ selected->load(promise)
+ .then([promise](auto variant) { promise->resolve(variant); })
+ .onFailed([self, selected] { self(self, selected); });
+ } else
+ promise->reject("No thumb");
+ };
+ reallyLoad(reallyLoad);
+
+ return *promise;
}
#include <QtCore>
#include <QtGui>
+#include "ytthumb.h"
+
class YTVideo;
class YTJSVideo;
+class VariantPromise;
class Video : public QObject {
Q_OBJECT
const QString &getWebpage();
void setWebpage(const QString &value);
- void loadThumbnail();
- const QPixmap &getThumbnail() const { return thumbnail; }
-
- const QString &getThumbnailUrl() const { return thumbnailUrl; }
- void setThumbnailUrl(const QString &value) { thumbnailUrl = value; }
-
- const QString &getMediumThumbnailUrl() const { return mediumThumbnailUrl; }
- void setMediumThumbnailUrl(const QString &value) { mediumThumbnailUrl = value; }
-
- const QString &getLargeThumbnailUrl() const { return largeThumbnailUrl; }
- void setLargeThumbnailUrl(const QString &value) { largeThumbnailUrl = value; }
-
int getDuration() const { return duration; }
void setDuration(int value);
const QString &getFormattedDuration() const { return formattedDuration; }
void loadStreamUrl();
const QString &getStreamUrl() { return streamUrl; }
- bool isLoadingStreamUrl() const { return ytVideo != nullptr; }
+ bool isLoadingStreamUrl() const;
void abortLoadStreamUrl();
const QString &getId() const { return id; }
License getLicense() const { return license; }
void setLicense(License value) { license = value; }
+ const auto &getThumbs() const { return thumbs; }
+ void addThumb(int width, int height, QString url);
+ VariantPromise &loadThumb(QSize size, qreal pixelRatio);
+
signals:
- void gotThumbnail();
- void gotMediumThumbnail(const QByteArray &bytes);
- void gotLargeThumbnail(const QByteArray &bytes);
void gotStreamUrl(const QString &videoUrl, const QString &audioUrl);
void errorStreamUrl(const QString &message);
+ void changed();
private slots:
- void setThumbnail(const QByteArray &bytes);
void streamUrlLoaded(const QString &streamUrl, const QString &audioUrl);
private:
QString channelId;
QString webpage;
QString streamUrl;
- QPixmap thumbnail;
- QString thumbnailUrl;
- QString mediumThumbnailUrl;
- QString largeThumbnailUrl;
int duration;
QString formattedDuration;
QString id;
int definitionCode;
- bool loadingThumbnail;
-
YTVideo *ytVideo;
YTJSVideo *ytjsVideo;
+
+ QVector<YTThumb> thumbs;
+ bool thumbsNeedSorting = false;
};
// This is required in order to use QPointer<Video> as a QVariant
$END_LICENSE */
#include "videosourcewidget.h"
-#include "videosource.h"
-#include "video.h"
#include "fontutils.h"
-#include "iconutils.h"
#include "http.h"
#include "httputils.h"
+#include "iconutils.h"
+#include "variantpromise.h"
+#include "video.h"
+#include "videosource.h"
VideoSourceWidget::VideoSourceWidget(VideoSource *videoSource, QWidget *parent)
: GridWidget(parent),
lastPixelRatio(0) {
videoSource->setParent(this);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
-
loadPreview();
-
connect(this, SIGNAL(activated()), SLOT(activate()));
}
return;
}
Video *video = videos.at(0);
- lastPixelRatio = window()->devicePixelRatio();
- bool needLargeThumb = lastPixelRatio > 1.0 || window()->width() > 1000;
- QString url = needLargeThumb ? video->getLargeThumbnailUrl() : video->getMediumThumbnailUrl();
- if (url.isEmpty()) url = video->getThumbnailUrl();
- video->deleteLater();
- QObject *reply = HttpUtils::yt().get(url);
- connect(reply, SIGNAL(data(QByteArray)), SLOT(setPixmapData(QByteArray)));
+ lastPixelRatio = devicePixelRatio();
+
+ video->loadThumb(size(), lastPixelRatio)
+ .then([this](auto variant) { setPixmapData(variant.toByteArray()); })
+ .onFailed([](auto msg) { qDebug() << msg; })
+ .finally([videos] {
+ for (auto v : videos)
+ v->deleteLater();
+ });
}
void VideoSourceWidget::setPixmapData(const QByteArray &bytes) {
const int s = height() / 2;
const int padding = s / 8;
- qreal ratio = window()->devicePixelRatio();
+ qreal ratio = devicePixelRatio();
QPixmap playIcon = QPixmap(s * ratio, s * ratio);
playIcon.setDevicePixelRatio(ratio);
playIcon.fill(Qt::transparent);
void VideoSourceWidget::paintEvent(QPaintEvent *event) {
GridWidget::paintEvent(event);
+ // if (devicePixelRatio() != lastPixelRatio) loadPreview();
+
if (pixmap.isNull()) return;
- if (window()->devicePixelRatio() != lastPixelRatio) loadPreview();
QPainter p(this);
QString name = videoSource->getName();
bool tooBig = false;
p.save();
- p.setFont(FontUtils::medium());
+ p.setFont(FontUtils::big());
QRect textBox = p.boundingRect(nameBox, Qt::AlignCenter | Qt::TextWordWrap, name);
if (textBox.height() > nameBox.height()) {
p.setFont(font());
video->setTitle(title);
video->setDescription(item[QLatin1String("descriptionHtml")].toString());
- const auto thumbnails = item[QLatin1String("videoThumbnails")].toArray();
- for (const auto &thumbnail : thumbnails) {
- auto q = thumbnail["quality"];
- if (q == QLatin1String("medium")) {
- video->setThumbnailUrl(thumbnail["url"].toString());
- } else if (q == QLatin1String("high")) {
- video->setMediumThumbnailUrl(thumbnail["url"].toString());
- } else if (q == QLatin1String("sddefault")) {
- video->setLargeThumbnailUrl(thumbnail["url"].toString());
- }
+ const auto thumbs = item[QLatin1String("videoThumbnails")].toArray();
+ for (const auto &t : thumbs) {
+ video->addThumb(t["width"].toInt(), t["height"].toInt(), t["url"].toString());
}
video->setChannelTitle(item[QLatin1String("author")].toString());
HEADERS += \
$$PWD/searchvideosource.h \
- $$PWD/singlevideosource.h
+ $$PWD/singlevideosource.h \
+ $$PWD/ytthumb.h
SOURCES += \
$$PWD/searchvideosource.cpp \
- $$PWD/singlevideosource.cpp
+ $$PWD/singlevideosource.cpp \
+ $$PWD/ytthumb.cpp
$$PWD/ytjschannelsource.h \
$$PWD/ytjssearch.h \
$$PWD/ytjssinglevideosource.h \
+ $$PWD/ytjstrending.h \
$$PWD/ytjsvideo.h
SOURCES += \
$$PWD/ytjschannelsource.cpp \
$$PWD/ytjssearch.cpp \
$$PWD/ytjssinglevideosource.cpp \
+ $$PWD/ytjstrending.cpp \
$$PWD/ytjsvideo.cpp
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;
+ QString url = thumbObj["url"].toString();
thumbnailUrl = url;
}
}
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);
+ for (const auto &t : thumbs) {
+ video->addThumb(t["width"].toInt(), t["height"].toInt(),
+ t["url"].toString());
}
int views = i["viewCount"].toInt();
emit gotVideos(videos);
emit finished(videos.size());
})
- .onError([this, &js, max, startIndex](auto &msg) {
+ .onError([this, max, startIndex](auto &msg) {
static int retries = 0;
if (retries < 3) {
qDebug() << "Retrying...";
- auto nam = js.getEngine().networkAccessManager();
- nam->clearAccessCache();
- nam->setCookieJar(new QNetworkCookieJar());
QTimer::singleShot(0, this,
[this, max, startIndex] { loadVideos(max, startIndex); });
retries++;
jsMapSet.callWithInstance(filterMap, {name, value});
};
- addFilter("Type", "Video");
+ // addFilter("Type", "Video");
switch (searchParams->sortBy()) {
case SearchParams::SortByNewest:
addFilter("Duration", "Short");
break;
case SearchParams::DurationMedium:
+ addFilter("Duration", "Medium");
+ break;
case SearchParams::DurationLong:
addFilter("Duration", "Long");
break;
case SearchParams::TimeMonth:
addFilter("Upload date", "This month");
break;
+ case SearchParams::TimeYear:
+ addFilter("Upload date", "This year");
+ break;
}
switch (searchParams->quality()) {
QString desc = i["description"].toString();
video->setDescription(desc);
- QString thumb = i["thumbnail"].toString();
- video->setThumbnailUrl(thumb);
+ const auto thumbs = i["thumbnails"].toArray();
+ for (const auto &t : thumbs) {
+ video->addThumb(t["width"].toInt(), t["height"].toInt(),
+ t["url"].toString());
+ }
int views = i["views"].toInt();
video->setViewCount(views);
emit finished(videos.size());
}
})
- .onError([this, &js, max, startIndex](auto &msg) {
+ .onError([this, max, startIndex](auto &msg) {
static int retries = 0;
if (retries < 3) {
qDebug() << "Retrying...";
- auto nam = js.getEngine().networkAccessManager();
- nam->clearAccessCache();
- nam->setCookieJar(new QNetworkCookieJar());
QTimer::singleShot(0, this,
[this, max, startIndex] { loadVideos(max, startIndex); });
retries++;
#include "js.h"
#include "video.h"
+namespace {
+
+QDateTime parsePublishedText(const QString &s) {
+ int num = 0;
+ const auto parts = s.splitRef(' ');
+ for (const auto &part : parts) {
+ num = part.toInt();
+ if (num > 0) break;
+ }
+ if (num == 0) return QDateTime();
+
+ auto now = QDateTime::currentDateTimeUtc();
+ if (s.contains("hour")) {
+ return now.addSecs(-num * 3600);
+ } else if (s.contains("day")) {
+ return now.addDays(-num);
+ } else if (s.contains("week")) {
+ return now.addDays(-num * 7);
+ } else if (s.contains("month")) {
+ return now.addMonths(-num);
+ } else if (s.contains("year")) {
+ return now.addDays(-num * 365);
+ }
+ return QDateTime();
+}
+
+} // namespace
+
YTJSSingleVideoSource::YTJSSingleVideoSource(QObject *parent)
: VideoSource(parent), video(nullptr) {}
if (aborted) return;
auto obj = doc.object();
- // qDebug() << doc.toJson();
auto parseVideoObject = [](QJsonObject i) {
Video *video = new Video();
QString id = i["id"].toString();
+ if (id.isEmpty()) id = i["videoId"].toString();
video->setId(id);
QString title = i["title"].toString();
QString desc = i["description"].toString();
if (desc.isEmpty()) desc = i["desc"].toString();
+ if (desc.isEmpty()) desc = i["shortDescription"].toString();
+
video->setDescription(desc);
const auto thumbs = i["thumbnails"].toArray();
- for (const auto &thumb : thumbs) {
- QString url = thumb["url"].toString();
- int width = thumb["width"].toInt();
- if (width >= 336)
- video->setLargeThumbnailUrl(url);
- else if (width >= 246)
- video->setMediumThumbnailUrl(url);
- else if (width >= 168)
- video->setThumbnailUrl(url);
+ for (const auto &t : thumbs) {
+ video->addThumb(t["width"].toInt(), t["height"].toInt(),
+ t["url"].toString());
}
- int views = i["view_count"].toInt();
+ qDebug() << i["view_count"] << i["viewCount"];
+ int views = i["view_count"].toString().toInt();
+ if (views == 0) views = i["viewCount"].toString().toInt();
video->setViewCount(views);
int duration = i["length_seconds"].toInt();
- video->setViewCount(duration);
-
- QString channelId = i["ucid"].toString();
- video->setChannelId(channelId);
+ video->setDuration(duration);
+
+ auto author = i["author"];
+ if (author.isObject()) {
+ auto authorObject = author.toObject();
+ video->setChannelId(authorObject["id"].toString());
+ video->setChannelTitle(authorObject["name"].toString());
+ } else if (author.isString()) {
+ video->setChannelId(i["ucid"].toString());
+ video->setChannelTitle(author.toString());
+ }
- QString channelName = i["author"].toString();
- video->setChannelTitle(channelName);
+ auto published = parsePublishedText(i["published"].toString());
+ if (published.isValid()) video->setPublished(published);
return video;
};
QVector<Video *> videos;
if (!video) {
- // parse video details
- videos << parseVideoObject(obj["videoDetails"].toObject());
+ // first video
+ auto video = parseVideoObject(obj["videoDetails"].toObject());
+ videos << video;
+ name = video->getTitle();
+ qDebug() << "Emitting name changed" << name;
+ emit nameChanged(name);
}
const auto items = obj["related_videos"].toArray();
--- /dev/null
+#include "ytjstrending.h"
+
+#include "js.h"
+#include "video.h"
+
+namespace {
+
+QDateTime parsePublishedText(const QString &s) {
+ int num = 0;
+ const auto parts = s.splitRef(' ');
+ for (const auto &part : parts) {
+ num = part.toInt();
+ if (num > 0) break;
+ }
+ if (num == 0) return QDateTime();
+
+ auto now = QDateTime::currentDateTimeUtc();
+ if (s.contains("hour")) {
+ return now.addSecs(-num * 3600);
+ } else if (s.contains("day")) {
+ return now.addDays(-num);
+ } else if (s.contains("week")) {
+ return now.addDays(-num * 7);
+ } else if (s.contains("month")) {
+ return now.addMonths(-num);
+ } else if (s.contains("year")) {
+ return now.addDays(-num * 365);
+ }
+ return QDateTime();
+}
+
+} // namespace
+
+YTJSTrending::YTJSTrending(QString name, QVariantMap params, QObject *parent)
+ : VideoSource(parent), name(name), params(params) {}
+
+void YTJSTrending::loadVideos(int max, int startIndex) {
+ aborted = false;
+
+ auto &js = JS::instance();
+
+ QJSValue options = js.getEngine().toScriptValue(params);
+
+ js.callFunction(new JSResult(this), "trending", {options})
+ .onJson([this](auto &doc) {
+ const auto items = doc.array();
+
+ 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 &t : thumbs) {
+ video->addThumb(t["width"].toInt(), t["height"].toInt(),
+ t["url"].toString());
+ }
+
+ int views = i["viewCount"].toInt();
+ video->setViewCount(views);
+
+ int duration = i["lengthSeconds"].toInt();
+ video->setDuration(duration);
+
+ auto published = parsePublishedText(i["publishedText"].toString());
+ if (published.isValid()) video->setPublished(published);
+
+ QString channelName = i["author"].toString();
+ video->setChannelTitle(channelName);
+ QString channelId = i["authorId"].toString();
+ video->setChannelId(channelId);
+
+ videos << video;
+ }
+
+ emit gotVideos(videos);
+ emit finished(videos.size());
+ })
+ .onError([this, max, startIndex](auto &msg) {
+ static int retries = 0;
+ if (retries < 3) {
+ qDebug() << "Retrying...";
+ QTimer::singleShot(0, this,
+ [this, max, startIndex] { loadVideos(max, startIndex); });
+ retries++;
+ } else {
+ emit error(msg);
+ }
+ });
+}
--- /dev/null
+#ifndef YTJSTRENDING_H
+#define YTJSTRENDING_H
+
+#include "videosource.h"
+
+class Video;
+
+class YTJSTrending : public VideoSource {
+ Q_OBJECT
+
+public:
+ YTJSTrending(QString name, QVariantMap params, QObject *parent = 0);
+ void loadVideos(int max, int startIndex);
+ void abort() { aborted = true; }
+ QString getName() { return name; }
+
+private:
+ const QString name;
+ const QVariantMap params;
+ bool aborted = false;
+};
+
+#endif // YTJSTRENDING_H
--- /dev/null
+#include "ytthumb.h"
+
+#include "http.h"
+#include "httpreply.h"
+#include "httputils.h"
+
+YTThumb::YTThumb(int width, int height, const QString &url)
+ : width(width), height(height), url(url) {}
+
+VariantPromise &YTThumb::load(QObject *parent) {
+ qDebug() << parent;
+ if (promise) {
+ qDebug() << "Already loading" << promise;
+ return *promise;
+ }
+ promise = new VariantPromise(parent);
+ promise->connect(HttpUtils::yt().get(url), &HttpReply::finished, promise, [this](auto &reply) {
+ // clear promise member before emitting signals
+ auto promise2 = promise;
+ promise = nullptr;
+ if (reply.isSuccessful()) {
+ promise2->resolve(reply.body());
+ } else {
+ promise2->reject(reply.reasonPhrase());
+ }
+ });
+ return *promise;
+}
--- /dev/null
+#ifndef YTTHUMB_H
+#define YTTHUMB_H
+
+#include <QtCore>
+
+#include "variantpromise.h"
+
+class YTThumb {
+public:
+ YTThumb() {} // needed by QVector
+ YTThumb(int width, int height, const QString &url);
+ int getWidth() const { return width; }
+ int getHeight() const { return height; }
+ const QString &getUrl() const { return url; }
+
+ VariantPromise &load(QObject *parent);
+
+private:
+ int width;
+ int height;
+ QString url;
+ VariantPromise *promise = nullptr;
+};
+
+#endif // YTTHUMB_H
video->setDescription(snippet[QLatin1String("description")].toString());
QJsonObject thumbnails = snippet[QLatin1String("thumbnails")].toObject();
- QLatin1String url("url");
- video->setThumbnailUrl(thumbnails[QLatin1String("medium")].toObject()[url].toString());
- video->setMediumThumbnailUrl(thumbnails[QLatin1String("high")].toObject()[url].toString());
- video->setLargeThumbnailUrl(thumbnails[QLatin1String("standard")].toObject()[url].toString());
+ // TODO
video->setChannelTitle(snippet[QLatin1String("channelTitle")].toString());
return region;
}
-const YTRegion &YTRegions::worldwideRegion() {
- static const YTRegion region = {"", tr("Worldwide")};
+const YTRegion &YTRegions::defaultRegion() {
+ static const YTRegion region = {"US", tr("United States")};
return region;
}
}
const YTRegion &YTRegions::regionById(const QString &id) {
- if (id.isEmpty()) return worldwideRegion();
+ if (id.isEmpty()) return defaultRegion();
for (const YTRegion &r : list()) {
if (r.id == id) return r;
}
- return worldwideRegion();
+ return defaultRegion();
}
QIcon YTRegions::iconForRegionId(const QString ®ionId) {
public:
static const QVector<YTRegion> & list();
static const YTRegion & localRegion();
- static const YTRegion & worldwideRegion();
+ static const YTRegion & defaultRegion();
static void setRegion(const QString ®ionId);
static QString currentRegionId();
static const YTRegion ¤tRegion();