From 4cabce37f971149d6954b6672dd70e41c8d9ff54 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sun, 10 Jan 2021 11:37:05 +0100 Subject: [PATCH 01/14] Add extension configuration --- CMakeLists.txt | 5 +++++ spotify/CMakeLists.txt | 20 ++++++++++++++++++++ spotify/metadata.json | 9 +++++++++ 3 files changed, 34 insertions(+) create mode 100644 spotify/CMakeLists.txt create mode 100644 spotify/metadata.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 2612682d..534e1cda 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,11 @@ if (BUILD_SNIPPETS) add_subdirectory(snippets) endif() +option(BUILD_SNIPPETS "Build the extension" ON) +if (BUILD_SNIPPETS) + add_subdirectory(spotify) +endif() + option(BUILD_SSH "Build the extension" ON) if (BUILD_SSH) add_subdirectory(ssh) diff --git a/spotify/CMakeLists.txt b/spotify/CMakeLists.txt new file mode 100644 index 00000000..8887f9eb --- /dev/null +++ b/spotify/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.1.3) + +project(spotify) + +file(GLOB_RECURSE SRC src/* metadata.json) + +find_package(Qt5 5.5.0 REQUIRED COMPONENTS Widgets) + +add_library(${PROJECT_NAME} SHARED ${SRC}) + +target_include_directories(${PROJECT_NAME} PRIVATE src/) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + Qt5::Widgets + albert::lib + xdg +) + +install(TARGETS ${PROJECT_NAME} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/albert/plugins) diff --git a/spotify/metadata.json b/spotify/metadata.json new file mode 100644 index 00000000..bc8db2d3 --- /dev/null +++ b/spotify/metadata.json @@ -0,0 +1,9 @@ +{ + "id" : "org.albert.extension.spotify", + "name" : "Spotify", + "version" : "1.0", + "platform" : "All", + "group" : "Extensions", + "author" : "Ivo Šmerek", + "dependencies" : [] +} From 093769a8d2733c0e35213112d6676dde6b3a2f9c Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sun, 10 Jan 2021 11:38:43 +0100 Subject: [PATCH 02/14] Add basic UI settings --- spotify/src/configwidget.cpp | 15 +++ spotify/src/configwidget.h | 16 +++ spotify/src/configwidget.ui | 190 +++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 spotify/src/configwidget.cpp create mode 100644 spotify/src/configwidget.h create mode 100644 spotify/src/configwidget.ui diff --git a/spotify/src/configwidget.cpp b/spotify/src/configwidget.cpp new file mode 100644 index 00000000..9f308b21 --- /dev/null +++ b/spotify/src/configwidget.cpp @@ -0,0 +1,15 @@ +// Copyright (C) 2014-2018 Manuel Schneider + +#include "configwidget.h" + +/** ***************************************************************************/ +Spotify::ConfigWidget::ConfigWidget(QWidget *parent) : QWidget(parent) { + ui.setupUi(this); +} + + + +/** ***************************************************************************/ +Spotify::ConfigWidget::~ConfigWidget() { + +} diff --git a/spotify/src/configwidget.h b/spotify/src/configwidget.h new file mode 100644 index 00000000..5fdd3ea0 --- /dev/null +++ b/spotify/src/configwidget.h @@ -0,0 +1,16 @@ +// Copyright (C) 2014-2018 Manuel Schneider + +#pragma once +#include +#include "ui_configwidget.h" + +namespace Spotify { +class ConfigWidget final : public QWidget +{ + Q_OBJECT +public: + explicit ConfigWidget(QWidget *parent = nullptr); + ~ConfigWidget(); + Ui::ConfigWidget ui; +}; +} diff --git a/spotify/src/configwidget.ui b/spotify/src/configwidget.ui new file mode 100644 index 00000000..13f8eb0a --- /dev/null +++ b/spotify/src/configwidget.ui @@ -0,0 +1,190 @@ + + + Spotify::ConfigWidget + + + + 0 + 0 + 448 + 325 + + + + + + + + 75 + true + + + + <html><head/><body><p>Web API connection</p></body></html> + + + + + + + + + Client ID: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Client Secret: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Refresh Token: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + + + + + + Test connection + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + <html><head/><body><p><span style=" font-weight:600;">User preferences</span></p></body></html> + + + + + + + + + true + + + + + + + + + + Number of results: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + 1 + + + 10 + + + 5 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Allow explicit content: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + lineEdit_client_id + lineEdit_client_secret + lineEdit_refresh_token + + + + From 4801540ee47389f0d4a491eb19221537e63304e7 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Tue, 12 Jan 2021 18:19:51 +0100 Subject: [PATCH 03/14] Add prototype --- spotify/src/device.h | 18 +++ spotify/src/extension.cpp | 245 +++++++++++++++++++++++++++++ spotify/src/extension.h | 37 +++++ spotify/src/spotifyWebAPI.cpp | 285 ++++++++++++++++++++++++++++++++++ spotify/src/spotifyWebAPI.h | 80 ++++++++++ spotify/src/track.h | 22 +++ 6 files changed, 687 insertions(+) create mode 100644 spotify/src/device.h create mode 100644 spotify/src/extension.cpp create mode 100644 spotify/src/extension.h create mode 100644 spotify/src/spotifyWebAPI.cpp create mode 100644 spotify/src/spotifyWebAPI.h create mode 100644 spotify/src/track.h diff --git a/spotify/src/device.h b/spotify/src/device.h new file mode 100644 index 00000000..b512dcc3 --- /dev/null +++ b/spotify/src/device.h @@ -0,0 +1,18 @@ +// Copyright (C) 2020-2021 Ivo Šmerek + +#include + +namespace Spotify { + + class Device { + + public: + Device() = default; + ~Device() = default; + + QString id; + QString name; + QString type; + bool isActive; + }; +} \ No newline at end of file diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp new file mode 100644 index 00000000..8e2dac78 --- /dev/null +++ b/spotify/src/extension.cpp @@ -0,0 +1,245 @@ +// Copyright (C) 2014-2021 Manuel Schneider, Ivo Šmerek + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "albert/util/standarditem.h" +#include "configwidget.h" +#include "extension.h" +#include "spotifyWebAPI.h" +#include "xdg/iconlookup.h" +Q_LOGGING_CATEGORY(qlc, "spotify") +#define DEBG qCDebug(qlc,).noquote() +#define INFO qCInfo(qlc,).noquote() +#define WARN qCWarning(qlc,).noquote() +#define CRIT qCCritical(qlc,).noquote() +using namespace Core; +using namespace std; + +namespace { + +} + +class Spotify::Private +{ +public: + QPointer widget; + QString clientId; + QString clientSecret; + QString refreshToken; + QString accessToken; + SpotifyWebAPI *api = new SpotifyWebAPI(); +}; + + +/** ***************************************************************************/ +Spotify::Extension::Extension() + : Core::Extension("org.albert.extension.spotify"), // Must match the id in metadata + Core::QueryHandler(Core::Plugin::id()), + d(new Private) { + + registerQueryHandler(this); + + d->clientId = settings().value("client_id").toString(); + d->clientSecret = settings().value("client_secret").toString(); + d->refreshToken = settings().value("refresh_token").toString(); + + + // You can throw in the constructor if something fatal happened + // throw std::runtime_error( "Description of error." ); + // throw std::string( "Description of error." ); + // throw QString( "Description of error." ); + // throw "Description of error."; + // throw; // Whatever prints "unknown error" +} + + + +/** ***************************************************************************/ +Spotify::Extension::~Extension() = default; + + + +/** ***************************************************************************/ +QWidget *Spotify::Extension::widget(QWidget *parent) { + if (d->widget.isNull()) { + d->widget = new ConfigWidget(parent); + } + + // Initialize the content and connect the signals + + d->widget->ui.lineEdit_client_id->setText(d->clientId); + connect(d->widget->ui.lineEdit_client_id, &QLineEdit::textEdited, [this](const QString &s){ + d->clientId = s; + settings().setValue("client_id", s); + }); + + d->widget->ui.lineEdit_client_secret->setText(d->clientSecret); + connect(d->widget->ui.lineEdit_client_secret, &QLineEdit::textEdited, [this](const QString &s){ + d->clientSecret = s; + settings().setValue("client_secret", s); + }); + + d->widget->ui.lineEdit_refresh_token->setText(d->refreshToken); + connect(d->widget->ui.lineEdit_refresh_token, &QLineEdit::textEdited, [this](const QString &s){ + d->refreshToken = s; + settings().setValue("refresh_token", s); + }); + + // Bind "Test connection" button + + connect(d->widget->ui.pushButton_test_connection, &QPushButton::clicked, [this](){ + d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); + + bool status = d->api->testConnection(); + + QString message = "Everything is set up correctly."; + if (!status) { + message = QString("Spotify Web API returns: \"%1\"\nPlease check all input fields.") + .arg(d->api->lastErrorMessage); + } + + auto messageBox = new QMessageBox(); + messageBox->setWindowTitle(status ? "Success" : "API error"); + messageBox->setText(message); + messageBox->setIcon(status ? QMessageBox::Information : QMessageBox::Critical); + messageBox->exec(); + }); + + return d->widget; +} + + + +/** ***************************************************************************/ +void Spotify::Extension::setupSession() { + d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); + + if(!QDir("/tmp/albert-spotify").exists()) { + QDir().mkdir("/tmp/albert-spotify-covers"); + } +} + + + +/** ***************************************************************************/ +void Spotify::Extension::teardownSession() { + +} + + + +/** ***************************************************************************/ +void Spotify::Extension::handleQuery(Core::Query * query) const { + + /* + * Things change so often I wont maintain this tutorial here. Check the relevant headers. + * + * - core/extension.h + * - core/queryhandler.h + * - core/query.h + * - core/item.h + * - core/action.h + * - util/standarditem.h + * - util/offlineindex.h + * - util/standardindexitem.h + * + * Use + * + * query->addMatch(my_item) + * + * to add matches. If you created a throw away item MOVE it instead of + * copying e.g.: + * + * query->addMatch(std::move(my_tmp_item)) + * + * The relevance factor is optional. (Defaults to 0) its a usigned integer depicting the + * relevance of the item 0 mean not relevant UINT_MAX is totally relevant (exact match). + * E.g. it the query is "it" and your items name is "item" + * + * my_item.name().startswith(query->string) + * + * is a naive match criterion and + * + * UINT_MAX / ( query.searchterm().size() / my_item.name().size() ) + * + * a naive match factor. + * + * If you have a lot of items use the iterator versions addMatches, e.g. like that + * + * query->addMatches(my_items.begin(), my_items.end()); + * + * If the items in the container are temporary object move them to avoid uneccesary + * reference counting: + * + * query->addMatches(std::make_move_iterator(my_tmp_items.begin()), + * std::make_move_iterator(my_tmp_items.end())); + */ + + if (query->string().trimmed().isEmpty()) + return; + + if (d->api->expired()) { + DEBG << "Token expired. Refreshing"; + d->api->refreshToken(); + } + + auto results = d->api->searchTrack(query->string(), "5"); + auto devices = d->api->getDevices(); + + for (const auto& track : results) { + auto filename = QString("%1/%2.jpeg").arg(COVERS_DIR_PATH, track.albumId); + + QFileInfo fileInfo(filename); + if (!fileInfo.exists()) { + d->api->downloadImage(track.imageUrl, filename); + } + + auto result = makeStdItem( + track.id, + filename, + QString("%1").arg(track.name), + QString("%1 (%2)").arg(track.albumName, track.artists), + ActionList { }, + "none", + Item::Urgency::Alert + ); + + auto playTrack = makeFuncAction("Play on active Spotify device", [this, track]() + { + d->api->play(track.uri); + }); + + auto addToQueue = makeFuncAction("Add to the Spotify queue", [this, track]() + { + d->api->addItemToQueue(track.uri); + }); + + result->addAction(playTrack); + result->addAction(addToQueue); + + for (auto device : devices) { + if (device.isActive) continue; + + auto action = makeFuncAction(QString("Play on %1 (%2)").arg(device.type, device.name), [this, track, device]() + { + d->api->play(track.uri, device.id); + }); + + result->addAction(action); + } + + query->addMatch(result, UINT_MAX); + } +} + diff --git a/spotify/src/extension.h b/spotify/src/extension.h new file mode 100644 index 00000000..28703ac2 --- /dev/null +++ b/spotify/src/extension.h @@ -0,0 +1,37 @@ +// Copyright (C) 2014-2021 Manuel Schneider, Ivo Šmerek + +#pragma once +#include +#include +#include +#include "albert/extension.h" +#include "albert/queryhandler.h" +Q_DECLARE_LOGGING_CATEGORY(qlc) + +namespace Spotify { + +class Private; + +class Extension final : + public Core::Extension, + public Core::QueryHandler +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID ALBERT_EXTENSION_IID FILE "metadata.json") + +public: + Extension(); + ~Extension() override; + + QString name() const override { return "Spotify"; } + QWidget *widget(QWidget *parent = nullptr) override; +// QStringList triggers() const override { return {"spotify "}; } + void setupSession() override; + void teardownSession() override; + void handleQuery(Core::Query * query) const override; + +private: + std::unique_ptr d; + QString COVERS_DIR_PATH = "/tmp/albert-spotify-covers"; +}; +} diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp new file mode 100644 index 00000000..babc2f4e --- /dev/null +++ b/spotify/src/spotifyWebAPI.cpp @@ -0,0 +1,285 @@ +// Copyright (C) 2020-2021 Ivo Šmerek + +#include +#include +#include +#include +#include +#include +#include +#include +#include "spotifyWebAPI.h" + +namespace Spotify { + + SpotifyWebAPI::SpotifyWebAPI() { + manager = new QNetworkAccessManager(); + } + + SpotifyWebAPI::~SpotifyWebAPI() = default; + + QJsonObject SpotifyWebAPI::answerToJson_(const QString& answer) { + QJsonDocument doc = QJsonDocument::fromJson(answer.toUtf8()); + QJsonObject jsonObject = doc.object(); + return jsonObject; + } + + void SpotifyWebAPI::setConnection(QString clientId, QString clientSecret, QString refreshToken) { + clientId_ = std::move(clientId); + clientSecret_ = std::move(clientSecret); + refreshToken_ = std::move(refreshToken); + } + + bool SpotifyWebAPI::refreshToken() { + auto *url = new QUrl(TOKEN_URL); + QNetworkRequest request(*url); + + auto hash = QString("%1:%2").arg(clientId_, clientSecret_).toUtf8().toBase64(); + auto header = QString("Basic ").append(hash); + request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QVariant(QString("application/x-www-form-urlencoded"))); + + QByteArray postData = QString("grant_type=refresh_token&refresh_token=%1").arg(refreshToken_).toLocal8Bit(); + + QString savedToken = accessToken_; + + QNetworkReply *reply = manager->post(request, postData); + + connect(reply, &QNetworkReply::finished, [this, reply]() { + QString answer = reply->readAll(); + QJsonObject jsonVariant = answerToJson_(answer); + + qDebug() << answer; + + accessToken_ = ""; + + if (!jsonVariant["access_token"].isUndefined()) { + accessToken_ = jsonVariant["access_token"].toString(); + qDebug() << "Expires in " << jsonVariant["expires_in"]; + expirationTime_ = QDateTime::currentDateTime().addSecs(jsonVariant["expires_in"].toInt()); + + emit tokenReplyReceived(); + emit tokenRefreshed(); + } + + if (!jsonVariant["error_description"].isUndefined()) { + lastErrorMessage = jsonVariant["error_description"].toString(); + } else { + lastErrorMessage = jsonVariant["error"].toString(); + } + + emit tokenReplyReceived(); + }); + + QEventLoop loop; + connect(this, SIGNAL(tokenReplyReceived()), &loop, SLOT(quit())); + loop.exec(); + + return !accessToken_.isEmpty() && savedToken != accessToken_; + } + + bool SpotifyWebAPI::testConnection() { + return refreshToken(); + } + + bool SpotifyWebAPI::expired() { + return QDateTime::currentDateTime() > expirationTime_; + } + + QVector SpotifyWebAPI::searchTrack(const QString& query, const QString& limit) { + auto *url = new QUrl(SEARCH_URL.arg(query, "track", limit)); + QNetworkRequest request(*url); + + auto header = QString("Bearer ").append(accessToken_); + request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request.setRawHeader(QByteArray("Accept"), "application/json"); + request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + + QNetworkReply *reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, [this, reply]() { + + QString answer = reply->readAll(); + QJsonObject jsonObject = answerToJson_(answer); + + itemResults_ = jsonObject["tracks"].toObject()["items"].toArray(); + + emit searchReplyReceived(); + }); + + QEventLoop loop; + connect(this, SIGNAL(searchReplyReceived()), &loop, SLOT(quit())); + loop.exec(); + + auto results = new QVector(); + + for (auto item : itemResults_) { + auto trackData = item.toObject(); + auto artists = trackData["artists"].toArray(); + qDebug() << item; + + Track track; + + QString artistsText = ""; + int counter = 0; + for (auto artist : artists) { + if (counter > 0) { + artistsText.append(", "); + } + artistsText.append(artist.toObject()["name"].toString()); + counter++; + } + + track.id = trackData["id"].toString(); + track.name = trackData["name"].toString(); + track.artists = artistsText; + track.albumId = trackData["album"].toObject()["id"].toString(); + track.albumName = trackData["album"].toObject()["name"].toString(); + track.uri = trackData["uri"].toString(); + track.imageUrl = trackData["album"].toObject()["images"].toArray()[2].toObject()["url"].toString(); + track.isExplicit = trackData["explicit"].toBool(); + + results->append(track); + } + + return *results; + } + + void SpotifyWebAPI::downloadImage(const QString& imageUrl, const QString& imageFilePath) { + QNetworkRequest request(imageUrl); + QNetworkReply *reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, [this, reply, imageFilePath]() { + QSaveFile file(imageFilePath); + file.open(QIODevice::WriteOnly); + file.write(reply->readAll()); + file.commit(); + emit imageReceived(); + }); + + QEventLoop loop; + connect(this, SIGNAL(imageReceived()), &loop, SLOT(quit())); + loop.exec(); + } + + void SpotifyWebAPI::addItemToQueue(const QString& uri) { + auto *url = new QUrl(ADD_ITEM_URL.arg(uri)); + QNetworkRequest request(*url); + + auto header = QString("Bearer ").append(accessToken_); + request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request.setRawHeader(QByteArray("Accept"), "application/json"); + request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + + QByteArray postData; + + QNetworkReply *reply = manager->post(request, postData); + + connect(reply, &QNetworkReply::finished, [this]() { + emit addedToQueue(); + }); + + QEventLoop loop; + connect(this, SIGNAL(addedToQueue()), &loop, SLOT(quit())); + loop.exec(); + } + + void SpotifyWebAPI::skipToNextTrack() { + auto *url = new QUrl(NEXT_TRACK_URL); + QNetworkRequest request(*url); + + auto header = QString("Bearer ").append(accessToken_); + request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request.setRawHeader(QByteArray("Accept"), "application/json"); + request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + + QByteArray postData; + + QNetworkReply *reply = manager->post(request, postData); + + connect(reply, &QNetworkReply::finished, [this]() { + emit skippedTrack(); + }); + + QEventLoop loop; + connect(this, SIGNAL(skippedTrack()), &loop, SLOT(quit())); + loop.exec(); + } + + void SpotifyWebAPI::play(const QString& uri, QString device) { + if (device.isEmpty()) { + device = activeDevice->id; + } + auto *url = new QUrl(PLAY_URL.arg(device)); + QNetworkRequest request(*url); + + auto header = QString("Bearer ").append(accessToken_); + request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request.setRawHeader(QByteArray("Accept"), "application/json"); + request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + + QByteArray postData = QString(R"({"uris": ["%1"]})").arg(uri).toLocal8Bit(); + + QNetworkReply *reply = manager->put(request, postData); + + connect(reply, &QNetworkReply::finished, [this, reply]() { + qDebug() << "Reply:"; + qDebug() << reply->readAll(); + emit played(); + }); + + QEventLoop loop; + connect(this, SIGNAL(played()), &loop, SLOT(quit())); + loop.exec(); + } + + QVector SpotifyWebAPI::getDevices() { + auto *url = new QUrl(DEVICES_URL); + QNetworkRequest request(*url); + + auto header = QString("Bearer ").append(accessToken_); + request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request.setRawHeader(QByteArray("Accept"), "application/json"); + request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + + QNetworkReply *reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, [this, reply]() { + + QString answer = reply->readAll(); + QJsonObject jsonObject = answerToJson_(answer); + + devicesResult_ = jsonObject["devices"].toArray(); + + emit searchReplyReceived(); + }); + + QEventLoop loop; + connect(this, SIGNAL(searchReplyReceived()), &loop, SLOT(quit())); + loop.exec(); + + QVector result; + + activeDevice = nullptr; + + for (auto item : devicesResult_) { + auto deviceData = item.toObject(); + + auto *device = new Device(); + + device->id = deviceData["id"].toString(); + device->name = deviceData["name"].toString(); + device->type = deviceData["type"].toString(); + device->isActive = deviceData["is_active"].toBool(); + + if (device->isActive) { + activeDevice = device; + } + + result.append(*device); + } + + return result; + } +} \ No newline at end of file diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h new file mode 100644 index 00000000..3dc59ab4 --- /dev/null +++ b/spotify/src/spotifyWebAPI.h @@ -0,0 +1,80 @@ +// Copyright (C) 2020-2021 Ivo Šmerek + +#pragma once +#include +#include +#include +#include +#include +#include "track.h" +#include "device.h" + +namespace Spotify { + +class SpotifyWebAPI : public QObject { +Q_OBJECT + +public: + SpotifyWebAPI(); + ~SpotifyWebAPI() override; + + QNetworkAccessManager *manager; + QString lastErrorMessage; + Device *activeDevice; + + // Set Web API credentials. Use testConnection for check. + void setConnection(QString clientId, QString clientSecret, QString refreshToken); + + // This method will try to refresh the access_token and return true if successful. + bool refreshToken(); + + // Does the same as refreshToken. Is here for better code readability. + // In case it returns false, you can find error message in lastErrorMessage variable. + bool testConnection(); + + // Returns true if the access_token has expired. Use refreshToken function to get new token. + bool expired(); + + // Returns QVector with tracks matching the search query. + QVector searchTrack(const QString& query, const QString& limit); + + // This method downloads image from imageUrl to imageFilePath. + void downloadImage(const QString& imageUrl, const QString& imageFilePath); + + void addItemToQueue(const QString& uri); + + void skipToNextTrack(); + + void play(const QString& uri, QString device = ""); + + QVector getDevices(); + +private: + QString TOKEN_URL = "https://accounts.spotify.com/api/token"; + QString SEARCH_URL = "https://api.spotify.com/v1/search?q=%1&type=%2&limit=%3"; + QString PLAY_URL = "https://api.spotify.com/v1/me/player/play?device_id=%1"; + QString ADD_ITEM_URL = "https://api.spotify.com/v1/me/player/queue?uri=%1"; + QString NEXT_TRACK_URL = "https://api.spotify.com/v1/me/player/next"; + QString DEVICES_URL = "https://api.spotify.com/v1/me/player/devices"; + + QString clientId_; + QString clientSecret_; + QString refreshToken_; + QString accessToken_; + QDateTime expirationTime_; + QJsonArray itemResults_; + QJsonArray devicesResult_; + + static QJsonObject answerToJson_(const QString& answer); + +signals: + void tokenRefreshed(); + void tokenReplyReceived(); + void searchReplyReceived(); + void imageReceived(); + void addedToQueue(); + void skippedTrack(); + void played(); +}; + +} \ No newline at end of file diff --git a/spotify/src/track.h b/spotify/src/track.h new file mode 100644 index 00000000..1ad003d1 --- /dev/null +++ b/spotify/src/track.h @@ -0,0 +1,22 @@ +// Copyright (C) 2020-2021 Ivo Šmerek + +#include + +namespace Spotify { + +class Track { + +public: + Track() = default; + ~Track() = default; + + QString id; + QString name; + QString artists; + QString albumId; + QString albumName; + QString uri; + QString imageUrl; + bool isExplicit = false; +}; +} \ No newline at end of file From e98f0e50bebb7a4f926817c886904c6a6494cf5f Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Mon, 18 Jan 2021 15:17:53 +0100 Subject: [PATCH 04/14] Add README.md --- spotify/README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 spotify/README.md diff --git a/spotify/README.md b/spotify/README.md new file mode 100644 index 00000000..3e374284 --- /dev/null +++ b/spotify/README.md @@ -0,0 +1,52 @@ +# Spotify extension + +The Spotify extension for Albert launcher allows searching +tracks on Spotify and playing them immediately or adding them to the +queue. It also allows choosing from user devices, where to play +the track. + +The extension uses the Spotify Web API. For the proper +functionality of extension, Spotify premium is required. + +## Web API connection + +#### 1. Get your Client ID and Client Secret + +Visit: https://developer.spotify.com/dashboard/applications and log +in with your Spotify account. + +Click on the button **Create an app** +and fill the form. You can use for example name "Albert" and +description "Spotify extension for Albert launcher". + +Once you +click on **Create**, your new application window will appear. You +can copy your **Client ID** and show **Client Secret**. +Both are 32-character strings. + +Click on **Edit settings** and add new **Redirect URI**. It doesn't +have to exist. In this example I will use: `https://nonexistent-uri.net/` + +#### 2. Get `code` parameter + +Open your browser and visit: https://accounts.spotify.com/cs/authorize?response_type=code&client_id=[[client_id]]&scope=user-modify-playback-state%20user-read-playback-state&redirect_uri=https://nonexistent-uri.net/ + +You have to replace `[[client_id]]` with your actual **Client ID**. + +When you press enter, you will get redirected to `https://nonexistent-uri.net/` with `code` in URL parameters. Copy that string and note it down for next usage. + +#### 3. Get your Refresh Token + +I will use `curl` for this last step. Replace or export all variables and run this command: + +``` +curl -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET -d grant_type=authorization_code -d code=$CODE -d redirect_uri=$REDIRECT_URI https://accounts.spotify.com/api/token +``` + +Use your Client ID, Client Secret and `code` from previous step. + +It will send POST request and return JSON in the answer. You can finally get your **Refresh Token**. + +
+ +Whole process is also similarly described [here](https://benwiz.com/blog/create-spotify-refresh-token/). \ No newline at end of file From 9782a032cf16f8d0198139b36d22f1c8ba1bf65c Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Mon, 18 Jan 2021 15:20:19 +0100 Subject: [PATCH 05/14] Update README.md --- spotify/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spotify/README.md b/spotify/README.md index 3e374284..dc561b96 100644 --- a/spotify/README.md +++ b/spotify/README.md @@ -10,7 +10,7 @@ functionality of extension, Spotify premium is required. ## Web API connection -#### 1. Get your Client ID and Client Secret +### 1. Get your Client ID and Client Secret Visit: https://developer.spotify.com/dashboard/applications and log in with your Spotify account. @@ -27,7 +27,7 @@ Both are 32-character strings. Click on **Edit settings** and add new **Redirect URI**. It doesn't have to exist. In this example I will use: `https://nonexistent-uri.net/` -#### 2. Get `code` parameter +### 2. Get `code` parameter Open your browser and visit: https://accounts.spotify.com/cs/authorize?response_type=code&client_id=[[client_id]]&scope=user-modify-playback-state%20user-read-playback-state&redirect_uri=https://nonexistent-uri.net/ @@ -35,12 +35,12 @@ You have to replace `[[client_id]]` with your actual **Client ID**. When you press enter, you will get redirected to `https://nonexistent-uri.net/` with `code` in URL parameters. Copy that string and note it down for next usage. -#### 3. Get your Refresh Token +### 3. Get your Refresh Token I will use `curl` for this last step. Replace or export all variables and run this command: ``` -curl -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET -d grant_type=authorization_code -d code=$CODE -d redirect_uri=$REDIRECT_URI https://accounts.spotify.com/api/token +curl -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET -d grant_type=authorization_code -d code=$CODE -d redirect_uri=https://nonexistent-uri.net/ https://accounts.spotify.com/api/token ``` Use your Client ID, Client Secret and `code` from previous step. From 7c595c99c921c00a5a46a53ae651f5415a0682ed Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sat, 23 Jan 2021 18:43:02 +0100 Subject: [PATCH 06/14] Fix white cover bug --- spotify/src/extension.cpp | 9 ++------- spotify/src/spotifyWebAPI.cpp | 18 ++++++++++++++---- spotify/src/spotifyWebAPI.h | 2 ++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 8e2dac78..5bfe2351 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -37,7 +37,6 @@ class Spotify::Private QString clientId; QString clientSecret; QString refreshToken; - QString accessToken; SpotifyWebAPI *api = new SpotifyWebAPI(); }; @@ -200,10 +199,7 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { for (const auto& track : results) { auto filename = QString("%1/%2.jpeg").arg(COVERS_DIR_PATH, track.albumId); - QFileInfo fileInfo(filename); - if (!fileInfo.exists()) { - d->api->downloadImage(track.imageUrl, filename); - } + d->api->downloadImage(track.imageUrl, filename); auto result = makeStdItem( track.id, @@ -241,5 +237,4 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { query->addMatch(result, UINT_MAX); } -} - +} \ No newline at end of file diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp index babc2f4e..811157e7 100644 --- a/spotify/src/spotifyWebAPI.cpp +++ b/spotify/src/spotifyWebAPI.cpp @@ -1,12 +1,13 @@ // Copyright (C) 2020-2021 Ivo Šmerek +#include +#include +#include #include #include #include -#include -#include -#include #include +#include #include #include "spotifyWebAPI.h" @@ -147,6 +148,14 @@ namespace Spotify { } void SpotifyWebAPI::downloadImage(const QString& imageUrl, const QString& imageFilePath) { + fileLock_.lockForWrite(); + + QFileInfo fileInfo(imageFilePath); + if (fileInfo.exists()) { + fileLock_.unlock(); + return; + } + QNetworkRequest request(imageUrl); QNetworkReply *reply = manager->get(request); @@ -161,6 +170,7 @@ namespace Spotify { QEventLoop loop; connect(this, SIGNAL(imageReceived()), &loop, SLOT(quit())); loop.exec(); + fileLock_.unlock(); } void SpotifyWebAPI::addItemToQueue(const QString& uri) { @@ -208,7 +218,7 @@ namespace Spotify { } void SpotifyWebAPI::play(const QString& uri, QString device) { - if (device.isEmpty()) { + if (device.isEmpty() && activeDevice) { device = activeDevice->id; } auto *url = new QUrl(PLAY_URL.arg(device)); diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index 3dc59ab4..2c2ec0da 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "track.h" #include "device.h" @@ -64,6 +65,7 @@ Q_OBJECT QDateTime expirationTime_; QJsonArray itemResults_; QJsonArray devicesResult_; + QReadWriteLock fileLock_; static QJsonObject answerToJson_(const QString& answer); From ac05931c6470985bd749a68a22f0f86de7111727 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Sat, 23 Jan 2021 19:43:55 +0100 Subject: [PATCH 07/14] Implement explicit content filter and results number limit --- spotify/src/extension.cpp | 22 +++++++++++++++++++++- spotify/src/spotifyWebAPI.h | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 5bfe2351..0d2d95a4 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -37,6 +37,8 @@ class Spotify::Private QString clientId; QString clientSecret; QString refreshToken; + bool explicitState; + int numberOfResults; SpotifyWebAPI *api = new SpotifyWebAPI(); }; @@ -52,6 +54,8 @@ Spotify::Extension::Extension() d->clientId = settings().value("client_id").toString(); d->clientSecret = settings().value("client_secret").toString(); d->refreshToken = settings().value("refresh_token").toString(); + d->explicitState = settings().value("explicit_state").toBool(); + d->numberOfResults = settings().value("number_or_results").toInt(); // You can throw in the constructor if something fatal happened @@ -95,6 +99,18 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { settings().setValue("refresh_token", s); }); + d->widget->ui.checkBox_explicit->setCheckState(d->explicitState ? Qt::CheckState::Checked : Qt::CheckState::Unchecked); + connect(d->widget->ui.checkBox_explicit, &QCheckBox::stateChanged, [this](const int s){ + d->explicitState = s; + settings().setValue("explicit_state", s); + }); + + d->widget->ui.spinBox_number_of_results->setValue(d->numberOfResults); + connect(d->widget->ui.spinBox_number_of_results, &QSpinBox::textChanged, [this](const QString &s){ + d->numberOfResults = s.toInt(); + settings().setValue("number_or_results", s); + }); + // Bind "Test connection" button connect(d->widget->ui.pushButton_test_connection, &QPushButton::clicked, [this](){ @@ -193,10 +209,14 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { d->api->refreshToken(); } - auto results = d->api->searchTrack(query->string(), "5"); + auto results = d->api->searchTrack(query->string(), QString("%1").arg(d->numberOfResults)); auto devices = d->api->getDevices(); for (const auto& track : results) { + if (track.isExplicit && !d->explicitState) { + continue; + } + auto filename = QString("%1/%2.jpeg").arg(COVERS_DIR_PATH, track.albumId); d->api->downloadImage(track.imageUrl, filename); diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index 2c2ec0da..b2253f8e 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -21,7 +21,7 @@ Q_OBJECT QNetworkAccessManager *manager; QString lastErrorMessage; - Device *activeDevice; + Device *activeDevice = nullptr; // Set Web API credentials. Use testConnection for check. void setConnection(QString clientId, QString clientSecret, QString refreshToken); From 786a66805bc5889d8a7de46e6c7f85899861eeca Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Tue, 26 Jan 2021 21:47:25 +0100 Subject: [PATCH 08/14] Local client execution, internet connection test, cleanup --- applications/CMakeLists.txt | 3 +- spotify/README.md | 30 +++-- spotify/src/configwidget.ui | 35 ++++-- spotify/src/device.h | 2 +- spotify/src/extension.cpp | 167 +++++++++++++++------------- spotify/src/extension.h | 3 +- spotify/src/spotifyWebAPI.cpp | 204 ++++++++++++++++------------------ spotify/src/spotifyWebAPI.h | 76 +++++++------ 8 files changed, 282 insertions(+), 238 deletions(-) diff --git a/applications/CMakeLists.txt b/applications/CMakeLists.txt index d930643c..ded78436 100644 --- a/applications/CMakeLists.txt +++ b/applications/CMakeLists.txt @@ -4,7 +4,7 @@ project(applications) file(GLOB_RECURSE SRC src/*) -find_package(Qt5 5.5.0 REQUIRED COMPONENTS Widgets Concurrent) +find_package(Qt5 5.5.0 REQUIRED COMPONENTS Widgets Network Concurrent) add_library(${PROJECT_NAME} SHARED ${SRC} ${PROJECT_NAME}.qrc metadata.json) @@ -13,6 +13,7 @@ target_include_directories(${PROJECT_NAME} PRIVATE src/) target_link_libraries(${PROJECT_NAME} PRIVATE Qt5::Concurrent + Qt5::Network Qt5::Widgets albert::lib xdg diff --git a/spotify/README.md b/spotify/README.md index dc561b96..535c6259 100644 --- a/spotify/README.md +++ b/spotify/README.md @@ -1,12 +1,16 @@ # Spotify extension -The Spotify extension for Albert launcher allows searching -tracks on Spotify and playing them immediately or adding them to the -queue. It also allows choosing from user devices, where to play -the track. +The Spotify extension for Albert launcher allows you to search +tracks on Spotify and play them immediately or add them to the +queue. It also allows you to choose the Spotify client, where +to play the track. -The extension uses the Spotify Web API. For the proper -functionality of extension, Spotify premium is required. +The extension uses the Spotify Web API. + +For the proper +functionality of extension, **Spotify premium is required**. + +![Spotify extension](https://i.imgur.com/CoE2C5i.png) ## Web API connection @@ -25,7 +29,7 @@ can copy your **Client ID** and show **Client Secret**. Both are 32-character strings. Click on **Edit settings** and add new **Redirect URI**. It doesn't -have to exist. In this example I will use: `https://nonexistent-uri.net/` +have to exist. In this example, I will use: `https://nonexistent-uri.net/` ### 2. Get `code` parameter @@ -33,7 +37,9 @@ Open your browser and visit: https://accounts.spotify.com/cs/authorize?response_ You have to replace `[[client_id]]` with your actual **Client ID**. -When you press enter, you will get redirected to `https://nonexistent-uri.net/` with `code` in URL parameters. Copy that string and note it down for next usage. +When you press enter, you will get redirected to +`https://nonexistent-uri.net/` with `code` in URL parameters. +Copy that string and note it down for the next usage. ### 3. Get your Refresh Token @@ -43,10 +49,12 @@ I will use `curl` for this last step. Replace or export all variables and run th curl -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET -d grant_type=authorization_code -d code=$CODE -d redirect_uri=https://nonexistent-uri.net/ https://accounts.spotify.com/api/token ``` -Use your Client ID, Client Secret and `code` from previous step. +Use your Client ID, Client Secret and `code` from the previous step. -It will send POST request and return JSON in the answer. You can finally get your **Refresh Token**. +It will send POST request and return JSON in the answer. +You can finally get your **Refresh Token**.
-Whole process is also similarly described [here](https://benwiz.com/blog/create-spotify-refresh-token/). \ No newline at end of file +The whole process is also similarly described +[here](https://benwiz.com/blog/create-spotify-refresh-token/). \ No newline at end of file diff --git a/spotify/src/configwidget.ui b/spotify/src/configwidget.ui index 13f8eb0a..b8f4d7f9 100644 --- a/spotify/src/configwidget.ui +++ b/spotify/src/configwidget.ui @@ -103,13 +103,13 @@ - - - - true - + + - + Allow explicit content: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter @@ -123,6 +123,16 @@ + + + + true + + + + + + @@ -153,16 +163,23 @@ - - + + - Allow explicit content: + Spotify executable: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + spotify + + + diff --git a/spotify/src/device.h b/spotify/src/device.h index b512dcc3..e6caf6e1 100644 --- a/spotify/src/device.h +++ b/spotify/src/device.h @@ -13,6 +13,6 @@ namespace Spotify { QString id; QString name; QString type; - bool isActive; + bool isActive = false; }; } \ No newline at end of file diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 0d2d95a4..3c0098d4 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -1,17 +1,13 @@ // Copyright (C) 2014-2021 Manuel Schneider, Ivo Šmerek #include -#include #include #include -#include #include #include -#include #include #include #include -#include #include #include "albert/util/standarditem.h" #include "configwidget.h" @@ -37,8 +33,9 @@ class Spotify::Private QString clientId; QString clientSecret; QString refreshToken; - bool explicitState; - int numberOfResults; + QString spotifyExecutable; + bool explicitState = true; + int numberOfResults = 5; SpotifyWebAPI *api = new SpotifyWebAPI(); }; @@ -56,14 +53,15 @@ Spotify::Extension::Extension() d->refreshToken = settings().value("refresh_token").toString(); d->explicitState = settings().value("explicit_state").toBool(); d->numberOfResults = settings().value("number_or_results").toInt(); + d->spotifyExecutable = settings().value("spotify_executable").toString(); + if (d->numberOfResults == 0) { + d->numberOfResults = 5; + } - // You can throw in the constructor if something fatal happened - // throw std::runtime_error( "Description of error." ); - // throw std::string( "Description of error." ); - // throw QString( "Description of error." ); - // throw "Description of error."; - // throw; // Whatever prints "unknown error" + if (d->spotifyExecutable.isEmpty()) { + d->spotifyExecutable = "spotify"; + } } @@ -111,6 +109,12 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { settings().setValue("number_or_results", s); }); + d->widget->ui.lineEdit_spotify_executable->setText(d->spotifyExecutable); + connect(d->widget->ui.lineEdit_spotify_executable, &QLineEdit::textEdited, [this](const QString &s){ + d->spotifyExecutable = s; + settings().setValue("spotify_executable", s); + }); + // Bind "Test connection" button connect(d->widget->ui.pushButton_test_connection, &QPushButton::clicked, [this](){ @@ -120,8 +124,11 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { QString message = "Everything is set up correctly."; if (!status) { - message = QString("Spotify Web API returns: \"%1\"\nPlease check all input fields.") + message = QString("Spotify Web API returns: \"%1\"\nPlease, check all input fields.") .arg(d->api->lastErrorMessage); + if (d->api->lastErrorMessage.isEmpty()) { + message = "Can't get an answer from the server.\nPlease, check your internet connection."; + } } auto messageBox = new QMessageBox(); @@ -140,8 +147,8 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { void Spotify::Extension::setupSession() { d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); - if(!QDir("/tmp/albert-spotify").exists()) { - QDir().mkdir("/tmp/albert-spotify-covers"); + if(!QDir(COVERS_DIR_PATH).exists()) { + QDir().mkdir(COVERS_DIR_PATH); } } @@ -156,86 +163,88 @@ void Spotify::Extension::teardownSession() { /** ***************************************************************************/ void Spotify::Extension::handleQuery(Core::Query * query) const { + if (query->string().trimmed().isEmpty()) + return; - /* - * Things change so often I wont maintain this tutorial here. Check the relevant headers. - * - * - core/extension.h - * - core/queryhandler.h - * - core/query.h - * - core/item.h - * - core/action.h - * - util/standarditem.h - * - util/offlineindex.h - * - util/standardindexitem.h - * - * Use - * - * query->addMatch(my_item) - * - * to add matches. If you created a throw away item MOVE it instead of - * copying e.g.: - * - * query->addMatch(std::move(my_tmp_item)) - * - * The relevance factor is optional. (Defaults to 0) its a usigned integer depicting the - * relevance of the item 0 mean not relevant UINT_MAX is totally relevant (exact match). - * E.g. it the query is "it" and your items name is "item" - * - * my_item.name().startswith(query->string) - * - * is a naive match criterion and - * - * UINT_MAX / ( query.searchterm().size() / my_item.name().size() ) - * - * a naive match factor. - * - * If you have a lot of items use the iterator versions addMatches, e.g. like that - * - * query->addMatches(my_items.begin(), my_items.end()); - * - * If the items in the container are temporary object move them to avoid uneccesary - * reference counting: - * - * query->addMatches(std::make_move_iterator(my_tmp_items.begin()), - * std::make_move_iterator(my_tmp_items.end())); - */ + // If there is no internet connection, make one alerting item to let the user know. + if (!d->api->testInternetConnection()) { + DEBG << "No internet connection!"; - if (query->string().trimmed().isEmpty()) + auto result = makeStdItem("no-internet", "", "Can't get an answer from the server."); + result->setSubtext("Please, check your internet connection."); + + query->addMatch(move(result), UINT_MAX); return; + } + // If the access token expires, try to refresh it or alert the user what is wrong. if (d->api->expired()) { DEBG << "Token expired. Refreshing"; - d->api->refreshToken(); + if (!d->api->refreshToken()) { + auto result = makeStdItem("wrong-credentials", "", "Wrong credentials"); + result->setSubtext(d->api->lastErrorMessage + ". Please, check extension settings."); + + query->addMatch(move(result), UINT_MAX); + return; + } } - auto results = d->api->searchTrack(query->string(), QString("%1").arg(d->numberOfResults)); - auto devices = d->api->getDevices(); + // Search for tracks on Spotify using the query. + auto results = d->api->searchTracks(query->string(), d->numberOfResults); + + // Get available Spotify devices. + auto *devices = d->api->getDevices(); for (const auto& track : results) { + // Deal with explicit tracks according to user setting. if (track.isExplicit && !d->explicitState) { continue; } auto filename = QString("%1/%2.jpeg").arg(COVERS_DIR_PATH, track.albumId); + // Download cover image of the album. d->api->downloadImage(track.imageUrl, filename); - auto result = makeStdItem( - track.id, - filename, - QString("%1").arg(track.name), - QString("%1 (%2)").arg(track.albumName, track.artists), - ActionList { }, - "none", - Item::Urgency::Alert - ); - - auto playTrack = makeFuncAction("Play on active Spotify device", [this, track]() + // Create a standard item with a track name in title and album with artists in subtext. + auto result = makeStdItem(track.id, filename, track.name); + result->setSubtext(QString("%1 (%2)").arg(track.albumName, track.artists)); + + // First default action with intelligent device chooser. + auto playTrack = makeFuncAction("Play this track on Spotify", [this, track, devices]() { - d->api->play(track.uri); + // Check if the last-used device is still available. + bool lastDeviceConfirmed = false; + QString lastDevice = settings().value("last_device").toString(); + if (!lastDevice.isEmpty() || !devices->isEmpty()) { + for (const auto& device : *devices) { + if (device.id == lastDevice) { + lastDeviceConfirmed = true; + break; + } + } + } + + if (d->api->activeDevice) { + // If available, use an active device and play the track. + // TODO: Maybe let user choose in setting if prefer active or last-used device. + d->api->play(track.uri, d->api->activeDevice->id); + settings().setValue("last_device", d->api->activeDevice->id); + } else if (lastDeviceConfirmed) { + // If there is not an active device, use last-used one. + d->api->play(track.uri, lastDevice); + } else if (!devices->isEmpty()) { + // Use the first available device. + d->api->play(track.uri, devices[0][0].id); + settings().setValue("last_device", devices[0][0].id); + } else { + // Run local Spotify client, wait until it loads, and play the track. + makeProcAction("Run Spotify", QStringList() << d->spotifyExecutable)->activate(); + d->api->waitForDeviceAndPlay(track.uri, 10); + } }); + // Action to add track to the Spotify queue. auto addToQueue = makeFuncAction("Add to the Spotify queue", [this, track]() { d->api->addItemToQueue(track.uri); @@ -244,17 +253,23 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { result->addAction(playTrack); result->addAction(addToQueue); - for (auto device : devices) { + // For each device except active create action to transfer Spotify playback to this device. + for (const auto& device : *devices) { if (device.isActive) continue; auto action = makeFuncAction(QString("Play on %1 (%2)").arg(device.type, device.name), [this, track, device]() { d->api->play(track.uri, device.id); + settings().setValue("last_device", device.id); }); result->addAction(action); } - query->addMatch(result, UINT_MAX); + query->addMatch(move(result), UINT_MAX); } +} + +QueryHandler::ExecutionType Spotify::Extension::executionType() const { + return QueryHandler::ExecutionType::Realtime; } \ No newline at end of file diff --git a/spotify/src/extension.h b/spotify/src/extension.h index 28703ac2..1a27fb18 100644 --- a/spotify/src/extension.h +++ b/spotify/src/extension.h @@ -25,10 +25,11 @@ class Extension final : QString name() const override { return "Spotify"; } QWidget *widget(QWidget *parent = nullptr) override; -// QStringList triggers() const override { return {"spotify "}; } + QStringList triggers() const override { return {"spotify ", "play "}; } void setupSession() override; void teardownSession() override; void handleQuery(Core::Query * query) const override; + ExecutionType executionType() const override; private: std::unique_ptr d; diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp index 811157e7..1fa0fd18 100644 --- a/spotify/src/spotifyWebAPI.cpp +++ b/spotify/src/spotifyWebAPI.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include "spotifyWebAPI.h" namespace Spotify { @@ -25,15 +26,66 @@ namespace Spotify { return jsonObject; } + void SpotifyWebAPI::waitForSignal_(const QObject *sender, const char *signal) { + QEventLoop loop; + connect(sender, signal, &loop, SLOT(quit())); + loop.exec(); + } + + QString SpotifyWebAPI::waitForDevice_(QString uri, int timeout) { + int counter = 0; + while (counter < timeout) { + QThread::sleep(1); + auto device = getFirstDeviceId(); + if (!device.isEmpty()) { + emit deviceReady(std::move(uri), device); + qDebug() << "Using new device"; + return device; + } + counter++; + } + + return ""; + } + + QNetworkRequest SpotifyWebAPI::buildRequest_(const QUrl& url) { + auto request = new QNetworkRequest(url); + auto header = QString("Bearer ") + accessToken_; + request->setRawHeader(QByteArray("Authorization"), header.toUtf8()); + request->setRawHeader(QByteArray("Accept"), "application/json"); + request->setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + return *request; + } + + bool SpotifyWebAPI::testInternetConnection() { + try { + auto *url = new QUrl(TOKEN_URL); + QNetworkRequest request(*url); + + // New QNetworkAccessManager is necessary. If global manager loses connection in runtime, + // sending requests through it causes QDBusConnection warnings followed by freeze. + // TODO: Needs testing what happens to global manager when user changes network connection. (e.g. LAN to WiFi) + + auto *tmpManager = new QNetworkAccessManager(); + QNetworkReply *reply = tmpManager->get(request); + + waitForSignal_(reply, SIGNAL(finished())); + return reply->bytesAvailable(); + } catch (...) { + return false; + } + } + void SpotifyWebAPI::setConnection(QString clientId, QString clientSecret, QString refreshToken) { clientId_ = std::move(clientId); clientSecret_ = std::move(clientSecret); refreshToken_ = std::move(refreshToken); + expirationTime_ = QDateTime::currentDateTime(); } bool SpotifyWebAPI::refreshToken() { - auto *url = new QUrl(TOKEN_URL); - QNetworkRequest request(*url); + auto url = QUrl(TOKEN_URL); + QNetworkRequest request(url); auto hash = QString("%1:%2").arg(clientId_, clientSecret_).toUtf8().toBase64(); auto header = QString("Basic ").append(hash); @@ -51,17 +103,11 @@ namespace Spotify { QString answer = reply->readAll(); QJsonObject jsonVariant = answerToJson_(answer); - qDebug() << answer; - accessToken_ = ""; if (!jsonVariant["access_token"].isUndefined()) { accessToken_ = jsonVariant["access_token"].toString(); - qDebug() << "Expires in " << jsonVariant["expires_in"]; expirationTime_ = QDateTime::currentDateTime().addSecs(jsonVariant["expires_in"].toInt()); - - emit tokenReplyReceived(); - emit tokenRefreshed(); } if (!jsonVariant["error_description"].isUndefined()) { @@ -69,13 +115,9 @@ namespace Spotify { } else { lastErrorMessage = jsonVariant["error"].toString(); } - - emit tokenReplyReceived(); }); - QEventLoop loop; - connect(this, SIGNAL(tokenReplyReceived()), &loop, SLOT(quit())); - loop.exec(); + waitForSignal_(reply, SIGNAL(finished())); return !accessToken_.isEmpty() && savedToken != accessToken_; } @@ -88,34 +130,27 @@ namespace Spotify { return QDateTime::currentDateTime() > expirationTime_; } - QVector SpotifyWebAPI::searchTrack(const QString& query, const QString& limit) { - auto *url = new QUrl(SEARCH_URL.arg(query, "track", limit)); - QNetworkRequest request(*url); - - auto header = QString("Bearer ").append(accessToken_); - request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); - request.setRawHeader(QByteArray("Accept"), "application/json"); - request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + QVector SpotifyWebAPI::searchTracks(const QString& query, const int limit) { + auto url = QUrl(SEARCH_URL.arg(query, "track", QString::number(limit))); + QNetworkRequest request = buildRequest_(url); QNetworkReply *reply = manager->get(request); - connect(reply, &QNetworkReply::finished, [this, reply]() { + auto *itemResults = new QJsonArray(); + + connect(reply, &QNetworkReply::finished, [reply, itemResults]() { QString answer = reply->readAll(); QJsonObject jsonObject = answerToJson_(answer); - itemResults_ = jsonObject["tracks"].toObject()["items"].toArray(); - - emit searchReplyReceived(); + *itemResults = jsonObject["tracks"].toObject()["items"].toArray(); }); - QEventLoop loop; - connect(this, SIGNAL(searchReplyReceived()), &loop, SLOT(quit())); - loop.exec(); + waitForSignal_(reply, SIGNAL(finished())); auto results = new QVector(); - for (auto item : itemResults_) { + for (auto item : *itemResults) { auto trackData = item.toObject(); auto artists = trackData["artists"].toArray(); qDebug() << item; @@ -159,121 +194,68 @@ namespace Spotify { QNetworkRequest request(imageUrl); QNetworkReply *reply = manager->get(request); - connect(reply, &QNetworkReply::finished, [this, reply, imageFilePath]() { + connect(reply, &QNetworkReply::finished, [reply, imageFilePath]() { QSaveFile file(imageFilePath); file.open(QIODevice::WriteOnly); file.write(reply->readAll()); file.commit(); - emit imageReceived(); }); - QEventLoop loop; - connect(this, SIGNAL(imageReceived()), &loop, SLOT(quit())); - loop.exec(); + waitForSignal_(reply, SIGNAL(finished())); fileLock_.unlock(); } void SpotifyWebAPI::addItemToQueue(const QString& uri) { - auto *url = new QUrl(ADD_ITEM_URL.arg(uri)); - QNetworkRequest request(*url); - - auto header = QString("Bearer ").append(accessToken_); - request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); - request.setRawHeader(QByteArray("Accept"), "application/json"); - request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); - - QByteArray postData; + auto url = QUrl(ADD_ITEM_URL.arg(uri)); + QNetworkRequest request = buildRequest_(url); - QNetworkReply *reply = manager->post(request, postData); - - connect(reply, &QNetworkReply::finished, [this]() { - emit addedToQueue(); - }); - - QEventLoop loop; - connect(this, SIGNAL(addedToQueue()), &loop, SLOT(quit())); - loop.exec(); - } - - void SpotifyWebAPI::skipToNextTrack() { - auto *url = new QUrl(NEXT_TRACK_URL); - QNetworkRequest request(*url); - - auto header = QString("Bearer ").append(accessToken_); - request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); - request.setRawHeader(QByteArray("Accept"), "application/json"); - request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); - - QByteArray postData; - - QNetworkReply *reply = manager->post(request, postData); - - connect(reply, &QNetworkReply::finished, [this]() { - emit skippedTrack(); - }); - - QEventLoop loop; - connect(this, SIGNAL(skippedTrack()), &loop, SLOT(quit())); - loop.exec(); + manager->post(request, ""); } void SpotifyWebAPI::play(const QString& uri, QString device) { if (device.isEmpty() && activeDevice) { device = activeDevice->id; } - auto *url = new QUrl(PLAY_URL.arg(device)); - QNetworkRequest request(*url); - - auto header = QString("Bearer ").append(accessToken_); - request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); - request.setRawHeader(QByteArray("Accept"), "application/json"); - request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + auto url = QUrl(PLAY_URL.arg(device)); + QNetworkRequest request = buildRequest_(url); QByteArray postData = QString(R"({"uris": ["%1"]})").arg(uri).toLocal8Bit(); - QNetworkReply *reply = manager->put(request, postData); + manager->put(request, postData); + } - connect(reply, &QNetworkReply::finished, [this, reply]() { - qDebug() << "Reply:"; - qDebug() << reply->readAll(); - emit played(); + QString SpotifyWebAPI::waitForDeviceAndPlay(const QString& uri, int timeout) { + connect(this, SIGNAL(deviceReady(QString, QString)), this, SLOT(play(QString, QString))); + + QtConcurrent::run([=]() { + waitForDevice_(uri, timeout); }); - QEventLoop loop; - connect(this, SIGNAL(played()), &loop, SLOT(quit())); - loop.exec(); + return ""; } - QVector SpotifyWebAPI::getDevices() { - auto *url = new QUrl(DEVICES_URL); - QNetworkRequest request(*url); - - auto header = QString("Bearer ").append(accessToken_); - request.setRawHeader(QByteArray("Authorization"), header.toUtf8()); - request.setRawHeader(QByteArray("Accept"), "application/json"); - request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/json"))); + QVector *SpotifyWebAPI::getDevices() { + auto url = QUrl(DEVICES_URL); + QNetworkRequest request = buildRequest_(url); QNetworkReply *reply = manager->get(request); - connect(reply, &QNetworkReply::finished, [this, reply]() { + auto *devicesResult = new QJsonArray(); + connect(reply, &QNetworkReply::finished, [reply, devicesResult]() { QString answer = reply->readAll(); QJsonObject jsonObject = answerToJson_(answer); - devicesResult_ = jsonObject["devices"].toArray(); - - emit searchReplyReceived(); + *devicesResult = jsonObject["devices"].toArray(); }); - QEventLoop loop; - connect(this, SIGNAL(searchReplyReceived()), &loop, SLOT(quit())); - loop.exec(); + waitForSignal_(reply, SIGNAL(finished())); - QVector result; + auto result = new QVector(); activeDevice = nullptr; - for (auto item : devicesResult_) { + for (auto item : *devicesResult) { auto deviceData = item.toObject(); auto *device = new Device(); @@ -287,9 +269,17 @@ namespace Spotify { activeDevice = device; } - result.append(*device); + result->append(*device); } return result; } + + QString SpotifyWebAPI::getFirstDeviceId() { + QVector *devices_ = getDevices(); + if (devices_->isEmpty()) { + return ""; + } + return devices_[0][0].id; + } } \ No newline at end of file diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index b2253f8e..8636abdc 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -15,6 +15,31 @@ namespace Spotify { class SpotifyWebAPI : public QObject { Q_OBJECT +private: + QString TOKEN_URL = "https://accounts.spotify.com/api/token"; + QString SEARCH_URL = "https://api.spotify.com/v1/search?q=%1&type=%2&limit=%3"; + QString PLAY_URL = "https://api.spotify.com/v1/me/player/play?device_id=%1"; + QString ADD_ITEM_URL = "https://api.spotify.com/v1/me/player/queue?uri=%1"; + QString DEVICES_URL = "https://api.spotify.com/v1/me/player/devices"; + + QString clientId_; + QString clientSecret_; + QString refreshToken_; + QString accessToken_; + QDateTime expirationTime_; + QReadWriteLock fileLock_; + + // Helper function for parsing JSON from HTTP answer. + static QJsonObject answerToJson_(const QString& answer); + + // Helper function for waiting for signal. + static void waitForSignal_(const QObject *sender, const char *signal); + + // Helper function for waiting and signalling. + QString waitForDevice_(QString uri, int timeout); + + QNetworkRequest buildRequest_(const QUrl& url); + public: SpotifyWebAPI(); ~SpotifyWebAPI() override; @@ -23,10 +48,13 @@ Q_OBJECT QString lastErrorMessage; Device *activeDevice = nullptr; + // Tests internet connection to Spotify servers. + bool testInternetConnection(); + // Set Web API credentials. Use testConnection for check. void setConnection(QString clientId, QString clientSecret, QString refreshToken); - // This method will try to refresh the access_token and return true if successful. + // Try to refresh the access_token and return true if successful. bool refreshToken(); // Does the same as refreshToken. Is here for better code readability. @@ -37,46 +65,30 @@ Q_OBJECT bool expired(); // Returns QVector with tracks matching the search query. - QVector searchTrack(const QString& query, const QString& limit); + QVector searchTracks(const QString& query, int limit); - // This method downloads image from imageUrl to imageFilePath. + // Downloads image from imageUrl to imageFilePath. void downloadImage(const QString& imageUrl, const QString& imageFilePath); + // Adds track to Spotify listening queue. void addItemToQueue(const QString& uri); - void skipToNextTrack(); + // Asynchronously plays the song as soon as an available device appears. + // Gives up after the timeout. + QString waitForDeviceAndPlay(const QString& uri, int timeout); - void play(const QString& uri, QString device = ""); - - QVector getDevices(); + // Returns list of users available Spotify devices. + QVector *getDevices(); -private: - QString TOKEN_URL = "https://accounts.spotify.com/api/token"; - QString SEARCH_URL = "https://api.spotify.com/v1/search?q=%1&type=%2&limit=%3"; - QString PLAY_URL = "https://api.spotify.com/v1/me/player/play?device_id=%1"; - QString ADD_ITEM_URL = "https://api.spotify.com/v1/me/player/queue?uri=%1"; - QString NEXT_TRACK_URL = "https://api.spotify.com/v1/me/player/next"; - QString DEVICES_URL = "https://api.spotify.com/v1/me/player/devices"; - - QString clientId_; - QString clientSecret_; - QString refreshToken_; - QString accessToken_; - QDateTime expirationTime_; - QJsonArray itemResults_; - QJsonArray devicesResult_; - QReadWriteLock fileLock_; - - static QJsonObject answerToJson_(const QString& answer); + // Returns id of first available Spotify device. + QString getFirstDeviceId(); signals: - void tokenRefreshed(); - void tokenReplyReceived(); - void searchReplyReceived(); - void imageReceived(); - void addedToQueue(); - void skippedTrack(); - void played(); + void deviceReady(QString, QString); + +public slots: + // Plays track on Spotify device. + void play(const QString& uri, QString device = ""); }; } \ No newline at end of file From 4a9fe3bb73cbcbb47ae51c2b9f9293ecce995e0e Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Tue, 26 Jan 2021 23:31:04 +0100 Subject: [PATCH 09/14] Add parents to object instances --- spotify/src/extension.cpp | 4 +++- spotify/src/spotifyWebAPI.cpp | 10 ++++++---- spotify/src/spotifyWebAPI.h | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 3c0098d4..d5afc7c9 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -36,7 +36,7 @@ class Spotify::Private QString spotifyExecutable; bool explicitState = true; int numberOfResults = 5; - SpotifyWebAPI *api = new SpotifyWebAPI(); + SpotifyWebAPI *api = nullptr; }; @@ -48,6 +48,8 @@ Spotify::Extension::Extension() registerQueryHandler(this); + d->api = new SpotifyWebAPI(this); + d->clientId = settings().value("client_id").toString(); d->clientSecret = settings().value("client_secret").toString(); d->refreshToken = settings().value("refresh_token").toString(); diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp index 1fa0fd18..c968c4f4 100644 --- a/spotify/src/spotifyWebAPI.cpp +++ b/spotify/src/spotifyWebAPI.cpp @@ -14,11 +14,13 @@ namespace Spotify { - SpotifyWebAPI::SpotifyWebAPI() { - manager = new QNetworkAccessManager(); + SpotifyWebAPI::SpotifyWebAPI(QObject* parent) { + manager = new QNetworkAccessManager(parent); } - SpotifyWebAPI::~SpotifyWebAPI() = default; + SpotifyWebAPI::~SpotifyWebAPI() { + delete manager; + } QJsonObject SpotifyWebAPI::answerToJson_(const QString& answer) { QJsonDocument doc = QJsonDocument::fromJson(answer.toUtf8()); @@ -66,7 +68,7 @@ namespace Spotify { // sending requests through it causes QDBusConnection warnings followed by freeze. // TODO: Needs testing what happens to global manager when user changes network connection. (e.g. LAN to WiFi) - auto *tmpManager = new QNetworkAccessManager(); + auto *tmpManager = new QNetworkAccessManager(this); QNetworkReply *reply = tmpManager->get(request); waitForSignal_(reply, SIGNAL(finished())); diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index 8636abdc..0ca07681 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -41,7 +41,7 @@ Q_OBJECT QNetworkRequest buildRequest_(const QUrl& url); public: - SpotifyWebAPI(); + explicit SpotifyWebAPI(QObject* parent); ~SpotifyWebAPI() override; QNetworkAccessManager *manager; From 32f5e182656b15441442634c00ce2a8801e29d21 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Wed, 27 Jan 2021 11:45:15 +0100 Subject: [PATCH 10/14] Experimental QNetworkAccessManager re-creation in each thread --- spotify/src/extension.cpp | 3 +++ spotify/src/spotifyWebAPI.cpp | 14 +++++++------- spotify/src/spotifyWebAPI.h | 3 +++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index d5afc7c9..8e4cee35 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -121,6 +121,7 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { connect(d->widget->ui.pushButton_test_connection, &QPushButton::clicked, [this](){ d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); + d->api->setQNetworkAccessManager(new QNetworkAccessManager()); bool status = d->api->testConnection(); @@ -168,6 +169,8 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { if (query->string().trimmed().isEmpty()) return; + d->api->setQNetworkAccessManager(new QNetworkAccessManager()); + // If there is no internet connection, make one alerting item to let the user know. if (!d->api->testInternetConnection()) { DEBG << "No internet connection!"; diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp index c968c4f4..cffd6c9f 100644 --- a/spotify/src/spotifyWebAPI.cpp +++ b/spotify/src/spotifyWebAPI.cpp @@ -15,7 +15,8 @@ namespace Spotify { SpotifyWebAPI::SpotifyWebAPI(QObject* parent) { - manager = new QNetworkAccessManager(parent); + manager = new QNetworkAccessManager(this); + setParent(parent); } SpotifyWebAPI::~SpotifyWebAPI() { @@ -64,12 +65,7 @@ namespace Spotify { auto *url = new QUrl(TOKEN_URL); QNetworkRequest request(*url); - // New QNetworkAccessManager is necessary. If global manager loses connection in runtime, - // sending requests through it causes QDBusConnection warnings followed by freeze. - // TODO: Needs testing what happens to global manager when user changes network connection. (e.g. LAN to WiFi) - - auto *tmpManager = new QNetworkAccessManager(this); - QNetworkReply *reply = tmpManager->get(request); + QNetworkReply *reply = manager->get(request); waitForSignal_(reply, SIGNAL(finished())); return reply->bytesAvailable(); @@ -85,6 +81,10 @@ namespace Spotify { expirationTime_ = QDateTime::currentDateTime(); } + void SpotifyWebAPI::setQNetworkAccessManager(QNetworkAccessManager *newManager) { + manager = newManager; + } + bool SpotifyWebAPI::refreshToken() { auto url = QUrl(TOKEN_URL); QNetworkRequest request(url); diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index 0ca07681..88b9200c 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -54,6 +54,9 @@ Q_OBJECT // Set Web API credentials. Use testConnection for check. void setConnection(QString clientId, QString clientSecret, QString refreshToken); + // Set QNetworkAccessManager for this instance. + void setQNetworkAccessManager(QNetworkAccessManager *newManager); + // Try to refresh the access_token and return true if successful. bool refreshToken(); From be65c9c5971f43c01a2aed7743f508e1f60d6e92 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Wed, 27 Jan 2021 16:45:20 +0100 Subject: [PATCH 11/14] Suppress non-relevant threads, cleanup --- spotify/src/extension.cpp | 17 ++------- spotify/src/extension.h | 2 -- spotify/src/spotifyWebAPI.cpp | 67 ++++++++++++++++++++--------------- spotify/src/spotifyWebAPI.h | 5 --- 4 files changed, 41 insertions(+), 50 deletions(-) diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 8e4cee35..1836efa6 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -1,19 +1,12 @@ // Copyright (C) 2014-2021 Manuel Schneider, Ivo Šmerek #include -#include #include -#include -#include -#include -#include -#include -#include +#include "albert/util/standardactions.h" #include "albert/util/standarditem.h" #include "configwidget.h" #include "extension.h" #include "spotifyWebAPI.h" -#include "xdg/iconlookup.h" Q_LOGGING_CATEGORY(qlc, "spotify") #define DEBG qCDebug(qlc,).noquote() #define INFO qCInfo(qlc,).noquote() @@ -22,10 +15,6 @@ Q_LOGGING_CATEGORY(qlc, "spotify") using namespace Core; using namespace std; -namespace { - -} - class Spotify::Private { public: @@ -121,7 +110,7 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { connect(d->widget->ui.pushButton_test_connection, &QPushButton::clicked, [this](){ d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); - d->api->setQNetworkAccessManager(new QNetworkAccessManager()); + d->api->manager = new QNetworkAccessManager(); bool status = d->api->testConnection(); @@ -169,7 +158,7 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { if (query->string().trimmed().isEmpty()) return; - d->api->setQNetworkAccessManager(new QNetworkAccessManager()); + d->api->manager = new QNetworkAccessManager(); // If there is no internet connection, make one alerting item to let the user know. if (!d->api->testInternetConnection()) { diff --git a/spotify/src/extension.h b/spotify/src/extension.h index 1a27fb18..557c941f 100644 --- a/spotify/src/extension.h +++ b/spotify/src/extension.h @@ -2,8 +2,6 @@ #pragma once #include -#include -#include #include "albert/extension.h" #include "albert/queryhandler.h" Q_DECLARE_LOGGING_CATEGORY(qlc) diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp index cffd6c9f..8a1f8bae 100644 --- a/spotify/src/spotifyWebAPI.cpp +++ b/spotify/src/spotifyWebAPI.cpp @@ -1,15 +1,8 @@ // Copyright (C) 2020-2021 Ivo Šmerek -#include -#include -#include -#include -#include -#include -#include -#include -#include #include +#include + #include "spotifyWebAPI.h" namespace Spotify { @@ -42,7 +35,6 @@ namespace Spotify { auto device = getFirstDeviceId(); if (!device.isEmpty()) { emit deviceReady(std::move(uri), device); - qDebug() << "Using new device"; return device; } counter++; @@ -65,6 +57,9 @@ namespace Spotify { auto *url = new QUrl(TOKEN_URL); QNetworkRequest request(*url); + if (manager->thread() != QThread::currentThread()) { + return false; + } QNetworkReply *reply = manager->get(request); waitForSignal_(reply, SIGNAL(finished())); @@ -81,10 +76,6 @@ namespace Spotify { expirationTime_ = QDateTime::currentDateTime(); } - void SpotifyWebAPI::setQNetworkAccessManager(QNetworkAccessManager *newManager) { - manager = newManager; - } - bool SpotifyWebAPI::refreshToken() { auto url = QUrl(TOKEN_URL); QNetworkRequest request(url); @@ -99,6 +90,9 @@ namespace Spotify { QString savedToken = accessToken_; + if (manager->thread() != QThread::currentThread()) { + return false; + } QNetworkReply *reply = manager->post(request, postData); connect(reply, &QNetworkReply::finished, [this, reply]() { @@ -136,6 +130,9 @@ namespace Spotify { auto url = QUrl(SEARCH_URL.arg(query, "track", QString::number(limit))); QNetworkRequest request = buildRequest_(url); + if (manager->thread() != QThread::currentThread()) { + return QVector(); + } QNetworkReply *reply = manager->get(request); auto *itemResults = new QJsonArray(); @@ -155,9 +152,8 @@ namespace Spotify { for (auto item : *itemResults) { auto trackData = item.toObject(); auto artists = trackData["artists"].toArray(); - qDebug() << item; - Track track; + auto track = new Track(); QString artistsText = ""; int counter = 0; @@ -169,16 +165,16 @@ namespace Spotify { counter++; } - track.id = trackData["id"].toString(); - track.name = trackData["name"].toString(); - track.artists = artistsText; - track.albumId = trackData["album"].toObject()["id"].toString(); - track.albumName = trackData["album"].toObject()["name"].toString(); - track.uri = trackData["uri"].toString(); - track.imageUrl = trackData["album"].toObject()["images"].toArray()[2].toObject()["url"].toString(); - track.isExplicit = trackData["explicit"].toBool(); + track->id = trackData["id"].toString(); + track->name = trackData["name"].toString(); + track->artists = artistsText; + track->albumId = trackData["album"].toObject()["id"].toString(); + track->albumName = trackData["album"].toObject()["name"].toString(); + track->uri = trackData["uri"].toString(); + track->imageUrl = trackData["album"].toObject()["images"].toArray()[2].toObject()["url"].toString(); + track->isExplicit = trackData["explicit"].toBool(); - results->append(track); + results->append(*track); } return *results; @@ -193,14 +189,21 @@ namespace Spotify { return; } + if (manager->thread() != QThread::currentThread()) { + fileLock_.unlock(); + return; + } + QNetworkRequest request(imageUrl); QNetworkReply *reply = manager->get(request); connect(reply, &QNetworkReply::finished, [reply, imageFilePath]() { - QSaveFile file(imageFilePath); - file.open(QIODevice::WriteOnly); - file.write(reply->readAll()); - file.commit(); + if (reply->bytesAvailable()) { + QSaveFile file(imageFilePath); + file.open(QIODevice::WriteOnly); + file.write(reply->readAll()); + file.commit(); + } }); waitForSignal_(reply, SIGNAL(finished())); @@ -208,6 +211,7 @@ namespace Spotify { } void SpotifyWebAPI::addItemToQueue(const QString& uri) { + manager = new QNetworkAccessManager(); auto url = QUrl(ADD_ITEM_URL.arg(uri)); QNetworkRequest request = buildRequest_(url); @@ -215,6 +219,7 @@ namespace Spotify { } void SpotifyWebAPI::play(const QString& uri, QString device) { + manager = new QNetworkAccessManager(); if (device.isEmpty() && activeDevice) { device = activeDevice->id; } @@ -230,6 +235,7 @@ namespace Spotify { connect(this, SIGNAL(deviceReady(QString, QString)), this, SLOT(play(QString, QString))); QtConcurrent::run([=]() { + manager = new QNetworkAccessManager(); waitForDevice_(uri, timeout); }); @@ -240,6 +246,9 @@ namespace Spotify { auto url = QUrl(DEVICES_URL); QNetworkRequest request = buildRequest_(url); + if (manager->thread() != QThread::currentThread()) { + return new QVector(); + } QNetworkReply *reply = manager->get(request); auto *devicesResult = new QJsonArray(); diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index 88b9200c..b2fe0705 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -3,8 +3,6 @@ #pragma once #include #include -#include -#include #include #include #include "track.h" @@ -54,9 +52,6 @@ Q_OBJECT // Set Web API credentials. Use testConnection for check. void setConnection(QString clientId, QString clientSecret, QString refreshToken); - // Set QNetworkAccessManager for this instance. - void setQNetworkAccessManager(QNetworkAccessManager *newManager); - // Try to refresh the access_token and return true if successful. bool refreshToken(); From 0205f175375d430fefb2a9c8cb5407b7b81df4a0 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Wed, 27 Jan 2021 17:03:51 +0100 Subject: [PATCH 12/14] Small fixes --- applications/CMakeLists.txt | 3 +-- spotify/CMakeLists.txt | 3 ++- spotify/src/device.h | 2 +- spotify/src/extension.cpp | 2 +- spotify/src/spotifyWebAPI.cpp | 2 +- spotify/src/spotifyWebAPI.h | 2 +- spotify/src/track.h | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/applications/CMakeLists.txt b/applications/CMakeLists.txt index ded78436..d930643c 100644 --- a/applications/CMakeLists.txt +++ b/applications/CMakeLists.txt @@ -4,7 +4,7 @@ project(applications) file(GLOB_RECURSE SRC src/*) -find_package(Qt5 5.5.0 REQUIRED COMPONENTS Widgets Network Concurrent) +find_package(Qt5 5.5.0 REQUIRED COMPONENTS Widgets Concurrent) add_library(${PROJECT_NAME} SHARED ${SRC} ${PROJECT_NAME}.qrc metadata.json) @@ -13,7 +13,6 @@ target_include_directories(${PROJECT_NAME} PRIVATE src/) target_link_libraries(${PROJECT_NAME} PRIVATE Qt5::Concurrent - Qt5::Network Qt5::Widgets albert::lib xdg diff --git a/spotify/CMakeLists.txt b/spotify/CMakeLists.txt index 8887f9eb..52a97846 100644 --- a/spotify/CMakeLists.txt +++ b/spotify/CMakeLists.txt @@ -4,7 +4,7 @@ project(spotify) file(GLOB_RECURSE SRC src/* metadata.json) -find_package(Qt5 5.5.0 REQUIRED COMPONENTS Widgets) +find_package(Qt5 5.5.0 REQUIRED COMPONENTS Network Widgets) add_library(${PROJECT_NAME} SHARED ${SRC}) @@ -12,6 +12,7 @@ target_include_directories(${PROJECT_NAME} PRIVATE src/) target_link_libraries(${PROJECT_NAME} PRIVATE + Qt5::Network Qt5::Widgets albert::lib xdg diff --git a/spotify/src/device.h b/spotify/src/device.h index e6caf6e1..07c29cfb 100644 --- a/spotify/src/device.h +++ b/spotify/src/device.h @@ -15,4 +15,4 @@ namespace Spotify { QString type; bool isActive = false; }; -} \ No newline at end of file +} diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 1836efa6..7eccdfd6 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -266,4 +266,4 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { QueryHandler::ExecutionType Spotify::Extension::executionType() const { return QueryHandler::ExecutionType::Realtime; -} \ No newline at end of file +} diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp index 8a1f8bae..7a77bcf5 100644 --- a/spotify/src/spotifyWebAPI.cpp +++ b/spotify/src/spotifyWebAPI.cpp @@ -293,4 +293,4 @@ namespace Spotify { } return devices_[0][0].id; } -} \ No newline at end of file +} diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h index b2fe0705..b4ed88b5 100644 --- a/spotify/src/spotifyWebAPI.h +++ b/spotify/src/spotifyWebAPI.h @@ -89,4 +89,4 @@ public slots: void play(const QString& uri, QString device = ""); }; -} \ No newline at end of file +} diff --git a/spotify/src/track.h b/spotify/src/track.h index 1ad003d1..a683edf8 100644 --- a/spotify/src/track.h +++ b/spotify/src/track.h @@ -19,4 +19,4 @@ class Track { QString imageUrl; bool isExplicit = false; }; -} \ No newline at end of file +} From fd8b9e8d1b3778726a5b35047d4a1cae9601e7da Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Wed, 27 Jan 2021 17:41:04 +0100 Subject: [PATCH 13/14] Add settings option to specify cache directory --- spotify/src/configwidget.ui | 60 ++++++++++++++++++++++++------------- spotify/src/extension.cpp | 38 +++++++++++++++++++---- spotify/src/extension.h | 3 +- 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/spotify/src/configwidget.ui b/spotify/src/configwidget.ui index b8f4d7f9..027160f4 100644 --- a/spotify/src/configwidget.ui +++ b/spotify/src/configwidget.ui @@ -103,16 +103,6 @@ - - - - Allow explicit content: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - @@ -123,16 +113,6 @@ - - - - true - - - - - - @@ -176,10 +156,50 @@ + + + spotify + + + + true + + + + + + + + + + Allow explicit content: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Cache directory: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + /tmp/albert-spotify-covers + + + diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp index 7eccdfd6..fa18f853 100644 --- a/spotify/src/extension.cpp +++ b/spotify/src/extension.cpp @@ -23,6 +23,7 @@ class Spotify::Private QString clientSecret; QString refreshToken; QString spotifyExecutable; + QString cacheDirectory; bool explicitState = true; int numberOfResults = 5; SpotifyWebAPI *api = nullptr; @@ -45,13 +46,18 @@ Spotify::Extension::Extension() d->explicitState = settings().value("explicit_state").toBool(); d->numberOfResults = settings().value("number_or_results").toInt(); d->spotifyExecutable = settings().value("spotify_executable").toString(); + d->cacheDirectory = settings().value("cache_directory").toString(); if (d->numberOfResults == 0) { d->numberOfResults = 5; } if (d->spotifyExecutable.isEmpty()) { - d->spotifyExecutable = "spotify"; + d->spotifyExecutable = SPOTIFY_EXECUTABLE; + } + + if (d->cacheDirectory.isEmpty()) { + d->cacheDirectory = CACHE_DIRECTORY; } } @@ -100,12 +106,32 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { settings().setValue("number_or_results", s); }); - d->widget->ui.lineEdit_spotify_executable->setText(d->spotifyExecutable); + if (d->spotifyExecutable != SPOTIFY_EXECUTABLE) { + d->widget->ui.lineEdit_spotify_executable->setText(d->spotifyExecutable); + } + d->widget->ui.lineEdit_spotify_executable->setPlaceholderText(SPOTIFY_EXECUTABLE); connect(d->widget->ui.lineEdit_spotify_executable, &QLineEdit::textEdited, [this](const QString &s){ - d->spotifyExecutable = s; + if (s.isEmpty()) { + d->spotifyExecutable = SPOTIFY_EXECUTABLE; + } else { + d->spotifyExecutable = s; + } settings().setValue("spotify_executable", s); }); + if (d->cacheDirectory != CACHE_DIRECTORY) { + d->widget->ui.lineEdit_cache_directory->setText(d->cacheDirectory); + } + d->widget->ui.lineEdit_cache_directory->setPlaceholderText(CACHE_DIRECTORY); + connect(d->widget->ui.lineEdit_cache_directory, &QLineEdit::textEdited, [this](const QString &s){ + if (s.isEmpty()) { + d->cacheDirectory = CACHE_DIRECTORY; + } else { + d->cacheDirectory = s; + } + settings().setValue("cache_directory", s); + }); + // Bind "Test connection" button connect(d->widget->ui.pushButton_test_connection, &QPushButton::clicked, [this](){ @@ -139,8 +165,8 @@ QWidget *Spotify::Extension::widget(QWidget *parent) { void Spotify::Extension::setupSession() { d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); - if(!QDir(COVERS_DIR_PATH).exists()) { - QDir().mkdir(COVERS_DIR_PATH); + if(!QDir(d->cacheDirectory).exists()) { + QDir().mkdir(d->cacheDirectory); } } @@ -195,7 +221,7 @@ void Spotify::Extension::handleQuery(Core::Query * query) const { continue; } - auto filename = QString("%1/%2.jpeg").arg(COVERS_DIR_PATH, track.albumId); + auto filename = QString("%1/%2.jpeg").arg(d->cacheDirectory, track.albumId); // Download cover image of the album. d->api->downloadImage(track.imageUrl, filename); diff --git a/spotify/src/extension.h b/spotify/src/extension.h index 557c941f..3bd81150 100644 --- a/spotify/src/extension.h +++ b/spotify/src/extension.h @@ -31,6 +31,7 @@ class Extension final : private: std::unique_ptr d; - QString COVERS_DIR_PATH = "/tmp/albert-spotify-covers"; + QString SPOTIFY_EXECUTABLE = "spotify"; + QString CACHE_DIRECTORY = "/tmp/albert-spotify-covers"; }; } From 87c4b59223103f69e5a87473a5074e91489023d0 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Mon, 6 Jun 2022 15:36:53 +0200 Subject: [PATCH 14/14] Fix build option variable name --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 534e1cda..321be2d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,8 +71,8 @@ if (BUILD_SNIPPETS) add_subdirectory(snippets) endif() -option(BUILD_SNIPPETS "Build the extension" ON) -if (BUILD_SNIPPETS) +option(BUILD_SPOTIFY "Build the extension" ON) +if (BUILD_SPOTIFY) add_subdirectory(spotify) endif()