diff --git a/CMakeLists.txt b/CMakeLists.txt
index 143cf2a6b..1203be315 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -430,6 +430,7 @@ option(INSTALL_TRANSLATIONS "Install translations" OFF)
optional_component(SUBSONIC ON "Streaming: Subsonic")
optional_component(TIDAL ON "Streaming: Tidal")
+optional_component(SPOTIFY ON "Streaming: Spotify" DEPENDS "gstreamer" GSTREAMER_FOUND)
optional_component(QOBUZ ON "Streaming: Qobuz")
optional_component(MOODBAR ON "Moodbar"
diff --git a/README.md b/README.md
index 3bfd081ec..8495b1f15 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ Funding developers is a way to contribute to open source projects you appreciate
* Audio equalizer
* Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
* Scrobbler with support for [Last.fm](https://www.last.fm/), [Libre.fm](https://libre.fm/) and [ListenBrainz](https://listenbrainz.org/)
- * Subsonic, Tidal and Qobuz streaming support
+ * Subsonic, Tidal, Spotify and Qobuz streaming support
It has so far been tested to work on Linux, OpenBSD, FreeBSD, macOS and Windows.
diff --git a/data/data.qrc b/data/data.qrc
index 7bc01cd90..1ea198804 100644
--- a/data/data.qrc
+++ b/data/data.qrc
@@ -11,6 +11,7 @@
schema/schema-17.sql
schema/schema-18.sql
schema/schema-19.sql
+ schema/schema-20.sql
schema/device-schema.sql
style/strawberry.css
style/smartplaylistsearchterm.css
diff --git a/data/icons.qrc b/data/icons.qrc
index 847f5c96d..ebbfe2f3b 100644
--- a/data/icons.qrc
+++ b/data/icons.qrc
@@ -91,6 +91,7 @@
icons/128x128/love.png
icons/128x128/subsonic.png
icons/128x128/tidal.png
+ icons/128x128/spotify.png
icons/128x128/qobuz.png
icons/128x128/multimedia-player-ipod-standard-black.png
icons/128x128/radio.png
@@ -189,6 +190,7 @@
icons/64x64/love.png
icons/64x64/subsonic.png
icons/64x64/tidal.png
+ icons/64x64/spotify.png
icons/64x64/qobuz.png
icons/64x64/multimedia-player-ipod-standard-black.png
icons/64x64/radio.png
@@ -291,6 +293,7 @@
icons/48x48/love.png
icons/48x48/subsonic.png
icons/48x48/tidal.png
+ icons/48x48/spotify.png
icons/48x48/qobuz.png
icons/48x48/multimedia-player-ipod-standard-black.png
icons/48x48/radio.png
@@ -393,6 +396,7 @@
icons/32x32/love.png
icons/32x32/subsonic.png
icons/32x32/tidal.png
+ icons/32x32/spotify.png
icons/32x32/qobuz.png
icons/32x32/multimedia-player-ipod-standard-black.png
icons/32x32/radio.png
@@ -495,6 +499,7 @@
icons/22x22/love.png
icons/22x22/subsonic.png
icons/22x22/tidal.png
+ icons/22x22/spotify.png
icons/22x22/qobuz.png
icons/22x22/multimedia-player-ipod-standard-black.png
icons/22x22/radio.png
diff --git a/data/icons/128x128/spotify.png b/data/icons/128x128/spotify.png
new file mode 100644
index 000000000..fb62bfe1e
Binary files /dev/null and b/data/icons/128x128/spotify.png differ
diff --git a/data/icons/22x22/spotify.png b/data/icons/22x22/spotify.png
new file mode 100644
index 000000000..216241cf7
Binary files /dev/null and b/data/icons/22x22/spotify.png differ
diff --git a/data/icons/32x32/spotify.png b/data/icons/32x32/spotify.png
new file mode 100644
index 000000000..d8692c55b
Binary files /dev/null and b/data/icons/32x32/spotify.png differ
diff --git a/data/icons/48x48/spotify.png b/data/icons/48x48/spotify.png
new file mode 100644
index 000000000..4c1facad8
Binary files /dev/null and b/data/icons/48x48/spotify.png differ
diff --git a/data/icons/64x64/spotify.png b/data/icons/64x64/spotify.png
new file mode 100644
index 000000000..2fcc5d6df
Binary files /dev/null and b/data/icons/64x64/spotify.png differ
diff --git a/data/icons/full/spotify.png b/data/icons/full/spotify.png
new file mode 100755
index 000000000..26410e2fe
Binary files /dev/null and b/data/icons/full/spotify.png differ
diff --git a/data/schema/schema-20.sql b/data/schema/schema-20.sql
new file mode 100644
index 000000000..446b56aca
--- /dev/null
+++ b/data/schema/schema-20.sql
@@ -0,0 +1,244 @@
+CREATE TABLE IF NOT EXISTS spotify_artists_songs (
+
+ title TEXT,
+ album TEXT,
+ artist TEXT,
+ albumartist TEXT,
+ track INTEGER NOT NULL DEFAULT -1,
+ disc INTEGER NOT NULL DEFAULT -1,
+ year INTEGER NOT NULL DEFAULT -1,
+ originalyear INTEGER NOT NULL DEFAULT -1,
+ genre TEXT,
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT,
+ performer TEXT,
+ grouping TEXT,
+ comment TEXT,
+ lyrics TEXT,
+
+ artist_id TEXT,
+ album_id TEXT,
+ song_id TEXT,
+
+ beginning INTEGER NOT NULL DEFAULT 0,
+ length INTEGER NOT NULL DEFAULT 0,
+
+ bitrate INTEGER NOT NULL DEFAULT -1,
+ samplerate INTEGER NOT NULL DEFAULT -1,
+ bitdepth INTEGER NOT NULL DEFAULT -1,
+
+ source INTEGER NOT NULL DEFAULT 0,
+ directory_id INTEGER NOT NULL DEFAULT -1,
+ url TEXT NOT NULL,
+ filetype INTEGER NOT NULL DEFAULT 0,
+ filesize INTEGER NOT NULL DEFAULT -1,
+ mtime INTEGER NOT NULL DEFAULT -1,
+ ctime INTEGER NOT NULL DEFAULT -1,
+ unavailable INTEGER DEFAULT 0,
+
+ fingerprint TEXT,
+
+ playcount INTEGER NOT NULL DEFAULT 0,
+ skipcount INTEGER NOT NULL DEFAULT 0,
+ lastplayed INTEGER NOT NULL DEFAULT -1,
+ lastseen INTEGER NOT NULL DEFAULT -1,
+
+ compilation_detected INTEGER DEFAULT 0,
+ compilation_on INTEGER NOT NULL DEFAULT 0,
+ compilation_off INTEGER NOT NULL DEFAULT 0,
+ compilation_effective INTEGER NOT NULL DEFAULT 0,
+
+ art_embedded INTEGER DEFAULT 0,
+ art_automatic TEXT,
+ art_manual TEXT,
+ art_unset INTEGER DEFAULT 0,
+
+ effective_albumartist TEXT,
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT,
+
+ rating INTEGER DEFAULT -1,
+
+ acoustid_id TEXT,
+ acoustid_fingerprint TEXT,
+
+ musicbrainz_album_artist_id TEXT,
+ musicbrainz_artist_id TEXT,
+ musicbrainz_original_artist_id TEXT,
+ musicbrainz_album_id TEXT,
+ musicbrainz_original_album_id TEXT,
+ musicbrainz_recording_id TEXT,
+ musicbrainz_track_id TEXT,
+ musicbrainz_disc_id TEXT,
+ musicbrainz_release_group_id TEXT,
+ musicbrainz_work_id TEXT,
+
+ ebur128_integrated_loudness_lufs REAL,
+ ebur128_loudness_range_lu REAL
+
+);
+
+CREATE TABLE IF NOT EXISTS spotify_albums_songs (
+
+ title TEXT,
+ album TEXT,
+ artist TEXT,
+ albumartist TEXT,
+ track INTEGER NOT NULL DEFAULT -1,
+ disc INTEGER NOT NULL DEFAULT -1,
+ year INTEGER NOT NULL DEFAULT -1,
+ originalyear INTEGER NOT NULL DEFAULT -1,
+ genre TEXT,
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT,
+ performer TEXT,
+ grouping TEXT,
+ comment TEXT,
+ lyrics TEXT,
+
+ artist_id TEXT,
+ album_id TEXT,
+ song_id TEXT,
+
+ beginning INTEGER NOT NULL DEFAULT 0,
+ length INTEGER NOT NULL DEFAULT 0,
+
+ bitrate INTEGER NOT NULL DEFAULT -1,
+ samplerate INTEGER NOT NULL DEFAULT -1,
+ bitdepth INTEGER NOT NULL DEFAULT -1,
+
+ source INTEGER NOT NULL DEFAULT 0,
+ directory_id INTEGER NOT NULL DEFAULT -1,
+ url TEXT NOT NULL,
+ filetype INTEGER NOT NULL DEFAULT 0,
+ filesize INTEGER NOT NULL DEFAULT -1,
+ mtime INTEGER NOT NULL DEFAULT -1,
+ ctime INTEGER NOT NULL DEFAULT -1,
+ unavailable INTEGER DEFAULT 0,
+
+ fingerprint TEXT,
+
+ playcount INTEGER NOT NULL DEFAULT 0,
+ skipcount INTEGER NOT NULL DEFAULT 0,
+ lastplayed INTEGER NOT NULL DEFAULT -1,
+ lastseen INTEGER NOT NULL DEFAULT -1,
+
+ compilation_detected INTEGER DEFAULT 0,
+ compilation_on INTEGER NOT NULL DEFAULT 0,
+ compilation_off INTEGER NOT NULL DEFAULT 0,
+ compilation_effective INTEGER NOT NULL DEFAULT 0,
+
+ art_embedded INTEGER DEFAULT 0,
+ art_automatic TEXT,
+ art_manual TEXT,
+ art_unset INTEGER DEFAULT 0,
+
+ effective_albumartist TEXT,
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT,
+
+ rating INTEGER DEFAULT -1,
+
+ acoustid_id TEXT,
+ acoustid_fingerprint TEXT,
+
+ musicbrainz_album_artist_id TEXT,
+ musicbrainz_artist_id TEXT,
+ musicbrainz_original_artist_id TEXT,
+ musicbrainz_album_id TEXT,
+ musicbrainz_original_album_id TEXT,
+ musicbrainz_recording_id TEXT,
+ musicbrainz_track_id TEXT,
+ musicbrainz_disc_id TEXT,
+ musicbrainz_release_group_id TEXT,
+ musicbrainz_work_id TEXT,
+
+ ebur128_integrated_loudness_lufs REAL,
+ ebur128_loudness_range_lu REAL
+
+);
+
+CREATE TABLE IF NOT EXISTS spotify_songs (
+
+ title TEXT,
+ album TEXT,
+ artist TEXT,
+ albumartist TEXT,
+ track INTEGER NOT NULL DEFAULT -1,
+ disc INTEGER NOT NULL DEFAULT -1,
+ year INTEGER NOT NULL DEFAULT -1,
+ originalyear INTEGER NOT NULL DEFAULT -1,
+ genre TEXT,
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT,
+ performer TEXT,
+ grouping TEXT,
+ comment TEXT,
+ lyrics TEXT,
+
+ artist_id TEXT,
+ album_id TEXT,
+ song_id TEXT,
+
+ beginning INTEGER NOT NULL DEFAULT 0,
+ length INTEGER NOT NULL DEFAULT 0,
+
+ bitrate INTEGER NOT NULL DEFAULT -1,
+ samplerate INTEGER NOT NULL DEFAULT -1,
+ bitdepth INTEGER NOT NULL DEFAULT -1,
+
+ source INTEGER NOT NULL DEFAULT 0,
+ directory_id INTEGER NOT NULL DEFAULT -1,
+ url TEXT NOT NULL,
+ filetype INTEGER NOT NULL DEFAULT 0,
+ filesize INTEGER NOT NULL DEFAULT -1,
+ mtime INTEGER NOT NULL DEFAULT -1,
+ ctime INTEGER NOT NULL DEFAULT -1,
+ unavailable INTEGER DEFAULT 0,
+
+ fingerprint TEXT,
+
+ playcount INTEGER NOT NULL DEFAULT 0,
+ skipcount INTEGER NOT NULL DEFAULT 0,
+ lastplayed INTEGER NOT NULL DEFAULT -1,
+ lastseen INTEGER NOT NULL DEFAULT -1,
+
+ compilation_detected INTEGER DEFAULT 0,
+ compilation_on INTEGER NOT NULL DEFAULT 0,
+ compilation_off INTEGER NOT NULL DEFAULT 0,
+ compilation_effective INTEGER NOT NULL DEFAULT 0,
+
+ art_embedded INTEGER DEFAULT 0,
+ art_automatic TEXT,
+ art_manual TEXT,
+ art_unset INTEGER DEFAULT 0,
+
+ effective_albumartist TEXT,
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT,
+
+ rating INTEGER DEFAULT -1,
+
+ acoustid_id TEXT,
+ acoustid_fingerprint TEXT,
+
+ musicbrainz_album_artist_id TEXT,
+ musicbrainz_artist_id TEXT,
+ musicbrainz_original_artist_id TEXT,
+ musicbrainz_album_id TEXT,
+ musicbrainz_original_album_id TEXT,
+ musicbrainz_recording_id TEXT,
+ musicbrainz_track_id TEXT,
+ musicbrainz_disc_id TEXT,
+ musicbrainz_release_group_id TEXT,
+ musicbrainz_work_id TEXT,
+
+ ebur128_integrated_loudness_lufs REAL,
+ ebur128_loudness_range_lu REAL
+
+);
+
+UPDATE schema_version SET version=20;
diff --git a/data/schema/schema.sql b/data/schema/schema.sql
index 7acba1131..ee6573bc6 100644
--- a/data/schema/schema.sql
+++ b/data/schema/schema.sql
@@ -422,6 +422,249 @@ CREATE TABLE IF NOT EXISTS tidal_songs (
);
+CREATE TABLE IF NOT EXISTS spotify_artists_songs (
+
+ title TEXT,
+ album TEXT,
+ artist TEXT,
+ albumartist TEXT,
+ track INTEGER NOT NULL DEFAULT -1,
+ disc INTEGER NOT NULL DEFAULT -1,
+ year INTEGER NOT NULL DEFAULT -1,
+ originalyear INTEGER NOT NULL DEFAULT -1,
+ genre TEXT,
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT,
+ performer TEXT,
+ grouping TEXT,
+ comment TEXT,
+ lyrics TEXT,
+
+ artist_id TEXT,
+ album_id TEXT,
+ song_id TEXT,
+
+ beginning INTEGER NOT NULL DEFAULT 0,
+ length INTEGER NOT NULL DEFAULT 0,
+
+ bitrate INTEGER NOT NULL DEFAULT -1,
+ samplerate INTEGER NOT NULL DEFAULT -1,
+ bitdepth INTEGER NOT NULL DEFAULT -1,
+
+ source INTEGER NOT NULL DEFAULT 0,
+ directory_id INTEGER NOT NULL DEFAULT -1,
+ url TEXT NOT NULL,
+ filetype INTEGER NOT NULL DEFAULT 0,
+ filesize INTEGER NOT NULL DEFAULT -1,
+ mtime INTEGER NOT NULL DEFAULT -1,
+ ctime INTEGER NOT NULL DEFAULT -1,
+ unavailable INTEGER DEFAULT 0,
+
+ fingerprint TEXT,
+
+ playcount INTEGER NOT NULL DEFAULT 0,
+ skipcount INTEGER NOT NULL DEFAULT 0,
+ lastplayed INTEGER NOT NULL DEFAULT -1,
+ lastseen INTEGER NOT NULL DEFAULT -1,
+
+ compilation_detected INTEGER DEFAULT 0,
+ compilation_on INTEGER NOT NULL DEFAULT 0,
+ compilation_off INTEGER NOT NULL DEFAULT 0,
+ compilation_effective INTEGER NOT NULL DEFAULT 0,
+
+ art_embedded INTEGER DEFAULT 0,
+ art_automatic TEXT,
+ art_manual TEXT,
+ art_unset INTEGER DEFAULT 0,
+
+ effective_albumartist TEXT,
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT,
+
+ rating INTEGER DEFAULT -1,
+
+ acoustid_id TEXT,
+ acoustid_fingerprint TEXT,
+
+ musicbrainz_album_artist_id TEXT,
+ musicbrainz_artist_id TEXT,
+ musicbrainz_original_artist_id TEXT,
+ musicbrainz_album_id TEXT,
+ musicbrainz_original_album_id TEXT,
+ musicbrainz_recording_id TEXT,
+ musicbrainz_track_id TEXT,
+ musicbrainz_disc_id TEXT,
+ musicbrainz_release_group_id TEXT,
+ musicbrainz_work_id TEXT,
+
+ ebur128_integrated_loudness_lufs REAL,
+ ebur128_loudness_range_lu REAL
+
+);
+
+CREATE TABLE IF NOT EXISTS spotify_albums_songs (
+
+ title TEXT,
+ album TEXT,
+ artist TEXT,
+ albumartist TEXT,
+ track INTEGER NOT NULL DEFAULT -1,
+ disc INTEGER NOT NULL DEFAULT -1,
+ year INTEGER NOT NULL DEFAULT -1,
+ originalyear INTEGER NOT NULL DEFAULT -1,
+ genre TEXT,
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT,
+ performer TEXT,
+ grouping TEXT,
+ comment TEXT,
+ lyrics TEXT,
+
+ artist_id TEXT,
+ album_id TEXT,
+ song_id TEXT,
+
+ beginning INTEGER NOT NULL DEFAULT 0,
+ length INTEGER NOT NULL DEFAULT 0,
+
+ bitrate INTEGER NOT NULL DEFAULT -1,
+ samplerate INTEGER NOT NULL DEFAULT -1,
+ bitdepth INTEGER NOT NULL DEFAULT -1,
+
+ source INTEGER NOT NULL DEFAULT 0,
+ directory_id INTEGER NOT NULL DEFAULT -1,
+ url TEXT NOT NULL,
+ filetype INTEGER NOT NULL DEFAULT 0,
+ filesize INTEGER NOT NULL DEFAULT -1,
+ mtime INTEGER NOT NULL DEFAULT -1,
+ ctime INTEGER NOT NULL DEFAULT -1,
+ unavailable INTEGER DEFAULT 0,
+
+ fingerprint TEXT,
+
+ playcount INTEGER NOT NULL DEFAULT 0,
+ skipcount INTEGER NOT NULL DEFAULT 0,
+ lastplayed INTEGER NOT NULL DEFAULT -1,
+ lastseen INTEGER NOT NULL DEFAULT -1,
+
+ compilation_detected INTEGER DEFAULT 0,
+ compilation_on INTEGER NOT NULL DEFAULT 0,
+ compilation_off INTEGER NOT NULL DEFAULT 0,
+ compilation_effective INTEGER NOT NULL DEFAULT 0,
+
+ art_embedded INTEGER DEFAULT 0,
+ art_automatic TEXT,
+ art_manual TEXT,
+ art_unset INTEGER DEFAULT 0,
+
+ effective_albumartist TEXT,
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT,
+
+ rating INTEGER DEFAULT -1,
+
+ acoustid_id TEXT,
+ acoustid_fingerprint TEXT,
+
+ musicbrainz_album_artist_id TEXT,
+ musicbrainz_artist_id TEXT,
+ musicbrainz_original_artist_id TEXT,
+ musicbrainz_album_id TEXT,
+ musicbrainz_original_album_id TEXT,
+ musicbrainz_recording_id TEXT,
+ musicbrainz_track_id TEXT,
+ musicbrainz_disc_id TEXT,
+ musicbrainz_release_group_id TEXT,
+ musicbrainz_work_id TEXT,
+
+ ebur128_integrated_loudness_lufs REAL,
+ ebur128_loudness_range_lu REAL
+
+);
+
+CREATE TABLE IF NOT EXISTS spotify_songs (
+
+ title TEXT,
+ album TEXT,
+ artist TEXT,
+ albumartist TEXT,
+ track INTEGER NOT NULL DEFAULT -1,
+ disc INTEGER NOT NULL DEFAULT -1,
+ year INTEGER NOT NULL DEFAULT -1,
+ originalyear INTEGER NOT NULL DEFAULT -1,
+ genre TEXT,
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT,
+ performer TEXT,
+ grouping TEXT,
+ comment TEXT,
+ lyrics TEXT,
+
+ artist_id TEXT,
+ album_id TEXT,
+ song_id TEXT,
+
+ beginning INTEGER NOT NULL DEFAULT 0,
+ length INTEGER NOT NULL DEFAULT 0,
+
+ bitrate INTEGER NOT NULL DEFAULT -1,
+ samplerate INTEGER NOT NULL DEFAULT -1,
+ bitdepth INTEGER NOT NULL DEFAULT -1,
+
+ source INTEGER NOT NULL DEFAULT 0,
+ directory_id INTEGER NOT NULL DEFAULT -1,
+ url TEXT NOT NULL,
+ filetype INTEGER NOT NULL DEFAULT 0,
+ filesize INTEGER NOT NULL DEFAULT -1,
+ mtime INTEGER NOT NULL DEFAULT -1,
+ ctime INTEGER NOT NULL DEFAULT -1,
+ unavailable INTEGER DEFAULT 0,
+
+ fingerprint TEXT,
+
+ playcount INTEGER NOT NULL DEFAULT 0,
+ skipcount INTEGER NOT NULL DEFAULT 0,
+ lastplayed INTEGER NOT NULL DEFAULT -1,
+ lastseen INTEGER NOT NULL DEFAULT -1,
+
+ compilation_detected INTEGER DEFAULT 0,
+ compilation_on INTEGER NOT NULL DEFAULT 0,
+ compilation_off INTEGER NOT NULL DEFAULT 0,
+ compilation_effective INTEGER NOT NULL DEFAULT 0,
+
+ art_embedded INTEGER DEFAULT 0,
+ art_automatic TEXT,
+ art_manual TEXT,
+ art_unset INTEGER DEFAULT 0,
+
+ effective_albumartist TEXT,
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT,
+
+ rating INTEGER DEFAULT -1,
+
+ acoustid_id TEXT,
+ acoustid_fingerprint TEXT,
+
+ musicbrainz_album_artist_id TEXT,
+ musicbrainz_artist_id TEXT,
+ musicbrainz_original_artist_id TEXT,
+ musicbrainz_album_id TEXT,
+ musicbrainz_original_album_id TEXT,
+ musicbrainz_recording_id TEXT,
+ musicbrainz_track_id TEXT,
+ musicbrainz_disc_id TEXT,
+ musicbrainz_release_group_id TEXT,
+ musicbrainz_work_id TEXT,
+
+ ebur128_integrated_loudness_lufs REAL,
+ ebur128_loudness_range_lu REAL
+
+);
+
CREATE TABLE IF NOT EXISTS qobuz_artists_songs (
title TEXT,
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 3b8b544a0..68ab5f18b 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -175,7 +175,6 @@ set(SOURCES
covermanager/deezercoverprovider.cpp
covermanager/qobuzcoverprovider.cpp
covermanager/musixmatchcoverprovider.cpp
- covermanager/spotifycoverprovider.cpp
covermanager/opentidalcoverprovider.cpp
lyrics/lyricsproviders.cpp
@@ -425,7 +424,6 @@ set(HEADERS
covermanager/deezercoverprovider.h
covermanager/qobuzcoverprovider.h
covermanager/musixmatchcoverprovider.h
- covermanager/spotifycoverprovider.h
covermanager/opentidalcoverprovider.h
lyrics/lyricsproviders.h
@@ -908,6 +906,25 @@ optional_source(HAVE_TIDAL
settings/tidalsettingspage.ui
)
+optional_source(HAVE_SPOTIFY
+ SOURCES
+ spotify/spotifyservice.cpp
+ spotify/spotifybaserequest.cpp
+ spotify/spotifyrequest.cpp
+ spotify/spotifyfavoriterequest.cpp
+ settings/spotifysettingspage.cpp
+ covermanager/spotifycoverprovider.cpp
+ HEADERS
+ spotify/spotifyservice.h
+ spotify/spotifybaserequest.h
+ spotify/spotifyrequest.h
+ spotify/spotifyfavoriterequest.h
+ settings/spotifysettingspage.h
+ covermanager/spotifycoverprovider.h
+ UI
+ settings/spotifysettingspage.ui
+)
+
optional_source(HAVE_QOBUZ
SOURCES
qobuz/qobuzservice.cpp
diff --git a/src/config.h.in b/src/config.h.in
index 77063a293..f558d5dbe 100644
--- a/src/config.h.in
+++ b/src/config.h.in
@@ -29,6 +29,7 @@
#cmakedefine HAVE_SUBSONIC
#cmakedefine HAVE_TIDAL
+#cmakedefine HAVE_SPOTIFY
#cmakedefine HAVE_QOBUZ
#cmakedefine HAVE_MOODBAR
diff --git a/src/core/application.cpp b/src/core/application.cpp
index def4f9298..45bdeb320 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -56,7 +56,6 @@
#include "covermanager/musicbrainzcoverprovider.h"
#include "covermanager/deezercoverprovider.h"
#include "covermanager/musixmatchcoverprovider.h"
-#include "covermanager/spotifycoverprovider.h"
#include "covermanager/opentidalcoverprovider.h"
#include "lyrics/lyricsproviders.h"
@@ -90,6 +89,11 @@
# include "covermanager/tidalcoverprovider.h"
#endif
+#ifdef HAVE_SPOTIFY
+# include "spotify/spotifyservice.h"
+# include "covermanager/spotifycoverprovider.h"
+#endif
+
#ifdef HAVE_QOBUZ
# include "qobuz/qobuzservice.h"
# include "covermanager/qobuzcoverprovider.h"
@@ -143,11 +147,13 @@ class ApplicationImpl {
cover_providers->AddProvider(new DiscogsCoverProvider(app, app->network()));
cover_providers->AddProvider(new DeezerCoverProvider(app, app->network()));
cover_providers->AddProvider(new MusixmatchCoverProvider(app, app->network()));
- cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network()));
cover_providers->AddProvider(new OpenTidalCoverProvider(app, app->network()));
#ifdef HAVE_TIDAL
cover_providers->AddProvider(new TidalCoverProvider(app, app->network()));
#endif
+#ifdef HAVE_SPOTIFY
+ cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network()));
+#endif
#ifdef HAVE_QOBUZ
cover_providers->AddProvider(new QobuzCoverProvider(app, app->network()));
#endif
@@ -183,6 +189,9 @@ class ApplicationImpl {
#ifdef HAVE_TIDAL
streaming_services->AddService(make_shared(app));
#endif
+#ifdef HAVE_SPOTIFY
+ streaming_services->AddService(make_shared(app));
+#endif
#ifdef HAVE_QOBUZ
streaming_services->AddService(make_shared(app));
#endif
diff --git a/src/core/database.cpp b/src/core/database.cpp
index 78daf0472..040715543 100644
--- a/src/core/database.cpp
+++ b/src/core/database.cpp
@@ -49,7 +49,7 @@
#include "sqlquery.h"
#include "scopedtransaction.h"
-const int Database::kSchemaVersion = 19;
+const int Database::kSchemaVersion = 20;
namespace {
constexpr char kDatabaseFilename[] = "strawberry.db";
diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp
index 750177cc4..c7f096a2e 100644
--- a/src/core/mainwindow.cpp
+++ b/src/core/mainwindow.cpp
@@ -178,6 +178,9 @@
# include "tidal/tidalservice.h"
# include "settings/tidalsettingspage.h"
#endif
+#ifdef HAVE_SPOTIFY
+# include "settings/spotifysettingspage.h"
+#endif
#ifdef HAVE_QOBUZ
# include "settings/qobuzsettingspage.h"
#endif
@@ -308,6 +311,9 @@ MainWindow::MainWindow(Application *app, SharedPtr tray_icon, OS
#ifdef HAVE_TIDAL
tidal_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Tidal), QLatin1String(TidalSettingsPage::kSettingsGroup), SettingsDialog::Page::Tidal, this)),
#endif
+#ifdef HAVE_SPOTIFY
+ spotify_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Spotify), QLatin1String(SpotifySettingsPage::kSettingsGroup), SettingsDialog::Page::Spotify, this)),
+#endif
#ifdef HAVE_QOBUZ
qobuz_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Qobuz), QLatin1String(QobuzSettingsPage::kSettingsGroup), SettingsDialog::Page::Qobuz, this)),
#endif
@@ -392,6 +398,9 @@ MainWindow::MainWindow(Application *app, SharedPtr tray_icon, OS
#ifdef HAVE_TIDAL
ui_->tabs->AddTab(tidal_view_, QStringLiteral("tidal"), IconLoader::Load(QStringLiteral("tidal"), true, 0, 32), tr("Tidal"));
#endif
+#ifdef HAVE_SPOTIFY
+ ui_->tabs->AddTab(spotify_view_, QLatin1String("spotify"), IconLoader::Load(QStringLiteral("spotify"), true, 0, 32), tr("Spotify"));
+#endif
#ifdef HAVE_QOBUZ
ui_->tabs->AddTab(qobuz_view_, QStringLiteral("qobuz"), IconLoader::Load(QStringLiteral("qobuz"), true, 0, 32), tr("Qobuz"));
#endif
@@ -714,6 +723,13 @@ MainWindow::MainWindow(Application *app, SharedPtr tray_icon, OS
QObject::connect(qobuz_view_->search_view(), &StreamingSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
#endif
+#ifdef HAVE_SPOTIFY
+ QObject::connect(spotify_view_->artists_collection_view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
+ QObject::connect(spotify_view_->albums_collection_view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
+ QObject::connect(spotify_view_->songs_collection_view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
+ QObject::connect(spotify_view_->search_view(), &StreamingSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
+#endif
+
QObject::connect(radio_view_, &RadioViewContainer::Refresh, &*app_->radio_services(), &RadioServices::RefreshChannels);
QObject::connect(radio_view_->view(), &RadioView::GetChannels, &*app_->radio_services(), &RadioServices::GetChannels);
QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
@@ -1178,6 +1194,18 @@ void MainWindow::ReloadSettings() {
}
#endif
+#ifdef HAVE_SPOTIFY
+ s.beginGroup(SpotifySettingsPage::kSettingsGroup);
+ bool enable_spotify = s.value("enabled", false).toBool();
+ s.endGroup();
+ if (enable_spotify) {
+ ui_->tabs->EnableTab(spotify_view_);
+ }
+ else {
+ ui_->tabs->DisableTab(spotify_view_);
+ }
+#endif
+
#ifdef HAVE_QOBUZ
s.beginGroup(QobuzSettingsPage::kSettingsGroup);
bool enable_qobuz = s.value("enabled", false).toBool();
@@ -1226,6 +1254,9 @@ void MainWindow::ReloadAllSettings() {
#ifdef HAVE_TIDAL
tidal_view_->ReloadSettings();
#endif
+#ifdef HAVE_SPOTIFY
+ spotify_view_->ReloadSettings();
+#endif
#ifdef HAVE_QOBUZ
qobuz_view_->ReloadSettings();
#endif
@@ -3284,6 +3315,11 @@ void MainWindow::FocusSearchField() {
tidal_view_->FocusSearchField();
}
#endif
+#ifdef HAVE_SPOTIFY
+ else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(spotify_view_) && !spotify_view_->SearchFieldHasFocus()) {
+ spotify_view_->FocusSearchField();
+ }
+#endif
#ifdef HAVE_QOBUZ
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(qobuz_view_) && !qobuz_view_->SearchFieldHasFocus()) {
qobuz_view_->FocusSearchField();
diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h
index d294fef48..d2f9943b2 100644
--- a/src/core/mainwindow.h
+++ b/src/core/mainwindow.h
@@ -341,9 +341,18 @@ class MainWindow : public QMainWindow, public PlatformInterface {
SmartPlaylistsViewContainer *smartplaylists_view_;
+#ifdef HAVE_SUBSONIC
StreamingSongsView *subsonic_view_;
+#endif
+#ifdef HAVE_TIDAL
StreamingTabsView *tidal_view_;
+#endif
+#ifdef HAVE_SPOTIFY
+ StreamingTabsView *spotify_view_;
+#endif
+#ifdef HAVE_QOBUZ
StreamingTabsView *qobuz_view_;
+#endif
RadioViewContainer *radio_view_;
diff --git a/src/core/song.cpp b/src/core/song.cpp
index 0c68a4fd3..7986c2d52 100644
--- a/src/core/song.cpp
+++ b/src/core/song.cpp
@@ -565,11 +565,11 @@ const QString &Song::playlist_albumartist_sortable() const { return is_compilati
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); }
bool Song::is_collection_song() const { return d->source_ == Source::Collection; }
-bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz; }
+bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; }
-bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; }
+bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_module_music() const { return d->filetype_ == FileType::MOD || d->filetype_ == FileType::S3M || d->filetype_ == FileType::XM || d->filetype_ == FileType::IT; }
bool Song::has_cue() const { return !d->cue_path_.isEmpty(); }
@@ -938,11 +938,13 @@ Song::Source Song::SourceFromURL(const QUrl &url) {
if (url.isLocalFile()) return Source::LocalFile;
if (url.scheme() == QStringLiteral("cdda")) return Source::CDDA;
- if (url.scheme() == QStringLiteral("tidal")) return Source::Tidal;
if (url.scheme() == QStringLiteral("subsonic")) return Source::Subsonic;
+ if (url.scheme() == QStringLiteral("tidal")) return Source::Tidal;
+ if (url.scheme() == QStringLiteral("spotify")) return Source::Spotify;
if (url.scheme() == QStringLiteral("qobuz")) return Source::Qobuz;
if (url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https") || url.scheme() == QStringLiteral("rtsp")) {
if (url.host().endsWith(QLatin1String("tidal.com"), Qt::CaseInsensitive)) { return Source::Tidal; }
+ if (url.host().endsWith(QLatin1String("spotify.com"), Qt::CaseInsensitive)) { return Source::Spotify; }
if (url.host().endsWith(QLatin1String("qobuz.com"), Qt::CaseInsensitive)) { return Source::Qobuz; }
if (url.host().endsWith(QLatin1String("somafm.com"), Qt::CaseInsensitive)) { return Source::SomaFM; }
if (url.host().endsWith(QLatin1String("radioparadise.com"), Qt::CaseInsensitive)) { return Source::RadioParadise; }
@@ -960,8 +962,9 @@ QString Song::TextForSource(const Source source) {
case Source::CDDA: return QStringLiteral("cd");
case Source::Device: return QStringLiteral("device");
case Source::Stream: return QStringLiteral("stream");
- case Source::Tidal: return QStringLiteral("tidal");
case Source::Subsonic: return QStringLiteral("subsonic");
+ case Source::Tidal: return QStringLiteral("tidal");
+ case Source::Spotify: return QStringLiteral("spotify");
case Source::Qobuz: return QStringLiteral("qobuz");
case Source::SomaFM: return QStringLiteral("somafm");
case Source::RadioParadise: return QStringLiteral("radioparadise");
@@ -979,8 +982,9 @@ QString Song::DescriptionForSource(const Source source) {
case Source::CDDA: return QStringLiteral("CD");
case Source::Device: return QStringLiteral("Device");
case Source::Stream: return QStringLiteral("Stream");
- case Source::Tidal: return QStringLiteral("Tidal");
case Source::Subsonic: return QStringLiteral("Subsonic");
+ case Source::Tidal: return QStringLiteral("Tidal");
+ case Source::Spotify: return QStringLiteral("Spotify");
case Source::Qobuz: return QStringLiteral("Qobuz");
case Source::SomaFM: return QStringLiteral("SomaFM");
case Source::RadioParadise: return QStringLiteral("Radio Paradise");
@@ -997,8 +1001,9 @@ Song::Source Song::SourceFromText(const QString &source) {
if (source.compare(QLatin1String("cd"), Qt::CaseInsensitive) == 0) return Source::CDDA;
if (source.compare(QLatin1String("device"), Qt::CaseInsensitive) == 0) return Source::Device;
if (source.compare(QLatin1String("stream"), Qt::CaseInsensitive) == 0) return Source::Stream;
- if (source.compare(QLatin1String("tidal"), Qt::CaseInsensitive) == 0) return Source::Tidal;
if (source.compare(QLatin1String("subsonic"), Qt::CaseInsensitive) == 0) return Source::Subsonic;
+ if (source.compare(QLatin1String("tidal"), Qt::CaseInsensitive) == 0) return Source::Tidal;
+ if (source.compare(QLatin1String("spotify"), Qt::CaseInsensitive) == 0) return Source::Spotify;
if (source.compare(QLatin1String("qobuz"), Qt::CaseInsensitive) == 0) return Source::Qobuz;
if (source.compare(QLatin1String("somafm"), Qt::CaseInsensitive) == 0) return Source::SomaFM;
if (source.compare(QLatin1String("radioparadise"), Qt::CaseInsensitive) == 0) return Source::RadioParadise;
@@ -1015,8 +1020,9 @@ QIcon Song::IconForSource(const Source source) {
case Source::CDDA: return IconLoader::Load(QStringLiteral("media-optical"));
case Source::Device: return IconLoader::Load(QStringLiteral("device"));
case Source::Stream: return IconLoader::Load(QStringLiteral("applications-internet"));
- case Source::Tidal: return IconLoader::Load(QStringLiteral("tidal"));
case Source::Subsonic: return IconLoader::Load(QStringLiteral("subsonic"));
+ case Source::Tidal: return IconLoader::Load(QStringLiteral("tidal"));
+ case Source::Spotify: return IconLoader::Load(QStringLiteral("spotify"));
case Source::Qobuz: return IconLoader::Load(QStringLiteral("qobuz"));
case Source::SomaFM: return IconLoader::Load(QStringLiteral("somafm"));
case Source::RadioParadise: return IconLoader::Load(QStringLiteral("radioparadise"));
@@ -1237,6 +1243,8 @@ QString Song::ImageCacheDir(const Source source) {
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/subsonicalbumcovers");
case Source::Tidal:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/tidalalbumcovers");
+ case Source::Spotify:
+ return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/spotifyalbumcovers");
case Source::Qobuz:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/qobuzalbumcovers");
case Source::Device:
diff --git a/src/core/song.h b/src/core/song.h
index 0b1c150fc..7d9a204b3 100644
--- a/src/core/song.h
+++ b/src/core/song.h
@@ -77,7 +77,8 @@ class Song {
Subsonic = 7,
Qobuz = 8,
SomaFM = 9,
- RadioParadise = 10
+ RadioParadise = 10,
+ Spotify = 11
};
// Don't change these values - they're stored in the database, and defined in the tag reader protobuf.
diff --git a/src/covermanager/albumcoverchoicecontroller.cpp b/src/covermanager/albumcoverchoicecontroller.cpp
index 4399a3150..dc6e8acc8 100644
--- a/src/covermanager/albumcoverchoicecontroller.cpp
+++ b/src/covermanager/albumcoverchoicecontroller.cpp
@@ -575,9 +575,10 @@ void AlbumCoverChoiceController::SaveArtManualToSong(Song *song, const QUrl &art
case Song::Source::SomaFM:
case Song::Source::Unknown:
break;
- case Song::Source::Tidal:
- case Song::Source::Qobuz:
case Song::Source::Subsonic:
+ case Song::Source::Tidal:
+ case Song::Source::Spotify:
+ case Song::Source::Qobuz:
StreamingServicePtr service = app_->streaming_services()->ServiceBySource(song->source());
if (!service) break;
if (service->artists_collection_backend()) {
diff --git a/src/covermanager/spotifycoverprovider.cpp b/src/covermanager/spotifycoverprovider.cpp
index 6237d61c1..10117a840 100644
--- a/src/covermanager/spotifycoverprovider.cpp
+++ b/src/covermanager/spotifycoverprovider.cpp
@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
- * Copyright 2020-2021, Jonas Kvinge
+ * Copyright 2020-2024, Jonas Kvinge
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -29,11 +29,8 @@
#include
#include
#include
-#include
#include
#include
-#include
-#include
#include
#include
#include
@@ -49,46 +46,20 @@
#include "core/localredirectserver.h"
#include "utilities/randutils.h"
#include "utilities/timeconstants.h"
+#include "streaming/streamingservices.h"
+#include "spotify/spotifyservice.h"
#include "albumcoverfetcher.h"
#include "jsoncoverprovider.h"
#include "spotifycoverprovider.h"
namespace {
-constexpr char kSettingsGroup[] = "Spotify";
-constexpr char kOAuthAuthorizeUrl[] = "https://accounts.spotify.com/authorize";
-constexpr char kOAuthAccessTokenUrl[] = "https://accounts.spotify.com/api/token";
-constexpr char kOAuthRedirectUrl[] = "http://localhost:63111/";
-constexpr char kClientIDB64[] = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
-constexpr char kClientSecretB64[] = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
constexpr char kApiUrl[] = "https://api.spotify.com/v1";
constexpr int kLimit = 10;
} // namespace
SpotifyCoverProvider::SpotifyCoverProvider(Application *app, SharedPtr network, QObject *parent)
: JsonCoverProvider(QStringLiteral("Spotify"), true, true, 2.5, true, true, app, network, parent),
- server_(nullptr),
- expires_in_(0),
- login_time_(0) {
-
- refresh_login_timer_.setSingleShot(true);
- QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyCoverProvider::RequestNewAccessToken);
-
- Settings s;
- s.beginGroup(kSettingsGroup);
- access_token_ = s.value("access_token").toString();
- refresh_token_ = s.value("refresh_token").toString();
- expires_in_ = s.value("expires_in").toLongLong();
- login_time_ = s.value("login_time").toLongLong();
- s.endGroup();
-
- if (!refresh_token_.isEmpty()) {
- qint64 time = static_cast(expires_in_) - (QDateTime::currentDateTime().toSecsSinceEpoch() - static_cast(login_time_));
- if (time < 1) time = 1;
- refresh_login_timer_.setInterval(static_cast(time * kMsecPerSec));
- refresh_login_timer_.start();
- }
-
-}
+ service_(app->streaming_services()->Service()) {}
SpotifyCoverProvider::~SpotifyCoverProvider() {
@@ -101,265 +72,9 @@ SpotifyCoverProvider::~SpotifyCoverProvider() {
}
-void SpotifyCoverProvider::Authenticate() {
-
- QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
-
- if (!server_) {
- server_ = new LocalRedirectServer(this);
- int port = redirect_url.port();
- int port_max = port + 10;
- bool success = false;
- forever {
- server_->set_port(port);
- if (server_->Listen()) {
- success = true;
- break;
- }
- ++port;
- if (port > port_max) break;
- }
- if (!success) {
- AuthError(server_->error());
- server_->deleteLater();
- server_ = nullptr;
- return;
- }
- QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyCoverProvider::RedirectArrived);
- }
-
- code_verifier_ = Utilities::CryptographicRandomString(44);
- code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
- if (code_challenge_.lastIndexOf(QLatin1Char('=')) == code_challenge_.length() - 1) {
- code_challenge_.chop(1);
- }
-
- const ParamList params = ParamList() << Param(QStringLiteral("client_id"), QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
- << Param(QStringLiteral("response_type"), QStringLiteral("code"))
- << Param(QStringLiteral("redirect_uri"), redirect_url.toString())
- << Param(QStringLiteral("state"), code_challenge_);
-
- QUrlQuery url_query;
- for (const Param ¶m : params) {
- url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
- }
-
- QUrl url(QString::fromLatin1(kOAuthAuthorizeUrl));
- url.setQuery(url_query);
-
- const bool result = QDesktopServices::openUrl(url);
- if (!result) {
- QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":
%1").arg(url.toString()), QMessageBox::Ok);
- messagebox.setTextFormat(Qt::RichText);
- messagebox.exec();
- }
-
-}
-
-void SpotifyCoverProvider::Deauthenticate() {
-
- access_token_.clear();
- refresh_token_.clear();
- expires_in_ = 0;
- login_time_ = 0;
-
- Settings s;
- s.beginGroup(kSettingsGroup);
- s.remove("access_token");
- s.remove("refresh_token");
- s.remove("expires_in");
- s.remove("login_time");
- s.endGroup();
-
- refresh_login_timer_.stop();
-
-}
-
-void SpotifyCoverProvider::RedirectArrived() {
-
- if (!server_) return;
-
- if (server_->error().isEmpty()) {
- QUrl url = server_->request_url();
- if (url.isValid()) {
- QUrlQuery url_query(url);
- if (url_query.hasQueryItem(QStringLiteral("error"))) {
- AuthError(QUrlQuery(url).queryItemValue(QStringLiteral("error")));
- }
- else if (url_query.hasQueryItem(QStringLiteral("code")) && url_query.hasQueryItem(QStringLiteral("state"))) {
- qLog(Debug) << "Spotify: Authorization URL Received" << url;
- QString code = url_query.queryItemValue(QStringLiteral("code"));
- QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
- redirect_url.setPort(server_->url().port());
- RequestAccessToken(code, redirect_url);
- }
- else {
- AuthError(tr("Redirect missing token code or state!"));
- }
- }
- else {
- AuthError(tr("Received invalid reply from web browser."));
- }
- }
- else {
- AuthError(server_->error());
- }
-
- server_->close();
- server_->deleteLater();
- server_ = nullptr;
-
-}
-
-void SpotifyCoverProvider::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
-
- refresh_login_timer_.stop();
-
- ParamList params = ParamList() << Param(QStringLiteral("client_id"), QLatin1String(kClientIDB64))
- << Param(QStringLiteral("client_secret"), QLatin1String(kClientSecretB64));
-
- if (!code.isEmpty() && !redirect_url.isEmpty()) {
- params << Param(QStringLiteral("grant_type"), QStringLiteral("authorization_code"));
- params << Param(QStringLiteral("code"), code);
- params << Param(QStringLiteral("redirect_uri"), redirect_url.toString());
- }
- else if (!refresh_token_.isEmpty() && is_enabled()) {
- params << Param(QStringLiteral("grant_type"), QStringLiteral("refresh_token"));
- params << Param(QStringLiteral("refresh_token"), refresh_token_);
- }
- else {
- return;
- }
-
- QUrlQuery url_query;
- for (const Param ¶m : params) {
- url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
- }
-
- QUrl new_url(QString::fromLatin1(kOAuthAccessTokenUrl));
- QNetworkRequest req(new_url);
- req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
- req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
- QString auth_header_data = QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)) + QLatin1Char(':') + QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64));
- req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64());
-
- QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
-
- QNetworkReply *reply = network_->post(req, query);
- replies_ << reply;
- QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyCoverProvider::HandleLoginSSLErrors);
- QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); });
-
-}
-
-void SpotifyCoverProvider::HandleLoginSSLErrors(const QList &ssl_errors) {
-
- for (const QSslError &ssl_error : ssl_errors) {
- login_errors_ += ssl_error.errorString();
- }
-
-}
-
-void SpotifyCoverProvider::AccessTokenRequestFinished(QNetworkReply *reply) {
-
- if (!replies_.contains(reply)) return;
- replies_.removeAll(reply);
- QObject::disconnect(reply, nullptr, this, nullptr);
- reply->deleteLater();
-
- if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
- if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
- // This is a network error, there is nothing more to do.
- AuthError(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
- return;
- }
- else {
- // See if there is Json data containing "error" and "error_description" then use that instead.
- QByteArray data = reply->readAll();
- QJsonParseError json_error;
- QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
- if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
- QJsonObject json_obj = json_doc.object();
- if (!json_obj.isEmpty() && json_obj.contains(QLatin1String("error")) && json_obj.contains(QLatin1String("error_description"))) {
- QString error = json_obj[QLatin1String("error")].toString();
- QString error_description = json_obj[QLatin1String("error_description")].toString();
- login_errors_ << QStringLiteral("Authentication failure: %1 (%2)").arg(error, error_description);
- }
- }
- if (login_errors_.isEmpty()) {
- if (reply->error() != QNetworkReply::NoError) {
- login_errors_ << QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
- }
- else {
- login_errors_ << QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
- }
- }
- AuthError();
- return;
- }
- }
-
- QByteArray data = reply->readAll();
-
- QJsonParseError json_error;
- QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
-
- if (json_error.error != QJsonParseError::NoError) {
- Error(QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
- return;
- }
-
- if (json_doc.isEmpty()) {
- AuthError(QStringLiteral("Authentication reply from server has empty Json document."));
- return;
- }
-
- if (!json_doc.isObject()) {
- AuthError(QStringLiteral("Authentication reply from server has Json document that is not an object."), json_doc);
- return;
- }
-
- QJsonObject json_obj = json_doc.object();
- if (json_obj.isEmpty()) {
- AuthError(QStringLiteral("Authentication reply from server has empty Json object."), json_doc);
- return;
- }
-
- if (!json_obj.contains(QLatin1String("access_token")) || !json_obj.contains(QLatin1String("expires_in"))) {
- AuthError(QStringLiteral("Authentication reply from server is missing access token or expires in."), json_obj);
- return;
- }
-
- access_token_ = json_obj[QLatin1String("access_token")].toString();
- if (json_obj.contains(QLatin1String("refresh_token"))) {
- refresh_token_ = json_obj[QLatin1String("refresh_token")].toString();
- }
- expires_in_ = json_obj[QLatin1String("expires_in")].toInt();
- login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
-
- Settings s;
- s.beginGroup(kSettingsGroup);
- s.setValue("access_token", access_token_);
- s.setValue("refresh_token", refresh_token_);
- s.setValue("expires_in", expires_in_);
- s.setValue("login_time", login_time_);
- s.endGroup();
-
- if (expires_in_ > 0) {
- refresh_login_timer_.setInterval(static_cast(expires_in_ * kMsecPerSec));
- refresh_login_timer_.start();
- }
-
- qLog(Debug) << "Spotify: Authentication was successful, login expires in" << expires_in_;
-
- emit AuthenticationComplete(true);
- emit AuthenticationSuccess();
-
-}
-
bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
- if (access_token_.isEmpty()) return false;
+ if (!IsAuthenticated()) return false;
if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
@@ -395,7 +110,7 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
- req.setRawHeader("Authorization", "Bearer " + access_token_.toUtf8());
+ req.setRawHeader("Authorization", "Bearer " + service_->access_token().toUtf8());
QNetworkReply *reply = network_->get(req);
replies_ << reply;
@@ -432,13 +147,13 @@ QByteArray SpotifyCoverProvider::GetReplyData(QNetworkReply *reply) {
int status = obj_error[QLatin1String("status")].toInt();
QString message = obj_error[QLatin1String("message")].toString();
error = QStringLiteral("%1 (%2)").arg(message).arg(status);
- if (status == 401) access_token_.clear();
+ if (status == 401) Deauthenticate();
}
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
- if (reply->error() == 204) access_token_.clear();
+ if (reply->error() == 204) Deauthenticate();
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
@@ -541,20 +256,6 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id,
}
-void SpotifyCoverProvider::AuthError(const QString &error, const QVariant &debug) {
-
- if (!error.isEmpty()) login_errors_ << error;
-
- for (const QString &e : login_errors_) Error(e);
- if (debug.isValid()) qLog(Debug) << debug;
-
- emit AuthenticationFailure(login_errors_);
- emit AuthenticationComplete(false, login_errors_);
-
- login_errors_.clear();
-
-}
-
void SpotifyCoverProvider::Error(const QString &error, const QVariant &debug) {
qLog(Error) << "Spotify:" << error;
diff --git a/src/covermanager/spotifycoverprovider.h b/src/covermanager/spotifycoverprovider.h
index 3432b05d5..d797a1e0f 100644
--- a/src/covermanager/spotifycoverprovider.h
+++ b/src/covermanager/spotifycoverprovider.h
@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
- * Copyright 2018-2021, Jonas Kvinge
+ * Copyright 2018-2024, Jonas Kvinge
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -36,11 +36,11 @@
#include "core/shared_ptr.h"
#include "jsoncoverprovider.h"
+#include "spotify/spotifyservice.h"
class QNetworkReply;
class Application;
class NetworkAccessManager;
-class LocalRedirectServer;
class SpotifyCoverProvider : public JsonCoverProvider {
Q_OBJECT
@@ -52,33 +52,20 @@ class SpotifyCoverProvider : public JsonCoverProvider {
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
void CancelSearch(const int id) override;
- void Authenticate() override;
- void Deauthenticate() override;
- bool IsAuthenticated() const override { return !access_token_.isEmpty(); }
+ bool IsAuthenticated() const override { return service_ && service_->authenticated(); }
+ void Deauthenticate() override {
+ if (service_) service_->Deauthenticate();
+ }
private slots:
- void HandleLoginSSLErrors(const QList &ssl_errors);
- void RedirectArrived();
- void AccessTokenRequestFinished(QNetworkReply *reply);
void HandleSearchReply(QNetworkReply *reply, const int id, const QString &extract);
- void RequestNewAccessToken() { RequestAccessToken(); }
private:
QByteArray GetReplyData(QNetworkReply *reply);
- void AuthError(const QString &error = QString(), const QVariant &debug = QVariant());
void Error(const QString &error, const QVariant &debug = QVariant()) override;
- void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl());
private:
- LocalRedirectServer *server_;
- QStringList login_errors_;
- QString code_verifier_;
- QString code_challenge_;
- QString access_token_;
- QString refresh_token_;
- quint64 expires_in_;
- quint64 login_time_;
- QTimer refresh_login_timer_;
+ SharedPtr service_;
QList replies_;
};
diff --git a/src/engine/enginebase.cpp b/src/engine/enginebase.cpp
index fdf1680de..d0a873f93 100644
--- a/src/engine/enginebase.cpp
+++ b/src/engine/enginebase.cpp
@@ -37,6 +37,9 @@
#include "enginebase.h"
#include "settings/backendsettingspage.h"
#include "settings/networkproxysettingspage.h"
+#ifdef HAVE_SPOTIFY
+# include "settings/spotifysettingspage.h"
+#endif
EngineBase::EngineBase(QObject *parent)
: QObject(parent),
@@ -239,6 +242,15 @@ void EngineBase::ReloadSettings() {
s.endGroup();
+#ifdef HAVE_SPOTIFY
+ s.beginGroup(SpotifySettingsPage::kSettingsGroup);
+ spotify_username_ = s.value("username").toString();
+ QByteArray password = s.value("password").toByteArray();
+ if (password.isEmpty()) spotify_password_.clear();
+ else spotify_password_ = QString::fromUtf8(QByteArray::fromBase64(password));
+ s.endGroup();
+#endif
+
}
void EngineBase::EmitAboutToFinish() {
diff --git a/src/engine/enginebase.h b/src/engine/enginebase.h
index f6cb504cf..00be606ea 100644
--- a/src/engine/enginebase.h
+++ b/src/engine/enginebase.h
@@ -247,6 +247,12 @@ class EngineBase : public QObject {
bool http2_enabled_;
bool strict_ssl_enabled_;
+ // Spotify
+#ifdef HAVE_SPOTIFY
+ QString spotify_username_;
+ QString spotify_password_;
+#endif
+
bool about_to_end_emitted_;
Q_DISABLE_COPY(EngineBase)
diff --git a/src/engine/gstengine.cpp b/src/engine/gstengine.cpp
index 577dbd8b5..dea4076d8 100644
--- a/src/engine/gstengine.cpp
+++ b/src/engine/gstengine.cpp
@@ -173,7 +173,7 @@ void GstEngine::StartPreloading(const QUrl &media_url, const QUrl &stream_url, c
if (current_pipeline_) {
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_nanosec, force_stop_at_end ? end_nanosec : 0);
// Add request to discover the stream
- if (discoverer_) {
+ if (discoverer_ && media_url.scheme() != QStringLiteral("spotify")) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
}
@@ -230,7 +230,7 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
}
// Add request to discover the stream
- if (discoverer_) {
+ if (discoverer_ && media_url.scheme() != QStringLiteral("spotify")) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
}
@@ -816,6 +816,10 @@ SharedPtr GstEngine::CreatePipeline() {
ret->set_strict_ssl_enabled(strict_ssl_enabled_);
ret->set_fading_enabled(fadeout_enabled_ || autocrossfade_enabled_ || fadeout_pause_enabled_);
+#ifdef HAVE_SPOTIFY
+ ret->set_spotify_login(spotify_username_, spotify_password_);
+#endif
+
ret->AddBufferConsumer(this);
for (GstBufferConsumer *consumer : std::as_const(buffer_consumers_)) {
ret->AddBufferConsumer(consumer);
diff --git a/src/engine/gstenginepipeline.cpp b/src/engine/gstenginepipeline.cpp
index b2e9b856b..ef2da4931 100644
--- a/src/engine/gstenginepipeline.cpp
+++ b/src/engine/gstenginepipeline.cpp
@@ -300,6 +300,15 @@ void GstEnginePipeline::set_fading_enabled(const bool enabled) {
fading_enabled_ = enabled;
}
+#ifdef HAVE_SPOTIFY
+void GstEnginePipeline::set_spotify_login(const QString &spotify_username, const QString &spotify_password) {
+
+ spotify_username_ = spotify_username;
+ spotify_password_ = spotify_password;
+
+}
+#endif // HAVE_SPOTIFY
+
QString GstEnginePipeline::GstStateText(const GstState state) {
switch (state) {
@@ -996,6 +1005,17 @@ void GstEnginePipeline::SourceSetupCallback(GstElement *playbin, GstElement *sou
}
}
+#ifdef HAVE_SPOTIFY
+ if (instance->media_url_.scheme() == QStringLiteral("spotify") &&
+ !instance->spotify_username_.isEmpty() &&
+ !instance->spotify_password_.isEmpty() &&
+ g_object_class_find_property(G_OBJECT_GET_CLASS(source), "username") &&
+ g_object_class_find_property(G_OBJECT_GET_CLASS(source), "password")) {
+ g_object_set(source, "username", instance->spotify_username_.toUtf8().constData(), nullptr);
+ g_object_set(source, "password", instance->spotify_password_.toUtf8().constData(), nullptr);
+ }
+#endif
+
// If the pipeline was buffering we stop that now.
if (instance->buffering_) {
instance->buffering_ = false;
diff --git a/src/engine/gstenginepipeline.h b/src/engine/gstenginepipeline.h
index 6e3bb502c..03ef0eeb4 100644
--- a/src/engine/gstenginepipeline.h
+++ b/src/engine/gstenginepipeline.h
@@ -76,6 +76,9 @@ class GstEnginePipeline : public QObject {
void set_bs2b_enabled(const bool enabled);
void set_strict_ssl_enabled(const bool enabled);
void set_fading_enabled(const bool enabled);
+#ifdef HAVE_SPOTIFY
+ void set_spotify_login(const QString &spotify_username, const QString &spotify_password);
+#endif
// Creates the pipeline, returns false on error
bool InitFromUrl(const QUrl &media_url, const QUrl &stream_url, const QByteArray &gst_url, const qint64 end_nanosec, const double ebur128_loudness_normalizing_gain_db, QString &error);
@@ -249,6 +252,12 @@ class GstEnginePipeline : public QObject {
bool bs2b_enabled_;
bool strict_ssl_enabled_;
+ // Spotify
+#ifdef HAVE_SPOTIFY
+ QString spotify_username_;
+ QString spotify_password_;
+#endif
+
// These get called when there is a new audio buffer available
QList buffer_consumers_;
QMutex buffer_consumers_mutex_;
diff --git a/src/playlist/playlistitem.cpp b/src/playlist/playlistitem.cpp
index 156c8af4e..c86d05169 100644
--- a/src/playlist/playlistitem.cpp
+++ b/src/playlist/playlistitem.cpp
@@ -46,6 +46,7 @@ PlaylistItemPtr PlaylistItem::NewFromSource(const Song::Source source) {
return make_shared();
case Song::Source::Subsonic:
case Song::Source::Tidal:
+ case Song::Source::Spotify:
case Song::Source::Qobuz:
return make_shared(source);
case Song::Source::Stream:
@@ -70,6 +71,7 @@ PlaylistItemPtr PlaylistItem::NewFromSong(const Song &song) {
return make_shared(song);
case Song::Source::Subsonic:
case Song::Source::Tidal:
+ case Song::Source::Spotify:
case Song::Source::Qobuz:
return make_shared(song);
case Song::Source::Stream:
diff --git a/src/settings/coverssettingspage.cpp b/src/settings/coverssettingspage.cpp
index 3b8376182..fd40bfa5c 100644
--- a/src/settings/coverssettingspage.cpp
+++ b/src/settings/coverssettingspage.cpp
@@ -226,6 +226,10 @@ void CoversSettingsPage::ProvidersCurrentItemChanged(QListWidgetItem *item_curre
DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate."));
}
+ else if (provider->name() == QLatin1String("Spotify") && !provider->IsAuthenticated()) {
+ DisableAuthentication();
+ ui_->label_auth_info->setText(tr("Use Spotify settings to authenticate."));
+ }
else if (provider->name() == QLatin1String("Qobuz") && !provider->IsAuthenticated()) {
DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate."));
@@ -339,6 +343,10 @@ void CoversSettingsPage::LogoutClicked() {
DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate."));
}
+ else if (provider->name() == QLatin1String("Spotify")) {
+ DisableAuthentication();
+ ui_->label_auth_info->setText(tr("Use Spotify settings to authenticate."));
+ }
else if (provider->name() == QLatin1String("Qobuz")) {
DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate."));
diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp
index 98d752398..ec29d8c9f 100644
--- a/src/settings/settingsdialog.cpp
+++ b/src/settings/settingsdialog.cpp
@@ -78,6 +78,9 @@
#ifdef HAVE_TIDAL
# include "tidalsettingspage.h"
#endif
+#ifdef HAVE_SPOTIFY
+# include "spotifysettingspage.h"
+#endif
#ifdef HAVE_QOBUZ
# include "qobuzsettingspage.h"
#endif
@@ -155,7 +158,7 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main
AddPage(Page::Moodbar, new MoodbarSettingsPage(this, this), iface);
#endif
-#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_QOBUZ)
+#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_SPOTIFY) || defined(HAVE_QOBUZ)
QTreeWidgetItem *streaming = AddCategory(tr("Streaming"));
#endif
@@ -165,6 +168,9 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main
#ifdef HAVE_TIDAL
AddPage(Page::Tidal, new TidalSettingsPage(this, this), streaming);
#endif
+#ifdef HAVE_SPOTIFY
+ AddPage(Page::Spotify, new SpotifySettingsPage(this, this), streaming);
+#endif
#ifdef HAVE_QOBUZ
AddPage(Page::Qobuz, new QobuzSettingsPage(this, this), streaming);
#endif
diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h
index cbdb23e4c..076652257 100644
--- a/src/settings/settingsdialog.h
+++ b/src/settings/settingsdialog.h
@@ -92,6 +92,7 @@ class SettingsDialog : public QDialog {
Subsonic,
Tidal,
Qobuz,
+ Spotify,
};
enum Role {
diff --git a/src/settings/spotifysettingspage.cpp b/src/settings/spotifysettingspage.cpp
new file mode 100644
index 000000000..1898c86ad
--- /dev/null
+++ b/src/settings/spotifysettingspage.cpp
@@ -0,0 +1,170 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#include "config.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "settingsdialog.h"
+#include "spotifysettingspage.h"
+#include "ui_spotifysettingspage.h"
+#include "core/application.h"
+#include "core/iconloader.h"
+#include "core/settings.h"
+#include "streaming/streamingservices.h"
+#include "spotify/spotifyservice.h"
+#include "widgets/loginstatewidget.h"
+
+const char *SpotifySettingsPage::kSettingsGroup = "Spotify";
+
+SpotifySettingsPage::SpotifySettingsPage(SettingsDialog *dialog, QWidget *parent)
+ : SettingsPage(dialog, parent),
+ ui_(new Ui::SpotifySettingsPage),
+ service_(dialog->app()->streaming_services()->Service()) {
+
+ ui_->setupUi(this);
+ setWindowIcon(IconLoader::Load(QStringLiteral("spotify")));
+
+ QObject::connect(ui_->button_login, &QPushButton::clicked, this, &SpotifySettingsPage::LoginClicked);
+ QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &SpotifySettingsPage::LogoutClicked);
+
+ QObject::connect(this, &SpotifySettingsPage::Authorize, &*service_, &SpotifyService::Authenticate);
+
+ QObject::connect(&*service_, &StreamingService::LoginFailure, this, &SpotifySettingsPage::LoginFailure);
+ QObject::connect(&*service_, &StreamingService::LoginSuccess, this, &SpotifySettingsPage::LoginSuccess);
+
+ dialog->installEventFilter(this);
+
+ GstRegistry *reg = gst_registry_get();
+ if (reg) {
+ GstPluginFeature *spotifyaudiosrc = gst_registry_lookup_feature(reg, "spotifyaudiosrc");
+ if (spotifyaudiosrc) {
+ ui_->widget_warning->hide();
+ }
+ else {
+ ui_->widget_warning->show();
+ }
+ }
+
+}
+
+SpotifySettingsPage::~SpotifySettingsPage() { delete ui_; }
+
+void SpotifySettingsPage::Load() {
+
+ Settings s;
+ s.beginGroup(kSettingsGroup);
+ ui_->enable->setChecked(s.value("enabled", false).toBool());
+
+ ui_->username->setText(s.value("username").toString());
+ QByteArray password = s.value("password").toByteArray();
+ if (password.isEmpty()) ui_->password->clear();
+ else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password)));
+
+ ui_->searchdelay->setValue(s.value("searchdelay", 1500).toInt());
+ ui_->artistssearchlimit->setValue(s.value("artistssearchlimit", 4).toInt());
+ ui_->albumssearchlimit->setValue(s.value("albumssearchlimit", 10).toInt());
+ ui_->songssearchlimit->setValue(s.value("songssearchlimit", 10).toInt());
+ ui_->checkbox_fetchalbums->setChecked(s.value("fetchalbums", false).toBool());
+ ui_->checkbox_download_album_covers->setChecked(s.value("downloadalbumcovers", true).toBool());
+
+ s.endGroup();
+
+ if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedIn);
+
+ Init(ui_->layout_spotifysettingspage->parentWidget());
+
+ if (!Settings().childGroups().contains(QLatin1String(kSettingsGroup))) set_changed();
+
+}
+
+void SpotifySettingsPage::Save() {
+
+ Settings s;
+ s.beginGroup(kSettingsGroup);
+ s.setValue("enabled", ui_->enable->isChecked());
+
+ s.setValue("username", ui_->username->text());
+ s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64()));
+
+ s.setValue("searchdelay", ui_->searchdelay->value());
+ s.setValue("artistssearchlimit", ui_->artistssearchlimit->value());
+ s.setValue("albumssearchlimit", ui_->albumssearchlimit->value());
+ s.setValue("songssearchlimit", ui_->songssearchlimit->value());
+ s.setValue("fetchalbums", ui_->checkbox_fetchalbums->isChecked());
+ s.setValue("downloadalbumcovers", ui_->checkbox_download_album_covers->isChecked());
+ s.endGroup();
+
+}
+
+void SpotifySettingsPage::LoginClicked() {
+
+ emit Authorize();
+
+ ui_->button_login->setEnabled(false);
+
+}
+
+bool SpotifySettingsPage::eventFilter(QObject *object, QEvent *event) {
+
+ if (object == dialog() && event->type() == QEvent::Enter) {
+ ui_->button_login->setEnabled(true);
+ }
+
+ return SettingsPage::eventFilter(object, event);
+
+}
+
+void SpotifySettingsPage::LogoutClicked() {
+
+ service_->Deauthenticate();
+ ui_->button_login->setEnabled(true);
+ ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedOut);
+
+}
+
+void SpotifySettingsPage::LoginSuccess() {
+
+ if (!isVisible()) return;
+ ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedIn);
+ ui_->button_login->setEnabled(true);
+
+}
+
+void SpotifySettingsPage::LoginFailure(const QString &failure_reason) {
+
+ if (!isVisible()) return;
+ QMessageBox::warning(this, tr("Authentication failed"), failure_reason);
+ ui_->button_login->setEnabled(true);
+
+}
diff --git a/src/settings/spotifysettingspage.h b/src/settings/spotifysettingspage.h
new file mode 100644
index 000000000..0fb19ae6f
--- /dev/null
+++ b/src/settings/spotifysettingspage.h
@@ -0,0 +1,64 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#ifndef SPOTIFYSETTINGSPAGE_H
+#define SPOTIFYSETTINGSPAGE_H
+
+#include "config.h"
+
+#include
+#include
+
+#include "core/shared_ptr.h"
+#include "settings/settingspage.h"
+
+class QEvent;
+class SpotifyService;
+class SettingsDialog;
+class Ui_SpotifySettingsPage;
+
+class SpotifySettingsPage : public SettingsPage {
+ Q_OBJECT
+
+ public:
+ explicit SpotifySettingsPage(SettingsDialog *dialog, QWidget *parent = nullptr);
+ ~SpotifySettingsPage() override;
+
+ static const char *kSettingsGroup;
+
+ void Load() override;
+ void Save() override;
+
+ bool eventFilter(QObject *object, QEvent *event) override;
+
+ signals:
+ void Authorize();
+
+ private slots:
+ void LoginClicked();
+ void LogoutClicked();
+ void LoginSuccess();
+ void LoginFailure(const QString &failure_reason);
+
+ private:
+ Ui_SpotifySettingsPage *ui_;
+ SharedPtr service_;
+};
+
+#endif // SPOTIFYSETTINGSPAGE_H
diff --git a/src/settings/spotifysettingspage.ui b/src/settings/spotifysettingspage.ui
new file mode 100644
index 000000000..7ed6b689a
--- /dev/null
+++ b/src/settings/spotifysettingspage.ui
@@ -0,0 +1,321 @@
+
+
+ SpotifySettingsPage
+
+
+
+ 0
+ 0
+ 505
+ 853
+
+
+
+ Spotify
+
+
+ -
+
+
+ Enable
+
+
+
+ -
+
+
+ Basic authentication
+
+
+
-
+
+
+ -
+
+
+ Authenticate
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Device credentials
+
+
+
-
+
+
+ Username
+
+
+
+ -
+
+
+ Password
+
+
+
+ -
+
+
+ QLineEdit::Password
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
+ 2
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+
+
+ :/icons/64x64/dialog-warning.png
+
+
+
+ -
+
+
+ <html><head/><body><p>The GStreamer Spotify plugin is not detected, you will not be able to stream songs from Spotify without it. See: <a href="https://github.com/strawberrymusicplayer/strawberry/wiki/GStreamer-Spotify-plugin"><span style=" text-decoration: underline; color:#0000ff;">https://github.com/strawberrymusicplayer/strawberry/wiki/GStreamer-Spotify-plugin</span></a> for instructions on how to install the plugin.</p></body></html>
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+ true
+
+
+ 10
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+ Preferences
+
+
+
-
+
+
+ Search delay
+
+
+
+ -
+
+
+ ms
+
+
+ 500
+
+
+ 10000
+
+
+ 50
+
+
+ 1500
+
+
+
+ -
+
+
+ Artists search limit
+
+
+
+ -
+
+
+ 1
+
+
+ 100
+
+
+ 50
+
+
+
+ -
+
+
+ Albums search limit
+
+
+
+ -
+
+
+ 1
+
+
+ 1000
+
+
+ 50
+
+
+
+ -
+
+
+ Songs search limit
+
+
+
+ -
+
+
+ 1
+
+
+ 1000
+
+
+ 50
+
+
+
+ -
+
+
+ Download album covers
+
+
+
+ -
+
+
+ Fetch entire albums when searching songs
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 30
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 64
+ 64
+
+
+
+
+ 64
+ 64
+
+
+
+ :/icons/64x64/spotify.png
+
+
+
+
+
+
+
+
+
+ LoginStateWidget
+ QWidget
+ widgets/loginstatewidget.h
+ 1
+
+
+
+ enable
+ password
+ searchdelay
+ artistssearchlimit
+ albumssearchlimit
+ songssearchlimit
+ checkbox_download_album_covers
+ checkbox_fetchalbums
+
+
+
+
+
+
+
diff --git a/src/spotify/spotifybaserequest.cpp b/src/spotify/spotifybaserequest.cpp
new file mode 100644
index 000000000..d06831269
--- /dev/null
+++ b/src/spotify/spotifybaserequest.cpp
@@ -0,0 +1,184 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/logging.h"
+#include "core/networkaccessmanager.h"
+#include "spotifyservice.h"
+#include "spotifybaserequest.h"
+
+SpotifyBaseRequest::SpotifyBaseRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent)
+ : QObject(parent),
+ service_(service),
+ network_(network) {}
+
+QNetworkReply *SpotifyBaseRequest::CreateRequest(const QString &ressource_name, const ParamList ¶ms_provided) {
+
+ ParamList params = ParamList() << params_provided;
+
+ QUrlQuery url_query;
+ for (const Param ¶m : params) {
+ url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
+ }
+
+ QUrl url(QLatin1String(SpotifyService::kApiUrl) + QLatin1Char('/') + ressource_name);
+ url.setQuery(url_query);
+ QNetworkRequest req(url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
+ if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
+
+ QNetworkReply *reply = network_->get(req);
+ QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyBaseRequest::HandleSSLErrors);
+
+ qLog(Debug) << "Spotify: Sending request" << url;
+
+ return reply;
+
+}
+
+void SpotifyBaseRequest::HandleSSLErrors(const QList &ssl_errors) {
+
+ for (const QSslError &ssl_error : ssl_errors) {
+ Error(ssl_error.errorString());
+ }
+
+}
+
+QByteArray SpotifyBaseRequest::GetReplyData(QNetworkReply *reply) {
+
+ QByteArray data;
+
+ if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
+ data = reply->readAll();
+ }
+ else {
+ if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
+ // This is a network error, there is nothing more to do.
+ Error(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
+ }
+ else {
+ // See if there is Json data containing "error".
+ data = reply->readAll();
+ QString error;
+ QJsonParseError json_error;
+ QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
+ int status = 0;
+ if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
+ QJsonObject json_obj = json_doc.object();
+ if (!json_obj.isEmpty() && json_obj.contains(QLatin1String("error")) && json_obj[QLatin1String("error")].isObject()) {
+ QJsonObject obj_error = json_obj[QLatin1String("error")].toObject();
+ if (!obj_error.isEmpty() && obj_error.contains(QLatin1String("status")) && obj_error.contains(QLatin1String("message"))) {
+ status = obj_error[QLatin1String("status")].toInt();
+ QString user_message = obj_error[QLatin1String("message")].toString();
+ error = QStringLiteral("%1 (%2)").arg(user_message).arg(status);
+ }
+ }
+ }
+ if (error.isEmpty()) {
+ if (reply->error() == QNetworkReply::NoError) {
+ error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
+ }
+ else {
+ error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
+ }
+ }
+ Error(error);
+ }
+ return QByteArray();
+ }
+
+ return data;
+
+}
+
+QJsonObject SpotifyBaseRequest::ExtractJsonObj(const QByteArray &data) {
+
+ QJsonParseError json_error;
+ QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
+
+ if (json_error.error != QJsonParseError::NoError) {
+ Error(QStringLiteral("Reply from server missing Json data."), data);
+ return QJsonObject();
+ }
+
+ if (json_doc.isEmpty()) {
+ Error(QStringLiteral("Received empty Json document."), data);
+ return QJsonObject();
+ }
+
+ if (!json_doc.isObject()) {
+ Error(QStringLiteral("Json document is not an object."), json_doc);
+ return QJsonObject();
+ }
+
+ QJsonObject json_obj = json_doc.object();
+ if (json_obj.isEmpty()) {
+ Error(QStringLiteral("Received empty Json object."), json_doc);
+ return QJsonObject();
+ }
+
+ return json_obj;
+
+}
+
+QJsonValue SpotifyBaseRequest::ExtractItems(const QByteArray &data) {
+
+ QJsonObject json_obj = ExtractJsonObj(data);
+ if (json_obj.isEmpty()) return QJsonValue();
+ return ExtractItems(json_obj);
+
+}
+
+QJsonValue SpotifyBaseRequest::ExtractItems(const QJsonObject &json_obj) {
+
+ if (!json_obj.contains(QLatin1String("items"))) {
+ Error(QStringLiteral("Json reply is missing items."), json_obj);
+ return QJsonArray();
+ }
+ QJsonValue json_items = json_obj[QLatin1String("items")];
+ return json_items;
+
+}
+
+QString SpotifyBaseRequest::ErrorsToHTML(const QStringList &errors) {
+
+ QString error_html;
+ for (const QString &error : errors) {
+ error_html += error + QLatin1String("
");
+ }
+ return error_html;
+
+}
diff --git a/src/spotify/spotifybaserequest.h b/src/spotify/spotifybaserequest.h
new file mode 100644
index 000000000..faa43626e
--- /dev/null
+++ b/src/spotify/spotifybaserequest.h
@@ -0,0 +1,91 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#ifndef SPOTIFYBASEREQUEST_H
+#define SPOTIFYBASEREQUEST_H
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "spotifyservice.h"
+
+class QNetworkReply;
+class NetworkAccessManager;
+
+class SpotifyBaseRequest : public QObject {
+ Q_OBJECT
+
+ public:
+ explicit SpotifyBaseRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent = nullptr);
+
+ enum class QueryType {
+ None,
+ Artists,
+ Albums,
+ Songs,
+ SearchArtists,
+ SearchAlbums,
+ SearchSongs,
+ StreamURL,
+ };
+
+ protected:
+ typedef QPair Param;
+ typedef QList ParamList;
+
+ QNetworkReply *CreateRequest(const QString &ressource_name, const ParamList ¶ms_provided);
+ QByteArray GetReplyData(QNetworkReply *reply);
+ QJsonObject ExtractJsonObj(const QByteArray &data);
+ QJsonValue ExtractItems(const QByteArray &data);
+ QJsonValue ExtractItems(const QJsonObject &json_obj);
+
+ virtual void Error(const QString &error, const QVariant &debug = QVariant()) = 0;
+ static QString ErrorsToHTML(const QStringList &errors);
+
+ int artistssearchlimit() { return service_->artistssearchlimit(); }
+ int albumssearchlimit() { return service_->albumssearchlimit(); }
+ int songssearchlimit() { return service_->songssearchlimit(); }
+
+ QString access_token() { return service_->access_token(); }
+
+ bool authenticated() { return service_->authenticated(); }
+
+ private slots:
+ void HandleSSLErrors(const QList &ssl_errors);
+
+ private:
+ SpotifyService *service_;
+ NetworkAccessManager *network_;
+
+};
+
+#endif // SPOTIFYBASEREQUEST_H
diff --git a/src/spotify/spotifyfavoriterequest.cpp b/src/spotify/spotifyfavoriterequest.cpp
new file mode 100644
index 000000000..cba013b59
--- /dev/null
+++ b/src/spotify/spotifyfavoriterequest.cpp
@@ -0,0 +1,310 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/logging.h"
+#include "core/networkaccessmanager.h"
+#include "core/song.h"
+#include "spotifyservice.h"
+#include "spotifybaserequest.h"
+#include "spotifyfavoriterequest.h"
+
+SpotifyFavoriteRequest::SpotifyFavoriteRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent)
+ : SpotifyBaseRequest(service, network, parent),
+ service_(service),
+ network_(network) {}
+
+SpotifyFavoriteRequest::~SpotifyFavoriteRequest() {
+
+ while (!replies_.isEmpty()) {
+ QNetworkReply *reply = replies_.takeFirst();
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->abort();
+ reply->deleteLater();
+ }
+
+}
+
+QString SpotifyFavoriteRequest::FavoriteText(const FavoriteType type) {
+
+ switch (type) {
+ case FavoriteType_Artists:
+ return QStringLiteral("artists");
+ case FavoriteType_Albums:
+ return QStringLiteral("albums");
+ case FavoriteType_Songs:
+ return QStringLiteral("tracks");
+ }
+
+ return QString();
+
+}
+
+void SpotifyFavoriteRequest::AddArtists(const SongList &songs) {
+ AddFavorites(FavoriteType_Artists, songs);
+}
+
+void SpotifyFavoriteRequest::AddAlbums(const SongList &songs) {
+ AddFavorites(FavoriteType_Albums, songs);
+}
+
+void SpotifyFavoriteRequest::AddSongs(const SongList &songs) {
+ AddFavorites(FavoriteType_Songs, songs);
+}
+
+void SpotifyFavoriteRequest::AddSongs(const SongMap &songs) {
+ AddFavorites(FavoriteType_Songs, songs.values());
+}
+
+void SpotifyFavoriteRequest::AddFavorites(const FavoriteType type, const SongList &songs) {
+
+ QStringList list_ids;
+ QJsonArray array_ids;
+ for (const Song &song : songs) {
+ QString id;
+ switch (type) {
+ case FavoriteType_Artists:
+ id = song.artist_id();
+ break;
+ case FavoriteType_Albums:
+ id = song.album_id();
+ break;
+ case FavoriteType_Songs:
+ id = song.song_id();
+ break;
+ }
+ if (!id.isEmpty()) {
+ if (!list_ids.contains(id)) {
+ list_ids << id;
+ }
+ if (!array_ids.contains(id)) {
+ array_ids << id;
+ }
+ }
+ }
+
+ if (list_ids.isEmpty() || array_ids.isEmpty()) return;
+
+ QByteArray json_data = QJsonDocument(array_ids).toJson();
+ QString ids_list = list_ids.join(QLatin1Char(','));
+
+ AddFavoritesRequest(type, ids_list, json_data, songs);
+
+}
+
+void SpotifyFavoriteRequest::AddFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) {
+
+ QUrl url(QLatin1String(SpotifyService::kApiUrl) + (type == FavoriteType_Artists ? QStringLiteral("/me/following") : QStringLiteral("/me/") + FavoriteText(type)));
+ if (type == FavoriteType_Artists) {
+ QUrlQuery url_query;
+ url_query.addQueryItem(QStringLiteral("type"), QStringLiteral("artist"));
+ url_query.addQueryItem(QStringLiteral("ids"), ids_list);
+ url.setQuery(url_query);
+ }
+ QNetworkRequest req(url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
+ if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
+ QNetworkReply *reply = nullptr;
+ if (type == FavoriteType_Artists) {
+ reply = network_->put(req, "");
+ }
+ else {
+ reply = network_->put(req, json_data);
+ }
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { AddFavoritesReply(reply, type, songs); });
+ replies_ << reply;
+
+}
+
+void SpotifyFavoriteRequest::AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) {
+
+ if (!replies_.contains(reply)) return;
+ replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+
+ GetReplyData(reply);
+ if (reply->error() != QNetworkReply::NoError) {
+ return;
+ }
+
+ if (type == FavoriteType_Artists) {
+ qLog(Debug) << "Spotify:" << songs.count() << "songs added to followed" << FavoriteText(type);
+ }
+ else {
+ qLog(Debug) << "Spotify:" << songs.count() << "songs added to saved" << FavoriteText(type);
+ }
+
+ switch (type) {
+ case FavoriteType_Artists:
+ emit ArtistsAdded(songs);
+ break;
+ case FavoriteType_Albums:
+ emit AlbumsAdded(songs);
+ break;
+ case FavoriteType_Songs:
+ emit SongsAdded(songs);
+ break;
+ }
+
+}
+
+void SpotifyFavoriteRequest::RemoveArtists(const SongList &songs) {
+ RemoveFavorites(FavoriteType_Artists, songs);
+}
+
+void SpotifyFavoriteRequest::RemoveAlbums(const SongList &songs) {
+ RemoveFavorites(FavoriteType_Albums, songs);
+}
+
+void SpotifyFavoriteRequest::RemoveSongs(const SongList &songs) {
+ RemoveFavorites(FavoriteType_Songs, songs);
+}
+
+void SpotifyFavoriteRequest::RemoveSongs(const SongMap &songs) {
+
+ RemoveFavorites(FavoriteType_Songs, songs.values());
+
+}
+
+void SpotifyFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongList &songs) {
+
+ QStringList list_ids;
+ QJsonArray array_ids;
+ for (const Song &song : songs) {
+ QString id;
+ switch (type) {
+ case FavoriteType_Artists:
+ id = song.artist_id();
+ break;
+ case FavoriteType_Albums:
+ id = song.album_id();
+ break;
+ case FavoriteType_Songs:
+ id = song.song_id();
+ break;
+ }
+ if (!id.isEmpty()) {
+ if (!list_ids.contains(id)) {
+ list_ids << id;
+ }
+ if (!array_ids.contains(id)) {
+ array_ids << id;
+ }
+ }
+ }
+
+ if (list_ids.isEmpty() || array_ids.isEmpty()) return;
+
+ QByteArray json_data = QJsonDocument(array_ids).toJson();
+ QString ids_list = list_ids.join(QLatin1Char(','));
+
+ RemoveFavoritesRequest(type, ids_list, json_data, songs);
+
+}
+
+void SpotifyFavoriteRequest::RemoveFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) {
+
+ Q_UNUSED(json_data)
+
+ QUrl url(QLatin1String(SpotifyService::kApiUrl) + (type == FavoriteType_Artists ? QStringLiteral("/me/following") : QStringLiteral("/me/") + FavoriteText(type)));
+ if (type == FavoriteType_Artists) {
+ QUrlQuery url_query;
+ url_query.addQueryItem(QStringLiteral("type"), QStringLiteral("artist"));
+ url_query.addQueryItem(QStringLiteral("ids"), ids_list);
+ url.setQuery(url_query);
+ }
+ QNetworkRequest req(url);
+#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0)
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+#else
+ req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
+#endif
+ req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
+ if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
+ QNetworkReply *reply = nullptr;
+ if (type == FavoriteType_Artists) {
+ reply = network_->deleteResource(req);
+ }
+ else {
+ // FIXME
+ reply = network_->deleteResource(req);
+ }
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { RemoveFavoritesReply(reply, type, songs); });
+ replies_ << reply;
+
+}
+
+void SpotifyFavoriteRequest::RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) {
+
+ if (!replies_.contains(reply)) return;
+ replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+
+ GetReplyData(reply);
+ if (reply->error() != QNetworkReply::NoError) {
+ return;
+ }
+
+ if (type == FavoriteType_Artists) {
+ qLog(Debug) << "Spotify:" << songs.count() << "songs removed from followed" << FavoriteText(type);
+ }
+ else {
+ qLog(Debug) << "Spotify:" << songs.count() << "songs removed from saved" << FavoriteText(type);
+ }
+
+ switch (type) {
+ case FavoriteType_Artists:
+ emit ArtistsRemoved(songs);
+ break;
+ case FavoriteType_Albums:
+ emit AlbumsRemoved(songs);
+ break;
+ case FavoriteType_Songs:
+ emit SongsRemoved(songs);
+ break;
+ }
+
+}
+
+void SpotifyFavoriteRequest::Error(const QString &error, const QVariant &debug) {
+
+ qLog(Error) << "Spotify:" << error;
+ if (debug.isValid()) qLog(Debug) << debug;
+
+}
diff --git a/src/spotify/spotifyfavoriterequest.h b/src/spotify/spotifyfavoriterequest.h
new file mode 100644
index 000000000..cce04382f
--- /dev/null
+++ b/src/spotify/spotifyfavoriterequest.h
@@ -0,0 +1,90 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#ifndef SPOTIFYFAVORITEREQUEST_H
+#define SPOTIFYFAVORITEREQUEST_H
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "spotifybaserequest.h"
+#include "core/song.h"
+
+class QNetworkReply;
+class SpotifyService;
+class NetworkAccessManager;
+
+class SpotifyFavoriteRequest : public SpotifyBaseRequest {
+ Q_OBJECT
+
+ public:
+ explicit SpotifyFavoriteRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent = nullptr);
+ ~SpotifyFavoriteRequest() override;
+
+ enum FavoriteType {
+ FavoriteType_Artists,
+ FavoriteType_Albums,
+ FavoriteType_Songs
+ };
+
+ signals:
+ void ArtistsAdded(SongList);
+ void AlbumsAdded(SongList);
+ void SongsAdded(SongList);
+ void ArtistsRemoved(SongList);
+ void AlbumsRemoved(SongList);
+ void SongsRemoved(SongList);
+
+ private slots:
+ void AddFavoritesReply(QNetworkReply *reply, const SpotifyFavoriteRequest::FavoriteType type, const SongList &songs);
+ void RemoveFavoritesReply(QNetworkReply *reply, const SpotifyFavoriteRequest::FavoriteType type, const SongList &songs);
+
+ public slots:
+ void AddArtists(const SongList &songs);
+ void AddAlbums(const SongList &songs);
+ void AddSongs(const SongList &songs);
+ void AddSongs(const SongMap &songs);
+
+ void RemoveArtists(const SongList &songs);
+ void RemoveAlbums(const SongList &songs);
+ void RemoveSongs(const SongList &songs);
+ void RemoveSongs(const SongMap &songs);
+
+ private:
+ void Error(const QString &error, const QVariant &debug = QVariant()) override;
+ static QString FavoriteText(const FavoriteType type);
+ void AddFavorites(const FavoriteType type, const SongList &songs);
+ void AddFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs);
+ void RemoveFavorites(const FavoriteType type, const SongList &songs);
+ void RemoveFavorites(const FavoriteType type, const QString &id, const SongList &songs);
+ void RemoveFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs);
+
+ SpotifyService *service_;
+ NetworkAccessManager *network_;
+ QList replies_;
+
+};
+
+#endif // SPOTIFYFAVORITEREQUEST_H
diff --git a/src/spotify/spotifyrequest.cpp b/src/spotify/spotifyrequest.cpp
new file mode 100644
index 000000000..77060a863
--- /dev/null
+++ b/src/spotify/spotifyrequest.cpp
@@ -0,0 +1,1403 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/logging.h"
+#include "core/networkaccessmanager.h"
+#include "core/song.h"
+#include "core/application.h"
+#include "utilities/timeconstants.h"
+#include "utilities/imageutils.h"
+#include "utilities/coverutils.h"
+#include "spotifyservice.h"
+#include "spotifybaserequest.h"
+#include "spotifyrequest.h"
+
+namespace {
+const int kMaxConcurrentArtistsRequests = 1;
+const int kMaxConcurrentAlbumsRequests = 1;
+const int kMaxConcurrentSongsRequests = 1;
+const int kMaxConcurrentArtistAlbumsRequests = 1;
+const int kMaxConcurrentAlbumSongsRequests = 1;
+const int kMaxConcurrentAlbumCoverRequests = 10;
+const int kFlushRequestsDelay = 200;
+}
+
+SpotifyRequest::SpotifyRequest(SpotifyService *service, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent)
+ : SpotifyBaseRequest(service, network, parent),
+ service_(service),
+ app_(app),
+ network_(network),
+ timer_flush_requests_(new QTimer(this)),
+ type_(type),
+ fetchalbums_(service->fetchalbums()),
+ query_id_(-1),
+ finished_(false),
+ artists_requests_total_(0),
+ artists_requests_active_(0),
+ artists_requests_received_(0),
+ artists_total_(0),
+ artists_received_(0),
+ albums_requests_total_(0),
+ albums_requests_active_(0),
+ albums_requests_received_(0),
+ albums_total_(0),
+ albums_received_(0),
+ songs_requests_total_(0),
+ songs_requests_active_(0),
+ songs_requests_received_(0),
+ songs_total_(0),
+ songs_received_(0),
+ artist_albums_requests_total_(),
+ artist_albums_requests_active_(0),
+ artist_albums_requests_received_(0),
+ artist_albums_total_(0),
+ artist_albums_received_(0),
+ album_songs_requests_active_(0),
+ album_songs_requests_received_(0),
+ album_songs_requests_total_(0),
+ album_songs_total_(0),
+ album_songs_received_(0),
+ album_covers_requests_total_(0),
+ album_covers_requests_active_(0),
+ album_covers_requests_received_(0),
+ no_results_(false) {
+
+ timer_flush_requests_->setInterval(kFlushRequestsDelay);
+ timer_flush_requests_->setSingleShot(false);
+ QObject::connect(timer_flush_requests_, &QTimer::timeout, this, &SpotifyRequest::FlushRequests);
+
+}
+
+SpotifyRequest::~SpotifyRequest() {
+
+ if (timer_flush_requests_->isActive()) {
+ timer_flush_requests_->stop();
+ }
+
+ while (!replies_.isEmpty()) {
+ QNetworkReply *reply = replies_.takeFirst();
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ if (reply->isRunning()) reply->abort();
+ reply->deleteLater();
+ }
+
+ while (!album_cover_replies_.isEmpty()) {
+ QNetworkReply *reply = album_cover_replies_.takeFirst();
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ if (reply->isRunning()) reply->abort();
+ reply->deleteLater();
+ }
+
+}
+
+void SpotifyRequest::Process() {
+
+ if (!service_->authenticated()) {
+ emit UpdateStatus(query_id_, tr("Authenticating..."));
+ return;
+ }
+
+ switch (type_) {
+ case QueryType::Artists:
+ GetArtists();
+ break;
+ case QueryType::Albums:
+ GetAlbums();
+ break;
+ case QueryType::Songs:
+ GetSongs();
+ break;
+ case QueryType::SearchArtists:
+ ArtistsSearch();
+ break;
+ case QueryType::SearchAlbums:
+ AlbumsSearch();
+ break;
+ case QueryType::SearchSongs:
+ SongsSearch();
+ break;
+ default:
+ Error(QStringLiteral("Invalid query type."));
+ break;
+ }
+
+}
+
+void SpotifyRequest::StartRequests() {
+
+ if (!timer_flush_requests_->isActive()) {
+ timer_flush_requests_->start();
+ }
+
+}
+
+void SpotifyRequest::FlushRequests() {
+
+ if (!artists_requests_queue_.isEmpty()) {
+ FlushArtistsRequests();
+ return;
+ }
+
+ if (!albums_requests_queue_.isEmpty()) {
+ FlushAlbumsRequests();
+ return;
+ }
+
+ if (!artist_albums_requests_queue_.isEmpty()) {
+ FlushArtistAlbumsRequests();
+ return;
+ }
+
+ if (!album_songs_requests_queue_.isEmpty()) {
+ FlushAlbumSongsRequests();
+ return;
+ }
+
+ if (!songs_requests_queue_.isEmpty()) {
+ FlushSongsRequests();
+ return;
+ }
+
+ if (!album_cover_requests_queue_.isEmpty()) {
+ FlushAlbumCoverRequests();
+ return;
+ }
+
+ timer_flush_requests_->stop();
+
+}
+
+void SpotifyRequest::Search(const int query_id, const QString &search_text) {
+
+ query_id_ = query_id;
+ search_text_ = search_text;
+
+}
+
+void SpotifyRequest::GetArtists() {
+
+ emit UpdateStatus(query_id_, tr("Receiving artists..."));
+ emit UpdateProgress(query_id_, 0);
+ AddArtistsRequest();
+
+}
+
+void SpotifyRequest::AddArtistsRequest(const int offset, const int limit) {
+
+ Request request;
+ request.limit = limit;
+ request.offset = offset;
+ artists_requests_queue_.enqueue(request);
+
+ ++artists_requests_total_;
+
+ StartRequests();
+
+}
+
+void SpotifyRequest::FlushArtistsRequests() {
+
+ while (!artists_requests_queue_.isEmpty() && artists_requests_active_ < kMaxConcurrentArtistsRequests) {
+
+ Request request = artists_requests_queue_.dequeue();
+
+ ParamList parameters = ParamList() << Param(QStringLiteral("type"), QStringLiteral("artist"));
+ if (type_ == QueryType::SearchArtists) {
+ parameters << Param(QStringLiteral("q"), search_text_);
+ }
+ if (request.limit > 0) {
+ parameters << Param(QStringLiteral("limit"), QString::number(request.limit));
+ }
+ if (request.offset > 0) {
+ parameters << Param(QStringLiteral("offset"), QString::number(request.offset));
+ }
+ QNetworkReply *reply = nullptr;
+ if (type_ == QueryType::Artists) {
+ reply = CreateRequest(QStringLiteral("me/following"), parameters);
+ }
+ if (type_ == QueryType::SearchArtists) {
+ reply = CreateRequest(QStringLiteral("search"), parameters);
+ }
+ if (!reply) continue;
+ replies_ << reply;
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { ArtistsReplyReceived(reply, request.limit, request.offset); });
+
+ ++artists_requests_active_;
+
+ }
+
+}
+
+void SpotifyRequest::GetAlbums() {
+
+ emit UpdateStatus(query_id_, tr("Receiving albums..."));
+ emit UpdateProgress(query_id_, 0);
+ AddAlbumsRequest();
+
+}
+
+void SpotifyRequest::AddAlbumsRequest(const int offset, const int limit) {
+
+ Request request;
+ request.limit = limit;
+ request.offset = offset;
+ albums_requests_queue_.enqueue(request);
+
+ ++albums_requests_total_;
+
+ StartRequests();
+
+}
+
+void SpotifyRequest::FlushAlbumsRequests() {
+
+ while (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) {
+
+ Request request = albums_requests_queue_.dequeue();
+
+ ParamList parameters;
+ if (type_ == QueryType::SearchAlbums) {
+ parameters << Param(QStringLiteral("type"), QStringLiteral("album"));
+ parameters << Param(QStringLiteral("q"), search_text_);
+ }
+ if (request.limit > 0) parameters << Param(QStringLiteral("limit"), QString::number(request.limit));
+ if (request.offset > 0) parameters << Param(QStringLiteral("offset"), QString::number(request.offset));
+ QNetworkReply *reply = nullptr;
+ if (type_ == QueryType::Albums) {
+ reply = CreateRequest(QStringLiteral("me/albums"), parameters);
+ }
+ if (type_ == QueryType::SearchAlbums) {
+ reply = CreateRequest(QStringLiteral("search"), parameters);
+ }
+ if (!reply) continue;
+ replies_ << reply;
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { AlbumsReplyReceived(reply, request.limit, request.offset); });
+
+ ++albums_requests_active_;
+
+ }
+
+}
+
+void SpotifyRequest::GetSongs() {
+
+ emit UpdateStatus(query_id_, tr("Receiving songs..."));
+ emit UpdateProgress(query_id_, 0);
+ AddSongsRequest();
+
+}
+
+void SpotifyRequest::AddSongsRequest(const int offset, const int limit) {
+
+ Request request;
+ request.limit = limit;
+ request.offset = offset;
+ songs_requests_queue_.enqueue(request);
+
+ ++songs_requests_total_;
+
+ StartRequests();
+
+}
+
+void SpotifyRequest::FlushSongsRequests() {
+
+ while (!songs_requests_queue_.isEmpty() && songs_requests_active_ < kMaxConcurrentSongsRequests) {
+
+ Request request = songs_requests_queue_.dequeue();
+
+ ParamList parameters;
+ if (type_ == QueryType::SearchSongs) {
+ parameters << Param(QStringLiteral("type"), QStringLiteral("track"));
+ parameters << Param(QStringLiteral("q"), search_text_);
+ }
+ if (request.limit > 0) {
+ parameters << Param(QStringLiteral("limit"), QString::number(request.limit));
+ }
+ if (request.offset > 0) {
+ parameters << Param(QStringLiteral("offset"), QString::number(request.offset));
+ }
+ QNetworkReply *reply = nullptr;
+ if (type_ == QueryType::Songs) {
+ reply = CreateRequest(QStringLiteral("me/tracks"), parameters);
+ }
+ if (type_ == QueryType::SearchSongs) {
+ reply = CreateRequest(QStringLiteral("search"), parameters);
+ }
+ if (!reply) continue;
+ replies_ << reply;
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { SongsReplyReceived(reply, request.limit, request.offset); });
+
+ ++songs_requests_active_;
+
+ }
+
+}
+
+void SpotifyRequest::ArtistsSearch() {
+
+ emit UpdateStatus(query_id_, tr("Searching..."));
+ emit UpdateProgress(query_id_, 0);
+ AddArtistsSearchRequest();
+
+}
+
+void SpotifyRequest::AddArtistsSearchRequest(const int offset) {
+
+ AddArtistsRequest(offset, service_->artistssearchlimit());
+
+}
+
+void SpotifyRequest::AlbumsSearch() {
+
+ emit UpdateStatus(query_id_, tr("Searching..."));
+ emit UpdateProgress(query_id_, 0);
+ AddAlbumsSearchRequest();
+
+}
+
+void SpotifyRequest::AddAlbumsSearchRequest(const int offset) {
+
+ AddAlbumsRequest(offset, service_->albumssearchlimit());
+
+}
+
+void SpotifyRequest::SongsSearch() {
+
+ emit UpdateStatus(query_id_, tr("Searching..."));
+ emit UpdateProgress(query_id_, 0);
+ AddSongsSearchRequest();
+
+}
+
+void SpotifyRequest::AddSongsSearchRequest(const int offset) {
+
+ AddSongsRequest(offset, service_->songssearchlimit());
+
+}
+
+void SpotifyRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) {
+
+ if (!replies_.contains(reply)) return;
+ replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+
+ QByteArray data = GetReplyData(reply);
+
+ --artists_requests_active_;
+ ++artists_requests_received_;
+
+ if (finished_) return;
+
+ if (data.isEmpty()) {
+ ArtistsFinishCheck();
+ return;
+ }
+
+ QJsonObject json_obj = ExtractJsonObj(data);
+ if (json_obj.isEmpty()) {
+ ArtistsFinishCheck();
+ return;
+ }
+
+ if (!json_obj.contains(QLatin1String("artists")) || !json_obj[QLatin1String("artists")].isObject()) {
+ Error(QStringLiteral("Json object missing values."), json_obj);
+ ArtistsFinishCheck();
+ return;
+ }
+ QJsonObject obj_artists = json_obj[QLatin1String("artists")].toObject();
+
+ if (!obj_artists.contains(QLatin1String("limit")) ||
+ !obj_artists.contains(QLatin1String("total")) ||
+ !obj_artists.contains(QLatin1String("items"))) {
+ Error(QStringLiteral("Json object missing values."), obj_artists);
+ ArtistsFinishCheck();
+ return;
+ }
+
+ int offset = 0;
+ if (obj_artists.contains(QLatin1String("offset"))) {
+ offset = obj_artists[QLatin1String("offset")].toInt();
+ }
+ int artists_total = obj_artists[QLatin1String("total")].toInt();
+
+ if (offset_requested == 0) {
+ artists_total_ = artists_total;
+ }
+ else if (artists_total != artists_total_) {
+ Error(QStringLiteral("Total returned does not match previous total! %1 != %2").arg(artists_total).arg(artists_total_));
+ ArtistsFinishCheck();
+ return;
+ }
+
+ if (offset != offset_requested) {
+ Error(QStringLiteral("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested));
+ ArtistsFinishCheck();
+ return;
+ }
+
+ if (offset_requested == 0) {
+ emit UpdateProgress(query_id_, GetProgress(artists_received_, artists_total_));
+ }
+
+ QJsonValue value_items = ExtractItems(obj_artists);
+ if (!value_items.isArray()) {
+ ArtistsFinishCheck();
+ return;
+ }
+
+ QJsonArray array_items = value_items.toArray();
+ if (array_items.isEmpty()) { // Empty array means no results
+ if (offset_requested == 0) no_results_ = true;
+ ArtistsFinishCheck();
+ return;
+ }
+
+ int artists_received = 0;
+ for (const QJsonValueRef value_item : array_items) {
+
+ ++artists_received;
+
+ if (!value_item.isObject()) {
+ Error(QStringLiteral("Invalid Json reply, item in array is not a object."));
+ continue;
+ }
+ QJsonObject obj_item = value_item.toObject();
+
+ if (obj_item.contains(QLatin1String("item"))) {
+ QJsonValue json_item = obj_item[QLatin1String("item")];
+ if (!json_item.isObject()) {
+ Error(QStringLiteral("Invalid Json reply, item in array is not a object."), json_item);
+ continue;
+ }
+ obj_item = json_item.toObject();
+ }
+
+ if (!obj_item.contains(QLatin1String("id")) || !obj_item.contains(QLatin1String("name"))) {
+ Error(QStringLiteral("Invalid Json reply, item missing id or album."), obj_item);
+ continue;
+ }
+
+ QString artist_id = obj_item[QLatin1String("id")].toString();
+ QString artist = obj_item[QLatin1String("name")].toString();
+
+ if (artist_albums_requests_pending_.contains(artist_id)) continue;
+
+ ArtistAlbumsRequest request;
+ request.artist.artist_id = artist_id;
+ request.artist.artist = artist;
+ artist_albums_requests_pending_.insert(artist_id, request);
+
+ }
+ artists_received_ += artists_received;
+
+ if (offset_requested != 0) emit UpdateProgress(query_id_, GetProgress(artists_total_, artists_received_));
+
+ ArtistsFinishCheck(limit_requested, offset, artists_received);
+
+}
+
+void SpotifyRequest::ArtistsFinishCheck(const int limit, const int offset, const int artists_received) {
+
+ if (finished_) return;
+
+ if ((limit == 0 || limit > artists_received) && artists_received_ < artists_total_) {
+ int offset_next = offset + artists_received;
+ if (offset_next > 0 && offset_next < artists_total_) {
+ if (type_ == QueryType::Artists) AddArtistsRequest(offset_next);
+ else if (type_ == QueryType::SearchArtists) AddArtistsSearchRequest(offset_next);
+ }
+ }
+
+ if (artists_requests_queue_.isEmpty() && artists_requests_active_ <= 0) { // Artist query is finished, get all albums for all artists.
+
+ // Get artist albums
+ QList requests = artist_albums_requests_pending_.values();
+ for (const ArtistAlbumsRequest &request : requests) {
+ AddArtistAlbumsRequest(request.artist);
+ }
+ artist_albums_requests_pending_.clear();
+
+ if (artist_albums_requests_total_ > 0) {
+ if (artist_albums_requests_total_ == 1) emit UpdateStatus(query_id_, tr("Receiving albums for %1 artist...").arg(artist_albums_requests_total_));
+ else emit UpdateStatus(query_id_, tr("Receiving albums for %1 artists...").arg(artist_albums_requests_total_));
+ emit UpdateProgress(query_id_, 0);
+ }
+
+ }
+
+ FinishCheck();
+
+}
+
+void SpotifyRequest::AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) {
+
+ --albums_requests_active_;
+ ++albums_requests_received_;
+ AlbumsReceived(reply, Artist(), limit_requested, offset_requested);
+
+}
+
+void SpotifyRequest::AddArtistAlbumsRequest(const Artist &artist, const int offset) {
+
+ ArtistAlbumsRequest request;
+ request.artist = artist;
+ request.offset = offset;
+ artist_albums_requests_queue_.enqueue(request);
+
+ ++artist_albums_requests_total_;
+
+ StartRequests();
+
+}
+
+void SpotifyRequest::FlushArtistAlbumsRequests() {
+
+ while (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) {
+
+ ArtistAlbumsRequest request = artist_albums_requests_queue_.dequeue();
+
+ ParamList parameters;
+ if (request.offset > 0) parameters << Param(QStringLiteral("offset"), QString::number(request.offset));
+ QNetworkReply *reply = CreateRequest(QStringLiteral("artists/%1/albums").arg(request.artist.artist_id), parameters);
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { ArtistAlbumsReplyReceived(reply, request.artist, request.offset); });
+ replies_ << reply;
+
+ ++artist_albums_requests_active_;
+
+ }
+
+}
+
+void SpotifyRequest::ArtistAlbumsReplyReceived(QNetworkReply *reply, const Artist &artist, const int offset_requested) {
+
+ --artist_albums_requests_active_;
+ ++artist_albums_requests_received_;
+ emit UpdateProgress(query_id_, GetProgress(artist_albums_requests_received_, artist_albums_requests_total_));
+ AlbumsReceived(reply, artist, 0, offset_requested);
+
+}
+
+void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_artist, const int limit_requested, const int offset_requested) {
+
+ if (!replies_.contains(reply)) return;
+ replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+
+ QByteArray data = GetReplyData(reply);
+
+ if (finished_) return;
+
+ if (data.isEmpty()) {
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+
+ QJsonObject json_obj = ExtractJsonObj(data);
+ if (json_obj.isEmpty()) {
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+
+ if (json_obj.contains(QLatin1String("albums")) && json_obj[QLatin1String("albums")].isObject()) {
+ json_obj = json_obj[QLatin1String("albums")].toObject();
+ }
+
+ if (json_obj.contains(QLatin1String("tracks")) && json_obj[QLatin1String("tracks")].isObject()) {
+ json_obj = json_obj[QLatin1String("tracks")].toObject();
+ }
+
+ if (!json_obj.contains(QLatin1String("limit")) ||
+ !json_obj.contains(QLatin1String("offset")) ||
+ !json_obj.contains(QLatin1String("total")) ||
+ !json_obj.contains(QLatin1String("items"))) {
+ Error(QStringLiteral("Json object missing values."), json_obj);
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+
+ int offset = json_obj[QLatin1String("offset")].toInt();
+ int albums_total = json_obj[QLatin1String("total")].toInt();
+
+ if (type_ == QueryType::Albums || type_ == QueryType::SearchAlbums) {
+ albums_total_ = albums_total;
+ }
+
+ if (offset != offset_requested) {
+ Error(QStringLiteral("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested));
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+
+ QJsonValue value_items = ExtractItems(json_obj);
+ if (!value_items.isArray()) {
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+ QJsonArray array_items = value_items.toArray();
+ if (array_items.isEmpty()) {
+ if ((type_ == QueryType::Albums || type_ == QueryType::SearchAlbums || (type_ == QueryType::SearchSongs && fetchalbums_)) && offset_requested == 0) {
+ no_results_ = true;
+ }
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+
+ int albums_received = 0;
+ for (const QJsonValueRef value_item : array_items) {
+
+ ++albums_received;
+
+ if (!value_item.isObject()) {
+ Error(QStringLiteral("Invalid Json reply, item in array is not a object."));
+ continue;
+ }
+ QJsonObject obj_item = value_item.toObject();
+
+ if (obj_item.contains(QLatin1String("item"))) {
+ QJsonValue json_item = obj_item[QLatin1String("item")];
+ if (!json_item.isObject()) {
+ Error(QStringLiteral("Invalid Json reply, item in array is not a object."), json_item);
+ continue;
+ }
+ obj_item = json_item.toObject();
+ }
+
+ if (obj_item.contains(QLatin1String("album"))) {
+ QJsonValue json_item = obj_item[QLatin1String("album")];
+ if (!json_item.isObject()) {
+ Error(QStringLiteral("Invalid Json reply, album in array is not a object."), json_item);
+ continue;
+ }
+ obj_item = json_item.toObject();
+ }
+
+ Artist artist;
+ Album album;
+
+ if (!obj_item.contains(QLatin1String("id"))) {
+ Error(QStringLiteral("Invalid Json reply, item is missing ID."), obj_item);
+ continue;
+ }
+ if (!obj_item.contains(QLatin1String("name"))) {
+ Error(QStringLiteral("Invalid Json reply, item is missing name."), obj_item);
+ continue;
+ }
+ if (!obj_item.contains(QLatin1String("images"))) {
+ Error(QStringLiteral("Invalid Json reply, item is missing images."), obj_item);
+ continue;
+ }
+ album.album_id = obj_item[QLatin1String("id")].toString();
+ album.album = obj_item[QLatin1String("name")].toString();
+
+ if (artist_artist.artist_id.isEmpty() && obj_item.contains(QLatin1String("artists")) && obj_item[QLatin1String("artists")].isArray()) {
+ QJsonArray array_artists = obj_item[QLatin1String("artists")].toArray();
+ for (const QJsonValueRef value : array_artists) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_artist = value.toObject();
+ if (obj_artist.isEmpty() || !obj_artist.contains(QLatin1String("id")) || !obj_artist.contains(QLatin1String("name"))) continue;
+ artist.artist_id = obj_artist[QLatin1String("id")].toString();
+ artist.artist = obj_artist[QLatin1String("name")].toString();
+ break;
+ }
+ }
+ else {
+ artist = artist_artist;
+ }
+
+ if (obj_item.contains(QLatin1String("images")) && obj_item[QLatin1String("images")].isArray()) {
+ QJsonArray array_images = obj_item[QLatin1String("images")].toArray();
+ for (const QJsonValueRef value : array_images) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_image = value.toObject();
+ if (obj_image.isEmpty() || !obj_image.contains(QLatin1String("url")) || !obj_image.contains(QLatin1String("width")) || !obj_image.contains(QLatin1String("height"))) continue;
+ int width = obj_image[QLatin1String("width")].toInt();
+ int height = obj_image[QLatin1String("height")].toInt();
+ if (width <= 300 || height <= 300) {
+ continue;
+ }
+ album.cover_url = QUrl(obj_image[QLatin1String("url")].toString());
+ }
+ }
+
+ if (obj_item.contains(QLatin1String("tracks")) && obj_item[QLatin1String("tracks")].isObject()) {
+ QJsonObject obj_tracks = obj_item[QLatin1String("tracks")].toObject();
+ if (obj_tracks.contains(QLatin1String("items")) && obj_tracks[QLatin1String("items")].isArray()) {
+ QJsonArray array_tracks = obj_tracks[QLatin1String("items")].toArray();
+ bool compilation = false;
+ bool multidisc = false;
+ SongList songs;
+ int songs_received = 0;
+ for (const QJsonValueRef value : array_tracks) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_track = value.toObject();
+ if (obj_track.contains(QLatin1String("track")) && obj_track[QLatin1String("track")].isObject()) {
+ obj_track = obj_track[QLatin1String("track")].toObject();
+ }
+ ++songs_received;
+ Song song(Song::Source::Spotify);
+ ParseSong(song, obj_track, artist, album);
+ if (!song.is_valid()) continue;
+ if (song.disc() >= 2) multidisc = true;
+ if (song.is_compilation()) compilation = true;
+ songs << song;
+ }
+ for (Song song : songs) {
+ if (compilation) song.set_compilation_detected(true);
+ if (!multidisc) song.set_disc(0);
+ songs_.insert(song.song_id(), song);
+ }
+ }
+ }
+ else if (!album_songs_requests_pending_.contains(album.album_id)) {
+ AlbumSongsRequest request;
+ request.artist = artist;
+ request.album = album;
+ album_songs_requests_pending_.insert(album.album_id, request);
+ }
+
+ }
+
+ if (type_ == QueryType::Albums || type_ == QueryType::SearchAlbums) {
+ albums_received_ += albums_received;
+ emit UpdateProgress(query_id_, GetProgress(albums_received_, albums_total_));
+ }
+
+ AlbumsFinishCheck(artist_artist, limit_requested, offset, albums_total, albums_received);
+
+}
+
+void SpotifyRequest::AlbumsFinishCheck(const Artist &artist, const int limit, const int offset, const int albums_total, const int albums_received) {
+
+ if (finished_) return;
+
+ if (limit == 0 || limit > albums_received) {
+ int offset_next = offset + albums_received;
+ if (offset_next > 0 && offset_next < albums_total) {
+ switch (type_) {
+ case QueryType::Albums:
+ AddAlbumsRequest(offset_next);
+ break;
+ case QueryType::SearchAlbums:
+ AddAlbumsSearchRequest(offset_next);
+ break;
+ case QueryType::Artists:
+ case QueryType::SearchArtists:
+ AddArtistAlbumsRequest(artist, offset_next);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ if (
+ artists_requests_queue_.isEmpty() &&
+ artists_requests_active_ <= 0 &&
+ albums_requests_queue_.isEmpty() &&
+ albums_requests_active_ <= 0 &&
+ artist_albums_requests_queue_.isEmpty() &&
+ artist_albums_requests_active_ <= 0
+ ) { // Artist albums query is finished, get all songs for all albums.
+
+ // Get songs for all the albums.
+
+ for (QMap ::iterator it = album_songs_requests_pending_.begin(); it != album_songs_requests_pending_.end(); ++it) {
+ AlbumSongsRequest request = it.value();
+ AddAlbumSongsRequest(request.artist, request.album);
+ }
+ album_songs_requests_pending_.clear();
+
+ if (album_songs_requests_total_ > 0) {
+ if (album_songs_requests_total_ == 1) emit UpdateStatus(query_id_, tr("Receiving songs for %1 album...").arg(album_songs_requests_total_));
+ else emit UpdateStatus(query_id_, tr("Receiving songs for %1 albums...").arg(album_songs_requests_total_));
+ emit UpdateProgress(query_id_, 0);
+ }
+ }
+
+ GetAlbumCoversCheck();
+
+ FinishCheck();
+
+}
+
+void SpotifyRequest::SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) {
+
+ --songs_requests_active_;
+ ++songs_requests_received_;
+ if (type_ == QueryType::SearchSongs && fetchalbums_) {
+ AlbumsReceived(reply, Artist(), limit_requested, offset_requested);
+ }
+ else {
+ SongsReceived(reply, Artist(), Album(), limit_requested, offset_requested);
+ }
+
+}
+
+void SpotifyRequest::AddAlbumSongsRequest(const Artist &artist, const Album &album, const int offset) {
+
+ AlbumSongsRequest request;
+ request.artist = artist;
+ request.album = album;
+ request.offset = offset;
+ album_songs_requests_queue_.enqueue(request);
+
+ ++album_songs_requests_total_;
+
+ StartRequests();
+
+}
+
+void SpotifyRequest::FlushAlbumSongsRequests() {
+
+ while (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) {
+
+ AlbumSongsRequest request = album_songs_requests_queue_.dequeue();
+ ++album_songs_requests_active_;
+ ParamList parameters;
+ if (request.offset > 0) parameters << Param(QStringLiteral("offset"), QString::number(request.offset));
+ QNetworkReply *reply = CreateRequest(QStringLiteral("albums/%1/tracks").arg(request.album.album_id), parameters);
+ replies_ << reply;
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { AlbumSongsReplyReceived(reply, request.artist, request.album, request.offset); });
+
+ }
+
+}
+
+void SpotifyRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int offset_requested) {
+
+ --album_songs_requests_active_;
+ ++album_songs_requests_received_;
+ if (offset_requested == 0) {
+ emit UpdateProgress(query_id_, GetProgress(album_songs_requests_received_, album_songs_requests_total_));
+ }
+ SongsReceived(reply, artist, album, 0, offset_requested);
+
+}
+
+void SpotifyRequest::SongsReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int limit_requested, const int offset_requested) {
+
+ if (!replies_.contains(reply)) return;
+ replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+
+ QByteArray data = GetReplyData(reply);
+
+ if (finished_) return;
+
+ if (data.isEmpty()) {
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0);
+ return;
+ }
+
+ QJsonObject json_obj = ExtractJsonObj(data);
+ if (json_obj.isEmpty()) {
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0);
+ return;
+ }
+
+ if (json_obj.contains(QLatin1String("tracks")) && json_obj[QLatin1String("tracks")].isObject()) {
+ json_obj = json_obj[QLatin1String("tracks")].toObject();
+ }
+
+ if (!json_obj.contains(QLatin1String("limit")) ||
+ !json_obj.contains(QLatin1String("offset")) ||
+ !json_obj.contains(QLatin1String("total")) ||
+ !json_obj.contains(QLatin1String("items"))) {
+ Error(QStringLiteral("Json object missing values."), json_obj);
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0);
+ return;
+ }
+
+ int offset = json_obj[QLatin1String("offset")].toInt();
+ int songs_total = json_obj[QLatin1String("total")].toInt();
+
+ if (type_ == QueryType::Songs || type_ == QueryType::SearchSongs) {
+ songs_total_ = songs_total;
+ }
+
+ if (offset != offset_requested) {
+ Error(QStringLiteral("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested));
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, 0);
+ return;
+ }
+
+ QJsonValue json_value = ExtractItems(json_obj);
+ if (!json_value.isArray()) {
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, 0);
+ return;
+ }
+
+ QJsonArray array_items = json_value.toArray();
+ if (array_items.isEmpty()) {
+ if ((type_ == QueryType::Songs || type_ == QueryType::SearchSongs) && offset_requested == 0) {
+ no_results_ = true;
+ }
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, 0);
+ return;
+ }
+
+ bool compilation = false;
+ bool multidisc = false;
+ SongList songs;
+ int songs_received = 0;
+ for (const QJsonValueRef value_item : array_items) {
+
+ if (!value_item.isObject()) {
+ Error(QStringLiteral("Invalid Json reply, track is not a object."));
+ continue;
+ }
+ QJsonObject obj_item = value_item.toObject();
+
+ if (obj_item.contains(QLatin1String("item")) && obj_item[QLatin1String("item")].isObject()) {
+ obj_item = obj_item[QLatin1String("item")].toObject();
+ }
+
+ if (obj_item.contains(QLatin1String("track")) && obj_item[QLatin1String("track")].isObject()) {
+ obj_item = obj_item[QLatin1String("track")].toObject();
+ }
+
+ ++songs_received;
+ Song song(Song::Source::Spotify);
+ ParseSong(song, obj_item, artist, album);
+ if (!song.is_valid()) continue;
+ if (song.disc() >= 2) multidisc = true;
+ if (song.is_compilation()) compilation = true;
+ songs << song;
+ }
+
+ for (Song song : songs) {
+ if (compilation) song.set_compilation_detected(true);
+ if (!multidisc) song.set_disc(0);
+ songs_.insert(song.song_id(), song);
+ }
+
+ if (type_ == QueryType::Songs || type_ == QueryType::SearchSongs) {
+ songs_received_ += songs_received;
+ emit UpdateProgress(query_id_, GetProgress(songs_received_, songs_total_));
+ }
+
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, songs_received);
+
+}
+
+void SpotifyRequest::SongsFinishCheck(const Artist &artist, const Album &album, const int limit, const int offset, const int songs_total, const int songs_received) {
+
+ if (finished_) return;
+
+ if (limit == 0 || limit > songs_received) {
+ int offset_next = offset + songs_received;
+ if (offset_next > 0 && offset_next < songs_total) {
+ switch (type_) {
+ case QueryType::Songs:
+ AddSongsRequest(offset_next);
+ break;
+ case QueryType::SearchSongs:
+ // If artist_id and album_id isn't zero it means that it's a songs search where we fetch all albums too. So fallthrough.
+ if (artist.artist_id.isEmpty() && album.album_id.isEmpty()) {
+ AddSongsSearchRequest(offset_next);
+ break;
+ }
+ // fallthrough
+ case QueryType::Artists:
+ case QueryType::SearchArtists:
+ case QueryType::Albums:
+ case QueryType::SearchAlbums:
+ AddAlbumSongsRequest(artist, album, offset_next);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ GetAlbumCoversCheck();
+
+ FinishCheck();
+
+}
+
+void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Artist &album_artist, const Album &album) {
+
+ if (
+ !json_obj.contains(QLatin1String("type")) ||
+ !json_obj.contains(QLatin1String("id")) ||
+ !json_obj.contains(QLatin1String("name")) ||
+ !json_obj.contains(QLatin1String("uri")) ||
+ !json_obj.contains(QLatin1String("duration_ms")) ||
+ !json_obj.contains(QLatin1String("track_number")) ||
+ !json_obj.contains(QLatin1String("disc_number"))
+ ) {
+ Error(QStringLiteral("Invalid Json reply, track is missing one or more values."), json_obj);
+ return;
+ }
+
+ QString artist_id;
+ QString artist_title;
+ if (json_obj.contains(QLatin1String("artists")) && json_obj[QLatin1String("artists")].isArray()) {
+ QJsonArray array_artists = json_obj[QLatin1String("artists")].toArray();
+ for (const QJsonValueRef value_artist : array_artists) {
+ if (!value_artist.isObject()) continue;
+ QJsonObject obj_artist = value_artist.toObject();
+ if (!obj_artist.contains(QLatin1String("type")) || !obj_artist.contains(QLatin1String("id")) || !obj_artist.contains(QLatin1String("name"))) {
+ continue;
+ }
+ artist_id = obj_artist[QLatin1String("id")].toString();
+ artist_title = obj_artist[QLatin1String("name")].toString();
+ break;
+ }
+ }
+
+ QString album_id;
+ QString album_title;
+ QUrl cover_url;
+ if (json_obj.contains(QLatin1String("album")) && json_obj[QLatin1String("album")].isObject()) {
+ QJsonObject obj_album = json_obj[QLatin1String("album")].toObject();
+ if (obj_album.contains(QLatin1String("type")) && obj_album.contains(QLatin1String("id")) && obj_album.contains(QLatin1String("name"))) {
+ album_id = obj_album[QLatin1String("id")].toString();
+ album_title = obj_album[QLatin1String("name")].toString();
+ if (obj_album.contains(QLatin1String("images")) && obj_album[QLatin1String("images")].isArray()) {
+ QJsonArray array_images = obj_album[QLatin1String("images")].toArray();
+ for (const QJsonValueRef value : array_images) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_image = value.toObject();
+ if (obj_image.isEmpty() || !obj_image.contains(QLatin1String("url")) || !obj_image.contains(QLatin1String("width")) || !obj_image.contains(QLatin1String("height"))) continue;
+ int width = obj_image[QLatin1String("width")].toInt();
+ int height = obj_image[QLatin1String("height")].toInt();
+ if (width <= 300 || height <= 300) {
+ continue;
+ }
+ cover_url = QUrl(obj_image[QLatin1String("url")].toString());
+ }
+ }
+ }
+ }
+
+ if (artist_id.isEmpty() || artist_title.isEmpty()) {
+ artist_id = album_artist.artist_id;
+ artist_title = album_artist.artist;
+ }
+
+ if (album_id.isEmpty() || album_title.isEmpty() || cover_url.isEmpty()) {
+ album_id = album.album_id;
+ album_title = album.album;
+ cover_url = album.cover_url;
+ }
+
+ QJsonValue json_duration = json_obj[QLatin1String("duration")];
+
+ QString song_id = json_obj[QLatin1String("id")].toString();
+ QString title = json_obj[QLatin1String("name")].toString();
+ QString uri = json_obj[QLatin1String("uri")].toString();
+ qint64 duration = json_obj[QLatin1String("duration_ms")].toVariant().toLongLong() * kNsecPerMsec;
+ int track = json_obj[QLatin1String("track_number")].toInt();
+ int disc = json_obj[QLatin1String("disc_number")].toInt();
+
+ QUrl url(uri);
+
+ title = Song::TitleRemoveMisc(title);
+
+ song.set_source(Song::Source::Spotify);
+ song.set_song_id(song_id);
+ song.set_album_id(album_id);
+ song.set_artist_id(artist_id);
+ if (album_artist.artist != artist_title) {
+ song.set_albumartist(album_artist.artist);
+ }
+ song.set_album(album_title);
+ song.set_artist(artist_title);
+ song.set_title(title);
+ song.set_track(track);
+ song.set_disc(disc);
+ song.set_url(url);
+ song.set_length_nanosec(duration);
+ song.set_art_automatic(cover_url);
+ song.set_directory_id(0);
+ song.set_filetype(Song::FileType::Stream);
+ song.set_filesize(0);
+ song.set_mtime(0);
+ song.set_ctime(0);
+ song.set_valid(true);
+
+}
+
+void SpotifyRequest::GetAlbumCoversCheck() {
+
+ if (
+ !finished_ &&
+ artists_requests_queue_.isEmpty() &&
+ albums_requests_queue_.isEmpty() &&
+ songs_requests_queue_.isEmpty() &&
+ artist_albums_requests_queue_.isEmpty() &&
+ album_songs_requests_queue_.isEmpty() &&
+ album_cover_requests_queue_.isEmpty() &&
+ artist_albums_requests_pending_.isEmpty() &&
+ album_songs_requests_pending_.isEmpty() &&
+ album_covers_requests_sent_.isEmpty() &&
+ artists_requests_active_ <= 0 &&
+ albums_requests_active_ <= 0 &&
+ songs_requests_active_ <= 0 &&
+ artist_albums_requests_active_ <= 0 &&
+ album_songs_requests_active_ <= 0 &&
+ album_covers_requests_active_ <= 0
+ ) {
+ GetAlbumCovers();
+ }
+
+}
+
+void SpotifyRequest::GetAlbumCovers() {
+
+ const SongList songs = songs_.values();
+ for (const Song &song : songs) {
+ AddAlbumCoverRequest(song);
+ }
+
+ if (album_covers_requests_total_ == 1) emit UpdateStatus(query_id_, tr("Receiving album cover for %1 album...").arg(album_covers_requests_total_));
+ else emit UpdateStatus(query_id_, tr("Receiving album covers for %1 albums...").arg(album_covers_requests_total_));
+ emit UpdateProgress(query_id_, 0);
+
+ StartRequests();
+
+}
+
+void SpotifyRequest::AddAlbumCoverRequest(const Song &song) {
+
+ if (album_covers_requests_sent_.contains(song.album_id())) {
+ album_covers_requests_sent_.insert(song.album_id(), song.song_id());
+ return;
+ }
+
+ AlbumCoverRequest request;
+ request.album_id = song.album_id();
+ request.url = song.art_automatic();
+ request.filename = CoverUtils::CoverFilePath(CoverOptions(), song.source(), song.effective_albumartist(), song.effective_album(), song.album_id(), QString(), request.url);
+ if (request.filename.isEmpty()) return;
+
+ album_covers_requests_sent_.insert(song.album_id(), song.song_id());
+ ++album_covers_requests_total_;
+
+ album_cover_requests_queue_.enqueue(request);
+
+}
+
+void SpotifyRequest::FlushAlbumCoverRequests() {
+
+ while (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) {
+
+ AlbumCoverRequest request = album_cover_requests_queue_.dequeue();
+
+ QNetworkRequest req(request.url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ QNetworkReply *reply = network_->get(req);
+ album_cover_replies_ << reply;
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { AlbumCoverReceived(reply, request.album_id, request.url, request.filename); });
+
+ ++album_covers_requests_active_;
+
+ }
+
+}
+
+void SpotifyRequest::AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename) {
+
+ if (album_cover_replies_.contains(reply)) {
+ album_cover_replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+ }
+ else {
+ AlbumCoverFinishCheck();
+ return;
+ }
+
+ --album_covers_requests_active_;
+ ++album_covers_requests_received_;
+
+ if (finished_) return;
+
+ emit UpdateProgress(query_id_, GetProgress(album_covers_requests_received_, album_covers_requests_total_));
+
+ if (!album_covers_requests_sent_.contains(album_id)) {
+ AlbumCoverFinishCheck();
+ return;
+ }
+
+ if (reply->error() != QNetworkReply::NoError) {
+ Error(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
+ album_covers_requests_sent_.remove(album_id);
+ AlbumCoverFinishCheck();
+ return;
+ }
+
+ if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
+ Error(QStringLiteral("Received HTTP code %1 for %2.").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()).arg(url.toString()));
+ if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id);
+ AlbumCoverFinishCheck();
+ return;
+ }
+
+ QString mimetype = reply->header(QNetworkRequest::ContentTypeHeader).toString();
+ if (mimetype.contains(QLatin1Char(';'))) {
+ mimetype = mimetype.left(mimetype.indexOf(QLatin1Char(';')));
+ }
+ if (!ImageUtils::SupportedImageMimeTypes().contains(mimetype, Qt::CaseInsensitive) && !ImageUtils::SupportedImageFormats().contains(mimetype, Qt::CaseInsensitive)) {
+ Error(QStringLiteral("Unsupported mimetype for image reader %1 for %2").arg(mimetype, url.toString()));
+ if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id);
+ AlbumCoverFinishCheck();
+ return;
+ }
+
+ QByteArray data = reply->readAll();
+ if (data.isEmpty()) {
+ Error(QStringLiteral("Received empty image data for %1").arg(url.toString()));
+ if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id);
+ AlbumCoverFinishCheck();
+ return;
+ }
+
+ QList format_list = QImageReader::imageFormatsForMimeType(mimetype.toUtf8());
+ char *format = nullptr;
+ if (!format_list.isEmpty()) {
+ format = format_list.first().data();
+ }
+
+ QImage image;
+ if (image.loadFromData(data, format)) {
+ if (image.save(filename, format)) {
+ while (album_covers_requests_sent_.contains(album_id)) {
+ const QString song_id = album_covers_requests_sent_.take(album_id);
+ if (songs_.contains(song_id)) {
+ songs_[song_id].set_art_automatic(QUrl::fromLocalFile(filename));
+ }
+ }
+ }
+ else {
+ Error(QStringLiteral("Error saving image data to %1").arg(filename));
+ if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id);
+ }
+ }
+ else {
+ if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id);
+ Error(QStringLiteral("Error decoding image data from %1").arg(url.toString()));
+ }
+
+ AlbumCoverFinishCheck();
+
+}
+
+void SpotifyRequest::AlbumCoverFinishCheck() {
+
+ FinishCheck();
+
+}
+
+void SpotifyRequest::FinishCheck() {
+
+ if (
+ !finished_ &&
+ artists_requests_queue_.isEmpty() &&
+ albums_requests_queue_.isEmpty() &&
+ songs_requests_queue_.isEmpty() &&
+ artist_albums_requests_queue_.isEmpty() &&
+ album_songs_requests_queue_.isEmpty() &&
+ album_cover_requests_queue_.isEmpty() &&
+ artist_albums_requests_pending_.isEmpty() &&
+ album_songs_requests_pending_.isEmpty() &&
+ album_covers_requests_sent_.isEmpty() &&
+ artists_requests_active_ <= 0 &&
+ albums_requests_active_ <= 0 &&
+ songs_requests_active_ <= 0 &&
+ artist_albums_requests_active_ <= 0 &&
+ album_songs_requests_active_ <= 0 &&
+ album_covers_requests_active_ <= 0
+ ) {
+ if (timer_flush_requests_->isActive()) {
+ timer_flush_requests_->stop();
+ }
+ finished_ = true;
+ if (no_results_ && songs_.isEmpty()) {
+ if (IsSearch())
+ emit Results(query_id_, SongMap(), tr("No match."));
+ else
+ emit Results(query_id_, SongMap(), QString());
+ }
+ else {
+ if (songs_.isEmpty() && errors_.isEmpty()) {
+ emit Results(query_id_, songs_, tr("Data missing error"));
+ }
+ else {
+ emit Results(query_id_, songs_, ErrorsToHTML(errors_));
+ }
+ }
+ }
+
+}
+
+int SpotifyRequest::GetProgress(const int count, const int total) {
+
+ return static_cast((static_cast(count) / static_cast(total)) * 100.0F);
+
+}
+
+void SpotifyRequest::Error(const QString &error, const QVariant &debug) {
+
+ if (!error.isEmpty()) {
+ errors_ << error;
+ qLog(Error) << "Spotify:" << error;
+ }
+
+ if (debug.isValid()) qLog(Debug) << debug;
+
+ FinishCheck();
+
+}
+
+void SpotifyRequest::Warn(const QString &error, const QVariant &debug) {
+
+ qLog(Error) << "Spotify:" << error;
+ if (debug.isValid()) qLog(Debug) << debug;
+
+}
diff --git a/src/spotify/spotifyrequest.h b/src/spotify/spotifyrequest.h
new file mode 100644
index 000000000..02aadfb2e
--- /dev/null
+++ b/src/spotify/spotifyrequest.h
@@ -0,0 +1,233 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#ifndef SPOTIFYREQUEST_H
+#define SPOTIFYREQUEST_H
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/song.h"
+#include "spotifybaserequest.h"
+
+class QNetworkReply;
+class Application;
+class NetworkAccessManager;
+class SpotifyService;
+
+class SpotifyRequest : public SpotifyBaseRequest {
+ Q_OBJECT
+
+ public:
+ explicit SpotifyRequest(SpotifyService *service, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent);
+ ~SpotifyRequest() override;
+
+ void ReloadSettings();
+
+ void Process();
+ void Search(const int query_id, const QString &search_text);
+
+ private:
+ struct Artist {
+ QString artist_id;
+ QString artist;
+ };
+ struct Album {
+ QString album_id;
+ QString album;
+ QUrl cover_url;
+ };
+ struct Request {
+ Request() : offset(0), limit(0) {}
+ int offset;
+ int limit;
+ };
+ struct ArtistAlbumsRequest {
+ ArtistAlbumsRequest() : offset(0), limit(0) {}
+ Artist artist;
+ int offset;
+ int limit;
+ };
+ struct AlbumSongsRequest {
+ AlbumSongsRequest() : offset(0), limit(0) {}
+ Artist artist;
+ Album album;
+ int offset;
+ int limit;
+ };
+ struct AlbumCoverRequest {
+ QString artist_id;
+ QString album_id;
+ QUrl url;
+ QString filename;
+ };
+
+ signals:
+ void Results(int id, SongMap songs, QString error);
+ void UpdateStatus(int id, QString text);
+ void ProgressSetMaximum(int id, int max);
+ void UpdateProgress(int id, int max);
+ void StreamURLFinished(QUrl original_url, QUrl url, Song::FileType, QString error = QString());
+
+ private slots:
+ void FlushRequests();
+
+ void ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
+
+ void AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
+ void AlbumsReceived(QNetworkReply *reply, const Artist &artist_artist, const int limit_requested, const int offset_requested);
+
+ void SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
+ void SongsReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int limit_requested, const int offset_requested);
+
+ void ArtistAlbumsReplyReceived(QNetworkReply *reply, const Artist &artist, const int offset_requested);
+ void AlbumSongsReplyReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int offset_requested);
+ void AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename);
+
+ private:
+ void StartRequests();
+
+ bool IsQuery() const { return (type_ == QueryType::Artists || type_ == QueryType::Albums || type_ == QueryType::Songs); }
+ bool IsSearch() const { return (type_ == QueryType::SearchArtists || type_ == QueryType::SearchAlbums || type_ == QueryType::SearchSongs); }
+
+ void GetArtists();
+ void GetAlbums();
+ void GetSongs();
+
+ void ArtistsSearch();
+ void AlbumsSearch();
+ void SongsSearch();
+
+ void AddArtistsRequest(const int offset = 0, const int limit = 0);
+ void AddArtistsSearchRequest(const int offset = 0);
+ void FlushArtistsRequests();
+ void AddAlbumsRequest(const int offset = 0, const int limit = 0);
+ void AddAlbumsSearchRequest(const int offset = 0);
+ void FlushAlbumsRequests();
+ void AddSongsRequest(const int offset = 0, const int limit = 0);
+ void AddSongsSearchRequest(const int offset = 0);
+ void FlushSongsRequests();
+
+ void ArtistsFinishCheck(const int limit = 0, const int offset = 0, const int artists_received = 0);
+ void AlbumsFinishCheck(const Artist &artist, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0);
+ void SongsFinishCheck(const Artist &artist, const Album &album, const int limit = 0, const int offset = 0, const int songs_total = 0, const int songs_received = 0);
+
+ void AddArtistAlbumsRequest(const Artist &artist, const int offset = 0);
+ void FlushArtistAlbumsRequests();
+
+ void AddAlbumSongsRequest(const Artist &artist, const Album &album, const int offset = 0);
+ void FlushAlbumSongsRequests();
+
+ void ParseSong(Song &song, const QJsonObject &json_obj, const Artist &album_artist, const Album &album);
+
+ void GetAlbumCoversCheck();
+ void GetAlbumCovers();
+ void AddAlbumCoverRequest(const Song &song);
+ void FlushAlbumCoverRequests();
+ void AlbumCoverFinishCheck();
+
+ int GetProgress(const int count, const int total);
+ void FinishCheck();
+ static void Warn(const QString &error, const QVariant &debug = QVariant());
+ void Error(const QString &error, const QVariant &debug = QVariant()) override;
+
+ private:
+ SpotifyService *service_;
+ Application *app_;
+ NetworkAccessManager *network_;
+ QTimer *timer_flush_requests_;
+
+ QueryType type_;
+ bool fetchalbums_;
+ QString coversize_;
+
+ int query_id_;
+ QString search_text_;
+
+ bool finished_;
+
+ QQueue artists_requests_queue_;
+ QQueue albums_requests_queue_;
+ QQueue songs_requests_queue_;
+
+ QQueue artist_albums_requests_queue_;
+ QQueue album_songs_requests_queue_;
+ QQueue album_cover_requests_queue_;
+
+ QMap artist_albums_requests_pending_;
+ QMap album_songs_requests_pending_;
+ QMultiMap album_covers_requests_sent_;
+
+ int artists_requests_total_;
+ int artists_requests_active_;
+ int artists_requests_received_;
+ int artists_total_;
+ int artists_received_;
+
+ int albums_requests_total_;
+ int albums_requests_active_;
+ int albums_requests_received_;
+ int albums_total_;
+ int albums_received_;
+
+ int songs_requests_total_;
+ int songs_requests_active_;
+ int songs_requests_received_;
+ int songs_total_;
+ int songs_received_;
+
+ int artist_albums_requests_total_;
+ int artist_albums_requests_active_;
+ int artist_albums_requests_received_;
+ int artist_albums_total_;
+ int artist_albums_received_;
+
+ int album_songs_requests_active_;
+ int album_songs_requests_received_;
+ int album_songs_requests_total_;
+ int album_songs_total_;
+ int album_songs_received_;
+
+ int album_covers_requests_total_;
+ int album_covers_requests_active_;
+ int album_covers_requests_received_;
+
+ SongMap songs_;
+ QStringList errors_;
+ bool no_results_;
+ QList replies_;
+ QList album_cover_replies_;
+
+};
+
+#endif // SPOTIFYREQUEST_H
diff --git a/src/spotify/spotifyservice.cpp b/src/spotify/spotifyservice.cpp
new file mode 100644
index 000000000..c0a8cb9fa
--- /dev/null
+++ b/src/spotify/spotifyservice.cpp
@@ -0,0 +1,749 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#include "config.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/application.h"
+#include "core/player.h"
+#include "core/logging.h"
+#include "core/networkaccessmanager.h"
+#include "core/database.h"
+#include "core/song.h"
+#include "core/settings.h"
+#include "core/localredirectserver.h"
+#include "utilities/timeconstants.h"
+#include "utilities/randutils.h"
+#include "streaming/streamingsearchview.h"
+#include "collection/collectionbackend.h"
+#include "collection/collectionmodel.h"
+#include "spotifyservice.h"
+#include "spotifybaserequest.h"
+#include "spotifyrequest.h"
+#include "spotifyfavoriterequest.h"
+#include "settings/settingsdialog.h"
+#include "settings/spotifysettingspage.h"
+
+const Song::Source SpotifyService::kSource = Song::Source::Spotify;
+const char SpotifyService::kApiUrl[] = "https://api.spotify.com/v1";
+
+namespace {
+
+constexpr char kOAuthAuthorizeUrl[] = "https://accounts.spotify.com/authorize";
+constexpr char kOAuthAccessTokenUrl[] = "https://accounts.spotify.com/api/token";
+constexpr char kOAuthRedirectUrl[] = "http://localhost:63111/";
+constexpr char kClientIDB64[] = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
+constexpr char kClientSecretB64[] = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
+
+constexpr char kArtistsSongsTable[] = "spotify_artists_songs";
+constexpr char kAlbumsSongsTable[] = "spotify_albums_songs";
+constexpr char kSongsTable[] = "spotify_songs";
+
+} // namespace
+
+using std::make_shared;
+using namespace std::chrono_literals;
+
+SpotifyService::SpotifyService(Application *app, QObject *parent)
+ : StreamingService(Song::Source::Spotify, QStringLiteral("Spotify"), QStringLiteral("spotify"), QLatin1String(SpotifySettingsPage::kSettingsGroup), SettingsDialog::Page::Spotify, app, parent),
+ app_(app),
+ network_(new NetworkAccessManager(this)),
+ artists_collection_backend_(nullptr),
+ albums_collection_backend_(nullptr),
+ songs_collection_backend_(nullptr),
+ artists_collection_model_(nullptr),
+ albums_collection_model_(nullptr),
+ songs_collection_model_(nullptr),
+ timer_search_delay_(new QTimer(this)),
+ timer_refresh_login_(new QTimer(this)),
+ favorite_request_(new SpotifyFavoriteRequest(this, network_, this)),
+ enabled_(false),
+ artistssearchlimit_(1),
+ albumssearchlimit_(1),
+ songssearchlimit_(1),
+ fetchalbums_(true),
+ download_album_covers_(true),
+ expires_in_(0),
+ login_time_(0),
+ pending_search_id_(0),
+ next_pending_search_id_(1),
+ pending_search_type_(StreamingSearchView::SearchType::Artists),
+ search_id_(0),
+ server_(nullptr) {
+
+ // Backends
+
+ artists_collection_backend_ = make_shared();
+ artists_collection_backend_->moveToThread(app_->database()->thread());
+ artists_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, QLatin1String(kArtistsSongsTable));
+
+ albums_collection_backend_ = make_shared();
+ albums_collection_backend_->moveToThread(app_->database()->thread());
+ albums_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, QLatin1String(kAlbumsSongsTable));
+
+ songs_collection_backend_ = make_shared();
+ songs_collection_backend_->moveToThread(app_->database()->thread());
+ songs_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, QLatin1String(kSongsTable));
+
+ // Models
+ artists_collection_model_ = new CollectionModel(artists_collection_backend_, app_, this);
+ albums_collection_model_ = new CollectionModel(albums_collection_backend_, app_, this);
+ songs_collection_model_ = new CollectionModel(songs_collection_backend_, app_, this);
+
+ timer_refresh_login_->setSingleShot(true);
+ QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken);
+
+ timer_search_delay_->setSingleShot(true);
+ QObject::connect(timer_search_delay_, &QTimer::timeout, this, &SpotifyService::StartSearch);
+
+ QObject::connect(this, &SpotifyService::AddArtists, favorite_request_, &SpotifyFavoriteRequest::AddArtists);
+ QObject::connect(this, &SpotifyService::AddAlbums, favorite_request_, &SpotifyFavoriteRequest::AddAlbums);
+ QObject::connect(this, &SpotifyService::AddSongs, favorite_request_, QOverload::of(&SpotifyFavoriteRequest::AddSongs));
+
+ QObject::connect(this, &SpotifyService::RemoveArtists, favorite_request_, &SpotifyFavoriteRequest::RemoveArtists);
+ QObject::connect(this, &SpotifyService::RemoveAlbums, favorite_request_, &SpotifyFavoriteRequest::RemoveAlbums);
+ QObject::connect(this, &SpotifyService::RemoveSongsByList, favorite_request_, QOverload::of(&SpotifyFavoriteRequest::RemoveSongs));
+ QObject::connect(this, &SpotifyService::RemoveSongsByMap, favorite_request_, QOverload::of(&SpotifyFavoriteRequest::RemoveSongs));
+
+ QObject::connect(favorite_request_, &SpotifyFavoriteRequest::ArtistsAdded, &*artists_collection_backend_, &CollectionBackend::AddOrUpdateSongs);
+ QObject::connect(favorite_request_, &SpotifyFavoriteRequest::AlbumsAdded, &*albums_collection_backend_, &CollectionBackend::AddOrUpdateSongs);
+ QObject::connect(favorite_request_, &SpotifyFavoriteRequest::SongsAdded, &*songs_collection_backend_, &CollectionBackend::AddOrUpdateSongs);
+
+ QObject::connect(favorite_request_, &SpotifyFavoriteRequest::ArtistsRemoved, &*artists_collection_backend_, &CollectionBackend::DeleteSongs);
+ QObject::connect(favorite_request_, &SpotifyFavoriteRequest::AlbumsRemoved, &*albums_collection_backend_, &CollectionBackend::DeleteSongs);
+ QObject::connect(favorite_request_, &SpotifyFavoriteRequest::SongsRemoved, &*songs_collection_backend_, &CollectionBackend::DeleteSongs);
+
+ SpotifyService::ReloadSettings();
+ LoadSession();
+
+}
+
+SpotifyService::~SpotifyService() {
+
+ while (!replies_.isEmpty()) {
+ QNetworkReply *reply = replies_.takeFirst();
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->abort();
+ reply->deleteLater();
+ }
+
+ artists_collection_backend_->deleteLater();
+ albums_collection_backend_->deleteLater();
+ songs_collection_backend_->deleteLater();
+
+}
+
+void SpotifyService::Exit() {
+
+ wait_for_exit_ << &*artists_collection_backend_ << &*albums_collection_backend_ << &*songs_collection_backend_;
+
+ QObject::connect(&*artists_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived);
+ QObject::connect(&*albums_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived);
+ QObject::connect(&*songs_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived);
+
+ artists_collection_backend_->ExitAsync();
+ albums_collection_backend_->ExitAsync();
+ songs_collection_backend_->ExitAsync();
+
+}
+
+void SpotifyService::ExitReceived() {
+
+ QObject *obj = sender();
+ QObject::disconnect(obj, nullptr, this, nullptr);
+ qLog(Debug) << obj << "successfully exited.";
+ wait_for_exit_.removeAll(obj);
+ if (wait_for_exit_.isEmpty()) emit ExitFinished();
+
+}
+
+void SpotifyService::ShowConfig() {
+ app_->OpenSettingsDialogAtPage(SettingsDialog::Page::Spotify);
+}
+
+void SpotifyService::LoadSession() {
+
+ refresh_login_timer_.setSingleShot(true);
+ QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken);
+
+ Settings s;
+ s.beginGroup(SpotifySettingsPage::kSettingsGroup);
+ access_token_ = s.value("access_token").toString();
+ refresh_token_ = s.value("refresh_token").toString();
+ expires_in_ = s.value("expires_in").toLongLong();
+ login_time_ = s.value("login_time").toLongLong();
+ s.endGroup();
+
+ if (!refresh_token_.isEmpty()) {
+ qint64 time = static_cast(expires_in_) - (QDateTime::currentDateTime().toSecsSinceEpoch() - static_cast(login_time_));
+ if (time < 1) time = 1;
+ refresh_login_timer_.setInterval(static_cast(time * kMsecPerSec));
+ refresh_login_timer_.start();
+ }
+
+}
+
+void SpotifyService::ReloadSettings() {
+
+ Settings s;
+ s.beginGroup(SpotifySettingsPage::kSettingsGroup);
+
+ enabled_ = s.value("enabled", false).toBool();
+
+ quint64 search_delay = std::max(s.value("searchdelay", 1500).toInt(), 500);
+ artistssearchlimit_ = s.value("artistssearchlimit", 4).toInt();
+ albumssearchlimit_ = s.value("albumssearchlimit", 10).toInt();
+ songssearchlimit_ = s.value("songssearchlimit", 10).toInt();
+ fetchalbums_ = s.value("fetchalbums", false).toBool();
+ download_album_covers_ = s.value("downloadalbumcovers", true).toBool();
+
+ s.endGroup();
+
+ timer_search_delay_->setInterval(static_cast(search_delay));
+
+}
+
+void SpotifyService::Authenticate() {
+
+ QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
+
+ if (!server_) {
+ server_ = new LocalRedirectServer(this);
+ int port = redirect_url.port();
+ int port_max = port + 10;
+ bool success = false;
+ forever {
+ server_->set_port(port);
+ if (server_->Listen()) {
+ success = true;
+ break;
+ }
+ ++port;
+ if (port > port_max) break;
+ }
+ if (!success) {
+ LoginError(server_->error());
+ server_->deleteLater();
+ server_ = nullptr;
+ return;
+ }
+ QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyService::RedirectArrived);
+ }
+
+ code_verifier_ = Utilities::CryptographicRandomString(44);
+ code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
+ if (code_challenge_.lastIndexOf(QLatin1Char('=')) == code_challenge_.length() - 1) {
+ code_challenge_.chop(1);
+ }
+
+ const ParamList params = ParamList() << Param(QStringLiteral("client_id"), QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
+ << Param(QStringLiteral("response_type"), QStringLiteral("code"))
+ << Param(QStringLiteral("redirect_uri"), redirect_url.toString())
+ << Param(QStringLiteral("state"), code_challenge_)
+ << Param(QStringLiteral("scope"), QStringLiteral("user-follow-read user-follow-modify user-library-read user-library-modify streaming"));
+
+ QUrlQuery url_query;
+ for (const Param ¶m : params) {
+ url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
+ }
+
+ QUrl url(QString::fromLatin1(kOAuthAuthorizeUrl));
+ url.setQuery(url_query);
+
+ const bool result = QDesktopServices::openUrl(url);
+ if (!result) {
+ QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":
%1").arg(url.toString()), QMessageBox::Ok);
+ messagebox.setTextFormat(Qt::RichText);
+ messagebox.exec();
+ }
+
+}
+
+void SpotifyService::Deauthenticate() {
+
+ access_token_.clear();
+ refresh_token_.clear();
+ expires_in_ = 0;
+ login_time_ = 0;
+
+ Settings s;
+ s.beginGroup(SpotifySettingsPage::kSettingsGroup);
+ s.remove("access_token");
+ s.remove("refresh_token");
+ s.remove("expires_in");
+ s.remove("login_time");
+ s.endGroup();
+
+ refresh_login_timer_.stop();
+
+}
+
+void SpotifyService::RedirectArrived() {
+
+ if (!server_) return;
+
+ if (server_->error().isEmpty()) {
+ QUrl url = server_->request_url();
+ if (url.isValid()) {
+ QUrlQuery url_query(url);
+ if (url_query.hasQueryItem(QStringLiteral("error"))) {
+ LoginError(QUrlQuery(url).queryItemValue(QStringLiteral("error")));
+ }
+ else if (url_query.hasQueryItem(QStringLiteral("code")) && url_query.hasQueryItem(QStringLiteral("state"))) {
+ qLog(Debug) << "Spotify: Authorization URL Received" << url;
+ QString code = url_query.queryItemValue(QStringLiteral("code"));
+ QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
+ redirect_url.setPort(server_->url().port());
+ RequestAccessToken(code, redirect_url);
+ }
+ else {
+ LoginError(tr("Redirect missing token code or state!"));
+ }
+ }
+ else {
+ LoginError(tr("Received invalid reply from web browser."));
+ }
+ }
+ else {
+ LoginError(server_->error());
+ }
+
+ server_->close();
+ server_->deleteLater();
+ server_ = nullptr;
+
+}
+
+void SpotifyService::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
+
+ refresh_login_timer_.stop();
+
+ ParamList params = ParamList() << Param(QStringLiteral("client_id"), QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
+ << Param(QStringLiteral("client_secret"), QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64)));
+
+ if (!code.isEmpty() && !redirect_url.isEmpty()) {
+ params << Param(QStringLiteral("grant_type"), QStringLiteral("authorization_code"));
+ params << Param(QStringLiteral("code"), code);
+ params << Param(QStringLiteral("redirect_uri"), redirect_url.toString());
+ }
+ else if (!refresh_token_.isEmpty() && enabled_) {
+ params << Param(QStringLiteral("grant_type"), QStringLiteral("refresh_token"));
+ params << Param(QStringLiteral("refresh_token"), refresh_token_);
+ }
+ else {
+ return;
+ }
+
+ QUrlQuery url_query;
+ for (const Param ¶m : params) {
+ url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
+ }
+
+ QUrl new_url(QString::fromLatin1(kOAuthAccessTokenUrl));
+ QNetworkRequest req(new_url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
+ QString auth_header_data = QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)) + QLatin1Char(':') + QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64));
+ req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64());
+
+ QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
+
+ QNetworkReply *reply = network_->post(req, query);
+ replies_ << reply;
+ QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyService::HandleLoginSSLErrors);
+ QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); });
+
+}
+
+void SpotifyService::HandleLoginSSLErrors(const QList &ssl_errors) {
+
+ for (const QSslError &ssl_error : ssl_errors) {
+ login_errors_ += ssl_error.errorString();
+ }
+
+}
+
+void SpotifyService::AccessTokenRequestFinished(QNetworkReply *reply) {
+
+ if (!replies_.contains(reply)) return;
+ replies_.removeAll(reply);
+ QObject::disconnect(reply, nullptr, this, nullptr);
+ reply->deleteLater();
+
+ if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
+ if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
+ // This is a network error, there is nothing more to do.
+ LoginError(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
+ return;
+ }
+ else {
+ // See if there is Json data containing "error" and "error_description" then use that instead.
+ QByteArray data = reply->readAll();
+ QJsonParseError json_error;
+ QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
+ if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
+ QJsonObject json_obj = json_doc.object();
+ if (!json_obj.isEmpty() && json_obj.contains(QLatin1String("error")) && json_obj.contains(QLatin1String("error_description"))) {
+ QString error = json_obj[QLatin1String("error")].toString();
+ QString error_description = json_obj[QLatin1String("error_description")].toString();
+ login_errors_ << QStringLiteral("Authentication failure: %1 (%2)").arg(error, error_description);
+ }
+ }
+ if (login_errors_.isEmpty()) {
+ if (reply->error() != QNetworkReply::NoError) {
+ login_errors_ << QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
+ }
+ else {
+ login_errors_ << QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
+ }
+ }
+ LoginError();
+ return;
+ }
+ }
+
+ QByteArray data = reply->readAll();
+
+ QJsonParseError json_error;
+ QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
+
+ if (json_error.error != QJsonParseError::NoError) {
+ Error(QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
+ return;
+ }
+
+ if (json_doc.isEmpty()) {
+ LoginError(QStringLiteral("Authentication reply from server has empty Json document."));
+ return;
+ }
+
+ if (!json_doc.isObject()) {
+ LoginError(QStringLiteral("Authentication reply from server has Json document that is not an object."), json_doc);
+ return;
+ }
+
+ QJsonObject json_obj = json_doc.object();
+ if (json_obj.isEmpty()) {
+ LoginError(QStringLiteral("Authentication reply from server has empty Json object."), json_doc);
+ return;
+ }
+
+ if (!json_obj.contains(QLatin1String("access_token")) || !json_obj.contains(QLatin1String("expires_in"))) {
+ LoginError(QStringLiteral("Authentication reply from server is missing access token or expires in."), json_obj);
+ return;
+ }
+
+ access_token_ = json_obj[QLatin1String("access_token")].toString();
+ if (json_obj.contains(QLatin1String("refresh_token"))) {
+ refresh_token_ = json_obj[QLatin1String("refresh_token")].toString();
+ }
+ expires_in_ = json_obj[QLatin1String("expires_in")].toInt();
+ login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
+
+ Settings s;
+ s.beginGroup(SpotifySettingsPage::kSettingsGroup);
+ s.setValue("access_token", access_token_);
+ s.setValue("refresh_token", refresh_token_);
+ s.setValue("expires_in", expires_in_);
+ s.setValue("login_time", login_time_);
+ s.endGroup();
+
+ if (expires_in_ > 0) {
+ refresh_login_timer_.setInterval(static_cast(expires_in_ * kMsecPerSec));
+ refresh_login_timer_.start();
+ }
+
+ qLog(Debug) << "Spotify: Authentication was successful, login expires in" << expires_in_;
+
+ emit LoginComplete(true);
+ emit LoginSuccess();
+
+}
+
+void SpotifyService::ResetArtistsRequest() {
+
+ if (artists_request_) {
+ QObject::disconnect(&*artists_request_, nullptr, this, nullptr);
+ QObject::disconnect(this, nullptr, &*artists_request_, nullptr);
+ artists_request_.reset();
+ }
+
+}
+
+void SpotifyService::GetArtists() {
+
+ if (!authenticated()) {
+ emit ArtistsResults(SongMap(), tr("Not authenticated with Spotify."));
+ ShowConfig();
+ return;
+ }
+
+ ResetArtistsRequest();
+ artists_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType::Artists, this), [](SpotifyRequest *request) { request->deleteLater(); });
+ QObject::connect(&*artists_request_, &SpotifyRequest::Results, this, &SpotifyService::ArtistsResultsReceived);
+ QObject::connect(&*artists_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::ArtistsUpdateStatusReceived);
+ QObject::connect(&*artists_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::ArtistsProgressSetMaximumReceived);
+ QObject::connect(&*artists_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::ArtistsUpdateProgressReceived);
+
+ artists_request_->Process();
+
+}
+
+void SpotifyService::ArtistsResultsReceived(const int id, const SongMap &songs, const QString &error) {
+
+ Q_UNUSED(id);
+ emit ArtistsResults(songs, error);
+ ResetArtistsRequest();
+
+}
+
+void SpotifyService::ArtistsUpdateStatusReceived(const int id, const QString &text) {
+ Q_UNUSED(id);
+ emit ArtistsUpdateStatus(text);
+}
+
+void SpotifyService::ArtistsProgressSetMaximumReceived(const int id, const int max) {
+ Q_UNUSED(id);
+ emit ArtistsProgressSetMaximum(max);
+}
+
+void SpotifyService::ArtistsUpdateProgressReceived(const int id, const int progress) {
+ Q_UNUSED(id);
+ emit ArtistsUpdateProgress(progress);
+}
+
+void SpotifyService::ResetAlbumsRequest() {
+
+ if (albums_request_) {
+ QObject::disconnect(&*albums_request_, nullptr, this, nullptr);
+ QObject::disconnect(this, nullptr, &*albums_request_, nullptr);
+ albums_request_.reset();
+ }
+
+}
+
+void SpotifyService::GetAlbums() {
+
+ if (!authenticated()) {
+ emit AlbumsResults(SongMap(), tr("Not authenticated with Spotify."));
+ ShowConfig();
+ return;
+ }
+
+ ResetAlbumsRequest();
+ albums_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType::Albums, this), [](SpotifyRequest *request) { request->deleteLater(); });
+ QObject::connect(&*albums_request_, &SpotifyRequest::Results, this, &SpotifyService::AlbumsResultsReceived);
+ QObject::connect(&*albums_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::AlbumsUpdateStatusReceived);
+ QObject::connect(&*albums_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::AlbumsProgressSetMaximumReceived);
+ QObject::connect(&*albums_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::AlbumsUpdateProgressReceived);
+
+ albums_request_->Process();
+
+}
+
+void SpotifyService::AlbumsResultsReceived(const int id, const SongMap &songs, const QString &error) {
+
+ Q_UNUSED(id);
+ emit AlbumsResults(songs, error);
+ ResetAlbumsRequest();
+
+}
+
+void SpotifyService::AlbumsUpdateStatusReceived(const int id, const QString &text) {
+ Q_UNUSED(id);
+ emit AlbumsUpdateStatus(text);
+}
+
+void SpotifyService::AlbumsProgressSetMaximumReceived(const int id, const int max) {
+ Q_UNUSED(id);
+ emit AlbumsProgressSetMaximum(max);
+}
+
+void SpotifyService::AlbumsUpdateProgressReceived(const int id, const int progress) {
+ Q_UNUSED(id);
+ emit AlbumsUpdateProgress(progress);
+}
+
+void SpotifyService::ResetSongsRequest() {
+
+ if (songs_request_) {
+ QObject::disconnect(&*songs_request_, nullptr, this, nullptr);
+ QObject::disconnect(this, nullptr, &*songs_request_, nullptr);
+ songs_request_.reset();
+ }
+
+}
+
+void SpotifyService::GetSongs() {
+
+ if (!authenticated()) {
+ emit SongsResults(SongMap(), tr("Not authenticated with Spotify."));
+ ShowConfig();
+ return;
+ }
+
+ ResetSongsRequest();
+ songs_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType::Songs, this), [](SpotifyRequest *request) { request->deleteLater(); });
+ QObject::connect(&*songs_request_, &SpotifyRequest::Results, this, &SpotifyService::SongsResultsReceived);
+ QObject::connect(&*songs_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::SongsUpdateStatusReceived);
+ QObject::connect(&*songs_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SongsProgressSetMaximumReceived);
+ QObject::connect(&*songs_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::SongsUpdateProgressReceived);
+
+ songs_request_->Process();
+
+}
+
+void SpotifyService::SongsResultsReceived(const int id, const SongMap &songs, const QString &error) {
+
+ Q_UNUSED(id);
+ emit SongsResults(songs, error);
+ ResetSongsRequest();
+
+}
+
+void SpotifyService::SongsUpdateStatusReceived(const int id, const QString &text) {
+ Q_UNUSED(id);
+ emit SongsUpdateStatus(text);
+}
+
+void SpotifyService::SongsProgressSetMaximumReceived(const int id, const int max) {
+ Q_UNUSED(id);
+ emit SongsProgressSetMaximum(max);
+}
+
+void SpotifyService::SongsUpdateProgressReceived(const int id, const int progress) {
+ Q_UNUSED(id);
+ emit SongsUpdateProgress(progress);
+}
+
+int SpotifyService::Search(const QString &text, StreamingSearchView::SearchType type) {
+
+ pending_search_id_ = next_pending_search_id_;
+ pending_search_text_ = text;
+ pending_search_type_ = type;
+
+ next_pending_search_id_++;
+
+ if (text.isEmpty()) {
+ timer_search_delay_->stop();
+ return pending_search_id_;
+ }
+ timer_search_delay_->start();
+
+ return pending_search_id_;
+
+}
+
+void SpotifyService::StartSearch() {
+
+ if (!authenticated()) {
+ emit SearchResults(pending_search_id_, SongMap(), tr("Not authenticated with Spotify."));
+ ShowConfig();
+ return;
+ }
+
+ search_id_ = pending_search_id_;
+ search_text_ = pending_search_text_;
+
+ SendSearch();
+
+}
+
+void SpotifyService::CancelSearch() {
+}
+
+void SpotifyService::SendSearch() {
+
+ SpotifyBaseRequest::QueryType type = SpotifyBaseRequest::QueryType::None;
+
+ switch (pending_search_type_) {
+ case StreamingSearchView::SearchType::Artists:
+ type = SpotifyBaseRequest::QueryType::SearchArtists;
+ break;
+ case StreamingSearchView::SearchType::Albums:
+ type = SpotifyBaseRequest::QueryType::SearchAlbums;
+ break;
+ case StreamingSearchView::SearchType::Songs:
+ type = SpotifyBaseRequest::QueryType::SearchSongs;
+ break;
+ default:
+ //Error("Invalid search type.");
+ return;
+ }
+
+ search_request_.reset(new SpotifyRequest(this, app_, network_, type, this), [](SpotifyRequest *request) { request->deleteLater(); });
+
+ QObject::connect(search_request_.get(), &SpotifyRequest::Results, this, &SpotifyService::SearchResultsReceived);
+ QObject::connect(search_request_.get(), &SpotifyRequest::UpdateStatus, this, &SpotifyService::SearchUpdateStatus);
+ QObject::connect(search_request_.get(), &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SearchProgressSetMaximum);
+ QObject::connect(search_request_.get(), &SpotifyRequest::UpdateProgress, this, &SpotifyService::SearchUpdateProgress);
+
+ search_request_->Search(search_id_, search_text_);
+ search_request_->Process();
+
+}
+
+void SpotifyService::SearchResultsReceived(const int id, const SongMap &songs, const QString &error) {
+
+ emit SearchResults(id, songs, error);
+ search_request_.reset();
+
+}
+
+void SpotifyService::LoginError(const QString &error, const QVariant &debug) {
+
+ if (!error.isEmpty()) login_errors_ << error;
+
+ QString error_html;
+ for (const QString &e : login_errors_) {
+ qLog(Error) << "Spotify:" << e;
+ error_html += e + QLatin1String("
");
+ }
+ if (debug.isValid()) qLog(Debug) << debug;
+
+ emit LoginFailure(error_html);
+ emit LoginComplete(false);
+
+ login_errors_.clear();
+
+}
diff --git a/src/spotify/spotifyservice.h b/src/spotify/spotifyservice.h
new file mode 100644
index 000000000..55c0b586e
--- /dev/null
+++ b/src/spotify/spotifyservice.h
@@ -0,0 +1,192 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022-2024, Jonas Kvinge
+ *
+ * Strawberry 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.
+ *
+ * Strawberry 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 Strawberry. If not, see .
+ *
+ */
+
+#ifndef SPOTIFYSERVICE_H
+#define SPOTIFYSERVICE_H
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/shared_ptr.h"
+#include "core/song.h"
+#include "streaming/streamingservice.h"
+#include "streaming/streamingsearchview.h"
+#include "settings/spotifysettingspage.h"
+
+class QNetworkReply;
+
+class Application;
+class NetworkAccessManager;
+class SpotifyRequest;
+class SpotifyFavoriteRequest;
+class SpotifyStreamURLRequest;
+class CollectionBackend;
+class CollectionModel;
+class CollectionFilter;
+class LocalRedirectServer;
+
+class SpotifyService : public StreamingService {
+ Q_OBJECT
+
+ public:
+ explicit SpotifyService(Application *app, QObject *parent = nullptr);
+ ~SpotifyService() override;
+
+ static const Song::Source kSource;
+ static const char kApiUrl[];
+
+ void Exit() override;
+ void ReloadSettings() override;
+
+ int Search(const QString &text, StreamingSearchView::SearchType type) override;
+ void CancelSearch() override;
+
+ Application *app() { return app_; }
+
+ int artistssearchlimit() { return artistssearchlimit_; }
+ int albumssearchlimit() { return albumssearchlimit_; }
+ int songssearchlimit() { return songssearchlimit_; }
+ bool fetchalbums() { return fetchalbums_; }
+ bool download_album_covers() { return download_album_covers_; }
+
+ QString access_token() { return access_token_; }
+
+ bool authenticated() const override { return !access_token_.isEmpty(); }
+
+ SharedPtr artists_collection_backend() override { return artists_collection_backend_; }
+ SharedPtr albums_collection_backend() override { return albums_collection_backend_; }
+ SharedPtr songs_collection_backend() override { return songs_collection_backend_; }
+
+ CollectionModel *artists_collection_model() override { return artists_collection_model_; }
+ CollectionModel *albums_collection_model() override { return albums_collection_model_; }
+ CollectionModel *songs_collection_model() override { return songs_collection_model_; }
+
+ CollectionFilter *artists_collection_filter_model() override { return artists_collection_model_->filter(); }
+ CollectionFilter *albums_collection_filter_model() override { return albums_collection_model_->filter(); }
+ CollectionFilter *songs_collection_filter_model() override { return songs_collection_model_->filter(); }
+
+ public slots:
+ void ShowConfig() override;
+ void Authenticate();
+ void Deauthenticate();
+ void GetArtists() override;
+ void GetAlbums() override;
+ void GetSongs() override;
+ void ResetArtistsRequest() override;
+ void ResetAlbumsRequest() override;
+ void ResetSongsRequest() override;
+
+ private slots:
+ void ExitReceived();
+ void RedirectArrived();
+ void RequestNewAccessToken() { RequestAccessToken(); }
+ void HandleLoginSSLErrors(const QList &ssl_errors);
+ void AccessTokenRequestFinished(QNetworkReply *reply);
+ void StartSearch();
+ void ArtistsResultsReceived(const int id, const SongMap &songs, const QString &error);
+ void AlbumsResultsReceived(const int id, const SongMap &songs, const QString &error);
+ void SongsResultsReceived(const int id, const SongMap &songs, const QString &error);
+ void SearchResultsReceived(const int id, const SongMap &songs, const QString &error);
+ void ArtistsUpdateStatusReceived(const int id, const QString &text);
+ void AlbumsUpdateStatusReceived(const int id, const QString &text);
+ void SongsUpdateStatusReceived(const int id, const QString &text);
+ void ArtistsProgressSetMaximumReceived(const int id, const int max);
+ void AlbumsProgressSetMaximumReceived(const int id, const int max);
+ void SongsProgressSetMaximumReceived(const int id, const int max);
+ void ArtistsUpdateProgressReceived(const int id, const int progress);
+ void AlbumsUpdateProgressReceived(const int id, const int progress);
+ void SongsUpdateProgressReceived(const int id, const int progress);
+
+ private:
+ typedef QPair Param;
+ typedef QList ParamList;
+
+ void LoadSession();
+ void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl());
+ void SendSearch();
+ void LoginError(const QString &error = QString(), const QVariant &debug = QVariant());
+
+ Application *app_;
+ NetworkAccessManager *network_;
+
+ SharedPtr artists_collection_backend_;
+ SharedPtr albums_collection_backend_;
+ SharedPtr songs_collection_backend_;
+
+ CollectionModel *artists_collection_model_;
+ CollectionModel *albums_collection_model_;
+ CollectionModel *songs_collection_model_;
+
+ QTimer *timer_search_delay_;
+ QTimer *timer_refresh_login_;
+
+ SharedPtr artists_request_;
+ SharedPtr albums_request_;
+ SharedPtr songs_request_;
+ SharedPtr search_request_;
+ SpotifyFavoriteRequest *favorite_request_;
+
+ bool enabled_;
+ int artistssearchlimit_;
+ int albumssearchlimit_;
+ int songssearchlimit_;
+ bool fetchalbums_;
+ bool download_album_covers_;
+
+ QString access_token_;
+ QString refresh_token_;
+ quint64 expires_in_;
+ quint64 login_time_;
+
+ int pending_search_id_;
+ int next_pending_search_id_;
+ QString pending_search_text_;
+ StreamingSearchView::SearchType pending_search_type_;
+
+ int search_id_;
+ QString search_text_;
+
+ QString code_verifier_;
+ QString code_challenge_;
+
+ LocalRedirectServer *server_;
+ QStringList login_errors_;
+ QTimer refresh_login_timer_;
+
+ QList wait_for_exit_;
+ QList replies_;
+};
+
+using SpotifyServicePtr = SharedPtr;
+
+#endif // SPOTIFYSERVICE_H
diff --git a/src/utilities/coverutils.cpp b/src/utilities/coverutils.cpp
index b9e7bba58..2abe65c22 100644
--- a/src/utilities/coverutils.cpp
+++ b/src/utilities/coverutils.cpp
@@ -123,6 +123,7 @@ QString CoverUtils::CoverFilenameFromSource(const Song::Source source, const QUr
}
[[fallthrough]];
case Song::Source::Subsonic:
+ case Song::Source::Spotify:
case Song::Source::Qobuz:
if (!album_id.isEmpty()) {
filename = album_id;