diff --git a/CMakeLists.txt b/CMakeLists.txt index 2612682d..321be2d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,11 @@ if (BUILD_SNIPPETS) add_subdirectory(snippets) endif() +option(BUILD_SPOTIFY "Build the extension" ON) +if (BUILD_SPOTIFY) + 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..52a97846 --- /dev/null +++ b/spotify/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.1.3) + +project(spotify) + +file(GLOB_RECURSE SRC src/* metadata.json) + +find_package(Qt5 5.5.0 REQUIRED COMPONENTS Network Widgets) + +add_library(${PROJECT_NAME} SHARED ${SRC}) + +target_include_directories(${PROJECT_NAME} PRIVATE src/) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + Qt5::Network + Qt5::Widgets + albert::lib + xdg +) + +install(TARGETS ${PROJECT_NAME} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/albert/plugins) diff --git a/spotify/README.md b/spotify/README.md new file mode 100644 index 00000000..535c6259 --- /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/). \ No newline at end of file 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" : [] +} 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..027160f4 --- /dev/null +++ b/spotify/src/configwidget.ui @@ -0,0 +1,227 @@ + + + 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> + + + + + + + + + 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 + + + + + + + /tmp/albert-spotify-covers + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + lineEdit_client_id + lineEdit_client_secret + lineEdit_refresh_token + + + + diff --git a/spotify/src/device.h b/spotify/src/device.h new file mode 100644 index 00000000..07c29cfb --- /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 = false; + }; +} diff --git a/spotify/src/extension.cpp b/spotify/src/extension.cpp new file mode 100644 index 00000000..fa18f853 --- /dev/null +++ b/spotify/src/extension.cpp @@ -0,0 +1,295 @@ +// Copyright (C) 2014-2021 Manuel Schneider, Ivo Šmerek + +#include +#include +#include "albert/util/standardactions.h" +#include "albert/util/standarditem.h" +#include "configwidget.h" +#include "extension.h" +#include "spotifyWebAPI.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; + +class Spotify::Private +{ +public: + QPointer widget; + QString clientId; + QString clientSecret; + QString refreshToken; + QString spotifyExecutable; + QString cacheDirectory; + bool explicitState = true; + int numberOfResults = 5; + SpotifyWebAPI *api = nullptr; +}; + + +/** ***************************************************************************/ +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->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(); + 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_EXECUTABLE; + } + + if (d->cacheDirectory.isEmpty()) { + d->cacheDirectory = CACHE_DIRECTORY; + } +} + + + +/** ***************************************************************************/ +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); + }); + + 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); + }); + + 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){ + 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](){ + d->api->setConnection(d->clientId, d->clientSecret, d->refreshToken); + d->api->manager = new QNetworkAccessManager(); + + 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); + if (d->api->lastErrorMessage.isEmpty()) { + message = "Can't get an answer from the server.\nPlease, check your internet connection."; + } + } + + 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(d->cacheDirectory).exists()) { + QDir().mkdir(d->cacheDirectory); + } +} + + + +/** ***************************************************************************/ +void Spotify::Extension::teardownSession() { + +} + + + +/** ***************************************************************************/ +void Spotify::Extension::handleQuery(Core::Query * query) const { + if (query->string().trimmed().isEmpty()) + return; + + d->api->manager = 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!"; + + 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"; + 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; + } + } + + // 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(d->cacheDirectory, track.albumId); + + // Download cover image of the album. + d->api->downloadImage(track.imageUrl, filename); + + // 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]() + { + // 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); + }); + + result->addAction(playTrack); + result->addAction(addToQueue); + + // 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(move(result), UINT_MAX); + } +} + +QueryHandler::ExecutionType Spotify::Extension::executionType() const { + return QueryHandler::ExecutionType::Realtime; +} diff --git a/spotify/src/extension.h b/spotify/src/extension.h new file mode 100644 index 00000000..3bd81150 --- /dev/null +++ b/spotify/src/extension.h @@ -0,0 +1,37 @@ +// Copyright (C) 2014-2021 Manuel Schneider, Ivo Šmerek + +#pragma once +#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 ", "play "}; } + void setupSession() override; + void teardownSession() override; + void handleQuery(Core::Query * query) const override; + ExecutionType executionType() const override; + +private: + std::unique_ptr d; + QString SPOTIFY_EXECUTABLE = "spotify"; + QString CACHE_DIRECTORY = "/tmp/albert-spotify-covers"; +}; +} diff --git a/spotify/src/spotifyWebAPI.cpp b/spotify/src/spotifyWebAPI.cpp new file mode 100644 index 00000000..7a77bcf5 --- /dev/null +++ b/spotify/src/spotifyWebAPI.cpp @@ -0,0 +1,296 @@ +// Copyright (C) 2020-2021 Ivo Šmerek + +#include +#include + +#include "spotifyWebAPI.h" + +namespace Spotify { + + SpotifyWebAPI::SpotifyWebAPI(QObject* parent) { + manager = new QNetworkAccessManager(this); + setParent(parent); + } + + SpotifyWebAPI::~SpotifyWebAPI() { + delete manager; + } + + QJsonObject SpotifyWebAPI::answerToJson_(const QString& answer) { + QJsonDocument doc = QJsonDocument::fromJson(answer.toUtf8()); + QJsonObject jsonObject = doc.object(); + 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); + 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); + + if (manager->thread() != QThread::currentThread()) { + return false; + } + QNetworkReply *reply = manager->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 = 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_; + + if (manager->thread() != QThread::currentThread()) { + return false; + } + QNetworkReply *reply = manager->post(request, postData); + + connect(reply, &QNetworkReply::finished, [this, reply]() { + QString answer = reply->readAll(); + QJsonObject jsonVariant = answerToJson_(answer); + + accessToken_ = ""; + + if (!jsonVariant["access_token"].isUndefined()) { + accessToken_ = jsonVariant["access_token"].toString(); + expirationTime_ = QDateTime::currentDateTime().addSecs(jsonVariant["expires_in"].toInt()); + } + + if (!jsonVariant["error_description"].isUndefined()) { + lastErrorMessage = jsonVariant["error_description"].toString(); + } else { + lastErrorMessage = jsonVariant["error"].toString(); + } + }); + + waitForSignal_(reply, SIGNAL(finished())); + + return !accessToken_.isEmpty() && savedToken != accessToken_; + } + + bool SpotifyWebAPI::testConnection() { + return refreshToken(); + } + + bool SpotifyWebAPI::expired() { + return QDateTime::currentDateTime() > expirationTime_; + } + + QVector SpotifyWebAPI::searchTracks(const QString& query, const int limit) { + 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(); + + connect(reply, &QNetworkReply::finished, [reply, itemResults]() { + + QString answer = reply->readAll(); + QJsonObject jsonObject = answerToJson_(answer); + + *itemResults = jsonObject["tracks"].toObject()["items"].toArray(); + }); + + waitForSignal_(reply, SIGNAL(finished())); + + auto results = new QVector(); + + for (auto item : *itemResults) { + auto trackData = item.toObject(); + auto artists = trackData["artists"].toArray(); + + auto track = new 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) { + fileLock_.lockForWrite(); + + QFileInfo fileInfo(imageFilePath); + if (fileInfo.exists()) { + fileLock_.unlock(); + return; + } + + if (manager->thread() != QThread::currentThread()) { + fileLock_.unlock(); + return; + } + + QNetworkRequest request(imageUrl); + QNetworkReply *reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, [reply, imageFilePath]() { + if (reply->bytesAvailable()) { + QSaveFile file(imageFilePath); + file.open(QIODevice::WriteOnly); + file.write(reply->readAll()); + file.commit(); + } + }); + + waitForSignal_(reply, SIGNAL(finished())); + fileLock_.unlock(); + } + + void SpotifyWebAPI::addItemToQueue(const QString& uri) { + manager = new QNetworkAccessManager(); + auto url = QUrl(ADD_ITEM_URL.arg(uri)); + QNetworkRequest request = buildRequest_(url); + + manager->post(request, ""); + } + + void SpotifyWebAPI::play(const QString& uri, QString device) { + manager = new QNetworkAccessManager(); + if (device.isEmpty() && activeDevice) { + device = activeDevice->id; + } + auto url = QUrl(PLAY_URL.arg(device)); + QNetworkRequest request = buildRequest_(url); + + QByteArray postData = QString(R"({"uris": ["%1"]})").arg(uri).toLocal8Bit(); + + manager->put(request, postData); + } + + QString SpotifyWebAPI::waitForDeviceAndPlay(const QString& uri, int timeout) { + connect(this, SIGNAL(deviceReady(QString, QString)), this, SLOT(play(QString, QString))); + + QtConcurrent::run([=]() { + manager = new QNetworkAccessManager(); + waitForDevice_(uri, timeout); + }); + + return ""; + } + + QVector *SpotifyWebAPI::getDevices() { + 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(); + + connect(reply, &QNetworkReply::finished, [reply, devicesResult]() { + QString answer = reply->readAll(); + QJsonObject jsonObject = answerToJson_(answer); + + *devicesResult = jsonObject["devices"].toArray(); + }); + + waitForSignal_(reply, SIGNAL(finished())); + + auto result = new QVector(); + + 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; + } + + QString SpotifyWebAPI::getFirstDeviceId() { + QVector *devices_ = getDevices(); + if (devices_->isEmpty()) { + return ""; + } + return devices_[0][0].id; + } +} diff --git a/spotify/src/spotifyWebAPI.h b/spotify/src/spotifyWebAPI.h new file mode 100644 index 00000000..b4ed88b5 --- /dev/null +++ b/spotify/src/spotifyWebAPI.h @@ -0,0 +1,92 @@ +// Copyright (C) 2020-2021 Ivo Šmerek + +#pragma once +#include +#include +#include +#include +#include "track.h" +#include "device.h" + +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: + explicit SpotifyWebAPI(QObject* parent); + ~SpotifyWebAPI() override; + + QNetworkAccessManager *manager; + 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); + + // 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 searchTracks(const QString& query, int limit); + + // 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); + + // Asynchronously plays the song as soon as an available device appears. + // Gives up after the timeout. + QString waitForDeviceAndPlay(const QString& uri, int timeout); + + // Returns list of users available Spotify devices. + QVector *getDevices(); + + // Returns id of first available Spotify device. + QString getFirstDeviceId(); + +signals: + void deviceReady(QString, QString); + +public slots: + // Plays track on Spotify device. + void play(const QString& uri, QString device = ""); +}; + +} diff --git a/spotify/src/track.h b/spotify/src/track.h new file mode 100644 index 00000000..a683edf8 --- /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; +}; +}