From 6982b47819bff89c92e15703937e401030c19ff1 Mon Sep 17 00:00:00 2001 From: kentsangkm Date: Wed, 30 Jun 2021 20:51:09 +0200 Subject: [PATCH] Search song from Spotify via web api --- ext/clementine-spotifyblob/CMakeLists.txt | 4 +- src/CMakeLists.txt | 4 + .../spotifywebapisearchprovider.cpp | 97 +++++++++ .../spotifywebapisearchprovider.h | 48 +++++ src/internet/core/internetmodel.cpp | 2 + .../spotifywebapi/spotifywebapiservice.cpp | 198 ++++++++++++++++++ .../spotifywebapi/spotifywebapiservice.h | 60 ++++++ 7 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 src/globalsearch/spotifywebapisearchprovider.cpp create mode 100644 src/globalsearch/spotifywebapisearchprovider.h create mode 100644 src/internet/spotifywebapi/spotifywebapiservice.cpp create mode 100644 src/internet/spotifywebapi/spotifywebapiservice.h diff --git a/ext/clementine-spotifyblob/CMakeLists.txt b/ext/clementine-spotifyblob/CMakeLists.txt index 809b999a4..e00aa9ebb 100644 --- a/ext/clementine-spotifyblob/CMakeLists.txt +++ b/ext/clementine-spotifyblob/CMakeLists.txt @@ -1,4 +1,4 @@ -include_directories(${SPOTIFY_INCLUDE_DIRS}) +include_directories(${LIBSPOTIFY_INCLUDE_DIRS}) include_directories(${PROTOBUF_INCLUDE_DIRS}) include_directories(${CMAKE_CURRENT_BINARY_DIR}) include_directories(${CMAKE_CURRENT_SOURCE_DIR}) @@ -8,7 +8,7 @@ include_directories(${CMAKE_SOURCE_DIR}/ext/libclementine-common) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Woverloaded-virtual -Wall -Wno-sign-compare -Wno-deprecated-declarations -Wno-unused-local-typedefs -Wno-unused-private-field -Wno-unknown-warning-option") -link_directories(${SPOTIFY_LIBRARY_DIRS}) +link_directories(${LIBSPOTIFY_LIBRARY_DIRS}) set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR}) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a180d8f33..88d4165ed 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -888,12 +888,16 @@ optional_source(HAVE_SPOTIFY internet/spotify/spotifyserver.cpp internet/spotify/spotifyservice.cpp internet/spotify/spotifysettingspage.cpp + internet/spotifywebapi/spotifywebapiservice.cpp globalsearch/spotifysearchprovider.cpp + globalsearch/spotifywebapisearchprovider.cpp HEADERS globalsearch/spotifysearchprovider.h + globalsearch/spotifywebapisearchprovider.h internet/spotify/spotifyserver.h internet/spotify/spotifyservice.h internet/spotify/spotifysettingspage.h + internet/spotifywebapi/spotifywebapiservice.h UI internet/spotify/spotifysettingspage.ui ) diff --git a/src/globalsearch/spotifywebapisearchprovider.cpp b/src/globalsearch/spotifywebapisearchprovider.cpp new file mode 100644 index 000000000..f73cd6391 --- /dev/null +++ b/src/globalsearch/spotifywebapisearchprovider.cpp @@ -0,0 +1,97 @@ +/* This file is part of Clementine. + Copyright 2021, Kenman Tsang + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#include "spotifywebapisearchprovider.h" + +#include + +#include + +#include "internet/spotifywebapi/spotifywebapiservice.h" +#include "ui/iconloader.h" + +namespace { +static constexpr int kNoRunningSearch = -1; +} + +SpotifyWebApiSearchProvider::SpotifyWebApiSearchProvider( + Application* app, SpotifyWebApiService* parent) + : SearchProvider(app, parent), + parent_{parent}, + last_search_id_{kNoRunningSearch} { + Init("Spotify (Experimential)", "spotify_web_api", + IconLoader::Load("spotify", IconLoader::Provider), + WantsDelayedQueries | WantsSerialisedArtQueries | ArtIsProbablyRemote | + CanGiveSuggestions); + + connect(parent, &SpotifyWebApiService::SearchFinished, this, + &SpotifyWebApiSearchProvider::SearchFinishedSlot); +} + +void SpotifyWebApiSearchProvider::SearchAsync(int id, const QString& query) { + if (last_search_id_ != kNoRunningSearch) { + // Cancel last pending search + emit SearchFinished(last_search_id_); + + // Set the pending query + last_search_id_ = id; + last_query_ = query; + + // And wait for the current search to be completed + return; + } + + last_search_id_ = id; + last_query_ = query; + + parent_->Search(last_search_id_, last_query_); +} + +void SpotifyWebApiSearchProvider::SearchFinishedSlot( + int searchId, const QList& apiResult) { + ResultList ret; + for (auto&& item : apiResult) { + Result result{this}; + result.group_automatically_ = true; + result.metadata_ = item; + ret += result; + } + emit ResultsAvailable(searchId, ret); + emit SearchFinished(searchId); + + // Search again if we have a pending query + if (searchId != last_search_id_) { + parent_->Search(last_search_id_, last_query_); + } else { + last_search_id_ = kNoRunningSearch; + } +} + +void SpotifyWebApiSearchProvider::LoadArtAsync(int id, const Result& result) { + // TODO +} + +void SpotifyWebApiSearchProvider::ShowConfig() {} + +InternetService* SpotifyWebApiSearchProvider::internet_service() { + return parent_; +} + +QStringList SpotifyWebApiSearchProvider::GetSuggestions(int count) { + // TODO + return QStringList{}; +} diff --git a/src/globalsearch/spotifywebapisearchprovider.h b/src/globalsearch/spotifywebapisearchprovider.h new file mode 100644 index 000000000..f59a8d0ed --- /dev/null +++ b/src/globalsearch/spotifywebapisearchprovider.h @@ -0,0 +1,48 @@ +/* This file is part of Clementine. + Copyright 2021, Kenman Tsang + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#ifndef SPOTIFYWEBAPISEARCHPROVIDER_H +#define SPOTIFYWEBAPISEARCHPROVIDER_H + +#include "core/song.h" +#include "searchprovider.h" + +class SpotifyWebApiService; + +class SpotifyWebApiSearchProvider : public SearchProvider { + Q_OBJECT + + public: + SpotifyWebApiSearchProvider(Application* app, SpotifyWebApiService* parent); + + void SearchAsync(int id, const QString& query) override; + void LoadArtAsync(int id, const Result& result) override; + QStringList GetSuggestions(int count) override; + + void ShowConfig() override; + InternetService* internet_service() override; + + private slots: + void SearchFinishedSlot(int id, const QList&); + + private: + SpotifyWebApiService* parent_; + int last_search_id_; + QString last_query_; +}; + +#endif // SPOTIFYWEBAPISEARCHPROVIDER_H diff --git a/src/internet/core/internetmodel.cpp b/src/internet/core/internetmodel.cpp index 8c1275c92..f6056a3c9 100644 --- a/src/internet/core/internetmodel.cpp +++ b/src/internet/core/internetmodel.cpp @@ -62,6 +62,7 @@ #endif #ifdef HAVE_SPOTIFY #include "internet/spotify/spotifyservice.h" +#include "internet/spotifywebapi/spotifywebapiservice.h" #endif using smart_playlists::Generator; @@ -100,6 +101,7 @@ InternetModel::InternetModel(Application* app, QObject* parent) AddService(new RadioBrowserService(app, this)); #ifdef HAVE_SPOTIFY AddService(new SpotifyService(app, this)); + AddService(new SpotifyWebApiService(app, this)); #endif AddService(new SubsonicService(app, this)); #ifdef HAVE_BOX diff --git a/src/internet/spotifywebapi/spotifywebapiservice.cpp b/src/internet/spotifywebapi/spotifywebapiservice.cpp new file mode 100644 index 000000000..4bd08101f --- /dev/null +++ b/src/internet/spotifywebapi/spotifywebapiservice.cpp @@ -0,0 +1,198 @@ +/* This file is part of Clementine. + Copyright 2021, Kenman Tsang + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#include "spotifywebapiservice.h" + +#include +#include +#include +#include +#include + +#include "3rdparty/qtiocompressor/qtiocompressor.h" +#include "core/application.h" +#include "core/network.h" +#include "core/timeconstants.h" +#include "globalsearch/globalsearch.h" +#include "globalsearch/spotifywebapisearchprovider.h" +#include "ui/iconloader.h" + +namespace { +static constexpr const char* kServiceName = "SpotifyWebApi"; +static constexpr const char* kGetAccessTokenUrl = + "https://open.spotify.com/" + "get_access_token?reason=transport&productType=web_player"; +static constexpr const char* kSearchUrl = + "https://api.spotify.com/v1/search?q=%1&type=track&limit=50"; + +template +inline QJsonValue Get(QJsonValue obj, Args&&... args) { + std::array names = { + std::forward(args)...}; + for (auto&& name : names) { + Q_ASSERT(obj.isObject()); + obj = obj.toObject()[name]; + } + return obj; +} + +template +inline QJsonValue Get(const QJsonDocument& obj, Args&&... args) { + return Get(obj.object(), std::forward(args)...); +} + +QString concat(const QJsonArray& array, const char* name) { + QStringList ret; + for (auto&& item : array) { + ret << Get(item, name).toString(); + } + return ret.join(", "); +} + +} // namespace + +SpotifyWebApiService::SpotifyWebApiService(Application* app, + InternetModel* parent) + : InternetService(kServiceName, app, parent, parent), + network_(new NetworkAccessManager{this}), + token_expiration_ms_{0} { + app_->global_search()->AddProvider( + new SpotifyWebApiSearchProvider(app_, this)); +} + +SpotifyWebApiService::~SpotifyWebApiService() {} + +QStandardItem* SpotifyWebApiService::CreateRootItem() { + root_ = new QStandardItem(IconLoader::Load("spotify", IconLoader::Provider), + kServiceName); + return root_; +} + +void SpotifyWebApiService::LazyPopulate(QStandardItem* item) {} + +void SpotifyWebApiService::Search(int searchId, QString queryStr) { + if (QDateTime::currentDateTime().toMSecsSinceEpoch() >= + token_expiration_ms_) { + QNetworkRequest request{QUrl{kGetAccessTokenUrl}}; + request.setRawHeader("Accept-Encoding", "gzip"); + + QNetworkReply* reply = network_->get(request); + connect(reply, &QNetworkReply::finished, [=]() { + reply->deleteLater(); + + OnTokenReady(ParseJsonReplyWithGzip(reply), searchId, queryStr); + }); + } else { + OnReadyToSearch(searchId, queryStr); + } +} + +void SpotifyWebApiService::OnTokenReady(const QJsonDocument& json, int searchId, + QString queryStr) { + if (!json.isEmpty()) { + token_ = Get(json, "accessToken").toString(); + token_expiration_ms_ = static_cast( + Get(json, "accessTokenExpirationTimestampMs").toDouble()); + + qLog(Debug) << "Spotify API Token:" << token_; + + OnReadyToSearch(searchId, queryStr); + } +} + +void SpotifyWebApiService::OnReadyToSearch(int searchId, QString queryStr) { + qLog(Debug) << "Spotify API Searching: " << queryStr; + + QNetworkRequest request{ + QUrl{QString(kSearchUrl).arg(queryStr.toHtmlEscaped())}}; + + request.setRawHeader("Accept-Encoding", "gzip"); + request.setRawHeader("Authorization", ("Bearer " + token_).toUtf8()); + + QNetworkReply* reply = network_->get(request); + connect(reply, &QNetworkReply::finished, [=] { + reply->deleteLater(); + + BuildResultList(ParseJsonReplyWithGzip(reply), searchId); + }); +} + +void SpotifyWebApiService::BuildResultList(const QJsonDocument& json, + int searchId) { + QList result; + + for (auto&& item : Get(json, "tracks", "items").toArray()) { + Song song; + + song.set_albumartist( + concat(Get(item, "album", "artists").toArray(), "name")); + song.set_album(Get(item, "album", "name").toString()); + song.set_artist(concat(Get(item, "artists").toArray(), "name")); + song.set_disc(Get(item, "disc_number").toInt()); + song.set_length_nanosec(Get(item, "duration_ms").toInt() * kNsecPerMsec); + song.set_title(Get(item, "name").toString()); + song.set_track(Get(item, "track_number").toInt()); + song.set_url(QUrl{Get(item, "uri").toString()}); + song.set_filetype(Song::Type_Stream); + song.set_valid(true); + song.set_directory_id(0); + song.set_mtime(0); + song.set_ctime(0); + song.set_filesize(0); + + result += song; + } + + emit SearchFinished(searchId, result); +} + +QJsonDocument SpotifyWebApiService::ParseJsonReplyWithGzip( + QNetworkReply* reply) { + if (reply->error() != QNetworkReply::NoError) { + app_->AddError(tr("%1 request failed:\n%2") + .arg(kServiceName) + .arg(reply->errorString())); + return QJsonDocument(); + } + + QByteArray output; + if (reply->hasRawHeader("content-encoding") && + reply->rawHeader("content-encoding") == "gzip") { + QtIOCompressor gzip(reply); + gzip.setStreamFormat(QtIOCompressor::GzipFormat); + if (!gzip.open(QIODevice::ReadOnly)) { + app_->AddError(tr("%1 failed to decode as gzip stream:\n%2") + .arg(kServiceName) + .arg(gzip.errorString())); + return QJsonDocument(); + } + output = gzip.readAll(); + } else { + output = reply->readAll(); + } + + QJsonParseError error; + QJsonDocument document = QJsonDocument::fromJson(output, &error); + if (error.error != QJsonParseError::NoError) { + app_->AddError(tr("Failed to parse %1 response:\n%2") + .arg(kServiceName) + .arg(error.errorString())); + return QJsonDocument(); + } + + return document; +} diff --git a/src/internet/spotifywebapi/spotifywebapiservice.h b/src/internet/spotifywebapi/spotifywebapiservice.h new file mode 100644 index 000000000..04e97bb13 --- /dev/null +++ b/src/internet/spotifywebapi/spotifywebapiservice.h @@ -0,0 +1,60 @@ +/* This file is part of Clementine. + Copyright 2021, Kenman Tsang + + Clementine is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Clementine is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Clementine. If not, see . +*/ + +#ifndef SPOTIFYWEBAPISERVICE_H +#define SPOTIFYWEBAPISERVICE_H + +#include + +#include "internet/core/internetmodel.h" +#include "internet/core/internetservice.h" + +class NetworkAccessManager; + +class SpotifyWebApiService : public InternetService { + Q_OBJECT + + public: + SpotifyWebApiService(Application* app, InternetModel* parent); + ~SpotifyWebApiService(); + + QStandardItem* CreateRootItem() override; + void LazyPopulate(QStandardItem* parent) override; + + public: + void Search(int searchId, QString queryStr); + + private: + void OnTokenReady(const QJsonDocument&, int searchId, QString queryStr); + void OnReadyToSearch(int searchId, QString queryStr); + void BuildResultList(const QJsonDocument&, int searchId); + + signals: + void SearchFinished(int searchId, const QList&); + + private: + QJsonDocument ParseJsonReplyWithGzip(QNetworkReply* reply); + + private: + QStandardItem* root_; + NetworkAccessManager* network_; + + QString token_; + qint64 token_expiration_ms_; +}; + +#endif // SPOTIFYWEBAPISERVICE_H