Compare commits
7
Commits
v25.08.1
...
work/library
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
188f4363c4 | ||
|
|
4c43b60714 | ||
|
|
7824d796b4 | ||
|
|
692df68464 | ||
|
|
45a3e9540f | ||
|
|
d2561d8f1f | ||
|
|
aa8c5fefca |
No files matched your search
+1
-1
@@ -33,7 +33,7 @@ ecm_setup_version(${PROJECT_VERSION}
|
||||
|
||||
kde_enable_exceptions()
|
||||
|
||||
find_package(Qt5 ${QT5_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml QuickControls2 Svg)
|
||||
find_package(Qt5 ${QT5_MIN_VERSION} REQUIRED COMPONENTS Core Gui Qml QuickControls2 Svg Sql)
|
||||
find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS Kirigami2 I18n CoreAddons Crash)
|
||||
find_package(pybind11 REQUIRED)
|
||||
find_package(Ytdlp REQUIRED RUNTIME)
|
||||
|
||||
+12
-1
@@ -10,6 +10,14 @@ target_link_libraries(ytm PUBLIC pybind11::embed)
|
||||
target_include_directories(ytm PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_definitions(ytm PRIVATE -DRANDALL_WAS_HERE)
|
||||
|
||||
add_library(asyncdatabase STATIC
|
||||
asyncdatabase.cpp
|
||||
asyncdatabase_p.h
|
||||
)
|
||||
|
||||
target_include_directories(asyncdatabase PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(asyncdatabase PUBLIC Qt5::Sql)
|
||||
|
||||
add_subdirectory(example)
|
||||
|
||||
add_executable(audiotube
|
||||
@@ -26,6 +34,7 @@ add_executable(audiotube
|
||||
playerutils.cpp
|
||||
abstractytmusicmodel.cpp
|
||||
multiiterableview.h
|
||||
library.cpp
|
||||
resources.qrc
|
||||
)
|
||||
|
||||
@@ -40,7 +49,9 @@ target_link_libraries(audiotube
|
||||
KF5::CoreAddons
|
||||
KF5::Crash
|
||||
ytm
|
||||
asyncdatabase
|
||||
)
|
||||
target_compile_definitions(audiotube PRIVATE -DQT_NO_KEYWORDS -DQT_NO_URL_CAST_FROM_STRING)
|
||||
|
||||
target_compile_definitions(audiotube PRIVATE -DQT_NO_KEYWORDS -DQT_NO_URL_CAST_FROM_STRING -DQT_QML_DEBUG)
|
||||
|
||||
install(TARGETS audiotube ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
|
||||
+13
-12
@@ -11,21 +11,22 @@ AlbumModel::AlbumModel(QObject *parent)
|
||||
{
|
||||
connect(this, &AlbumModel::browseIdChanged, this, [this] {
|
||||
setLoading(true);
|
||||
YTMusicThread::instance()->fetchAlbum(m_browseId);
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchAlbumFinished, this, [this](const album::Album &album) {
|
||||
setLoading(false);
|
||||
auto future = YTMusicThread::instance()->fetchAlbum(m_browseId);
|
||||
connectFuture(future, this, [=, this](const album::Album &album) {
|
||||
setLoading(false);
|
||||
|
||||
beginResetModel();
|
||||
m_album = album;
|
||||
endResetModel();
|
||||
std::sort(m_album.thumbnails.begin(), m_album.thumbnails.end());
|
||||
beginResetModel();
|
||||
m_album = album;
|
||||
endResetModel();
|
||||
std::sort(m_album.thumbnails.begin(), m_album.thumbnails.end());
|
||||
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT thumbnailUrlChanged();
|
||||
Q_EMIT playlistIdChanged();
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT thumbnailUrlChanged();
|
||||
Q_EMIT playlistIdChanged();
|
||||
});
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
|
||||
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [=, this] {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
+16
-16
@@ -17,26 +17,26 @@ ArtistModel::ArtistModel(QObject *parent)
|
||||
|
||||
setLoading(true);
|
||||
|
||||
YTMusicThread::instance()->fetchArtist(m_channelId);
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchArtistFinished, this, [this](const artist::Artist &artist) {
|
||||
setLoading(false);
|
||||
auto future = YTMusicThread::instance()->fetchArtist(m_channelId);
|
||||
connectFuture(future, this, [=, this](const artist::Artist &artist) {
|
||||
setLoading(false);
|
||||
|
||||
beginResetModel();
|
||||
m_artist = artist;
|
||||
std::sort(m_artist.thumbnails.begin(), m_artist.thumbnails.end());
|
||||
beginResetModel();
|
||||
m_artist = artist;
|
||||
std::sort(m_artist.thumbnails.begin(), m_artist.thumbnails.end());
|
||||
|
||||
albums = m_artist.albums ? m_artist.albums->results : std::vector<artist::Artist::Album>();
|
||||
singles = m_artist.singles ? m_artist.singles->results : std::vector<artist::Artist::Single>();
|
||||
songs = m_artist.songs ? m_artist.songs->results : std::vector<artist::Artist::Song>();
|
||||
videos = m_artist.videos ? m_artist.videos->results : std::vector<artist::Artist::Video>();
|
||||
albums = m_artist.albums ? m_artist.albums->results : std::vector<artist::Artist::Album>();
|
||||
singles = m_artist.singles ? m_artist.singles->results : std::vector<artist::Artist::Single>();
|
||||
songs = m_artist.songs ? m_artist.songs->results : std::vector<artist::Artist::Song>();
|
||||
videos = m_artist.videos ? m_artist.videos->results : std::vector<artist::Artist::Video>();
|
||||
|
||||
// std::span can't know if the data pointer underneath it was changed, so re-create
|
||||
m_view = MultiIterableView(albums, singles, songs, videos);
|
||||
endResetModel();
|
||||
// std::span can't know if the data pointer underneath it was changed, so re-create
|
||||
m_view = MultiIterableView(albums, singles, songs, videos);
|
||||
endResetModel();
|
||||
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT thumbnailUrlChanged();
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT thumbnailUrlChanged();
|
||||
});
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
|
||||
setLoading(false);
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#include "asyncdatabase.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QUrl>
|
||||
#include <QStringBuilder>
|
||||
#include <QVariant>
|
||||
#include <QSqlResult>
|
||||
#include <QSqlError>
|
||||
#include <QLoggingCategory>
|
||||
|
||||
#define SCHAMA_MIGRATIONS_TABLE "__qt_schema_migrations"
|
||||
|
||||
Q_DECLARE_LOGGING_CATEGORY(asyncdatabase)
|
||||
Q_LOGGING_CATEGORY(asyncdatabase, "asyncdatabase")
|
||||
|
||||
namespace asyncdatabase_private {
|
||||
|
||||
// migrations
|
||||
void createInternalTable(QSqlDatabase &database) {
|
||||
QSqlQuery query(QStringLiteral("create table if not exists " SCHAMA_MIGRATIONS_TABLE " ("
|
||||
"version Text primary key not null, "
|
||||
"run_on timestamp not null default current_timestamp)"), database);
|
||||
if (!query.exec()) {
|
||||
printSqlError(query);
|
||||
}
|
||||
}
|
||||
|
||||
void markMigrationRun(QSqlDatabase &database, const QString &name) {
|
||||
qCDebug(asyncdatabase) << "Marking migration" << name << "as done.";
|
||||
|
||||
QSqlQuery query(database);
|
||||
if (!query.prepare(QStringLiteral("insert into " SCHAMA_MIGRATIONS_TABLE " (version) values (:name)"))) {
|
||||
printSqlError(query);
|
||||
}
|
||||
query.bindValue(QStringLiteral(":name"), QVariant(name));
|
||||
if (!query.exec()) {
|
||||
printSqlError(query);
|
||||
}
|
||||
}
|
||||
|
||||
bool checkMigrationAlreadyRun(QSqlDatabase &database, const QString &name) {
|
||||
qCDebug(asyncdatabase) << "Checking whether migration" << name << "is already applied";
|
||||
QSqlQuery query(database);
|
||||
query.prepare(QStringLiteral("select count(*) from " SCHAMA_MIGRATIONS_TABLE " where version = :name"));
|
||||
query.bindValue(QStringLiteral(":name"), QVariant(name));
|
||||
query.exec();
|
||||
|
||||
query.next();
|
||||
int count = query.value(0).toInt();
|
||||
bool isApplied = count > 0;
|
||||
if (isApplied) {
|
||||
qCDebug(asyncdatabase) << "… yes";
|
||||
} else {
|
||||
qDebug(asyncdatabase) << "… no";
|
||||
}
|
||||
return isApplied;
|
||||
}
|
||||
|
||||
void runDatabaseMigrations(QSqlDatabase &database, const QString &migrationDirectory)
|
||||
{
|
||||
createInternalTable(database);
|
||||
|
||||
QDir dir(migrationDirectory);
|
||||
auto entries = dir.entryList(QDir::Filter::Dirs | QDir::Filter::NoDotAndDotDot, QDir::SortFlag::Name);
|
||||
|
||||
for (const auto &entry : entries) {
|
||||
QDir subdir(entry);
|
||||
if (!checkMigrationAlreadyRun(database, subdir.dirName())) {
|
||||
QFile file(migrationDirectory % QDir::separator() % entry % QDir::separator() % "up.sql");
|
||||
if (!file.open(QFile::ReadOnly)) {
|
||||
qCDebug(asyncdatabase) << "Failed to open migration file" << file.fileName();
|
||||
}
|
||||
qCDebug(asyncdatabase) << "Running migration" << subdir.dirName();
|
||||
|
||||
// Hackish
|
||||
const auto statements = file.readAll().split(';');
|
||||
|
||||
bool migrationSuccessful = true;
|
||||
for (const QByteArray &statement : statements) {
|
||||
const auto trimmedStatement = statement.trimmed();
|
||||
QSqlQuery query(database);
|
||||
|
||||
if (!trimmedStatement.isEmpty()) {
|
||||
qCDebug(asyncdatabase) << "Running" << trimmedStatement;
|
||||
if (!query.prepare(trimmedStatement)) {
|
||||
printSqlError(query);
|
||||
} else {
|
||||
bool success = query.exec();
|
||||
migrationSuccessful &= success;
|
||||
if (!success) {
|
||||
printSqlError(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (migrationSuccessful) {
|
||||
markMigrationRun(database, subdir.dirName());
|
||||
}
|
||||
}
|
||||
}
|
||||
qCDebug(asyncdatabase) << "Migrations finished";
|
||||
}
|
||||
|
||||
// Internal asynchronous database class
|
||||
QFuture<void> AsyncSqlDatabase::establishConnection(const DatabaseConfiguration &configuration)
|
||||
{
|
||||
return runAsync<void>([=, this] {
|
||||
m_database = std::make_unique<QSqlDatabase>(QSqlDatabase::addDatabase(configuration.type()));
|
||||
if (configuration.databaseName()) {
|
||||
m_database->setDatabaseName(*configuration.databaseName());
|
||||
}
|
||||
if (configuration.hostName()) {
|
||||
m_database->setHostName(*configuration.hostName());
|
||||
}
|
||||
if (configuration.userName()) {
|
||||
m_database->setUserName(*configuration.userName());
|
||||
}
|
||||
if (configuration.password()) {
|
||||
m_database->setPassword(*configuration.password());
|
||||
}
|
||||
|
||||
m_database->open();
|
||||
});
|
||||
}
|
||||
|
||||
AsyncSqlDatabase::AsyncSqlDatabase()
|
||||
: QObject()
|
||||
, m_database(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
Row AsyncSqlDatabase::retrieveRow(const QSqlQuery &query) {
|
||||
Row row;
|
||||
int i = 0;
|
||||
|
||||
while (true) {
|
||||
if (query.isValid()) {
|
||||
QVariant value = query.value(i);
|
||||
if (value.isValid()) {
|
||||
row.push_back(std::move(value));
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
Rows AsyncSqlDatabase::retrieveRows(QSqlQuery &query)
|
||||
{
|
||||
Rows rows;
|
||||
while (query.next()) {
|
||||
rows.push_back(retrieveRow(query));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
std::optional<Row> AsyncSqlDatabase::retrieveOptionalRow(QSqlQuery &query)
|
||||
{
|
||||
query.next();
|
||||
|
||||
if (query.isValid()) {
|
||||
return retrieveRow(query);
|
||||
} else {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
AsyncSqlDatabase::~AsyncSqlDatabase() = default;
|
||||
|
||||
void printSqlError(const QSqlQuery &query)
|
||||
{
|
||||
qCDebug(asyncdatabase) << "SQL error:" << query.lastError().text();
|
||||
}
|
||||
|
||||
QSqlQuery prepareQuery(const QSqlDatabase &database, const QString &sqlQuery)
|
||||
{
|
||||
qCDebug(asyncdatabase) << "Running" << sqlQuery;
|
||||
QSqlQuery query(database);
|
||||
if (!query.prepare(sqlQuery)) {
|
||||
printSqlError(query);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DatabaseConfigurationPrivate : public QSharedData {
|
||||
QString type;
|
||||
std::optional<QString> hostName;
|
||||
std::optional<QString> databaseName;
|
||||
std::optional<QString> userName;
|
||||
std::optional<QString> password;
|
||||
};
|
||||
|
||||
DatabaseConfiguration::DatabaseConfiguration() : d(new DatabaseConfigurationPrivate)
|
||||
{}
|
||||
|
||||
DatabaseConfiguration::~DatabaseConfiguration() = default;
|
||||
DatabaseConfiguration::DatabaseConfiguration(const DatabaseConfiguration &) = default;
|
||||
|
||||
void DatabaseConfiguration::setType(const QString &type) {
|
||||
d->type = type;
|
||||
}
|
||||
|
||||
const QString &DatabaseConfiguration::type() const {
|
||||
return d->type;
|
||||
}
|
||||
|
||||
void DatabaseConfiguration::setHostName(const QString &hostName) {
|
||||
d->hostName = hostName;
|
||||
}
|
||||
|
||||
const std::optional<QString> &DatabaseConfiguration::hostName() const {
|
||||
return d->hostName;
|
||||
}
|
||||
|
||||
void DatabaseConfiguration::setDatabaseName(const QString &databaseName) {
|
||||
d->databaseName = databaseName;
|
||||
}
|
||||
|
||||
const std::optional<QString> &DatabaseConfiguration::databaseName() const {
|
||||
return d->databaseName;
|
||||
}
|
||||
|
||||
void DatabaseConfiguration::setUserName(const QString &userName) {
|
||||
d->userName = userName;
|
||||
}
|
||||
|
||||
const std::optional<QString> &DatabaseConfiguration::userName() const {
|
||||
return d->userName;
|
||||
}
|
||||
|
||||
void DatabaseConfiguration::setPassword(const QString &password) {
|
||||
d->password = password;
|
||||
}
|
||||
|
||||
const std::optional<QString> &DatabaseConfiguration::password() const {
|
||||
return d->password;
|
||||
}
|
||||
|
||||
|
||||
std::unique_ptr<ThreadedDatabase> ThreadedDatabase::establishConnection(DatabaseConfiguration config) {
|
||||
auto threadedDb = std::make_unique<ThreadedDatabase>();
|
||||
threadedDb->setObjectName(QStringLiteral("database thread"));
|
||||
threadedDb->m_db->moveToThread(&*threadedDb);
|
||||
threadedDb->start();
|
||||
threadedDb->m_db->establishConnection(config);
|
||||
return threadedDb;
|
||||
}
|
||||
|
||||
ThreadedDatabase::ThreadedDatabase()
|
||||
: QThread()
|
||||
, m_db(std::make_unique<asyncdatabase_private::AsyncSqlDatabase>())
|
||||
{
|
||||
}
|
||||
|
||||
ThreadedDatabase::~ThreadedDatabase()
|
||||
{
|
||||
quit();
|
||||
wait();
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#pragma once
|
||||
|
||||
class QUrl;
|
||||
class QSqlDatabase;
|
||||
|
||||
#include <QString>
|
||||
#include <QObject>
|
||||
#include <QFuture>
|
||||
#include <QFutureWatcher>
|
||||
#include <QSqlQuery>
|
||||
#include <QDebug>
|
||||
#include <QThread>
|
||||
#include <QSharedDataPointer>
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include "asyncdatabase_p.h"
|
||||
|
||||
using Row = std::vector<QVariant>;
|
||||
using Rows = std::vector<Row>;
|
||||
|
||||
struct DatabaseConfigurationPrivate;
|
||||
|
||||
///
|
||||
/// Options for connecting to a database
|
||||
///
|
||||
class DatabaseConfiguration {
|
||||
public:
|
||||
DatabaseConfiguration();
|
||||
DatabaseConfiguration(const DatabaseConfiguration &);
|
||||
~DatabaseConfiguration();
|
||||
|
||||
/// Set the name of the database driver, for example DATABASE_TYPE_SQLITE or a custom string.
|
||||
void setType(const QString &type);
|
||||
const QString &type() const;
|
||||
|
||||
/// Set the hostname
|
||||
void setHostName(const QString &hostName);
|
||||
const std::optional<QString> &hostName() const;
|
||||
|
||||
/// Set the name of the database (path of the file for SQLite)
|
||||
void setDatabaseName(const QString &databaseName);
|
||||
const std::optional<QString> &databaseName() const;
|
||||
|
||||
/// Set user name
|
||||
void setUserName(const QString &userName);
|
||||
const std::optional<QString> &userName() const;
|
||||
|
||||
/// Set password
|
||||
void setPassword(const QString &password);
|
||||
const std::optional<QString> &password() const;
|
||||
|
||||
private:
|
||||
QSharedDataPointer<DatabaseConfigurationPrivate> d;
|
||||
};
|
||||
|
||||
///
|
||||
/// The SQLite database driver
|
||||
///
|
||||
const QString DATABASE_TYPE_SQLITE = QStringLiteral("QSQLITE");
|
||||
|
||||
///
|
||||
/// Parse the row into a tuple of the given types.
|
||||
/// The types need to be deserializable from QVariant.
|
||||
///
|
||||
template <typename ...RowTypes>
|
||||
auto parseRow(const Row &row) -> std::tuple<RowTypes...>
|
||||
{
|
||||
auto tuple = std::tuple<RowTypes...>();
|
||||
int i = 0;
|
||||
asyncdatabase_private::iterate_tuple(tuple, [&](auto &elem) {
|
||||
elem = row.at(i).value<std::decay_t<decltype(elem)>>();
|
||||
i++;
|
||||
});
|
||||
return tuple;
|
||||
}
|
||||
|
||||
///
|
||||
/// Parse the rows into a list of tuples of the given types.
|
||||
/// The types need to be deserializable from QVariant.
|
||||
///
|
||||
template <typename ...RowTypes>
|
||||
auto parseRows(const Rows &rows) -> std::vector<std::tuple<RowTypes...>> {
|
||||
std::vector<std::tuple<RowTypes...>> parsedRows;
|
||||
parsedRows.reserve(rows.size());
|
||||
for (const auto &row : rows) {
|
||||
parsedRows.push_back(parseRow<RowTypes...>(row));
|
||||
}
|
||||
|
||||
return parsedRows;
|
||||
}
|
||||
|
||||
class ThreadedDatabase : public QThread {
|
||||
public:
|
||||
///
|
||||
/// \brief Connect to a database
|
||||
/// \param configuration of the database connection
|
||||
/// \return
|
||||
///
|
||||
static std::unique_ptr<ThreadedDatabase> establishConnection(DatabaseConfiguration config);
|
||||
|
||||
///
|
||||
/// \brief Execute an SQL query on the database, ignoring the result.
|
||||
/// \param SQL query string to execute
|
||||
/// \param Parameters to bind to the placeholders in the SQL Query
|
||||
/// \return
|
||||
///
|
||||
template <typename ...Args>
|
||||
auto execute(const QString &sqlQuery, Args... args) -> QFuture<void> {
|
||||
return m_db->execute(sqlQuery, args...);
|
||||
}
|
||||
|
||||
///
|
||||
/// Run database migrations in the given directory.
|
||||
/// The directory needs to contain a subdirectory for each migration.
|
||||
/// The subdirectories need to be named so that when sorted alphabetically the migrations will be run in the correct order.
|
||||
/// Each subdirectory needs to contain a file named up.sql.
|
||||
///
|
||||
/// \param Directory which contains the migrations.
|
||||
/// \return
|
||||
///
|
||||
auto runMigrations(const QString &migrationDirectory) -> QFuture<void> {
|
||||
return m_db->runMigrations(migrationDirectory);
|
||||
}
|
||||
|
||||
///
|
||||
/// \brief Execute an SQL query on the database, retrieving the result.
|
||||
/// \param SQL Query to execute
|
||||
/// \param parameters to bind to the placeholders in the SQL query.
|
||||
/// \return Future of a list of lists of variants.
|
||||
///
|
||||
template <typename ...Args>
|
||||
auto getResults(const QString &sqlQuery, Args... args) -> QFuture<Rows> {
|
||||
return m_db->getResults(sqlQuery, args...);
|
||||
}
|
||||
|
||||
///
|
||||
/// \brief Like getResults, but for retrieving just one row.
|
||||
///
|
||||
template <typename ...Args>
|
||||
auto getResult(const QString &sqlQuery, Args... args) -> QFuture<std::optional<Row>> {
|
||||
return m_db->getResult(sqlQuery, args...);
|
||||
}
|
||||
|
||||
ThreadedDatabase();
|
||||
~ThreadedDatabase();
|
||||
|
||||
private:
|
||||
std::unique_ptr<asyncdatabase_private::AsyncSqlDatabase> m_db;
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include <optional>
|
||||
|
||||
#include <QFuture>
|
||||
#include <QFutureWatcher>
|
||||
#include <QSqlQuery>
|
||||
#include <QSqlDatabase>
|
||||
#include <QThread>
|
||||
|
||||
class DatabaseConfiguration;
|
||||
|
||||
namespace asyncdatabase_private {
|
||||
|
||||
// Helpers
|
||||
template <typename Tuple, typename Func, std::size_t i>
|
||||
inline constexpr void iterate_impl(Tuple &tup, Func fun)
|
||||
{
|
||||
if constexpr(i >= std::tuple_size_v<std::decay_t<decltype(tup)>>) {
|
||||
return;
|
||||
} else {
|
||||
fun(std::get<i>(tup));
|
||||
return asyncdatabase_private::iterate_impl<Tuple, Func, i + 1>(tup, fun);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Tuple, typename Func>
|
||||
inline constexpr void iterate_tuple(Tuple &tup, Func fun)
|
||||
{
|
||||
asyncdatabase_private::iterate_impl<Tuple, Func, 0>(tup, fun);
|
||||
}
|
||||
|
||||
template <typename T, typename QObjectDerivedType, typename Function>
|
||||
void connectFuture(const QFuture<T> &future, QObjectDerivedType *self, const Function &fun) {
|
||||
auto watcher = std::make_shared<QFutureWatcher<T>>();
|
||||
watcher->setFuture(future);
|
||||
QObject::connect(watcher.get(), &QFutureWatcherBase::finished, self, [self, watcher, fun, future] {
|
||||
if constexpr (std::is_same_v<void, T>) {
|
||||
if constexpr (std::is_member_function_pointer_v<Function>) {
|
||||
fun->*(self);
|
||||
} else {
|
||||
fun();
|
||||
}
|
||||
} else if (future.resultCount() > 0) {
|
||||
if constexpr (std::is_member_function_pointer_v<Function>) {
|
||||
(self->*fun)(watcher->result());
|
||||
} else {
|
||||
fun(watcher->result());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
using Row = std::vector<QVariant>;
|
||||
using Rows = std::vector<Row>;
|
||||
|
||||
void runDatabaseMigrations(QSqlDatabase &database, const QString &migrationDirectory);
|
||||
|
||||
void printSqlError(const QSqlQuery &query);
|
||||
|
||||
QSqlQuery prepareQuery(const QSqlDatabase &database, const QString &sqlQuery);
|
||||
|
||||
class AsyncSqlDatabase : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
QFuture<void> establishConnection(const DatabaseConfiguration &configuration);
|
||||
|
||||
template <typename ...Args>
|
||||
auto getResults(const QString &sqlQuery, Args... args) -> QFuture<Rows> {
|
||||
return runAsync<Rows>([=, this] {
|
||||
auto query = executeQuery(sqlQuery, args...);
|
||||
return retrieveRows(query);
|
||||
});
|
||||
}
|
||||
|
||||
template <typename ...Args>
|
||||
auto getResult(const QString &sqlQuery, Args... args) -> QFuture<std::optional<Row>> {
|
||||
return runAsync<std::optional<Row>>([=, this] {
|
||||
auto query = executeQuery(sqlQuery, args...);
|
||||
return retrieveOptionalRow(query);
|
||||
});
|
||||
}
|
||||
|
||||
template <typename ...Args>
|
||||
auto execute(const QString &sqlQuery, Args... args) -> QFuture<void> {
|
||||
return runAsync<void>([=, this] {
|
||||
executeQuery(sqlQuery, args...);
|
||||
});
|
||||
}
|
||||
|
||||
auto runMigrations(const QString &migrationDirectory) -> QFuture<void> {
|
||||
return runAsync<void>([=, this] {
|
||||
runDatabaseMigrations(*m_database, migrationDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
AsyncSqlDatabase();
|
||||
~AsyncSqlDatabase();
|
||||
|
||||
private:
|
||||
template <typename ...Args>
|
||||
QSqlQuery executeQuery(const QString &sqlQuery, Args... args) {
|
||||
auto query = prepareQuery(*m_database, sqlQuery);
|
||||
auto argsTuple = std::make_tuple<Args...>(std::move(args)...);
|
||||
int i = 0;
|
||||
asyncdatabase_private::iterate_tuple(argsTuple, [&](auto &arg) {
|
||||
query.bindValue(i, arg);
|
||||
i++;
|
||||
});
|
||||
|
||||
if (!query.exec()) {
|
||||
printSqlError(query);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
template <typename T, typename Functor>
|
||||
QFuture<T> runAsync(Functor func) {
|
||||
auto interface = std::make_shared<QFutureInterface<T>>();
|
||||
QMetaObject::invokeMethod(this, [interface, func] {
|
||||
if constexpr (!std::is_same_v<T, void>) {
|
||||
auto result = func();
|
||||
interface->reportResult(result);
|
||||
} else {
|
||||
func();
|
||||
}
|
||||
|
||||
interface->reportFinished();
|
||||
});
|
||||
|
||||
return interface->future();
|
||||
}
|
||||
|
||||
Row retrieveRow(const QSqlQuery &query);
|
||||
Rows retrieveRows(QSqlQuery &query);
|
||||
std::optional<Row> retrieveOptionalRow(QSqlQuery &query);
|
||||
|
||||
std::unique_ptr<QSqlDatabase> m_database;
|
||||
};
|
||||
|
||||
}
|
||||
+35
-38
@@ -6,6 +6,9 @@
|
||||
|
||||
#include <QThread>
|
||||
#include <QDebug>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QFutureInterface>
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
@@ -13,6 +16,13 @@
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
#include <iostream>
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <ranges>
|
||||
#include <type_traits>
|
||||
#include <memory>
|
||||
|
||||
template <typename R, typename T, typename OP>
|
||||
std::optional<R> map_optional(const std::optional<T> &optional, OP op) {
|
||||
if (optional.has_value()) {
|
||||
@@ -49,95 +59,82 @@ AsyncYTMusic::AsyncYTMusic(QObject *parent)
|
||||
//
|
||||
// search
|
||||
//
|
||||
void AsyncYTMusic::search(const QString &query)
|
||||
QFuture<std::vector<search::SearchResultItem>> AsyncYTMusic::search(const QString &query)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT searchFinished(m_ytm->search(query.toStdString()));
|
||||
return invokeAndCatchOnThread<std::vector<search::SearchResultItem>>([=, this]() {
|
||||
return m_ytm->search(query.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// fetchArtist
|
||||
//
|
||||
void AsyncYTMusic::fetchArtist(const QString &channelId)
|
||||
QFuture<artist::Artist> AsyncYTMusic::fetchArtist(const QString &channelId)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT fetchArtistFinished(m_ytm->get_artist(channelId.toStdString()));
|
||||
return invokeAndCatchOnThread<artist::Artist>([=, this]() {
|
||||
return m_ytm->get_artist(channelId.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// fetchAlbum
|
||||
//
|
||||
void AsyncYTMusic::fetchAlbum(const QString &browseId)
|
||||
QFuture<album::Album> AsyncYTMusic::fetchAlbum(const QString &browseId)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT fetchAlbumFinished(m_ytm->get_album(browseId.toStdString()));
|
||||
return invokeAndCatchOnThread<album::Album>([=, this]() {
|
||||
return m_ytm->get_album(browseId.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// fetchSong
|
||||
//
|
||||
void AsyncYTMusic::fetchSong(const QString &videoId)
|
||||
QFuture<std::optional<song::Song>> AsyncYTMusic::fetchSong(const QString &videoId)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
auto maybeSong = m_ytm->get_song(videoId.toStdString());
|
||||
if (maybeSong.has_value()) {
|
||||
Q_EMIT fetchSongFinished(*maybeSong);
|
||||
}
|
||||
return invokeAndCatchOnThread<std::optional<song::Song>>([=, this]() {
|
||||
return m_ytm->get_song(videoId.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// fetchPlaylist
|
||||
//
|
||||
void AsyncYTMusic::fetchPlaylist(const QString &playlistId) {
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT fetchPlaylistFinished(m_ytm->get_playlist(playlistId.toStdString()));
|
||||
QFuture<playlist::Playlist> AsyncYTMusic::fetchPlaylist(const QString &playlistId) {
|
||||
return invokeAndCatchOnThread<playlist::Playlist>([=, this]() {
|
||||
return m_ytm->get_playlist(playlistId.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// fetchArtistAlbum
|
||||
//
|
||||
void AsyncYTMusic::fetchArtistAlbums(const QString &channelId, const QString ¶ms)
|
||||
QFuture<std::vector<artist::Artist::Album>> AsyncYTMusic::fetchArtistAlbums(const QString &channelId, const QString ¶ms)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT fetchArtistAlbumsFinished(m_ytm->get_artist_albums(channelId.toStdString(), params.toStdString()));
|
||||
return invokeAndCatchOnThread<std::vector<artist::Artist::Album>>([=, this]() {
|
||||
return m_ytm->get_artist_albums(channelId.toStdString(), params.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// extractVideoInfo
|
||||
//
|
||||
void AsyncYTMusic::extractVideoInfo(const QString &videoId)
|
||||
QFuture<video_info::VideoInfo> AsyncYTMusic::extractVideoInfo(const QString &videoId)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT extractVideoInfoFinished(m_ytm->extract_video_info(videoId.toStdString()));
|
||||
return invokeAndCatchOnThread<video_info::VideoInfo>([=, this]() {
|
||||
return m_ytm->extract_video_info(videoId.toStdString());
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// fetchWatchPlaylist
|
||||
//
|
||||
void AsyncYTMusic::fetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
|
||||
QFuture<watch::Playlist> AsyncYTMusic::fetchWatchPlaylist(const std::optional<QString> &videoId, const std::optional<QString> &playlistId)
|
||||
{
|
||||
invokeAndCatchOnThread([=, this]() {
|
||||
Q_EMIT fetchWatchPlaylistFinished(m_ytm->get_watch_playlist(
|
||||
return invokeAndCatchOnThread<watch::Playlist>([=, this]() {
|
||||
return m_ytm->get_watch_playlist(
|
||||
map_optional<std::string>(videoId, &QString::toStdString),
|
||||
map_optional<std::string>(playlistId, &QString::toStdString)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
void AsyncYTMusic::invokeAndCatchOnThread(const std::function<void()> &fun) {
|
||||
QMetaObject::invokeMethod(this, [=, this]() {
|
||||
try {
|
||||
fun();
|
||||
} catch (const py::error_already_set &err) {
|
||||
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+69
-17
@@ -6,6 +6,8 @@
|
||||
|
||||
#include <QObject>
|
||||
#include <QThread>
|
||||
#include <QFuture>
|
||||
#include <QFutureWatcher>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -45,6 +47,50 @@ private:
|
||||
std::unique_ptr<T> m_item = nullptr;
|
||||
};
|
||||
|
||||
///
|
||||
/// Similar to QObject::connect, except that the first two arguments are replaced with a QFuture.
|
||||
///
|
||||
template <typename T, typename QObjectDerivedType, typename Function>
|
||||
void connectFuture(const QFuture<T> &future, QObjectDerivedType *self, const Function &fun) {
|
||||
auto watcher = std::make_shared<QFutureWatcher<T>>();
|
||||
watcher->setFuture(future);
|
||||
QObject::connect(watcher.get(), &QFutureWatcherBase::finished, self, [self, watcher, fun, future] {
|
||||
if constexpr (std::is_same_v<void, T>) {
|
||||
if constexpr (std::is_member_function_pointer_v<Function>) {
|
||||
(self->*fun)();
|
||||
} else {
|
||||
fun();
|
||||
}
|
||||
} else if (future.resultCount() > 0) {
|
||||
if constexpr (std::is_member_function_pointer_v<Function>) {
|
||||
(self->*fun)(watcher->result());
|
||||
} else {
|
||||
fun(watcher->result());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
///
|
||||
/// Applies a function to a the contained value of a QFuture once it finishes.
|
||||
/// @returns a QFuture that finishes once the future given as first argument finished
|
||||
///
|
||||
template <typename T, typename U, typename Func>
|
||||
QFuture<U> mapFuture(const QFuture<T> &future, Func mapFunction) {
|
||||
auto watcher = std::make_shared<QFutureWatcher<T>>();
|
||||
watcher->setFuture(future);
|
||||
|
||||
auto interface = std::make_shared<QFutureInterface<U>>();
|
||||
QObject::connect(watcher.get(), &QFutureWatcherBase::finished, watcher.get(), [interface, watcher, mapFunction] {
|
||||
auto result = watcher->result();
|
||||
auto mapped = mapFunction(std::move(result));
|
||||
interface->reportResult(mapped);
|
||||
interface->reportFinished();
|
||||
});
|
||||
|
||||
return interface->future();
|
||||
}
|
||||
|
||||
class AsyncYTMusic : public QObject
|
||||
{
|
||||
friend class YTMusicThread;
|
||||
@@ -53,30 +99,22 @@ class AsyncYTMusic : public QObject
|
||||
|
||||
public:
|
||||
// public functions need to be thread safe
|
||||
void search(const QString &query);
|
||||
Q_SIGNAL void searchFinished(std::vector<search::SearchResultItem>);
|
||||
QFuture<std::vector<search::SearchResultItem>> search(const QString &query);
|
||||
|
||||
void fetchArtist(const QString &channelId);
|
||||
Q_SIGNAL void fetchArtistFinished(artist::Artist);
|
||||
QFuture<artist::Artist> fetchArtist(const QString &channelId);
|
||||
|
||||
void fetchAlbum(const QString &browseId);
|
||||
Q_SIGNAL void fetchAlbumFinished(album::Album);
|
||||
QFuture<album::Album> fetchAlbum(const QString &browseId);
|
||||
|
||||
void fetchSong(const QString &videoId);
|
||||
Q_SIGNAL void fetchSongFinished(song::Song);
|
||||
QFuture<std::optional<song::Song> > fetchSong(const QString &videoId);
|
||||
|
||||
void fetchPlaylist(const QString &playlistId);
|
||||
Q_SIGNAL void fetchPlaylistFinished(playlist::Playlist);
|
||||
QFuture<playlist::Playlist> fetchPlaylist(const QString &playlistId);
|
||||
|
||||
void fetchArtistAlbums(const QString &channelId, const QString ¶ms);
|
||||
Q_SIGNAL void fetchArtistAlbumsFinished(std::vector<artist::Artist::Album>);
|
||||
QFuture<std::vector<artist::Artist::Album>> fetchArtistAlbums(const QString &channelId, const QString ¶ms);
|
||||
|
||||
void extractVideoInfo(const QString &videoId);
|
||||
Q_SIGNAL void extractVideoInfoFinished(video_info::VideoInfo);
|
||||
QFuture<video_info::VideoInfo> extractVideoInfo(const QString &videoId);
|
||||
|
||||
void fetchWatchPlaylist(const std::optional<QString> &videoId = std::nullopt ,
|
||||
QFuture<watch::Playlist> fetchWatchPlaylist(const std::optional<QString> &videoId = std::nullopt ,
|
||||
const std::optional<QString> &playlistId = std::nullopt);
|
||||
Q_SIGNAL void fetchWatchPlaylistFinished(watch::Playlist);
|
||||
|
||||
Q_SIGNAL void errorOccurred(const QString &error);
|
||||
|
||||
@@ -85,7 +123,21 @@ protected:
|
||||
|
||||
private:
|
||||
/// Invokes the given function on the thread of the YTMusic object, and handles exceptions that occur while invoking it.
|
||||
void invokeAndCatchOnThread(const std::function<void()> &fun);
|
||||
template <typename ReturnType>
|
||||
QFuture<ReturnType> invokeAndCatchOnThread(const std::function<ReturnType()> &fun) {
|
||||
auto interface = std::make_shared<QFutureInterface<ReturnType>>();
|
||||
QMetaObject::invokeMethod(this, [=, this]() {
|
||||
try {
|
||||
ReturnType val = fun();
|
||||
interface->reportResult(val);
|
||||
interface->reportFinished();
|
||||
} catch (const std::exception &err) {
|
||||
interface->reportFinished();
|
||||
Q_EMIT errorOccurred(QString::fromLocal8Bit(err.what()));
|
||||
}
|
||||
});
|
||||
return interface->future();
|
||||
}
|
||||
|
||||
// Python interpreter will be initialized from the thread calling the methods
|
||||
Lazy<YTMusic> m_ytm;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
import QtQuick 2.1
|
||||
import QtQuick.Controls 2.12 as Controls
|
||||
import QtQuick.Layouts 1.3
|
||||
import org.kde.kirigami 2.14 as Kirigami
|
||||
|
||||
import org.kde.ytmusic 1.0
|
||||
|
||||
Kirigami.ScrollablePage {
|
||||
ColumnLayout {
|
||||
Kirigami.Heading {
|
||||
text: i18n("Favourites")
|
||||
}
|
||||
Controls.ScrollView {
|
||||
Layout.fillWidth: true
|
||||
RowLayout {
|
||||
Repeater {
|
||||
Layout.fillWidth: true
|
||||
model: Library.favourites
|
||||
delegate: ColumnLayout {
|
||||
id: delegateItem
|
||||
required property string title
|
||||
required property string artist
|
||||
required property string videoId
|
||||
|
||||
Layout.fillWidth: false
|
||||
Layout.maximumWidth: 200
|
||||
Kirigami.Card {
|
||||
id: card
|
||||
Layout.preferredHeight: 200
|
||||
|
||||
onClicked: play(delegateItem.videoId)
|
||||
|
||||
ThumbnailSource {
|
||||
id: thumbnailSource
|
||||
videoId: delegateItem.videoId
|
||||
}
|
||||
|
||||
header: Image {
|
||||
sourceSize: "200x200"
|
||||
source: thumbnailSource.cachedPath
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
}
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
icon.name: "delete"
|
||||
onTriggered: Library.removeFavourite(delegateItem.videoId)
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
Controls.Label {
|
||||
Layout.maximumWidth: 200
|
||||
text: delegateItem.title
|
||||
elide: Qt.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Kirigami.Heading {
|
||||
text: i18n("Most played")
|
||||
}
|
||||
Controls.ScrollView {
|
||||
Layout.fillWidth: true
|
||||
RowLayout {
|
||||
Repeater {
|
||||
Layout.fillWidth: true
|
||||
model: Library.mostPlayed
|
||||
delegate: ColumnLayout {
|
||||
id: mpdelegateItem
|
||||
required property string title
|
||||
required property string artist
|
||||
required property string videoId
|
||||
|
||||
Layout.fillWidth: false
|
||||
Layout.maximumWidth: 200
|
||||
Kirigami.Card {
|
||||
id: mpcard
|
||||
Layout.preferredHeight: 200
|
||||
|
||||
onClicked: play(mpdelegateItem.videoId)
|
||||
|
||||
ThumbnailSource {
|
||||
id: mpthumbnailSource
|
||||
videoId: mpdelegateItem.videoId
|
||||
}
|
||||
|
||||
header: Image {
|
||||
sourceSize: "200x200"
|
||||
source: mpthumbnailSource.cachedPath
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
}
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
icon.name: "delete"
|
||||
onTriggered: Library.removeFavourite(mpdelegateItem.videoId)
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
Controls.Label {
|
||||
Layout.maximumWidth: 200
|
||||
text: mpdelegateItem.title
|
||||
elide: Qt.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ Item {
|
||||
anchors.fill: footerLayout
|
||||
}
|
||||
|
||||
|
||||
ColumnLayout {
|
||||
id: footerLayout
|
||||
height: parent.height
|
||||
@@ -38,20 +39,37 @@ Item {
|
||||
flow: width > height ? GridLayout.LeftToRight : GridLayout.TopToBottom
|
||||
visible: footerItem.maximized
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: info.thumbnail
|
||||
Layout.fillHeight: info.thumbnail
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
ColumnLayout {
|
||||
Layout.preferredWidth: footerLayout.width * (playerLayout.mobile ? 1 : 0.5)
|
||||
Layout.preferredHeight: footerLayout.height * (playerLayout.mobile ? 0.5 : 1)
|
||||
|
||||
source: info.thumbnail
|
||||
fillMode: Image.PreserveAspectFit
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: info.thumbnail
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
}
|
||||
Controls.RoundButton {
|
||||
readonly property QtObject favouriteWatcher: Library.favouriteWatcher(info.videoId)
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
icon.name: favouriteWatcher ? (favouriteWatcher.isFavourite ? "starred-symbolic" : "non-starred-symbolic") : ""
|
||||
onClicked: if (favouriteWatcher)
|
||||
favouriteWatcher.isFavourite
|
||||
? Library.removeFavourite(info.videoId)
|
||||
: Library.addFavourite(info.videoId, info.title)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: footerLayout.width * (playerLayout.mobile ? 1 : 0.5)
|
||||
Layout.preferredHeight: footerLayout.height * (playerLayout.mobile ? 0.5 : 1)
|
||||
|
||||
Controls.ScrollView {
|
||||
anchors.fill: parent
|
||||
@@ -151,6 +169,7 @@ Item {
|
||||
|
||||
onSongChanged: audio.play()
|
||||
videoId: UserPlaylistModel.currentVideoId
|
||||
onTitleChanged: Library.addPlaybackHistoryItem(info.videoId, info.title)
|
||||
}
|
||||
|
||||
Audio {
|
||||
|
||||
@@ -36,10 +36,51 @@ Kirigami.ApplicationWindow {
|
||||
selectByMouse: true
|
||||
Layout.fillWidth: true
|
||||
Layout.maximumWidth: 400
|
||||
onPressed: {
|
||||
popup.open()
|
||||
}
|
||||
|
||||
Controls.Popup {
|
||||
id: popup
|
||||
x: searchField.y
|
||||
y: searchField.y + searchField.height
|
||||
visible: true
|
||||
width: searchField.width
|
||||
height: completionList
|
||||
? Math.min(completionList.count * Kirigami.Units.gridUnit * 2 + Kirigami.Units.gridUnit * 2, Kirigami.Units.gridUnit * 20)
|
||||
: Kirigami.Units.gridUnit * 20
|
||||
|
||||
contentItem: Controls.ScrollView {
|
||||
Controls.ScrollBar.horizontal.policy: Controls.ScrollBar.AlwaysOff
|
||||
ListView {
|
||||
id: completionList
|
||||
model: Library.searches
|
||||
delegate: Controls.ItemDelegate {
|
||||
id: completionDelegate
|
||||
width: parent.width
|
||||
height: Kirigami.Units.gridUnit * 2
|
||||
text: model.display
|
||||
onClicked: {
|
||||
searchField.text = model.display
|
||||
searchField.accepted()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
pageStack.clear()
|
||||
pageStack.push("qrc:/SearchPage.qml", {
|
||||
"searchQuery": text})
|
||||
|
||||
if (text) {
|
||||
Library.addSearch(text)
|
||||
pageStack.push("qrc:/SearchPage.qml", {
|
||||
"searchQuery": text})
|
||||
} else {
|
||||
pageStack.replace("qrc:/LibraryPage.qml")
|
||||
}
|
||||
|
||||
popup.close()
|
||||
}
|
||||
}
|
||||
Item {
|
||||
@@ -56,7 +97,7 @@ Kirigami.ApplicationWindow {
|
||||
id: contextDrawer
|
||||
}
|
||||
|
||||
pageStack.initialPage: "qrc:/SearchPage.qml"
|
||||
pageStack.initialPage: "qrc:/LibraryPage.qml"
|
||||
|
||||
function play(videoId) {
|
||||
UserPlaylistModel.initialVideoId = videoId
|
||||
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
// SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#include "library.h"
|
||||
|
||||
#include <QStandardPaths>
|
||||
#include <QDir>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QStringBuilder>
|
||||
|
||||
#include "asyncdatabase.h"
|
||||
|
||||
namespace ranges = std::ranges;
|
||||
|
||||
Library::Library(QObject *parent)
|
||||
: QObject{parent}
|
||||
, m_database(ThreadedDatabase::establishConnection([]() -> DatabaseConfiguration {
|
||||
DatabaseConfiguration config;
|
||||
config.setDatabaseName(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) % QDir::separator() % "library.sqlite");
|
||||
config.setType(DATABASE_TYPE_SQLITE);
|
||||
return config;
|
||||
}()))
|
||||
{
|
||||
m_database->runMigrations(":/migrations/");
|
||||
}
|
||||
|
||||
Library::~Library() = default;
|
||||
|
||||
Library &Library::instance()
|
||||
{
|
||||
static Library inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
FavouritesModel *Library::favourites()
|
||||
{
|
||||
auto future = mapFuture<Rows, std::vector<Song>>(
|
||||
m_database->getResults("select * from favourites natural join songs"),
|
||||
[](auto rows)
|
||||
{
|
||||
std::vector<Song> songs;
|
||||
ranges::transform(parseRows<QString, QString, QString, QString>(rows),
|
||||
std::back_inserter(songs),
|
||||
Song::fromSql);
|
||||
return songs;
|
||||
});
|
||||
return new FavouritesModel(std::move(future), this);
|
||||
}
|
||||
|
||||
void Library::addFavourite(const QString &videoId, const QString &title)
|
||||
{
|
||||
connectFuture(addSong(videoId, title), this, [=, this] {
|
||||
connectFuture(m_database->execute("insert or ignore into favourites (video_id) values (?)", videoId), this, &Library::favouritesChanged);
|
||||
});
|
||||
}
|
||||
|
||||
void Library::removeFavourite(const QString &videoId)
|
||||
{
|
||||
connectFuture(m_database->execute("delete from favourites where video_id = ?", videoId), this, &Library::favouritesChanged);
|
||||
}
|
||||
|
||||
FavouriteWatcher *Library::favouriteWatcher(const QString &videoId)
|
||||
{
|
||||
if (videoId.isEmpty()) {
|
||||
return nullptr;
|
||||
}
|
||||
return new FavouriteWatcher(this, videoId);
|
||||
}
|
||||
|
||||
SearchHistoryModel *Library::searches()
|
||||
{
|
||||
return new SearchHistoryModel(mapFuture<Rows, std::vector<QString>>(
|
||||
m_database->getResults("select (search_query) from searches order by search_id desc"),
|
||||
[](const auto &rows)
|
||||
{
|
||||
std::vector<QString> out;
|
||||
ranges::transform(rows, std::back_inserter(out), [](auto row) {
|
||||
return row[0].toString();
|
||||
});
|
||||
return out;
|
||||
}), this);
|
||||
}
|
||||
|
||||
void Library::addSearch(const QString &text)
|
||||
{
|
||||
connectFuture(m_database->execute("insert into searches (search_query) values (?)", text), this, &Library::searchesChanged);
|
||||
}
|
||||
|
||||
PlaybackHistoryModel *Library::playbackHistory()
|
||||
{
|
||||
auto future = mapFuture<Rows, std::vector<PlayedSong>>(
|
||||
m_database->getResults("select * from played_songs natural join songs"),
|
||||
[](auto rows)
|
||||
{
|
||||
std::vector<PlayedSong> playedSongs;
|
||||
ranges::transform(parseRows<QString, int, QString, QString, QString>(rows),
|
||||
std::back_inserter(playedSongs),
|
||||
PlayedSong::fromSql);
|
||||
return playedSongs;
|
||||
});
|
||||
return new PlaybackHistoryModel(std::move(future), this);
|
||||
}
|
||||
|
||||
void Library::addPlaybackHistoryItem(const QString &videoId, const QString &title)
|
||||
{
|
||||
connectFuture(addSong(videoId, title), this, [=, this] {
|
||||
connectFuture(m_database->execute("insert or ignore into played_songs (video_id, plays) values (?, ?)", videoId, 0), this, [=, this] {
|
||||
connectFuture(m_database->execute("update played_songs set plays = plays + 1 where video_id = ? ", videoId), this, &Library::playbackHistoryChanged);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PlaybackHistoryModel *Library::mostPlayed()
|
||||
{
|
||||
auto future = mapFuture<Rows, std::vector<PlayedSong>>(
|
||||
m_database->getResults("select * from played_songs natural join songs order by plays desc limit 10"),
|
||||
[](auto rows)
|
||||
{
|
||||
std::vector<PlayedSong> playedSongs;
|
||||
ranges::transform(parseRows<QString, int, QString, QString, QString>(rows),
|
||||
std::back_inserter(playedSongs),
|
||||
PlayedSong::fromSql);
|
||||
return playedSongs;
|
||||
});
|
||||
return new PlaybackHistoryModel(std::move(future), this);
|
||||
}
|
||||
|
||||
QNetworkAccessManager &Library::nam()
|
||||
{
|
||||
return m_networkImageCacher;
|
||||
}
|
||||
|
||||
QFuture<void> Library::addSong(const QString &videoId, const QString &title)
|
||||
{
|
||||
return m_database->execute("insert or ignore into songs (video_id, title, artist, album) values (?, ?, null, null)", videoId, title);
|
||||
}
|
||||
|
||||
void ThumbnailSource::setVideoId(const QString &id) {
|
||||
if (m_videoId == id) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_videoId = id;
|
||||
Q_EMIT videoIdChanged();
|
||||
|
||||
const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) % QDir::separator() % "thumbnails";
|
||||
QDir(cacheDir).mkpath(QStringLiteral("."));
|
||||
const QString cacheLocation = cacheDir % QDir::separator() % id % ".webp";
|
||||
|
||||
if (QFile::exists(cacheLocation)) {
|
||||
setCachedPath(QUrl::fromLocalFile(cacheLocation));
|
||||
return;
|
||||
}
|
||||
|
||||
auto future = YTMusicThread::instance()->extractVideoInfo(id);
|
||||
connectFuture(future, this, [this, id, cacheLocation](video_info::VideoInfo &&info) {
|
||||
auto *reply = Library::instance().nam().get(QNetworkRequest(QUrl(QString::fromStdString(info.thumbnail))));
|
||||
connect(reply, &QNetworkReply::finished, this, [id, reply, this, cacheLocation]() {
|
||||
QFile file(cacheLocation);
|
||||
file.open(QFile::WriteOnly);
|
||||
file.write(reply->readAll());
|
||||
setCachedPath(QUrl::fromLocalFile(cacheLocation));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PlaybackHistoryModel::PlaybackHistoryModel(QFuture<std::vector<PlayedSong> > &&songs, QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
connectFuture(songs, this, [this](const auto songs) {
|
||||
beginResetModel();
|
||||
m_playedSongs = songs;
|
||||
endResetModel();
|
||||
});
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> PlaybackHistoryModel::roleNames() const {
|
||||
return {
|
||||
{Roles::VideoId, "videoId"},
|
||||
{Roles::Title, "title"},
|
||||
{Roles::Artist, "artist"},
|
||||
{Roles::Plays, "plays"}
|
||||
};
|
||||
}
|
||||
|
||||
int PlaybackHistoryModel::rowCount(const QModelIndex &parent) const {
|
||||
return parent.isValid() ? 0 : m_playedSongs.size();
|
||||
}
|
||||
|
||||
QVariant PlaybackHistoryModel::data(const QModelIndex &index, int role) const {
|
||||
switch (role) {
|
||||
case Roles::VideoId:
|
||||
return m_playedSongs.at(index.row()).videoId;
|
||||
case Roles::Title:
|
||||
return m_playedSongs.at(index.row()).title;
|
||||
case Roles::Artist:
|
||||
return m_playedSongs.at(index.row()).artist;
|
||||
case Roles::Plays:
|
||||
return m_playedSongs.at(index.row()).plays;
|
||||
}
|
||||
|
||||
Q_UNREACHABLE();
|
||||
}
|
||||
|
||||
FavouritesModel::FavouritesModel(QFuture<std::vector<Song>> &&songs, QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
connectFuture(songs, this, [this](const auto songs) {
|
||||
beginResetModel();
|
||||
m_favouriteSongs = songs;
|
||||
endResetModel();
|
||||
});
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> FavouritesModel::roleNames() const {
|
||||
return {
|
||||
{Roles::VideoId, "videoId"},
|
||||
{Roles::Title, "title"},
|
||||
{Roles::Artist, "artist"}
|
||||
};
|
||||
}
|
||||
|
||||
int FavouritesModel::rowCount(const QModelIndex &parent) const {
|
||||
return parent.isValid() ? 0 : m_favouriteSongs.size();
|
||||
}
|
||||
|
||||
QVariant FavouritesModel::data(const QModelIndex &index, int role) const {
|
||||
switch (role) {
|
||||
case Roles::VideoId:
|
||||
return m_favouriteSongs.at(index.row()).videoId;
|
||||
case Roles::Title:
|
||||
return m_favouriteSongs.at(index.row()).title;
|
||||
case Roles::Artist:
|
||||
return m_favouriteSongs.at(index.row()).artist;
|
||||
}
|
||||
|
||||
Q_UNREACHABLE();
|
||||
}
|
||||
|
||||
FavouriteWatcher::FavouriteWatcher(Library *library, const QString &videoId)
|
||||
: QObject(library), m_videoId(videoId), m_library(library)
|
||||
{
|
||||
auto update = [this] {
|
||||
connectFuture(m_library->database().getResult("select count(*) from favourites where video_id = ?", m_videoId), this, [this](auto row) {
|
||||
if (row) {
|
||||
auto [favCount] = parseRow<int>(*row);
|
||||
m_isFavourite = favCount > 0;
|
||||
Q_EMIT isFavouriteChanged();
|
||||
}
|
||||
});
|
||||
};
|
||||
update();
|
||||
connect(library, &Library::favouritesChanged, this, update);
|
||||
}
|
||||
|
||||
bool FavouriteWatcher::isFavourite() const {
|
||||
return m_isFavourite;
|
||||
}
|
||||
|
||||
SearchHistoryModel::SearchHistoryModel(QFuture<std::vector<QString>> &&historyFuture, QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
{
|
||||
connectFuture(historyFuture, this, [this](const auto history) {
|
||||
beginResetModel();
|
||||
m_history = history;
|
||||
endResetModel();
|
||||
});
|
||||
}
|
||||
|
||||
int SearchHistoryModel::rowCount(const QModelIndex &parent) const {
|
||||
return parent.isValid() ? 0 : m_history.size();
|
||||
}
|
||||
|
||||
QVariant SearchHistoryModel::data(const QModelIndex &index, int role) const {
|
||||
switch (role) {
|
||||
case Qt::DisplayRole:
|
||||
return m_history[index.row()];
|
||||
}
|
||||
|
||||
Q_UNREACHABLE();
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QQuickAsyncImageProvider>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QAbstractListModel>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "asyncytmusic.h"
|
||||
#include "asyncdatabase.h"
|
||||
|
||||
class FavouriteWatcher;
|
||||
|
||||
struct Song {
|
||||
static Song fromSql(std::tuple<QString, QString, QString, QString> tuple) {
|
||||
auto [videoId, title, artist, album] = tuple;
|
||||
return Song {videoId, title, artist, album};
|
||||
}
|
||||
|
||||
QString videoId;
|
||||
QString title;
|
||||
QString artist;
|
||||
QString album;
|
||||
};
|
||||
|
||||
class FavouritesModel : public QAbstractListModel {
|
||||
Q_OBJECT
|
||||
|
||||
enum Roles {
|
||||
VideoId = Qt::UserRole + 1,
|
||||
Title,
|
||||
Artist,
|
||||
};
|
||||
|
||||
public:
|
||||
FavouritesModel(QFuture<std::vector<Song>> &&songs, QObject *parent = nullptr);
|
||||
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
int rowCount(const QModelIndex &parent) const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
|
||||
private:
|
||||
std::vector<Song> m_favouriteSongs;
|
||||
};
|
||||
|
||||
struct PlayedSong {
|
||||
static PlayedSong fromSql(std::tuple<QString, int, QString, QString, QString> tuple) {
|
||||
auto [videoId, plays, title, artist, album] = tuple;
|
||||
return PlayedSong {videoId, title, artist, album, plays};
|
||||
}
|
||||
|
||||
QString videoId;
|
||||
QString title;
|
||||
QString artist;
|
||||
QString album;
|
||||
int plays;
|
||||
};
|
||||
|
||||
class PlaybackHistoryModel : public QAbstractListModel {
|
||||
Q_OBJECT
|
||||
|
||||
enum Roles {
|
||||
VideoId = Qt::UserRole + 1,
|
||||
Title,
|
||||
Artist,
|
||||
Plays
|
||||
};
|
||||
|
||||
public:
|
||||
PlaybackHistoryModel(QFuture<std::vector<PlayedSong>> &&songs, QObject *parent = nullptr);
|
||||
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
int rowCount(const QModelIndex &parent) const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
|
||||
private:
|
||||
std::vector<PlayedSong> m_playedSongs;
|
||||
};
|
||||
|
||||
class SearchHistoryModel : public QAbstractListModel {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
SearchHistoryModel(QFuture<std::vector<QString> > &&historyFuture, QObject *parent = nullptr);
|
||||
int rowCount(const QModelIndex &parent) const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
|
||||
private:
|
||||
std::vector<QString> m_history;
|
||||
};
|
||||
|
||||
class Library : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QAbstractListModel *favourites READ favourites NOTIFY favouritesChanged)
|
||||
Q_PROPERTY(QAbstractListModel *searches READ searches NOTIFY searchesChanged)
|
||||
Q_PROPERTY(QAbstractListModel *playbackHistory READ playbackHistory NOTIFY playbackHistoryChanged)
|
||||
Q_PROPERTY(QAbstractListModel *mostPlayed READ mostPlayed NOTIFY playbackHistoryChanged)
|
||||
|
||||
public:
|
||||
explicit Library(QObject *parent = nullptr);
|
||||
~Library();
|
||||
|
||||
static Library &instance();
|
||||
|
||||
FavouritesModel *favourites();
|
||||
Q_SIGNAL void favouritesChanged();
|
||||
Q_INVOKABLE void addFavourite(const QString &videoId, const QString &title);
|
||||
Q_INVOKABLE void removeFavourite(const QString &videoId);
|
||||
Q_INVOKABLE FavouriteWatcher *favouriteWatcher(const QString &videoId);
|
||||
|
||||
SearchHistoryModel *searches();
|
||||
Q_SIGNAL void searchesChanged();
|
||||
Q_INVOKABLE void addSearch(const QString &text);
|
||||
|
||||
PlaybackHistoryModel *playbackHistory();
|
||||
Q_SIGNAL void playbackHistoryChanged();
|
||||
Q_INVOKABLE void addPlaybackHistoryItem(const QString &videoId, const QString &title);
|
||||
|
||||
PlaybackHistoryModel *mostPlayed();
|
||||
|
||||
QNetworkAccessManager &nam();
|
||||
ThreadedDatabase &database() {
|
||||
return *m_database;
|
||||
}
|
||||
|
||||
private:
|
||||
QFuture<void> addSong(const QString &videoId, const QString &title);
|
||||
|
||||
QNetworkAccessManager m_networkImageCacher;
|
||||
std::unique_ptr<ThreadedDatabase> m_database;
|
||||
};
|
||||
|
||||
|
||||
class ThumbnailSource : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(QString videoId READ videoId WRITE setVideoId NOTIFY videoIdChanged)
|
||||
Q_PROPERTY(QUrl cachedPath READ cachedPath NOTIFY cachedPathChanged)
|
||||
|
||||
QString videoId() const {
|
||||
return m_videoId;
|
||||
}
|
||||
void setVideoId(const QString &id);
|
||||
Q_SIGNAL void videoIdChanged();
|
||||
|
||||
QUrl cachedPath() const {
|
||||
return m_cachedPath;
|
||||
}
|
||||
void setCachedPath(const QUrl &path) {
|
||||
m_cachedPath = path;
|
||||
cachedPathChanged();
|
||||
}
|
||||
Q_SIGNAL void cachedPathChanged();
|
||||
|
||||
private:
|
||||
QString m_videoId;
|
||||
QUrl m_cachedPath;
|
||||
};
|
||||
|
||||
class FavouriteWatcher : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(bool isFavourite READ isFavourite NOTIFY isFavouriteChanged)
|
||||
|
||||
public:
|
||||
FavouriteWatcher(Library *library, const QString &videoId);
|
||||
|
||||
bool isFavourite() const;
|
||||
Q_SIGNAL void isFavouriteChanged();
|
||||
|
||||
Q_SIGNAL void videoIdChanged();
|
||||
|
||||
private:
|
||||
QString m_videoId;
|
||||
Library *m_library;
|
||||
bool m_isFavourite = false;
|
||||
};
|
||||
@@ -19,6 +19,9 @@
|
||||
#include "errorhandler.h"
|
||||
#include "playlistmodel.h"
|
||||
#include "playerutils.h"
|
||||
#include "library.h"
|
||||
|
||||
#include "asyncdatabase.h"
|
||||
|
||||
constexpr auto URI = "org.kde.ytmusic";
|
||||
|
||||
@@ -51,6 +54,9 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
|
||||
qmlRegisterSingletonType<PlayerUtils>(URI, 1, 0, "PlayerUtils", [](QQmlEngine *, QJSEngine *) {
|
||||
return new PlayerUtils();
|
||||
});
|
||||
qmlRegisterSingletonInstance<Library>(URI, 1, 0, "Library", &Library::instance());
|
||||
qmlRegisterType<ThumbnailSource>(URI, 1, 0, "ThumbnailSource");
|
||||
qmlRegisterAnonymousType<FavouriteWatcher>(URI, 1);
|
||||
|
||||
engine.rootContext()->setContextObject(new KLocalizedContext(&engine));
|
||||
engine.load(QUrl(QStringLiteral("qrc:///main.qml")));
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,8 @@
|
||||
-- SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
--
|
||||
-- SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
drop table songs;
|
||||
drop table played_songs;
|
||||
drop table favourites;
|
||||
drop table searches;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- SPDX-FileCopyrightText: 2022 Jonah Brüchert <[email protected]>
|
||||
--
|
||||
-- SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
create table songs (
|
||||
video_id Text primary key not null,
|
||||
title Text not null,
|
||||
artist Text,
|
||||
album Text
|
||||
);
|
||||
|
||||
create table played_songs (
|
||||
video_id Text primary key not null,
|
||||
plays Int not null,
|
||||
foreign key (video_id) references songs(video_id)
|
||||
);
|
||||
|
||||
create table favourites (
|
||||
video_id Text primary key not null,
|
||||
foreign key (video_id) references songs(video_id)
|
||||
);
|
||||
|
||||
create table searches (
|
||||
search_id integer primary key autoincrement not null,
|
||||
search_query Text not null
|
||||
);
|
||||
+10
-10
@@ -15,17 +15,17 @@ PlaylistModel::PlaylistModel(QObject *parent)
|
||||
{
|
||||
connect(this, &PlaylistModel::playlistIdChanged, this, [=, this] {
|
||||
setLoading(true);
|
||||
YTMusicThread::instance()->fetchPlaylist(m_playlistId);
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchPlaylistFinished, this, [this](const playlist::Playlist &playlist) {
|
||||
setLoading(false);
|
||||
beginResetModel();
|
||||
m_playlist = playlist;
|
||||
std::sort(m_playlist.thumbnails.begin(), m_playlist.thumbnails.end());
|
||||
endResetModel();
|
||||
auto future = YTMusicThread::instance()->fetchPlaylist(m_playlistId);
|
||||
connectFuture(future, this, [=, this](const playlist::Playlist &playlist) {
|
||||
setLoading(false);
|
||||
beginResetModel();
|
||||
m_playlist = playlist;
|
||||
std::sort(m_playlist.thumbnails.begin(), m_playlist.thumbnails.end());
|
||||
endResetModel();
|
||||
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT thumbnailUrlChanged();
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT thumbnailUrlChanged();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,5 +13,8 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
|
||||
<file alias="SearchPage.qml">contents/ui/SearchPage.qml</file>
|
||||
<file alias="PlaylistPage.qml">contents/ui/PlaylistPage.qml</file>
|
||||
<file alias="ConfirmationMessage.qml">contents/ui/ConfirmationMessage.qml</file>
|
||||
<file alias="LibraryPage.qml">contents/ui/LibraryPage.qml</file>
|
||||
<file>migrations/2022-05-20-194850_init/down.sql</file>
|
||||
<file>migrations/2022-05-20-194850_init/up.sql</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
+7
-11
@@ -16,17 +16,13 @@ SearchModel::SearchModel(QObject *parent)
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
YTMusicThread::instance()->search(m_searchQuery);
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::searchFinished, this, [this](const std::vector<search::SearchResultItem> &results) {
|
||||
beginResetModel();
|
||||
setLoading(false);
|
||||
m_searchResults = results;
|
||||
std::sort(m_searchResults.begin(), m_searchResults.end(),
|
||||
[](search::SearchResultItem const & a, search::SearchResultItem const & b) {
|
||||
return SearchModel::itemType(a) < SearchModel::itemType(b);
|
||||
});
|
||||
endResetModel();
|
||||
auto future = YTMusicThread::instance()->search(m_searchQuery);
|
||||
connectFuture(future, this, [=, this](const std::vector<search::SearchResultItem> &results) {
|
||||
beginResetModel();
|
||||
setLoading(false);
|
||||
m_searchResults = results;
|
||||
endResetModel();
|
||||
});
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
|
||||
setLoading(false);
|
||||
|
||||
+20
-17
@@ -9,29 +9,14 @@
|
||||
#include <random>
|
||||
|
||||
#include <QRandomGenerator>
|
||||
#include <QFutureWatcher>
|
||||
|
||||
#include "playlistutils.h"
|
||||
|
||||
UserPlaylistModel::UserPlaylistModel(QObject *parent)
|
||||
: AbstractYTMusicModel(parent)
|
||||
{
|
||||
connect(this, &UserPlaylistModel::initialVideoIdChanged, this, [this] {
|
||||
if (m_initialVideoId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
YTMusicThread::instance()->fetchWatchPlaylist(m_initialVideoId);
|
||||
});
|
||||
connect(this, &UserPlaylistModel::playlistIdChanged, this, [this] {
|
||||
if (m_playlistId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
YTMusicThread::instance()->fetchWatchPlaylist(std::nullopt, m_playlistId);
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::fetchWatchPlaylistFinished, this, [this](const watch::Playlist &playlist) {
|
||||
auto handleResult = [=, this](const watch::Playlist &playlist) {
|
||||
setLoading(false);
|
||||
|
||||
beginResetModel();
|
||||
@@ -48,6 +33,24 @@ UserPlaylistModel::UserPlaylistModel(QObject *parent)
|
||||
} else {
|
||||
setCurrentVideoId({});
|
||||
}
|
||||
};
|
||||
connect(this, &UserPlaylistModel::initialVideoIdChanged, this, [=, this] {
|
||||
if (m_initialVideoId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
auto future = YTMusicThread::instance()->fetchWatchPlaylist(m_initialVideoId);
|
||||
connectFuture(future, this, handleResult);
|
||||
});
|
||||
connect(this, &UserPlaylistModel::playlistIdChanged, this, [=, this] {
|
||||
if (m_playlistId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
auto future = YTMusicThread::instance()->fetchWatchPlaylist(std::nullopt, m_playlistId);
|
||||
connectFuture(future, this, handleResult);;
|
||||
});
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::errorOccurred, this, [this] {
|
||||
setLoading(false);
|
||||
|
||||
+16
-10
@@ -4,7 +4,10 @@
|
||||
|
||||
#include "videoinfoextractor.h"
|
||||
|
||||
#include <asyncytmusic.h>
|
||||
#include <QFutureWatcher>
|
||||
|
||||
#include "asyncytmusic.h"
|
||||
#include "library.h"
|
||||
|
||||
VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
|
||||
: QObject(parent)
|
||||
@@ -20,16 +23,16 @@ VideoInfoExtractor::VideoInfoExtractor(QObject *parent)
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
YTMusicThread::instance()->extractVideoInfo(QString::fromStdString(m_videoId.toStdString()));
|
||||
});
|
||||
|
||||
connect(&YTMusicThread::instance().get(), &AsyncYTMusic::extractVideoInfoFinished, this, [this](const video_info::VideoInfo &info) {
|
||||
m_videoInfo = info;
|
||||
setLoading(false);
|
||||
Q_EMIT audioUrlChanged();
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT songChanged();
|
||||
Q_EMIT thumbnailChanged();
|
||||
auto future = YTMusicThread::instance()->extractVideoInfo(QString::fromStdString(m_videoId.toStdString()));
|
||||
connectFuture(future, this, [this](const video_info::VideoInfo &videoInfo) {
|
||||
m_videoInfo = videoInfo;
|
||||
setLoading(false);
|
||||
Q_EMIT audioUrlChanged();
|
||||
Q_EMIT titleChanged();
|
||||
Q_EMIT songChanged();
|
||||
Q_EMIT thumbnailChanged();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,6 +69,9 @@ QString VideoInfoExtractor::videoId() const
|
||||
|
||||
void VideoInfoExtractor::setVideoId(const QString &videoId)
|
||||
{
|
||||
if (m_videoId == videoId || videoId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
m_videoId = videoId;
|
||||
Q_EMIT videoIdChanged();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user