From a30c59a987e88c607b7f177de29b61695bb8e675 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Mon, 11 Nov 2024 15:00:57 +0100 Subject: [PATCH] Add new refactored plugin --- .gitignore | 1 + spotify/CMakeLists.txt | 8 + spotify/README.md | 60 +++++ spotify/metadata.json | 7 + spotify/src/configwidget.ui | 227 +++++++++++++++++++ spotify/src/plugin.cpp | 363 +++++++++++++++++++++++++++++++ spotify/src/plugin.h | 50 +++++ spotify/src/spotifyApiClient.cpp | 309 ++++++++++++++++++++++++++ spotify/src/spotifyApiClient.h | 181 +++++++++++++++ spotify/src/types/device.h | 13 ++ spotify/src/types/track.h | 17 ++ 11 files changed, 1236 insertions(+) create mode 100644 spotify/CMakeLists.txt create mode 100644 spotify/README.md create mode 100644 spotify/metadata.json create mode 100644 spotify/src/configwidget.ui create mode 100644 spotify/src/plugin.cpp create mode 100644 spotify/src/plugin.h create mode 100644 spotify/src/spotifyApiClient.cpp create mode 100644 spotify/src/spotifyApiClient.h create mode 100644 spotify/src/types/device.h create mode 100644 spotify/src/types/track.h 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**. + +![Spotify extension](https://i.imgur.com/CoE2C5i.png) + +## 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 SpotifyApiClient::searchTracks(const QString& query, const int limit) +{ + if (!isNetworkManagerSafe()) return {}; + + const auto url = QUrl(SEARCH_URL.arg(query, "track", QString::number(limit))); + const auto request = createRequest(url); + const auto reply = manager->get(request); + + auto tracksArray = std::make_shared(); + + connect(reply, &QNetworkReply::finished, [reply, tracksArray] + { + reply->deleteLater(); + + const auto jsonObject = stringToJson(reply->readAll()); + + *tracksArray = jsonObject["tracks"].toObject()["items"].toArray(); + }); + + waitForSignal(reply, SIGNAL(finished())); + + const auto tracks = std::make_shared>(); + + for (auto trackData : *tracksArray) + { + tracks->append(parseTrack(trackData.toObject())); + } + + return *tracks; +} + +QVector SpotifyApiClient::getDevices() +{ + if (!isNetworkManagerSafe()) return {}; + + const auto request = createRequest(QUrl(DEVICES_URL)); + const auto reply = manager->get(request); + + auto devicesArray = std::make_shared(); + + connect(reply, &QNetworkReply::finished, [reply, devicesArray] + { + reply->deleteLater(); + + QJsonObject jsonObject = stringToJson(reply->readAll()); + + *devicesArray = jsonObject["devices"].toArray(); + }); + + waitForSignal(reply, SIGNAL(finished())); + + const auto devices = std::make_shared>(); + + for (auto deviceData : *devicesArray) + { + devices->append(parseDevice(deviceData.toObject())); + } + + return *devices; +} + +void SpotifyApiClient::waitForDevice(const Track& track) +{ + if (!isNetworkManagerSafe()) return; + + const auto request = createRequest(QUrl(DEVICES_URL)); + const auto reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, [this, reply, track] + { + reply->deleteLater(); + + const auto jsonObject = stringToJson(reply->readAll()); + const auto devicesResult = jsonObject["devices"].toArray(); + + if (devicesResult.isEmpty()) + { + waitForDevice(track); + return; + } + + emit deviceReady(track, devicesResult.at(0).toObject()["id"].toString()); + }); +} + +void SpotifyApiClient::waitForDeviceAndPlay(const Track& track) +{ + connect(this, &SpotifyApiClient::deviceReady, this, &SpotifyApiClient::playTrack); + waitForDevice(track); +} + +void SpotifyApiClient::addTrackToQueue(const Track& track) const +{ + if (!isNetworkManagerSafe()) return; + + const auto request = createRequest(QUrl(QUEUE_URL.arg(track.uri))); + + manager->post(request, ""); +} + +void SpotifyApiClient::playTrack(const Track& track, const QString& deviceId) const +{ + if (!isNetworkManagerSafe()) return; + + const auto request = createRequest(QUrl(PLAY_URL.arg(deviceId))); + const auto postData = QString(R"({"uris": ["%1"]})").arg(track.uri).toLocal8Bit(); + + manager->put(request, postData); +} + +// PRIVATE METHODS + +void SpotifyApiClient::waitForSignal(const QObject* sender, const char* signal) +{ + QEventLoop loop; + connect(sender, signal, &loop, SLOT(quit())); + loop.exec(); +} + +QJsonObject SpotifyApiClient::stringToJson(const QString& string) +{ + return QJsonDocument::fromJson(string.toUtf8()).object(); +} + +QNetworkRequest SpotifyApiClient::createRequest(const QUrl& url) const +{ + const auto request = std::make_shared(url); + const 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"))); + request->setTransferTimeout(DEFAULT_TIMEOUT); + + return *request; +} + +bool SpotifyApiClient::isNetworkManagerSafe() const +{ + return manager != nullptr && manager->thread() == QThread::currentThread(); +} + +Device SpotifyApiClient::parseDevice(QJsonObject deviceData) +{ + auto device = Device(); + + device.id = deviceData["id"].toString(); + device.name = deviceData["name"].toString(); + device.type = deviceData["type"].toString(); + device.isActive = deviceData["is_active"].toBool(); + + return device; +} + +Track SpotifyApiClient::parseTrack(QJsonObject trackData) +{ + auto track = Track(); + + track.id = trackData["id"].toString(); + track.name = trackData["name"].toString(); + track.artists = linearizeArtists(trackData["artists"].toArray()); + 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(); + + return track; +} + +QString SpotifyApiClient::linearizeArtists(const QJsonArray& artists) +{ + QStringList linearizedArtists; + std::ranges::transform(artists, std::back_inserter(linearizedArtists), [](const QJsonValue& artist) + { + return artist.toObject()["name"].toString(); + }); + + return linearizedArtists.join(", "); +} diff --git a/spotify/src/spotifyApiClient.h b/spotify/src/spotifyApiClient.h new file mode 100644 index 00000000..70e858f6 --- /dev/null +++ b/spotify/src/spotifyApiClient.h @@ -0,0 +1,181 @@ +// Copyright (c) 2020-2024 Ivo Šmerek + +#pragma once +#include +#include + +#include "types/device.h" +#include "types/track.h" + + +inline QString TOKEN_URL = "https://accounts.spotify.com/api/token"; +inline QString SEARCH_URL = "https://api.spotify.com/v1/search?q=%1&type=%2&limit=%3"; +inline QString DEVICES_URL = "https://api.spotify.com/v1/me/player/devices"; +inline QString QUEUE_URL = "https://api.spotify.com/v1/me/player/queue?uri=%1"; +inline QString PLAY_URL = "https://api.spotify.com/v1/me/player/play?device_id=%1"; + +inline int DEFAULT_TIMEOUT = 10000; + +struct apiCredentials +{ + QString clientId; + QString clientSecret; + QString refreshToken; +}; + + +/** + * Spotify API client for interacting with the Spotify Web API. + */ +class SpotifyApiClient final : public QObject +{ +public: + /** Contains string description of the last error message. */ + QString lastErrorMessage; + + void setClientId(const QString& id) { clientId = id; } + void setClientSecret(const QString& secret) { clientSecret = secret; } + void setRefreshToken(const QString& token) { refreshToken = token; } + + explicit SpotifyApiClient(const apiCredentials& credentials); + ~SpotifyApiClient() override; + + /** + * Reset the network manager. This is necessary because the network manager + * is not guaranteed to be thread-safe and shouldn't be shared between threads. + * Call this method each time in a new thread. + * @see https://stackoverflow.com/questions/35684123 + */ + void resetNetworkManager(); + + /** + * Check if the access token is expired. + * @return true if the access token is expired, false otherwise. + */ + bool isAccessTokenExpired() const; + + // WEB API CALLS // + + /** + * Request and store a new access token from Spotify. + * @return true if the accessToken was successfully refreshed. + */ + bool refreshAccessToken(); + + /** + * Check response of Spotify API server. + * @return true if server returns any response, false otherwise. + */ + bool checkServerResponse() const; + + /** + * Download a file from the given URL and save it to the given file path. + * It will not download the file if the given pilePath already exists. + * @param url URL to download. + * @param filePath File path to save the file to. + */ + void downloadFile(const QString& url, const QString& filePath); + + /** + * Search for tracks on Spotify. + * @param query The search query. + * @param limit The maximum number of tracks to return. + * @return A list of tracks found by the search. + */ + QVector searchTracks(const QString& query, int limit); + + /** + * Returns list of users available Spotify devices. + */ + QVector getDevices(); + + /** + * Wait for any device to be ready. + * @param track + */ + void waitForDevice(const Track& track); + + /** + * Wait for any device to be ready and play a track on it. + * @param track The track object to play. + */ + void waitForDeviceAndPlay(const Track& track); + + /** + * Add a track to the queue of a specific device. + * @param track The track object to add to the queue. + */ + void addTrackToQueue(const Track& track) const; + + public slots: + /** + * Play a track on a specific device. + * @param track The track object to play. + * @param deviceId The ID of the device to play the track on. + */ + void playTrack(const Track& track, const QString& deviceId) const; + +private: + Q_OBJECT + + /** Network manager for sending requests. */ + QNetworkAccessManager* manager = nullptr; + + QString clientId; + QString clientSecret; + QString refreshToken; + QString accessToken; + + QDateTime expirationTime; + QReadWriteLock fileLock; + + /** + * Wait for a specific signal from an object. + * @param sender The object emitting the signal. + * @param signal The signal to wait for. + */ + static void waitForSignal(const QObject* sender, const char* signal); + + /** + * Convert a JSON string to a JSON object. + * @param string The JSON string to convert. + * @return The JSON object. + */ + static QJsonObject stringToJson(const QString& string); + + /** + * Create a network request with the given URL. + * @param url The URL to create the request for. + * @return The created request with access token from instance. + */ + QNetworkRequest createRequest(const QUrl& url) const; + + /** + * @return true if the network manager is ready for current thread. + */ + bool isNetworkManagerSafe() const; + + /** + * Parse a JSON object to a device object. + * @param deviceData The JSON object to parse. + * @return The parsed device object. + */ + static Device parseDevice(QJsonObject deviceData); + + /** + * Parse a JSON object to a track object. + * @param trackData The JSON object to parse. + * @return The parsed track object. + */ + static Track parseTrack(QJsonObject trackData); + + /** + * Linearize a list of artists to a single string. + * @param artists The list of artists to linearize. + * @return String of artists separated by commas. + */ + static QString linearizeArtists(const QJsonArray& artists); + +signals: + void deviceReady(const Track&, QString); +}; diff --git a/spotify/src/types/device.h b/spotify/src/types/device.h new file mode 100644 index 00000000..4da35d07 --- /dev/null +++ b/spotify/src/types/device.h @@ -0,0 +1,13 @@ +// Copyright (C) 2020-2024 Ivo Šmerek + +#pragma once +#include + +class Device +{ +public: + QString id; + QString name; + QString type; + bool isActive = false; +}; diff --git a/spotify/src/types/track.h b/spotify/src/types/track.h new file mode 100644 index 00000000..d3d5bccb --- /dev/null +++ b/spotify/src/types/track.h @@ -0,0 +1,17 @@ +// Copyright (C) 2020-2024 Ivo Šmerek + +#pragma once +#include + +class Track +{ +public: + QString id; + QString name; + QString artists; + QString albumId; + QString albumName; + QString uri; + QString imageUrl; + bool isExplicit = false; +};