diff --git a/CMakeLists.txt b/CMakeLists.txt
index 07409145..e0d82e9d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -439,6 +439,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/data/icons.qrc b/data/icons.qrc
index 59e94625..ebbfe2f3 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
@@ -290,8 +292,9 @@
icons/48x48/moodbar.png
icons/48x48/love.png
icons/48x48/subsonic.png
- icons/48x48/tidal.png
- icons/48x48/qobuz.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
icons/48x48/somafm.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 00000000..fb62bfe1
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 00000000..216241cf
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 00000000..d8692c55
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 00000000..4c1facad
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 00000000..2fcc5d6d
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 00000000..26410e2f
Binary files /dev/null and b/data/icons/full/spotify.png differ
diff --git a/data/schema/schema-19.sql b/data/schema/schema-19.sql
new file mode 100644
index 00000000..216c2717
--- /dev/null
+++ b/data/schema/schema-19.sql
@@ -0,0 +1,289 @@
+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 VIRTUAL TABLE IF NOT EXISTS spotify_artists_songs_fts USING fts5(
+
+ ftstitle,
+ ftsalbum,
+ ftsartist,
+ ftsalbumartist,
+ ftscomposer,
+ ftsperformer,
+ ftsgrouping,
+ ftsgenre,
+ ftscomment,
+ tokenize = "unicode61 remove_diacritics 1"
+
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS spotify_albums_songs_fts USING fts5(
+
+ ftstitle,
+ ftsalbum,
+ ftsartist,
+ ftsalbumartist,
+ ftscomposer,
+ ftsperformer,
+ ftsgrouping,
+ ftsgenre,
+ ftscomment,
+ tokenize = "unicode61 remove_diacritics 1"
+
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS spotify_songs_fts USING fts5(
+
+ ftstitle,
+ ftsalbum,
+ ftsartist,
+ ftsalbumartist,
+ ftscomposer,
+ ftsperformer,
+ ftsgrouping,
+ ftsgenre,
+ ftscomment,
+ tokenize = "unicode61 remove_diacritics 1"
+
+);
+
+UPDATE schema_version SET version=19;
diff --git a/data/schema/schema.sql b/data/schema/schema.sql
index fb2e2417..350a5b41 100644
--- a/data/schema/schema.sql
+++ b/data/schema/schema.sql
@@ -422,6 +422,192 @@ CREATE TABLE IF NOT EXISTS tidal_songs (
);
+CREATE TABLE IF NOT EXISTS spotify_artists_songs (
+
+ title TEXT DEFAULT '',
+ album TEXT DEFAULT '',
+ artist TEXT DEFAULT '',
+ albumartist TEXT DEFAULT '',
+ 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 DEFAULT '',
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT DEFAULT '',
+ performer TEXT DEFAULT '',
+ grouping TEXT DEFAULT '',
+ comment TEXT DEFAULT '',
+ lyrics TEXT DEFAULT '',
+
+ artist_id TEXT DEFAULT '',
+ album_id TEXT DEFAULT '',
+ song_id TEXT DEFAULT '',
+
+ 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 DEFAULT '',
+ 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 DEFAULT '',
+
+ 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_automatic TEXT DEFAULT '',
+ art_manual TEXT DEFAULT '',
+
+ effective_albumartist TEXT DEFAULT '',
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT DEFAULT '',
+
+ rating INTEGER DEFAULT -1
+
+);
+
+CREATE TABLE IF NOT EXISTS spotify_albums_songs (
+
+ title TEXT DEFAULT '',
+ album TEXT DEFAULT '',
+ artist TEXT DEFAULT '',
+ albumartist TEXT DEFAULT '',
+ 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 DEFAULT '',
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT DEFAULT '',
+ performer TEXT DEFAULT '',
+ grouping TEXT DEFAULT '',
+ comment TEXT DEFAULT '',
+ lyrics TEXT DEFAULT '',
+
+ artist_id TEXT DEFAULT '',
+ album_id TEXT DEFAULT '',
+ song_id TEXT DEFAULT '',
+
+ 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 DEFAULT '',
+ 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 DEFAULT '',
+
+ 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_automatic TEXT DEFAULT '',
+ art_manual TEXT DEFAULT '',
+
+ effective_albumartist TEXT DEFAULT '',
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT DEFAULT '',
+
+ rating INTEGER DEFAULT -1
+
+);
+
+CREATE TABLE IF NOT EXISTS spotify_songs (
+
+ title TEXT DEFAULT '',
+ album TEXT DEFAULT '',
+ artist TEXT DEFAULT '',
+ albumartist TEXT DEFAULT '',
+ 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 DEFAULT '',
+ compilation INTEGER NOT NULL DEFAULT 0,
+ composer TEXT DEFAULT '',
+ performer TEXT DEFAULT '',
+ grouping TEXT DEFAULT '',
+ comment TEXT DEFAULT '',
+ lyrics TEXT DEFAULT '',
+
+ artist_id TEXT DEFAULT '',
+ album_id TEXT DEFAULT '',
+ song_id TEXT DEFAULT '',
+
+ 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 DEFAULT '',
+ 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 DEFAULT '',
+
+ 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_automatic TEXT DEFAULT '',
+ art_manual TEXT DEFAULT '',
+
+ effective_albumartist TEXT DEFAULT '',
+ effective_originalyear INTEGER NOT NULL DEFAULT 0,
+
+ cue_path TEXT DEFAULT '',
+
+ rating INTEGER DEFAULT -1
+
+);
+
CREATE TABLE IF NOT EXISTS qobuz_artists_songs (
title TEXT,
@@ -872,6 +1058,51 @@ CREATE VIRTUAL TABLE IF NOT EXISTS tidal_songs_fts USING fts5(
);
+CREATE VIRTUAL TABLE IF NOT EXISTS spotify_artists_songs_fts USING fts5(
+
+ ftstitle,
+ ftsalbum,
+ ftsartist,
+ ftsalbumartist,
+ ftscomposer,
+ ftsperformer,
+ ftsgrouping,
+ ftsgenre,
+ ftscomment,
+ tokenize = "unicode61 remove_diacritics 1"
+
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS spotify_albums_songs_fts USING fts5(
+
+ ftstitle,
+ ftsalbum,
+ ftsartist,
+ ftsalbumartist,
+ ftscomposer,
+ ftsperformer,
+ ftsgrouping,
+ ftsgenre,
+ ftscomment,
+ tokenize = "unicode61 remove_diacritics 1"
+
+);
+
+CREATE VIRTUAL TABLE IF NOT EXISTS spotify_songs_fts USING fts5(
+
+ ftstitle,
+ ftsalbum,
+ ftsartist,
+ ftsalbumartist,
+ ftscomposer,
+ ftsperformer,
+ ftsgrouping,
+ ftsgenre,
+ ftscomment,
+ tokenize = "unicode61 remove_diacritics 1"
+
+);
+
CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_artists_songs_fts USING fts5(
ftstitle,
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b695dd6b..d9b247ac 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -170,7 +170,6 @@ set(SOURCES
covermanager/deezercoverprovider.cpp
covermanager/qobuzcoverprovider.cpp
covermanager/musixmatchcoverprovider.cpp
- covermanager/spotifycoverprovider.cpp
lyrics/lyricsproviders.cpp
lyrics/lyricsprovider.cpp
@@ -415,7 +414,6 @@ set(HEADERS
covermanager/deezercoverprovider.h
covermanager/qobuzcoverprovider.h
covermanager/musixmatchcoverprovider.h
- covermanager/spotifycoverprovider.h
lyrics/lyricsproviders.h
lyrics/lyricsprovider.h
@@ -896,6 +894,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 3be0c0f2..f481d829 100644
--- a/src/config.h.in
+++ b/src/config.h.in
@@ -30,6 +30,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 84c39b5a..5758d023 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 "lyrics/lyricsproviders.h"
#include "lyrics/geniuslyricsprovider.h"
@@ -89,6 +88,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"
@@ -142,10 +146,12 @@ 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()));
#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
@@ -181,6 +187,9 @@ class ApplicationImpl {
#ifdef HAVE_TIDAL
internet_services->AddService(make_shared(app));
#endif
+#ifdef HAVE_SPOTIFY
+ internet_services->AddService(make_shared(app));
+#endif
#ifdef HAVE_QOBUZ
internet_services->AddService(make_shared(app));
#endif
diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp
index 350c9828..27123b6e 100644
--- a/src/core/mainwindow.cpp
+++ b/src/core/mainwindow.cpp
@@ -172,6 +172,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
@@ -300,6 +303,9 @@ MainWindow::MainWindow(Application *app, SharedPtr tray_icon, OS
#ifdef HAVE_TIDAL
tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source::Tidal), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page::Tidal, this)),
#endif
+#ifdef HAVE_SPOTIFY
+ spotify_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source::Spotify), SpotifySettingsPage::kSettingsGroup, SettingsDialog::Page::Spotify, this)),
+#endif
#ifdef HAVE_QOBUZ
qobuz_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source::Qobuz), QobuzSettingsPage::kSettingsGroup, SettingsDialog::Page::Qobuz, this)),
#endif
@@ -382,6 +388,9 @@ MainWindow::MainWindow(Application *app, SharedPtr tray_icon, OS
#ifdef HAVE_TIDAL
ui_->tabs->AddTab(tidal_view_, "tidal", IconLoader::Load("tidal", true, 0, 32), tr("Tidal"));
#endif
+#ifdef HAVE_SPOTIFY
+ ui_->tabs->AddTab(spotify_view_, "spotify", IconLoader::Load("spotify"), tr("Spotify"));
+#endif
#ifdef HAVE_QOBUZ
ui_->tabs->AddTab(qobuz_view_, "qobuz", IconLoader::Load("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(), &InternetSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
#endif
+#ifdef HAVE_SPOTIFY
+ QObject::connect(spotify_view_->artists_collection_view(), &InternetCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
+ QObject::connect(spotify_view_->albums_collection_view(), &InternetCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
+ QObject::connect(spotify_view_->songs_collection_view(), &InternetCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
+ QObject::connect(spotify_view_->search_view(), &InternetSearchView::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);
@@ -1168,6 +1184,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();
@@ -1216,6 +1244,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
@@ -3219,6 +3250,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 31a71017..2eb75c4f 100644
--- a/src/core/mainwindow.h
+++ b/src/core/mainwindow.h
@@ -333,9 +333,18 @@ class MainWindow : public QMainWindow, public PlatformInterface {
SmartPlaylistsViewContainer *smartplaylists_view_;
+#ifdef HAVE_SUBSONIC
InternetSongsView *subsonic_view_;
+#endif
+#ifdef HAVE_TIDAL
InternetTabsView *tidal_view_;
+#endif
+#ifdef HAVE_SPOTIFY
+ InternetTabsView *spotify_view_;
+#endif
+#ifdef HAVE_QOBUZ
InternetTabsView *qobuz_view_;
+#endif
RadioViewContainer *radio_view_;
diff --git a/src/core/song.cpp b/src/core/song.cpp
index f8402d02..b82af914 100644
--- a/src/core/song.cpp
+++ b/src/core/song.cpp
@@ -510,11 +510,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(); }
@@ -854,11 +854,13 @@ Song::Source Song::SourceFromURL(const QUrl &url) {
if (url.isLocalFile()) return Source::LocalFile;
else if (url.scheme() == "cdda") return Source::CDDA;
- else if (url.scheme() == "tidal") return Source::Tidal;
else if (url.scheme() == "subsonic") return Source::Subsonic;
+ else if (url.scheme() == "tidal") return Source::Tidal;
+ else if (url.scheme() == "spotify") return Source::Spotify;
else if (url.scheme() == "qobuz") return Source::Qobuz;
else if (url.scheme() == "http" || url.scheme() == "https" || url.scheme() == "rtsp") {
if (url.host().endsWith("tidal.com", Qt::CaseInsensitive)) { return Source::Tidal; }
+ if (url.host().endsWith("spotify.com", Qt::CaseInsensitive)) { return Source::Spotify; }
if (url.host().endsWith("qobuz.com", Qt::CaseInsensitive)) { return Source::Qobuz; }
if (url.host().endsWith("somafm.com", Qt::CaseInsensitive)) { return Source::SomaFM; }
if (url.host().endsWith("radioparadise.com", Qt::CaseInsensitive)) { return Source::RadioParadise; }
@@ -876,8 +878,9 @@ QString Song::TextForSource(const Source source) {
case Source::CDDA: return "cd";
case Source::Device: return "device";
case Source::Stream: return "stream";
- case Source::Tidal: return "tidal";
case Source::Subsonic: return "subsonic";
+ case Source::Tidal: return "tidal";
+ case Source::Spotify: return "spotify";
case Source::Qobuz: return "qobuz";
case Source::SomaFM: return "somafm";
case Source::RadioParadise: return "radioparadise";
@@ -895,8 +898,9 @@ QString Song::DescriptionForSource(const Source source) {
case Source::CDDA: return "CD";
case Source::Device: return "Device";
case Source::Stream: return "Stream";
- case Source::Tidal: return "Tidal";
case Source::Subsonic: return "Subsonic";
+ case Source::Tidal: return "Tidal";
+ case Source::Spotify: return "Spotify";
case Source::Qobuz: return "Qobuz";
case Source::SomaFM: return "SomaFM";
case Source::RadioParadise: return "Radio Paradise";
@@ -913,8 +917,9 @@ Song::Source Song::SourceFromText(const QString &source) {
if (source.compare("cd", Qt::CaseInsensitive) == 0) return Source::CDDA;
if (source.compare("device", Qt::CaseInsensitive) == 0) return Source::Device;
if (source.compare("stream", Qt::CaseInsensitive) == 0) return Source::Stream;
- if (source.compare("tidal", Qt::CaseInsensitive) == 0) return Source::Tidal;
if (source.compare("subsonic", Qt::CaseInsensitive) == 0) return Source::Subsonic;
+ if (source.compare("tidal", Qt::CaseInsensitive) == 0) return Source::Tidal;
+ if (source.compare("spotify", Qt::CaseInsensitive) == 0) return Source::Spotify;
if (source.compare("qobuz", Qt::CaseInsensitive) == 0) return Source::Qobuz;
if (source.compare("somafm", Qt::CaseInsensitive) == 0) return Source::SomaFM;
if (source.compare("radioparadise", Qt::CaseInsensitive) == 0) return Source::RadioParadise;
@@ -931,8 +936,9 @@ QIcon Song::IconForSource(const Source source) {
case Source::CDDA: return IconLoader::Load("media-optical");
case Source::Device: return IconLoader::Load("device");
case Source::Stream: return IconLoader::Load("applications-internet");
- case Source::Tidal: return IconLoader::Load("tidal");
case Source::Subsonic: return IconLoader::Load("subsonic");
+ case Source::Tidal: return IconLoader::Load("tidal");
+ case Source::Spotify: return IconLoader::Load("spotify");
case Source::Qobuz: return IconLoader::Load("qobuz");
case Source::SomaFM: return IconLoader::Load("somafm");
case Source::RadioParadise: return IconLoader::Load("radioparadise");
@@ -1153,6 +1159,8 @@ QString Song::ImageCacheDir(const Source source) {
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/subsonicalbumcovers";
case Source::Tidal:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/tidalalbumcovers";
+ case Source::Spotify:
+ return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/spotifyalbumcovers";
case Source::Qobuz:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/qobuzalbumcovers";
case Source::Device:
diff --git a/src/core/song.h b/src/core/song.h
index 76b57118..2e7c9bef 100644
--- a/src/core/song.h
+++ b/src/core/song.h
@@ -76,7 +76,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 6c3c1691..42ebad67 100644
--- a/src/covermanager/albumcoverchoicecontroller.cpp
+++ b/src/covermanager/albumcoverchoicecontroller.cpp
@@ -574,9 +574,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:
InternetServicePtr service = app_->internet_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 375c7bc6..4a2c9fc8 100644
--- a/src/covermanager/spotifycoverprovider.cpp
+++ b/src/covermanager/spotifycoverprovider.cpp
@@ -29,11 +29,8 @@
#include
#include
#include
-#include
#include
#include
-#include
-#include
#include
#include
#include
@@ -47,45 +44,17 @@
#include "core/logging.h"
#include "utilities/randutils.h"
#include "utilities/timeconstants.h"
-#include "internet/localredirectserver.h"
+#include "internet/internetservices.h"
+#include "spotify/spotifyservice.h"
#include "albumcoverfetcher.h"
#include "jsoncoverprovider.h"
#include "spotifycoverprovider.h"
-const char *SpotifyCoverProvider::kSettingsGroup = "Spotify";
-const char *SpotifyCoverProvider::kOAuthAuthorizeUrl = "https://accounts.spotify.com/authorize";
-const char *SpotifyCoverProvider::kOAuthAccessTokenUrl = "https://accounts.spotify.com/api/token";
-const char *SpotifyCoverProvider::kOAuthRedirectUrl = "http://localhost:63111/";
-const char *SpotifyCoverProvider::kClientIDB64 = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
-const char *SpotifyCoverProvider::kClientSecretB64 = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
-const char *SpotifyCoverProvider::kApiUrl = "https://api.spotify.com/v1";
const int SpotifyCoverProvider::kLimit = 10;
SpotifyCoverProvider::SpotifyCoverProvider(Application *app, SharedPtr network, QObject *parent)
: JsonCoverProvider("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);
-
- QSettings 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->internet_services()->Service()) {}
SpotifyCoverProvider::~SpotifyCoverProvider() {
@@ -98,265 +67,9 @@ SpotifyCoverProvider::~SpotifyCoverProvider() {
}
-void SpotifyCoverProvider::Authenticate() {
-
- QUrl redirect_url(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(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
- if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) {
- code_challenge_.chop(1);
- }
-
- const ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64))
- << Param("response_type", "code")
- << Param("redirect_uri", redirect_url.toString())
- << Param("state", code_challenge_);
-
- QUrlQuery url_query;
- for (const Param ¶m : params) {
- url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
- }
-
- QUrl url(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") + QString(":
%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;
-
- QSettings 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("error")) {
- AuthError(QUrlQuery(url).queryItemValue("error"));
- }
- else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) {
- qLog(Debug) << "Spotify: Authorization URL Received" << url;
- QString code = url_query.queryItemValue("code");
- QUrl redirect_url(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("client_id", QByteArray::fromBase64(kClientIDB64))
- << Param("client_secret", QByteArray::fromBase64(kClientSecretB64));
-
- if (!code.isEmpty() && !redirect_url.isEmpty()) {
- params << Param("grant_type", "authorization_code");
- params << Param("code", code);
- params << Param("redirect_uri", redirect_url.toString());
- }
- else if (!refresh_token_.isEmpty() && is_enabled()) {
- params << Param("grant_type", "refresh_token");
- params << Param("refresh_token", refresh_token_);
- }
- else {
- return;
- }
-
- QUrlQuery url_query;
- for (const Param ¶m : params) {
- url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
- }
-
- QUrl new_url(kOAuthAccessTokenUrl);
- QNetworkRequest req(new_url);
- req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
- req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
- QString auth_header_data = QByteArray::fromBase64(kClientIDB64) + QString(":") + 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(QString("%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("error") && json_obj.contains("error_description")) {
- QString error = json_obj["error"].toString();
- QString error_description = json_obj["error_description"].toString();
- login_errors_ << QString("Authentication failure: %1 (%2)").arg(error, error_description);
- }
- }
- if (login_errors_.isEmpty()) {
- if (reply->error() != QNetworkReply::NoError) {
- login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
- }
- else {
- login_errors_ << QString("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(QString("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
- return;
- }
-
- if (json_doc.isEmpty()) {
- AuthError("Authentication reply from server has empty Json document.");
- return;
- }
-
- if (!json_doc.isObject()) {
- AuthError("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("Authentication reply from server has empty Json object.", json_doc);
- return;
- }
-
- if (!json_obj.contains("access_token") || !json_obj.contains("expires_in")) {
- AuthError("Authentication reply from server is missing access token or expires in.", json_obj);
- return;
- }
-
- access_token_ = json_obj["access_token"].toString();
- if (json_obj.contains("refresh_token")) {
- refresh_token_ = json_obj["refresh_token"].toString();
- }
- expires_in_ = json_obj["expires_in"].toInt();
- login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
-
- QSettings 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;
@@ -387,12 +100,12 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb
url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
- QUrl url(kApiUrl + QString("/search"));
+ QUrl url(SpotifyService::kApiUrl + QString("/search"));
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, "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;
@@ -429,13 +142,13 @@ QByteArray SpotifyCoverProvider::GetReplyData(QNetworkReply *reply) {
int status = obj_error["status"].toInt();
QString message = obj_error["message"].toString();
error = QString("%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 = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
@@ -538,20 +251,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 a0a5bd10..e4719a45 100644
--- a/src/covermanager/spotifycoverprovider.h
+++ b/src/covermanager/spotifycoverprovider.h
@@ -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,22 +52,17 @@ 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:
static const char *kSettingsGroup;
@@ -79,15 +74,7 @@ class SpotifyCoverProvider : public JsonCoverProvider {
static const char *kApiUrl;
static const int kLimit;
- 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 d40b8fed..50f5b133 100644
--- a/src/engine/enginebase.cpp
+++ b/src/engine/enginebase.cpp
@@ -36,6 +36,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),
@@ -235,6 +238,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 00afc663..33b93adb 100644
--- a/src/engine/enginebase.h
+++ b/src/engine/enginebase.h
@@ -245,6 +245,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 32080c22..891bb3d6 100644
--- a/src/engine/gstengine.cpp
+++ b/src/engine/gstengine.cpp
@@ -171,7 +171,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() != "spotify") {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
}
@@ -228,7 +228,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() != "spotify") {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
}
@@ -803,6 +803,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 : buffer_consumers_) {
ret->AddBufferConsumer(consumer);
diff --git a/src/engine/gstenginepipeline.cpp b/src/engine/gstenginepipeline.cpp
index 1dd3391a..d84ba301 100644
--- a/src/engine/gstenginepipeline.cpp
+++ b/src/engine/gstenginepipeline.cpp
@@ -290,6 +290,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) {
@@ -896,6 +905,13 @@ void GstEnginePipeline::SourceSetupCallback(GstElement *playbin, GstElement *sou
}
}
+#ifdef HAVE_SPOTIFY
+ if (instance->media_url_.scheme() == "spotify" && !instance->spotify_username_.isEmpty() && !instance->spotify_password_.isEmpty()) {
+ 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 7119855c..9dd269bb 100644
--- a/src/engine/gstenginepipeline.h
+++ b/src/engine/gstenginepipeline.h
@@ -75,6 +75,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);
@@ -246,6 +249,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 1a8e4dd9..a6413db4 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/settingsdialog.cpp b/src/settings/settingsdialog.cpp
index b87a88fb..aa78b0d0 100644
--- a/src/settings/settingsdialog.cpp
+++ b/src/settings/settingsdialog.cpp
@@ -77,6 +77,9 @@
#ifdef HAVE_TIDAL
# include "tidalsettingspage.h"
#endif
+#ifdef HAVE_SPOTIFY
+# include "spotifysettingspage.h"
+#endif
#ifdef HAVE_QOBUZ
# include "qobuzsettingspage.h"
#endif
@@ -154,7 +157,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
@@ -164,6 +167,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 013890dd..2581c635 100644
--- a/src/settings/settingsdialog.h
+++ b/src/settings/settingsdialog.h
@@ -93,6 +93,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 00000000..d552cb78
--- /dev/null
+++ b/src/settings/spotifysettingspage.cpp
@@ -0,0 +1,169 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 "internet/internetservices.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()->internet_services()->Service()) {
+
+ ui_->setupUi(this);
+ setWindowIcon(IconLoader::Load("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_, &InternetService::LoginFailure, this, &SpotifySettingsPage::LoginFailure);
+ QObject::connect(&*service_, &InternetService::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() {
+
+ QSettings 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 (!QSettings().childGroups().contains(kSettingsGroup)) set_changed();
+
+}
+
+void SpotifySettingsPage::Save() {
+
+ QSettings 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 00000000..88409ad3
--- /dev/null
+++ b/src/settings/spotifysettingspage.h
@@ -0,0 +1,64 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 00000000..7ed6b689
--- /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 00000000..ad8eb8cd
--- /dev/null
+++ b/src/spotify/spotifybaserequest.cpp
@@ -0,0 +1,185 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 "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(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
+ }
+
+ QUrl url(SpotifyService::kApiUrl + QString("/") + ressource_name);
+ url.setQuery(url_query);
+ QNetworkRequest req(url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setHeader(QNetworkRequest::ContentTypeHeader, "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(QString("%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("error") && json_obj["error"].isObject()) {
+ QJsonObject obj_error = json_obj["error"].toObject();
+ if (!obj_error.isEmpty() && obj_error.contains("status") && obj_error.contains("message")) {
+ status = obj_error["status"].toInt();
+ QString user_message = obj_error["message"].toString();
+ error = QString("%1 (%2)").arg(user_message).arg(status);
+ }
+ }
+ }
+ if (error.isEmpty()) {
+ if (reply->error() == QNetworkReply::NoError) {
+ error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
+ }
+ else {
+ error = QString("%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("Reply from server missing Json data.", data);
+ return QJsonObject();
+ }
+
+ if (json_doc.isEmpty()) {
+ Error("Received empty Json document.", data);
+ return QJsonObject();
+ }
+
+ if (!json_doc.isObject()) {
+ Error("Json document is not an object.", json_doc);
+ return QJsonObject();
+ }
+
+ QJsonObject json_obj = json_doc.object();
+ if (json_obj.isEmpty()) {
+ Error("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("items")) {
+ Error("Json reply is missing items.", json_obj);
+ return QJsonArray();
+ }
+ QJsonValue json_items = json_obj["items"];
+ return json_items;
+
+}
+
+QString SpotifyBaseRequest::ErrorsToHTML(const QStringList &errors) {
+
+ QString error_html;
+ for (const QString &error : errors) {
+ error_html += error + "
";
+ }
+ return error_html;
+
+}
diff --git a/src/spotify/spotifybaserequest.h b/src/spotify/spotifybaserequest.h
new file mode 100644
index 00000000..51a7da3b
--- /dev/null
+++ b/src/spotify/spotifybaserequest.h
@@ -0,0 +1,91 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 QueryType {
+ QueryType_None,
+ QueryType_Artists,
+ QueryType_Albums,
+ QueryType_Songs,
+ QueryType_SearchArtists,
+ QueryType_SearchAlbums,
+ QueryType_SearchSongs,
+ QueryType_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 00000000..613dcf7a
--- /dev/null
+++ b/src/spotify/spotifyfavoriterequest.cpp
@@ -0,0 +1,310 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 "artists";
+ case FavoriteType_Albums:
+ return "albums";
+ case FavoriteType_Songs:
+ return "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(",");
+
+ 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(SpotifyService::kApiUrl + (type == FavoriteType_Artists ? QString("/me/following") : QString("/me/") + FavoriteText(type)));
+ if (type == FavoriteType_Artists) {
+ QUrlQuery url_query;
+ url_query.addQueryItem("type", "artist");
+ url_query.addQueryItem("ids", ids_list);
+ url.setQuery(url_query);
+ }
+ QNetworkRequest req(url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setHeader(QNetworkRequest::ContentTypeHeader, "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(",");
+
+ 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(SpotifyService::kApiUrl + (type == FavoriteType_Artists ? QString("/me/following") : QString("/me/") + FavoriteText(type)));
+ if (type == FavoriteType_Artists) {
+ QUrlQuery url_query;
+ url_query.addQueryItem("type", "artist");
+ url_query.addQueryItem("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, "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 00000000..389f5164
--- /dev/null
+++ b/src/spotify/spotifyfavoriterequest.h
@@ -0,0 +1,90 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 00000000..f3a56c5c
--- /dev/null
+++ b/src/spotify/spotifyrequest.cpp
@@ -0,0 +1,1401 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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"
+
+const int SpotifyRequest::kMaxConcurrentArtistsRequests = 1;
+const int SpotifyRequest::kMaxConcurrentAlbumsRequests = 1;
+const int SpotifyRequest::kMaxConcurrentSongsRequests = 1;
+const int SpotifyRequest::kMaxConcurrentArtistAlbumsRequests = 1;
+const int SpotifyRequest::kMaxConcurrentAlbumSongsRequests = 1;
+const int SpotifyRequest::kMaxConcurrentAlbumCoverRequests = 10;
+const int SpotifyRequest::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::QueryType_Artists:
+ GetArtists();
+ break;
+ case QueryType::QueryType_Albums:
+ GetAlbums();
+ break;
+ case QueryType::QueryType_Songs:
+ GetSongs();
+ break;
+ case QueryType::QueryType_SearchArtists:
+ ArtistsSearch();
+ break;
+ case QueryType::QueryType_SearchAlbums:
+ AlbumsSearch();
+ break;
+ case QueryType::QueryType_SearchSongs:
+ SongsSearch();
+ break;
+ default:
+ Error("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("type", "artist");
+ if (type_ == QueryType_SearchArtists) {
+ parameters << Param("q", search_text_);
+ }
+ if (request.limit > 0) {
+ parameters << Param("limit", QString::number(request.limit));
+ }
+ if (request.offset > 0) {
+ parameters << Param("offset", QString::number(request.offset));
+ }
+ QNetworkReply *reply = nullptr;
+ if (type_ == QueryType_Artists) {
+ reply = CreateRequest("me/following", parameters);
+ }
+ if (type_ == QueryType_SearchArtists) {
+ reply = CreateRequest("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("type", "album");
+ parameters << Param("q", search_text_);
+ }
+ if (request.limit > 0) parameters << Param("limit", QString::number(request.limit));
+ if (request.offset > 0) parameters << Param("offset", QString::number(request.offset));
+ QNetworkReply *reply = nullptr;
+ if (type_ == QueryType_Albums) {
+ reply = CreateRequest("me/albums", parameters);
+ }
+ if (type_ == QueryType_SearchAlbums) {
+ reply = CreateRequest("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("type", "track");
+ parameters << Param("q", search_text_);
+ }
+ if (request.limit > 0) {
+ parameters << Param("limit", QString::number(request.limit));
+ }
+ if (request.offset > 0) {
+ parameters << Param("offset", QString::number(request.offset));
+ }
+ QNetworkReply *reply = nullptr;
+ if (type_ == QueryType_Songs) {
+ reply = CreateRequest("me/tracks", parameters);
+ }
+ if (type_ == QueryType_SearchSongs) {
+ reply = CreateRequest("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("artists") || !json_obj["artists"].isObject()) {
+ Error("Json object missing values.", json_obj);
+ ArtistsFinishCheck();
+ return;
+ }
+ QJsonObject obj_artists = json_obj["artists"].toObject();
+
+ if (!obj_artists.contains("limit") ||
+ !obj_artists.contains("total") ||
+ !obj_artists.contains("items")) {
+ Error("Json object missing values.", obj_artists);
+ ArtistsFinishCheck();
+ return;
+ }
+
+ int offset = 0;
+ if (obj_artists.contains("offset")) {
+ offset = obj_artists["offset"].toInt();
+ }
+ int artists_total = obj_artists["total"].toInt();
+
+ if (offset_requested == 0) {
+ artists_total_ = artists_total;
+ }
+ else if (artists_total != artists_total_) {
+ Error(QString("Total returned does not match previous total! %1 != %2").arg(artists_total).arg(artists_total_));
+ ArtistsFinishCheck();
+ return;
+ }
+
+ if (offset != offset_requested) {
+ Error(QString("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("Invalid Json reply, item in array is not a object.");
+ continue;
+ }
+ QJsonObject obj_item = value_item.toObject();
+
+ if (obj_item.contains("item")) {
+ QJsonValue json_item = obj_item["item"];
+ if (!json_item.isObject()) {
+ Error("Invalid Json reply, item in array is not a object.", json_item);
+ continue;
+ }
+ obj_item = json_item.toObject();
+ }
+
+ if (!obj_item.contains("id") || !obj_item.contains("name")) {
+ Error("Invalid Json reply, item missing id or album.", obj_item);
+ continue;
+ }
+
+ QString artist_id = obj_item["id"].toString();
+ QString artist = obj_item["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("offset", QString::number(request.offset));
+ QNetworkReply *reply = CreateRequest(QString("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("albums") && json_obj["albums"].isObject()) {
+ json_obj = json_obj["albums"].toObject();
+ }
+
+ if (json_obj.contains("tracks") && json_obj["tracks"].isObject()) {
+ json_obj = json_obj["tracks"].toObject();
+ }
+
+ if (!json_obj.contains("limit") ||
+ !json_obj.contains("offset") ||
+ !json_obj.contains("total") ||
+ !json_obj.contains("items")) {
+ Error("Json object missing values.", json_obj);
+ AlbumsFinishCheck(artist_artist);
+ return;
+ }
+
+ int offset = json_obj["offset"].toInt();
+ int albums_total = json_obj["total"].toInt();
+
+ if (type_ == QueryType_Albums || type_ == QueryType_SearchAlbums) {
+ albums_total_ = albums_total;
+ }
+
+ if (offset != offset_requested) {
+ Error(QString("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("Invalid Json reply, item in array is not a object.");
+ continue;
+ }
+ QJsonObject obj_item = value_item.toObject();
+
+ if (obj_item.contains("item")) {
+ QJsonValue json_item = obj_item["item"];
+ if (!json_item.isObject()) {
+ Error("Invalid Json reply, item in array is not a object.", json_item);
+ continue;
+ }
+ obj_item = json_item.toObject();
+ }
+
+ if (obj_item.contains("album")) {
+ QJsonValue json_item = obj_item["album"];
+ if (!json_item.isObject()) {
+ Error("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("id")) {
+ Error("Invalid Json reply, item is missing ID.", obj_item);
+ continue;
+ }
+ if (!obj_item.contains("name")) {
+ Error("Invalid Json reply, item is missing name.", obj_item);
+ continue;
+ }
+ if (!obj_item.contains("images")) {
+ Error("Invalid Json reply, item is missing images.", obj_item);
+ continue;
+ }
+ album.album_id = obj_item["id"].toString();
+ album.album = obj_item["name"].toString();
+
+ if (artist_artist.artist_id.isEmpty() && obj_item.contains("artists") && obj_item["artists"].isArray()) {
+ QJsonArray array_artists = obj_item["artists"].toArray();
+ for (const QJsonValueRef value : array_artists) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_artist = value.toObject();
+ if (obj_artist.isEmpty() || !obj_artist.contains("id") || !obj_artist.contains("name")) continue;
+ artist.artist_id = obj_artist["id"].toString();
+ artist.artist = obj_artist["name"].toString();
+ break;
+ }
+ }
+ else {
+ artist = artist_artist;
+ }
+
+ if (obj_item.contains("images") && obj_item["images"].isArray()) {
+ QJsonArray array_images = obj_item["images"].toArray();
+ for (const QJsonValueRef value : array_images) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_image = value.toObject();
+ if (obj_image.isEmpty() || !obj_image.contains("url") || !obj_image.contains("width") || !obj_image.contains("height")) continue;
+ int width = obj_image["width"].toInt();
+ int height = obj_image["height"].toInt();
+ if (width <= 300 || height <= 300) {
+ continue;
+ }
+ album.cover_url = QUrl(obj_image["url"].toString());
+ }
+ }
+
+ if (obj_item.contains("tracks") && obj_item["tracks"].isObject()) {
+ QJsonObject obj_tracks = obj_item["tracks"].toObject();
+ if (obj_tracks.contains("items") && obj_tracks["items"].isArray()) {
+ QJsonArray array_tracks = obj_tracks["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("track") && obj_track["track"].isObject()) {
+ obj_track = obj_track["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("offset", QString::number(request.offset));
+ QNetworkReply *reply = CreateRequest(QString("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("tracks") && json_obj["tracks"].isObject()) {
+ json_obj = json_obj["tracks"].toObject();
+ }
+
+ if (!json_obj.contains("limit") ||
+ !json_obj.contains("offset") ||
+ !json_obj.contains("total") ||
+ !json_obj.contains("items")) {
+ Error("Json object missing values.", json_obj);
+ SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0);
+ return;
+ }
+
+ int offset = json_obj["offset"].toInt();
+ int songs_total = json_obj["total"].toInt();
+
+ if (type_ == QueryType_Songs || type_ == QueryType_SearchSongs) {
+ songs_total_ = songs_total;
+ }
+
+ if (offset != offset_requested) {
+ Error(QString("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("Invalid Json reply, track is not a object.");
+ continue;
+ }
+ QJsonObject obj_item = value_item.toObject();
+
+ if (obj_item.contains("item") && obj_item["item"].isObject()) {
+ obj_item = obj_item["item"].toObject();
+ }
+
+ if (obj_item.contains("track") && obj_item["track"].isObject()) {
+ obj_item = obj_item["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("type") ||
+ !json_obj.contains("id") ||
+ !json_obj.contains("name") ||
+ !json_obj.contains("uri") ||
+ !json_obj.contains("duration_ms") ||
+ !json_obj.contains("track_number") ||
+ !json_obj.contains("disc_number")
+ ) {
+ Error("Invalid Json reply, track is missing one or more values.", json_obj);
+ return;
+ }
+
+ QString artist_id;
+ QString artist_title;
+ if (json_obj.contains("artists") && json_obj["artists"].isArray()) {
+ QJsonArray array_artists = json_obj["artists"].toArray();
+ for (const QJsonValueRef value_artist : array_artists) {
+ if (!value_artist.isObject()) continue;
+ QJsonObject obj_artist = value_artist.toObject();
+ if (!obj_artist.contains("type") || !obj_artist.contains("id") || !obj_artist.contains("name")) {
+ continue;
+ }
+ artist_id = obj_artist["id"].toString();
+ artist_title = obj_artist["name"].toString();
+ break;
+ }
+ }
+
+ QString album_id;
+ QString album_title;
+ QUrl cover_url;
+ if (json_obj.contains("album") && json_obj["album"].isObject()) {
+ QJsonObject obj_album = json_obj["album"].toObject();
+ if (obj_album.contains("type") && obj_album.contains("id") && obj_album.contains("name")) {
+ album_id = obj_album["id"].toString();
+ album_title = obj_album["name"].toString();
+ if (obj_album.contains("images") && obj_album["images"].isArray()) {
+ QJsonArray array_images = obj_album["images"].toArray();
+ for (const QJsonValueRef value : array_images) {
+ if (!value.isObject()) {
+ continue;
+ }
+ QJsonObject obj_image = value.toObject();
+ if (obj_image.isEmpty() || !obj_image.contains("url") || !obj_image.contains("width") || !obj_image.contains("height")) continue;
+ int width = obj_image["width"].toInt();
+ int height = obj_image["height"].toInt();
+ if (width <= 300 || height <= 300) {
+ continue;
+ }
+ cover_url = QUrl(obj_image["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["duration"];
+
+ QString song_id = json_obj["id"].toString();
+ QString title = json_obj["name"].toString();
+ QString uri = json_obj["uri"].toString();
+ qint64 duration = json_obj["duration_ms"].toVariant().toLongLong() * kNsecPerMsec;
+ int track = json_obj["track_number"].toInt();
+ int disc = json_obj["disc_number"].toInt();
+
+ QUrl url(uri);
+
+ title.remove(Song::kTitleRemoveMisc);
+
+ 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(QString("%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(QString("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(';')) {
+ mimetype = mimetype.left(mimetype.indexOf(';'));
+ }
+ if (!ImageUtils::SupportedImageMimeTypes().contains(mimetype, Qt::CaseInsensitive) && !ImageUtils::SupportedImageFormats().contains(mimetype, Qt::CaseInsensitive)) {
+ Error(QString("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(QString("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(QString("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(QString("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 00000000..a26424cb
--- /dev/null
+++ b/src/spotify/spotifyrequest.h
@@ -0,0 +1,241 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 SpotifyUrlHandler;
+
+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() { return (type_ == QueryType_Artists || type_ == QueryType_Albums || type_ == QueryType_Songs); }
+ bool IsSearch() { 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;
+
+ static const int kMaxConcurrentArtistsRequests;
+ static const int kMaxConcurrentAlbumsRequests;
+ static const int kMaxConcurrentSongsRequests;
+ static const int kMaxConcurrentArtistAlbumsRequests;
+ static const int kMaxConcurrentAlbumSongsRequests;
+ static const int kMaxConcurrentAlbumCoverRequests;
+ static const int kFlushRequestsDelay;
+
+ 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 00000000..63b0c061
--- /dev/null
+++ b/src/spotify/spotifyservice.cpp
@@ -0,0 +1,769 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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
+
+#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 "utilities/timeconstants.h"
+#include "utilities/randutils.h"
+#include "internet/localredirectserver.h"
+#include "internet/internetsearchview.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::kOAuthAuthorizeUrl = "https://accounts.spotify.com/authorize";
+const char *SpotifyService::kOAuthAccessTokenUrl = "https://accounts.spotify.com/api/token";
+const char *SpotifyService::kOAuthRedirectUrl = "http://localhost:63111/";
+const char *SpotifyService::kClientIDB64 = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
+const char *SpotifyService::kClientSecretB64 = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
+const char *SpotifyService::kApiUrl = "https://api.spotify.com/v1";
+
+const char *SpotifyService::kArtistsSongsTable = "spotify_artists_songs";
+const char *SpotifyService::kAlbumsSongsTable = "spotify_albums_songs";
+const char *SpotifyService::kSongsTable = "spotify_songs";
+
+const char *SpotifyService::kArtistsSongsFtsTable = "spotify_artists_songs_fts";
+const char *SpotifyService::kAlbumsSongsFtsTable = "spotify_albums_songs_fts";
+const char *SpotifyService::kSongsFtsTable = "spotify_songs_fts";
+
+using std::make_shared;
+using namespace std::chrono_literals;
+
+SpotifyService::SpotifyService(Application *app, QObject *parent)
+ : InternetService(Song::Source::Spotify, "Spotify", "spotify", 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),
+ artists_collection_sort_model_(new QSortFilterProxyModel(this)),
+ albums_collection_sort_model_(new QSortFilterProxyModel(this)),
+ songs_collection_sort_model_(new QSortFilterProxyModel(this)),
+ 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_(InternetSearchView::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, kArtistsSongsTable, kArtistsSongsFtsTable);
+
+ albums_collection_backend_ = make_shared();
+ albums_collection_backend_->moveToThread(app_->database()->thread());
+ albums_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kAlbumsSongsTable, kAlbumsSongsFtsTable);
+
+ songs_collection_backend_ = make_shared();
+ songs_collection_backend_->moveToThread(app_->database()->thread());
+ songs_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kSongsTable, kSongsFtsTable);
+
+ 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);
+
+ artists_collection_sort_model_->setSourceModel(artists_collection_model_);
+ artists_collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
+ artists_collection_sort_model_->setDynamicSortFilter(true);
+ artists_collection_sort_model_->setSortLocaleAware(true);
+ artists_collection_sort_model_->sort(0);
+
+ albums_collection_sort_model_->setSourceModel(albums_collection_model_);
+ albums_collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
+ albums_collection_sort_model_->setDynamicSortFilter(true);
+ albums_collection_sort_model_->setSortLocaleAware(true);
+ albums_collection_sort_model_->sort(0);
+
+ songs_collection_sort_model_->setSourceModel(songs_collection_model_);
+ songs_collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
+ songs_collection_sort_model_->setDynamicSortFilter(true);
+ songs_collection_sort_model_->setSortLocaleAware(true);
+ songs_collection_sort_model_->sort(0);
+
+ 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);
+
+ QSettings 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() {
+
+ QSettings 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(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(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
+ if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) {
+ code_challenge_.chop(1);
+ }
+
+ const ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64))
+ << Param("response_type", "code")
+ << Param("redirect_uri", redirect_url.toString())
+ << Param("state", code_challenge_)
+ << Param("scope", "user-follow-read user-follow-modify user-library-read user-library-modify streaming");
+
+ QUrlQuery url_query;
+ for (const Param ¶m : params) {
+ url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
+ }
+
+ QUrl url(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") + QString(":
%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;
+
+ QSettings 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("error")) {
+ LoginError(QUrlQuery(url).queryItemValue("error"));
+ }
+ else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) {
+ qLog(Debug) << "Spotify: Authorization URL Received" << url;
+ QString code = url_query.queryItemValue("code");
+ QUrl redirect_url(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("client_id", QByteArray::fromBase64(kClientIDB64))
+ << Param("client_secret", QByteArray::fromBase64(kClientSecretB64));
+
+ if (!code.isEmpty() && !redirect_url.isEmpty()) {
+ params << Param("grant_type", "authorization_code");
+ params << Param("code", code);
+ params << Param("redirect_uri", redirect_url.toString());
+ }
+ else if (!refresh_token_.isEmpty() && enabled_) {
+ params << Param("grant_type", "refresh_token");
+ params << Param("refresh_token", refresh_token_);
+ }
+ else {
+ return;
+ }
+
+ QUrlQuery url_query;
+ for (const Param ¶m : params) {
+ url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
+ }
+
+ QUrl new_url(kOAuthAccessTokenUrl);
+ QNetworkRequest req(new_url);
+ req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
+ req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
+ QString auth_header_data = QByteArray::fromBase64(kClientIDB64) + QString(":") + 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(QString("%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("error") && json_obj.contains("error_description")) {
+ QString error = json_obj["error"].toString();
+ QString error_description = json_obj["error_description"].toString();
+ login_errors_ << QString("Authentication failure: %1 (%2)").arg(error, error_description);
+ }
+ }
+ if (login_errors_.isEmpty()) {
+ if (reply->error() != QNetworkReply::NoError) {
+ login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
+ }
+ else {
+ login_errors_ << QString("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(QString("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
+ return;
+ }
+
+ if (json_doc.isEmpty()) {
+ LoginError("Authentication reply from server has empty Json document.");
+ return;
+ }
+
+ if (!json_doc.isObject()) {
+ LoginError("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("Authentication reply from server has empty Json object.", json_doc);
+ return;
+ }
+
+ if (!json_obj.contains("access_token") || !json_obj.contains("expires_in")) {
+ LoginError("Authentication reply from server is missing access token or expires in.", json_obj);
+ return;
+ }
+
+ access_token_ = json_obj["access_token"].toString();
+ if (json_obj.contains("refresh_token")) {
+ refresh_token_ = json_obj["refresh_token"].toString();
+ }
+ expires_in_ = json_obj["expires_in"].toInt();
+ login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
+
+ QSettings 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, InternetSearchView::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 InternetSearchView::SearchType::Artists:
+ type = SpotifyBaseRequest::QueryType_SearchArtists;
+ break;
+ case InternetSearchView::SearchType::Albums:
+ type = SpotifyBaseRequest::QueryType_SearchAlbums;
+ break;
+ case InternetSearchView::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 + "
";
+ }
+ 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 00000000..469e7d13
--- /dev/null
+++ b/src/spotify/spotifyservice.h
@@ -0,0 +1,214 @@
+/*
+ * Strawberry Music Player
+ * Copyright 2022, 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 "internet/internetservice.h"
+#include "internet/internetsearchview.h"
+#include "settings/spotifysettingspage.h"
+
+class QSortFilterProxyModel;
+class QNetworkReply;
+
+class Application;
+class NetworkAccessManager;
+class SpotifyUrlHandler;
+class SpotifyRequest;
+class SpotifyFavoriteRequest;
+class SpotifyStreamURLRequest;
+class CollectionBackend;
+class CollectionModel;
+class LocalRedirectServer;
+
+class SpotifyService : public InternetService {
+ Q_OBJECT
+
+ public:
+ explicit SpotifyService(Application *app, QObject *parent = nullptr);
+ ~SpotifyService() override;
+
+ static const Song::Source kSource;
+ static const char *kApiUrl;
+ static const char *kResourcesUrl;
+
+ void Exit() override;
+ void ReloadSettings() override;
+
+ int Search(const QString &text, InternetSearchView::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_; }
+
+ QSortFilterProxyModel *artists_collection_sort_model() override { return artists_collection_sort_model_; }
+ QSortFilterProxyModel *albums_collection_sort_model() override { return albums_collection_sort_model_; }
+ QSortFilterProxyModel *songs_collection_sort_model() override { return songs_collection_sort_model_; }
+
+ 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());
+
+ static const char *kOAuthAuthorizeUrl;
+ static const char *kOAuthAccessTokenUrl;
+ static const char *kOAuthRedirectUrl;
+ static const char *kAuthUrl;
+ static const char *kClientIDB64;
+ static const char *kClientSecretB64;
+
+ static const char *kArtistsSongsTable;
+ static const char *kAlbumsSongsTable;
+ static const char *kSongsTable;
+
+ static const char *kArtistsSongsFtsTable;
+ static const char *kAlbumsSongsFtsTable;
+ static const char *kSongsFtsTable;
+
+ Application *app_;
+ NetworkAccessManager *network_;
+ SpotifyUrlHandler *url_handler_;
+
+ SharedPtr artists_collection_backend_;
+ SharedPtr albums_collection_backend_;
+ SharedPtr songs_collection_backend_;
+
+ CollectionModel *artists_collection_model_;
+ CollectionModel *albums_collection_model_;
+ CollectionModel *songs_collection_model_;
+
+ QSortFilterProxyModel *artists_collection_sort_model_;
+ QSortFilterProxyModel *albums_collection_sort_model_;
+ QSortFilterProxyModel *songs_collection_sort_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_;
+ InternetSearchView::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