diff --git a/.gitignore b/.gitignore
index c19b3e87..8598f0ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ build*
*.qmlc
*.jsc
.DS_Store
+.idea
diff --git a/spotify/CMakeLists.txt b/spotify/CMakeLists.txt
new file mode 100644
index 00000000..b762529f
--- /dev/null
+++ b/spotify/CMakeLists.txt
@@ -0,0 +1,8 @@
+cmake_minimum_required(VERSION 3.16)
+find_package(Albert REQUIRED)
+
+project(spotify VERSION 1.0.0)
+
+file(GLOB_RECURSE SOURCES "src/*")
+
+albert_plugin(QT Widgets)
diff --git a/spotify/README.md b/spotify/README.md
new file mode 100644
index 00000000..661e8696
--- /dev/null
+++ b/spotify/README.md
@@ -0,0 +1,60 @@
+# Spotify extension
+
+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**.
+
+
+
+## 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 the 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=https://nonexistent-uri.net/ https://accounts.spotify.com/api/token
+```
+
+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**.
+
+
+
+The whole process is also similarly described
+[here](https://benwiz.com/blog/create-spotify-refresh-token/).
diff --git a/spotify/metadata.json b/spotify/metadata.json
new file mode 100644
index 00000000..449e2fea
--- /dev/null
+++ b/spotify/metadata.json
@@ -0,0 +1,7 @@
+{
+ "authors": ["@BlueManCZ"],
+ "description": "Control your Spotify player",
+ "license": "MIT",
+ "name": "Spotify",
+ "url": "https://github.com/albertlauncher/plugins/tree/main/spotify"
+}
diff --git a/spotify/src/configwidget.ui b/spotify/src/configwidget.ui
new file mode 100644
index 00000000..89c5d714
--- /dev/null
+++ b/spotify/src/configwidget.ui
@@ -0,0 +1,227 @@
+
+
+ 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>
+
+
+
+ -
+
+
-
+
+
+ Number of results:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
-
+
+
+ 1
+
+
+ 10
+
+
+ 5
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Spotify executable:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+
+
+
+ spotify
+
+
+
+ -
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+ Allow explicit content:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Cache directory:
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Using system temporary directory
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+ lineEdit_client_id
+ lineEdit_client_secret
+ lineEdit_refresh_token
+
+
+
+
diff --git a/spotify/src/plugin.cpp b/spotify/src/plugin.cpp
new file mode 100644
index 00000000..c35a1c30
--- /dev/null
+++ b/spotify/src/plugin.cpp
@@ -0,0 +1,363 @@
+// Copyright (c) 2020-2024 Ivo Šmerek
+
+#include "plugin.h"
+
+#include
+#include
+
+#include "ui_configwidget.h"
+#include
+#include
+
+#include "spotifyApiClient.h"
+#include "albert/util.h"
+
+ALBERT_LOGGING_CATEGORY("spotify")
+
+using namespace albert;
+
+
+inline auto CFG_CLIENT_ID = "client_id";
+inline auto CFG_CLIENT_SECRET = "client_secret";
+inline auto CFG_REFRESH_TOKEN = "refresh_token";
+inline auto CFG_ALLOW_EXPLICIT = "allow_explicit";
+inline auto CFG_NUM_RESULTS = "number_of_results";
+inline auto CFG_SPOTIFY_EXECUTABLE = "spotify_executable";
+inline auto CFG_CACHE_DIR = "cache_directory";
+inline auto CFG_LAST_DEVICE = "last_device";
+
+inline auto DEF_NUM_RESULTS = 5;
+inline auto DEF_SPOTIFY_EXECUTABLE = "spotify";
+
+
+Plugin::Plugin()
+{
+ const auto credentials = apiCredentials{
+ settingsString(CFG_CLIENT_ID),
+ settingsString(CFG_CLIENT_SECRET),
+ settingsString(CFG_REFRESH_TOKEN),
+ };
+ api = new SpotifyApiClient(credentials);
+}
+
+Plugin::~Plugin()
+{
+ delete api;
+}
+
+QString Plugin::defaultTrigger() const
+{
+ return "play ";
+}
+
+void Plugin::handleTriggerQuery(Query* query)
+{
+ if (const auto trimmed = query->string().trimmed(); trimmed.isEmpty())
+ return;
+
+ if (!query->isValid())
+ return;
+
+ // Each query is executed in different thread, so reset the network manager.
+ // Read the JSDoc of the method for more information.
+ api->resetNetworkManager();
+
+ // If there is no internet connection, make one alerting item to let the user know.
+ if (!api->checkServerResponse())
+ {
+ DEBG << "No internet connection!";
+ query->add(StandardItem::make(nullptr, "Can't get an answer from the server.",
+ "Please, check your internet connection.", nullptr));
+ return;
+ }
+
+ // If the access token expires, try to refresh it or alert the user what is wrong.
+ if (api->isAccessTokenExpired())
+ {
+ DEBG << "Token expired. Refreshing";
+ if (!api->refreshAccessToken())
+ {
+ query->add(StandardItem::make(nullptr, "Wrong credentials.",
+ "Please, check the extension settings.", nullptr));
+ return;
+ }
+ }
+
+ // Search for tracks on Spotify using the query.
+ const auto tracks = api->searchTracks(query->string(), settingsInt(CFG_NUM_RESULTS, DEF_NUM_RESULTS));
+
+ // Get available Spotify devices.
+ const auto devices = api->getDevices();
+
+ const auto activeDevice = findActiveDevice(devices);
+ const auto cacheDirectory = settingsString(CFG_CACHE_DIR, QDir::tempPath() + "/albert-spotify-covers");
+
+ if (!QDir(cacheDirectory).exists() && QDir().mkpath(cacheDirectory))
+ {
+ INFO << "Cache directory created:" << cacheDirectory;
+ }
+
+ for (const auto& track : tracks)
+ {
+ // If the track is explicit and the user doesn't want to see explicit tracks, skip it.
+ if (track.isExplicit && !settingsBool(CFG_ALLOW_EXPLICIT)) continue;
+
+ const auto filename = QString("%1/%2.jpeg").arg(cacheDirectory, track.albumId);
+
+ // Download cover image of the album.
+ api->downloadFile(track.imageUrl, filename);
+
+ // Create a standard item with a track name in title and album with artists in subtext.
+ const auto result = StandardItem::make(
+ track.id,
+ track.name,
+ QString("%1 (%2)").arg(track.albumName, track.artists),
+ nullptr,
+ {filename});
+
+ auto actions = std::vector();
+
+ actions.emplace_back(
+ "play",
+ "Play on Spotify",
+ [this, track, activeDevice, devices]
+ {
+ // Each action is executed in different thread, so reset the network manager.
+ // Read the JSDoc of the method for more information.
+ api->resetNetworkManager();
+
+ // Check if the last-used device is still available.
+ const auto lastDeviceId = settingsString(CFG_LAST_DEVICE);
+ const auto lastDeviceAvailable = findDevice(devices, lastDeviceId).id != "";
+
+ if (activeDevice.id != "")
+ {
+ // If available, use an active device and play the track.
+ api->playTrack(track, activeDevice.id);
+ INFO << "Playing on active device.";
+ settings()->setValue(CFG_LAST_DEVICE, activeDevice.id);
+ }
+ else if (lastDeviceAvailable)
+ {
+ // If there is not an active device, use last-used one.
+ api->playTrack(track, lastDeviceId);
+ INFO << "Playing on last device.";
+ }
+ else if (!devices.isEmpty())
+ {
+ // Use the first available device.
+ api->playTrack(track, devices[0].id);
+ INFO << "Playing on first found device.";
+ settings()->setValue(CFG_LAST_DEVICE, devices[0].id);
+ }
+ else
+ {
+ // Run local Spotify client, wait until it loads, and play the track.
+ runDetachedProcess(
+ QStringList() << settingsString(
+ CFG_SPOTIFY_EXECUTABLE,
+ DEF_SPOTIFY_EXECUTABLE));
+ api->waitForDeviceAndPlay(track);
+ INFO << "Playing on local Spotify.";
+ }
+ }
+ );
+
+ actions.emplace_back(
+ "queue",
+ "Add to the Spotify queue",
+ [this, track]
+ {
+ // Each action is executed in different thread, so reset the network manager.
+ // Read the JSDoc of the method for more information.
+ api->resetNetworkManager();
+
+ api->addTrackToQueue(track);
+ }
+ );
+
+ // For each device except active create action to transfer Spotify playback to this device.
+ for (const auto& device : devices)
+ {
+ if (device.isActive) continue;
+
+ actions.emplace_back(
+ QString("play_on_%1").arg(device.id),
+ QString("Play on %1 (%2)").arg(device.type, device.name),
+ [this, track, device]
+ {
+ // Each action is executed in different thread, so reset the network manager.
+ // Read the JSDoc of the method for more information.
+ api->resetNetworkManager();
+
+ api->playTrack(track, device.id);
+ settings()->setValue(CFG_LAST_DEVICE, device.id);
+ }
+ );
+ }
+
+ result->setActions(actions);
+
+ query->add(result);
+ }
+}
+
+QWidget* Plugin::buildConfigWidget()
+{
+ auto* widget = new QWidget();
+ Ui::ConfigWidget ui;
+ ui.setupUi(widget);
+
+ ui.lineEdit_client_id->setText(settingsString(CFG_CLIENT_ID));
+ connect(ui.lineEdit_client_id,
+ &QLineEdit::textEdited,
+ this, [this](const QString& value)
+ {
+ settings()->setValue(CFG_CLIENT_ID, value);
+ api->setClientId(value);
+ });
+
+ ui.lineEdit_client_secret->setText(settingsString(CFG_CLIENT_SECRET));
+ connect(ui.lineEdit_client_secret,
+ &QLineEdit::textEdited,
+ this, [this](const QString& value)
+ {
+ settings()->setValue(CFG_CLIENT_SECRET, value);
+ api->setClientSecret(value);
+ });
+
+ ui.lineEdit_refresh_token->setText(settingsString(CFG_REFRESH_TOKEN));
+ connect(ui.lineEdit_refresh_token,
+ &QLineEdit::textEdited,
+ this, [this](const QString& value)
+ {
+ settings()->setValue(CFG_REFRESH_TOKEN, value);
+ api->setRefreshToken(value);
+ });
+
+ // Bind "Test connection" button
+ connect(ui.pushButton_test_connection, &QPushButton::clicked, this, [this]
+ {
+ // UI actions are executed in different thread, so reset the network manager.
+ // Read the JSDoc of the method for more information.
+ api->resetNetworkManager();
+
+ const bool refreshStatus = api->refreshAccessToken();
+
+ QString message = "Everything is set up correctly.";
+ if (!refreshStatus)
+ {
+ message = api->lastErrorMessage.isEmpty()
+ ? "Can't get an answer from the server.\nPlease, check your internet connection."
+ : QString("Spotify Web API returns: \"%1\"\nPlease, check all input fields.")
+ .arg(api->lastErrorMessage);
+ }
+
+ const auto messageBox = new QMessageBox();
+ messageBox->setWindowTitle(refreshStatus ? "Success" : "API error");
+ messageBox->setText(message);
+ messageBox->setIcon(refreshStatus ? QMessageBox::Information : QMessageBox::Critical);
+ messageBox->exec();
+ delete messageBox;
+ });
+
+ ui.checkBox_explicit->setChecked(settingsBool(CFG_ALLOW_EXPLICIT));
+ connect(ui.checkBox_explicit,
+ &QCheckBox::toggled,
+ this, [this](const bool value)
+ {
+ settings()->setValue(CFG_ALLOW_EXPLICIT, value);
+ });
+
+ ui.spinBox_number_of_results->setValue(settingsInt(CFG_NUM_RESULTS, DEF_NUM_RESULTS));
+ connect(ui.spinBox_number_of_results,
+ &QSpinBox::valueChanged,
+ this, [this](const int value)
+ {
+ settings()->setValue(CFG_NUM_RESULTS, value);
+ });
+
+ ui.lineEdit_spotify_executable->setText(settingsString(CFG_SPOTIFY_EXECUTABLE));
+ connect(ui.lineEdit_spotify_executable,
+ &QLineEdit::textEdited,
+ this, [this](const QString& value)
+ {
+ if (value.isEmpty())
+ {
+ settings()->remove(CFG_SPOTIFY_EXECUTABLE);
+ return;
+ }
+ settings()->setValue(CFG_SPOTIFY_EXECUTABLE, value);
+ });
+
+ ui.lineEdit_cache_directory->setText(settingsString(CFG_CACHE_DIR));
+ connect(ui.lineEdit_cache_directory,
+ &QLineEdit::textEdited,
+ this, [this](const QString& value)
+ {
+ if (value.isEmpty())
+ {
+ settings()->remove(CFG_CACHE_DIR);
+ return;
+ }
+ settings()->setValue(CFG_CACHE_DIR, value);
+ });
+
+ return widget;
+}
+
+Device Plugin::findActiveDevice(const QVector& devices)
+{
+ for (const auto& device : devices)
+ {
+ if (device.isActive)
+ {
+ return device;
+ }
+ }
+
+ return {};
+}
+
+Device Plugin::findDevice(const QVector& devices, const QString& id)
+{
+ for (const auto& device : devices)
+ {
+ if (device.id == id)
+ {
+ return device;
+ }
+ }
+
+ return {};
+}
+
+QString Plugin::settingsString(const QAnyStringView key) const
+{
+ return settings()->value(key).toString();
+}
+
+QString Plugin::settingsString(const QAnyStringView key, const QVariant& defaultValue) const
+{
+ return settings()->value(key, defaultValue).toString();
+}
+
+int Plugin::settingsInt(const QAnyStringView key) const
+{
+ return settings()->value(key).toInt();
+}
+
+int Plugin::settingsInt(const QAnyStringView key, const QVariant& defaultValue) const
+{
+ return settings()->value(key, defaultValue).toInt();
+}
+
+bool Plugin::settingsBool(const QAnyStringView key) const
+{
+ return settings()->value(key).toBool();
+}
+
+bool Plugin::settingsBool(const QAnyStringView key, const QVariant& defaultValue) const
+{
+ return settings()->value(key, defaultValue).toBool();
+}
diff --git a/spotify/src/plugin.h b/spotify/src/plugin.h
new file mode 100644
index 00000000..e044bb17
--- /dev/null
+++ b/spotify/src/plugin.h
@@ -0,0 +1,50 @@
+// Copyright (c) 2020-2024 Ivo Šmerek
+
+#pragma once
+#include
+#include "albert/property.h"
+#include "albert/triggerqueryhandler.h"
+
+#include "spotifyApiClient.h"
+
+
+class Plugin final : public albert::ExtensionPlugin,
+ public albert::TriggerQueryHandler
+{
+ ALBERT_PLUGIN
+
+public:
+ Plugin();
+ ~Plugin() override;
+
+private:
+ SpotifyApiClient* api;
+
+ QString defaultTrigger() const override;
+ void handleTriggerQuery(albert::Query*) override;
+ QWidget* buildConfigWidget() override;
+
+ /**
+ * Find the active device from a list of devices.
+ * @param devices The list of devices to search.
+ * @return The active device, or an empty device if none is active.
+ */
+ static Device findActiveDevice(const QVector& devices);
+
+ /**
+ * Find a device by ID from a list of devices.
+ * @param devices The list of devices to search.
+ * @param id The ID of the device to find.
+ * @return The device with the given ID, or an empty device if none is found.
+ */
+ static Device findDevice(const QVector& devices, const QString& id);
+
+ // Helper functions for accessing settings
+
+ QString settingsString(QAnyStringView key) const;
+ QString settingsString(QAnyStringView key, const QVariant& defaultValue) const;
+ int settingsInt(QAnyStringView key) const;
+ int settingsInt(QAnyStringView key, const QVariant& defaultValue) const;
+ bool settingsBool(QAnyStringView key) const;
+ bool settingsBool(QAnyStringView key, const QVariant& defaultValue) const;
+};
diff --git a/spotify/src/spotifyApiClient.cpp b/spotify/src/spotifyApiClient.cpp
new file mode 100644
index 00000000..2ef54228
--- /dev/null
+++ b/spotify/src/spotifyApiClient.cpp
@@ -0,0 +1,309 @@
+// Copyright (c) 2020-2024 Ivo Šmerek
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "albert/logging.h"
+#include "spotifyApiClient.h"
+
+
+SpotifyApiClient::SpotifyApiClient(const apiCredentials& credentials)
+{
+ clientId = credentials.clientId;
+ clientSecret = credentials.clientSecret;
+ refreshToken = credentials.refreshToken;
+}
+
+SpotifyApiClient::~SpotifyApiClient()
+{
+ delete manager;
+}
+
+void SpotifyApiClient::resetNetworkManager()
+{
+ manager = new QNetworkAccessManager();
+}
+
+bool SpotifyApiClient::isAccessTokenExpired() const
+{
+ return QDateTime::currentDateTime() > expirationTime;
+}
+
+bool SpotifyApiClient::refreshAccessToken()
+{
+ if (!isNetworkManagerSafe()) return false;
+
+ auto request = QNetworkRequest(QUrl(TOKEN_URL));
+
+ const auto hash = QString("%1:%2").arg(clientId, clientSecret).toUtf8().toBase64();
+ const auto header = QString("Basic ").append(hash);
+
+ request.setRawHeader(QByteArray("Authorization"), header.toUtf8());
+ request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(QString("application/x-www-form-urlencoded")));
+ request.setTransferTimeout(DEFAULT_TIMEOUT);
+
+ const auto savedToken = accessToken;
+ const auto postData = QString("grant_type=refresh_token&refresh_token=%1").arg(refreshToken).toLocal8Bit();
+ const auto reply = this->manager->post(request, postData);
+
+ connect(reply, &QNetworkReply::finished, [this, reply]
+ {
+ reply->deleteLater();
+
+ const auto replyString = reply->readAll();
+
+ if (const auto jsonVariant = stringToJson(replyString); !jsonVariant["access_token"].isUndefined())
+ {
+ accessToken = jsonVariant["access_token"].toString();
+ expirationTime = QDateTime::currentDateTime().addSecs(jsonVariant["expires_in"].toInt());
+ lastErrorMessage = "";
+ }
+ else
+ {
+ accessToken = "";
+ lastErrorMessage = jsonVariant[
+ !jsonVariant["error_description"].isUndefined() ? "error_description" : "error"
+ ].toString();
+ }
+ });
+
+ waitForSignal(reply, SIGNAL(finished()));
+
+ return !accessToken.isEmpty() && savedToken != accessToken;
+}
+
+bool SpotifyApiClient::checkServerResponse() const
+{
+ try
+ {
+ if (!isNetworkManagerSafe()) return false;
+
+ const auto request = QNetworkRequest(QUrl(TOKEN_URL));
+ const auto reply = manager->get(request);
+
+ waitForSignal(reply, SIGNAL(finished()));
+
+ reply->deleteLater();
+
+ return reply->bytesAvailable();
+ }
+ catch (...)
+ {
+ return false;
+ }
+}
+
+void SpotifyApiClient::downloadFile(const QString& url, const QString& filePath)
+{
+ if (!isNetworkManagerSafe()) return;
+ if (const QFileInfo fileInfo(filePath); fileInfo.exists()) return;
+
+ fileLock.lockForWrite();
+
+ const auto request = QNetworkRequest(url);
+ const auto reply = manager->get(request);
+
+ connect(reply, &QNetworkReply::finished, [reply, filePath]
+ {
+ reply->deleteLater();
+
+ if (reply->bytesAvailable())
+ {
+ QSaveFile file(filePath);
+ file.open(QIODevice::WriteOnly);
+ file.write(reply->readAll());
+ file.commit();
+ }
+ });
+
+ waitForSignal(reply, SIGNAL(finished()));
+
+ fileLock.unlock();
+}
+
+QVector