Add Spotify support

This commit is contained in:
Jonas Kvinge
2022-06-04 15:51:35 +02:00
parent f33b30fe79
commit 5f540a4c08
44 changed files with 4486 additions and 346 deletions

View File

@@ -430,6 +430,7 @@ option(INSTALL_TRANSLATIONS "Install translations" OFF)
optional_component(SUBSONIC ON "Streaming: Subsonic") optional_component(SUBSONIC ON "Streaming: Subsonic")
optional_component(TIDAL ON "Streaming: Tidal") optional_component(TIDAL ON "Streaming: Tidal")
optional_component(SPOTIFY ON "Streaming: Spotify" DEPENDS "gstreamer" GSTREAMER_FOUND)
optional_component(QOBUZ ON "Streaming: Qobuz") optional_component(QOBUZ ON "Streaming: Qobuz")
optional_component(MOODBAR ON "Moodbar" optional_component(MOODBAR ON "Moodbar"

View File

@@ -59,7 +59,7 @@ Funding developers is a way to contribute to open source projects you appreciate
* Audio equalizer * Audio equalizer
* Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic * Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
* Scrobbler with support for [Last.fm](https://www.last.fm/), [Libre.fm](https://libre.fm/) and [ListenBrainz](https://listenbrainz.org/) * Scrobbler with support for [Last.fm](https://www.last.fm/), [Libre.fm](https://libre.fm/) and [ListenBrainz](https://listenbrainz.org/)
* Subsonic, Tidal and Qobuz streaming support * Subsonic, Tidal, Spotify and Qobuz streaming support
It has so far been tested to work on Linux, OpenBSD, FreeBSD, macOS and Windows. It has so far been tested to work on Linux, OpenBSD, FreeBSD, macOS and Windows.

View File

@@ -11,6 +11,7 @@
<file>schema/schema-17.sql</file> <file>schema/schema-17.sql</file>
<file>schema/schema-18.sql</file> <file>schema/schema-18.sql</file>
<file>schema/schema-19.sql</file> <file>schema/schema-19.sql</file>
<file>schema/schema-20.sql</file>
<file>schema/device-schema.sql</file> <file>schema/device-schema.sql</file>
<file>style/strawberry.css</file> <file>style/strawberry.css</file>
<file>style/smartplaylistsearchterm.css</file> <file>style/smartplaylistsearchterm.css</file>

View File

@@ -91,6 +91,7 @@
<file>icons/128x128/love.png</file> <file>icons/128x128/love.png</file>
<file>icons/128x128/subsonic.png</file> <file>icons/128x128/subsonic.png</file>
<file>icons/128x128/tidal.png</file> <file>icons/128x128/tidal.png</file>
<file>icons/128x128/spotify.png</file>
<file>icons/128x128/qobuz.png</file> <file>icons/128x128/qobuz.png</file>
<file>icons/128x128/multimedia-player-ipod-standard-black.png</file> <file>icons/128x128/multimedia-player-ipod-standard-black.png</file>
<file>icons/128x128/radio.png</file> <file>icons/128x128/radio.png</file>
@@ -189,6 +190,7 @@
<file>icons/64x64/love.png</file> <file>icons/64x64/love.png</file>
<file>icons/64x64/subsonic.png</file> <file>icons/64x64/subsonic.png</file>
<file>icons/64x64/tidal.png</file> <file>icons/64x64/tidal.png</file>
<file>icons/64x64/spotify.png</file>
<file>icons/64x64/qobuz.png</file> <file>icons/64x64/qobuz.png</file>
<file>icons/64x64/multimedia-player-ipod-standard-black.png</file> <file>icons/64x64/multimedia-player-ipod-standard-black.png</file>
<file>icons/64x64/radio.png</file> <file>icons/64x64/radio.png</file>
@@ -291,6 +293,7 @@
<file>icons/48x48/love.png</file> <file>icons/48x48/love.png</file>
<file>icons/48x48/subsonic.png</file> <file>icons/48x48/subsonic.png</file>
<file>icons/48x48/tidal.png</file> <file>icons/48x48/tidal.png</file>
<file>icons/48x48/spotify.png</file>
<file>icons/48x48/qobuz.png</file> <file>icons/48x48/qobuz.png</file>
<file>icons/48x48/multimedia-player-ipod-standard-black.png</file> <file>icons/48x48/multimedia-player-ipod-standard-black.png</file>
<file>icons/48x48/radio.png</file> <file>icons/48x48/radio.png</file>
@@ -393,6 +396,7 @@
<file>icons/32x32/love.png</file> <file>icons/32x32/love.png</file>
<file>icons/32x32/subsonic.png</file> <file>icons/32x32/subsonic.png</file>
<file>icons/32x32/tidal.png</file> <file>icons/32x32/tidal.png</file>
<file>icons/32x32/spotify.png</file>
<file>icons/32x32/qobuz.png</file> <file>icons/32x32/qobuz.png</file>
<file>icons/32x32/multimedia-player-ipod-standard-black.png</file> <file>icons/32x32/multimedia-player-ipod-standard-black.png</file>
<file>icons/32x32/radio.png</file> <file>icons/32x32/radio.png</file>
@@ -495,6 +499,7 @@
<file>icons/22x22/love.png</file> <file>icons/22x22/love.png</file>
<file>icons/22x22/subsonic.png</file> <file>icons/22x22/subsonic.png</file>
<file>icons/22x22/tidal.png</file> <file>icons/22x22/tidal.png</file>
<file>icons/22x22/spotify.png</file>
<file>icons/22x22/qobuz.png</file> <file>icons/22x22/qobuz.png</file>
<file>icons/22x22/multimedia-player-ipod-standard-black.png</file> <file>icons/22x22/multimedia-player-ipod-standard-black.png</file>
<file>icons/22x22/radio.png</file> <file>icons/22x22/radio.png</file>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
data/icons/full/spotify.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

244
data/schema/schema-20.sql Normal file
View File

@@ -0,0 +1,244 @@
CREATE TABLE IF NOT EXISTS spotify_artists_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS spotify_albums_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS spotify_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
UPDATE schema_version SET version=20;

View File

@@ -422,6 +422,249 @@ CREATE TABLE IF NOT EXISTS tidal_songs (
); );
CREATE TABLE IF NOT EXISTS spotify_artists_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS spotify_albums_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS spotify_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS qobuz_artists_songs ( CREATE TABLE IF NOT EXISTS qobuz_artists_songs (
title TEXT, title TEXT,

View File

@@ -175,7 +175,6 @@ set(SOURCES
covermanager/deezercoverprovider.cpp covermanager/deezercoverprovider.cpp
covermanager/qobuzcoverprovider.cpp covermanager/qobuzcoverprovider.cpp
covermanager/musixmatchcoverprovider.cpp covermanager/musixmatchcoverprovider.cpp
covermanager/spotifycoverprovider.cpp
covermanager/opentidalcoverprovider.cpp covermanager/opentidalcoverprovider.cpp
lyrics/lyricsproviders.cpp lyrics/lyricsproviders.cpp
@@ -425,7 +424,6 @@ set(HEADERS
covermanager/deezercoverprovider.h covermanager/deezercoverprovider.h
covermanager/qobuzcoverprovider.h covermanager/qobuzcoverprovider.h
covermanager/musixmatchcoverprovider.h covermanager/musixmatchcoverprovider.h
covermanager/spotifycoverprovider.h
covermanager/opentidalcoverprovider.h covermanager/opentidalcoverprovider.h
lyrics/lyricsproviders.h lyrics/lyricsproviders.h
@@ -908,6 +906,25 @@ optional_source(HAVE_TIDAL
settings/tidalsettingspage.ui 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 optional_source(HAVE_QOBUZ
SOURCES SOURCES
qobuz/qobuzservice.cpp qobuz/qobuzservice.cpp

View File

@@ -29,6 +29,7 @@
#cmakedefine HAVE_SUBSONIC #cmakedefine HAVE_SUBSONIC
#cmakedefine HAVE_TIDAL #cmakedefine HAVE_TIDAL
#cmakedefine HAVE_SPOTIFY
#cmakedefine HAVE_QOBUZ #cmakedefine HAVE_QOBUZ
#cmakedefine HAVE_MOODBAR #cmakedefine HAVE_MOODBAR

View File

@@ -56,7 +56,6 @@
#include "covermanager/musicbrainzcoverprovider.h" #include "covermanager/musicbrainzcoverprovider.h"
#include "covermanager/deezercoverprovider.h" #include "covermanager/deezercoverprovider.h"
#include "covermanager/musixmatchcoverprovider.h" #include "covermanager/musixmatchcoverprovider.h"
#include "covermanager/spotifycoverprovider.h"
#include "covermanager/opentidalcoverprovider.h" #include "covermanager/opentidalcoverprovider.h"
#include "lyrics/lyricsproviders.h" #include "lyrics/lyricsproviders.h"
@@ -90,6 +89,11 @@
# include "covermanager/tidalcoverprovider.h" # include "covermanager/tidalcoverprovider.h"
#endif #endif
#ifdef HAVE_SPOTIFY
# include "spotify/spotifyservice.h"
# include "covermanager/spotifycoverprovider.h"
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
# include "qobuz/qobuzservice.h" # include "qobuz/qobuzservice.h"
# include "covermanager/qobuzcoverprovider.h" # include "covermanager/qobuzcoverprovider.h"
@@ -143,11 +147,13 @@ class ApplicationImpl {
cover_providers->AddProvider(new DiscogsCoverProvider(app, app->network())); cover_providers->AddProvider(new DiscogsCoverProvider(app, app->network()));
cover_providers->AddProvider(new DeezerCoverProvider(app, app->network())); cover_providers->AddProvider(new DeezerCoverProvider(app, app->network()));
cover_providers->AddProvider(new MusixmatchCoverProvider(app, app->network())); cover_providers->AddProvider(new MusixmatchCoverProvider(app, app->network()));
cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network()));
cover_providers->AddProvider(new OpenTidalCoverProvider(app, app->network())); cover_providers->AddProvider(new OpenTidalCoverProvider(app, app->network()));
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
cover_providers->AddProvider(new TidalCoverProvider(app, app->network())); cover_providers->AddProvider(new TidalCoverProvider(app, app->network()));
#endif #endif
#ifdef HAVE_SPOTIFY
cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network()));
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
cover_providers->AddProvider(new QobuzCoverProvider(app, app->network())); cover_providers->AddProvider(new QobuzCoverProvider(app, app->network()));
#endif #endif
@@ -183,6 +189,9 @@ class ApplicationImpl {
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
streaming_services->AddService(make_shared<TidalService>(app)); streaming_services->AddService(make_shared<TidalService>(app));
#endif #endif
#ifdef HAVE_SPOTIFY
streaming_services->AddService(make_shared<SpotifyService>(app));
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
streaming_services->AddService(make_shared<QobuzService>(app)); streaming_services->AddService(make_shared<QobuzService>(app));
#endif #endif

View File

@@ -49,7 +49,7 @@
#include "sqlquery.h" #include "sqlquery.h"
#include "scopedtransaction.h" #include "scopedtransaction.h"
const int Database::kSchemaVersion = 19; const int Database::kSchemaVersion = 20;
namespace { namespace {
constexpr char kDatabaseFilename[] = "strawberry.db"; constexpr char kDatabaseFilename[] = "strawberry.db";

View File

@@ -178,6 +178,9 @@
# include "tidal/tidalservice.h" # include "tidal/tidalservice.h"
# include "settings/tidalsettingspage.h" # include "settings/tidalsettingspage.h"
#endif #endif
#ifdef HAVE_SPOTIFY
# include "settings/spotifysettingspage.h"
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
# include "settings/qobuzsettingspage.h" # include "settings/qobuzsettingspage.h"
#endif #endif
@@ -308,6 +311,9 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
tidal_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Tidal), QLatin1String(TidalSettingsPage::kSettingsGroup), SettingsDialog::Page::Tidal, this)), tidal_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Tidal), QLatin1String(TidalSettingsPage::kSettingsGroup), SettingsDialog::Page::Tidal, this)),
#endif #endif
#ifdef HAVE_SPOTIFY
spotify_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Spotify), QLatin1String(SpotifySettingsPage::kSettingsGroup), SettingsDialog::Page::Spotify, this)),
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
qobuz_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Qobuz), QLatin1String(QobuzSettingsPage::kSettingsGroup), SettingsDialog::Page::Qobuz, this)), qobuz_view_(new StreamingTabsView(app_, app->streaming_services()->ServiceBySource(Song::Source::Qobuz), QLatin1String(QobuzSettingsPage::kSettingsGroup), SettingsDialog::Page::Qobuz, this)),
#endif #endif
@@ -392,6 +398,9 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
ui_->tabs->AddTab(tidal_view_, QStringLiteral("tidal"), IconLoader::Load(QStringLiteral("tidal"), true, 0, 32), tr("Tidal")); ui_->tabs->AddTab(tidal_view_, QStringLiteral("tidal"), IconLoader::Load(QStringLiteral("tidal"), true, 0, 32), tr("Tidal"));
#endif #endif
#ifdef HAVE_SPOTIFY
ui_->tabs->AddTab(spotify_view_, QLatin1String("spotify"), IconLoader::Load(QStringLiteral("spotify"), true, 0, 32), tr("Spotify"));
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
ui_->tabs->AddTab(qobuz_view_, QStringLiteral("qobuz"), IconLoader::Load(QStringLiteral("qobuz"), true, 0, 32), tr("Qobuz")); ui_->tabs->AddTab(qobuz_view_, QStringLiteral("qobuz"), IconLoader::Load(QStringLiteral("qobuz"), true, 0, 32), tr("Qobuz"));
#endif #endif
@@ -714,6 +723,13 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(qobuz_view_->search_view(), &StreamingSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist); QObject::connect(qobuz_view_->search_view(), &StreamingSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
#endif #endif
#ifdef HAVE_SPOTIFY
QObject::connect(spotify_view_->artists_collection_view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
QObject::connect(spotify_view_->albums_collection_view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
QObject::connect(spotify_view_->songs_collection_view(), &StreamingCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
QObject::connect(spotify_view_->search_view(), &StreamingSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
#endif
QObject::connect(radio_view_, &RadioViewContainer::Refresh, &*app_->radio_services(), &RadioServices::RefreshChannels); QObject::connect(radio_view_, &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::GetChannels, &*app_->radio_services(), &RadioServices::GetChannels);
QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist); QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
@@ -1178,6 +1194,18 @@ void MainWindow::ReloadSettings() {
} }
#endif #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 #ifdef HAVE_QOBUZ
s.beginGroup(QobuzSettingsPage::kSettingsGroup); s.beginGroup(QobuzSettingsPage::kSettingsGroup);
bool enable_qobuz = s.value("enabled", false).toBool(); bool enable_qobuz = s.value("enabled", false).toBool();
@@ -1226,6 +1254,9 @@ void MainWindow::ReloadAllSettings() {
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
tidal_view_->ReloadSettings(); tidal_view_->ReloadSettings();
#endif #endif
#ifdef HAVE_SPOTIFY
spotify_view_->ReloadSettings();
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
qobuz_view_->ReloadSettings(); qobuz_view_->ReloadSettings();
#endif #endif
@@ -3284,6 +3315,11 @@ void MainWindow::FocusSearchField() {
tidal_view_->FocusSearchField(); tidal_view_->FocusSearchField();
} }
#endif #endif
#ifdef HAVE_SPOTIFY
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(spotify_view_) && !spotify_view_->SearchFieldHasFocus()) {
spotify_view_->FocusSearchField();
}
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(qobuz_view_) && !qobuz_view_->SearchFieldHasFocus()) { else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(qobuz_view_) && !qobuz_view_->SearchFieldHasFocus()) {
qobuz_view_->FocusSearchField(); qobuz_view_->FocusSearchField();

View File

@@ -341,9 +341,18 @@ class MainWindow : public QMainWindow, public PlatformInterface {
SmartPlaylistsViewContainer *smartplaylists_view_; SmartPlaylistsViewContainer *smartplaylists_view_;
#ifdef HAVE_SUBSONIC
StreamingSongsView *subsonic_view_; StreamingSongsView *subsonic_view_;
#endif
#ifdef HAVE_TIDAL
StreamingTabsView *tidal_view_; StreamingTabsView *tidal_view_;
#endif
#ifdef HAVE_SPOTIFY
StreamingTabsView *spotify_view_;
#endif
#ifdef HAVE_QOBUZ
StreamingTabsView *qobuz_view_; StreamingTabsView *qobuz_view_;
#endif
RadioViewContainer *radio_view_; RadioViewContainer *radio_view_;

View File

@@ -565,11 +565,11 @@ const QString &Song::playlist_albumartist_sortable() const { return is_compilati
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); } bool Song::is_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_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_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_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::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::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(); } bool Song::has_cue() const { return !d->cue_path_.isEmpty(); }
@@ -938,11 +938,13 @@ Song::Source Song::SourceFromURL(const QUrl &url) {
if (url.isLocalFile()) return Source::LocalFile; if (url.isLocalFile()) return Source::LocalFile;
if (url.scheme() == QStringLiteral("cdda")) return Source::CDDA; if (url.scheme() == QStringLiteral("cdda")) return Source::CDDA;
if (url.scheme() == QStringLiteral("tidal")) return Source::Tidal;
if (url.scheme() == QStringLiteral("subsonic")) return Source::Subsonic; if (url.scheme() == QStringLiteral("subsonic")) return Source::Subsonic;
if (url.scheme() == QStringLiteral("tidal")) return Source::Tidal;
if (url.scheme() == QStringLiteral("spotify")) return Source::Spotify;
if (url.scheme() == QStringLiteral("qobuz")) return Source::Qobuz; if (url.scheme() == QStringLiteral("qobuz")) return Source::Qobuz;
if (url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https") || url.scheme() == QStringLiteral("rtsp")) { if (url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https") || url.scheme() == QStringLiteral("rtsp")) {
if (url.host().endsWith(QLatin1String("tidal.com"), Qt::CaseInsensitive)) { return Source::Tidal; } if (url.host().endsWith(QLatin1String("tidal.com"), Qt::CaseInsensitive)) { return Source::Tidal; }
if (url.host().endsWith(QLatin1String("spotify.com"), Qt::CaseInsensitive)) { return Source::Spotify; }
if (url.host().endsWith(QLatin1String("qobuz.com"), Qt::CaseInsensitive)) { return Source::Qobuz; } if (url.host().endsWith(QLatin1String("qobuz.com"), Qt::CaseInsensitive)) { return Source::Qobuz; }
if (url.host().endsWith(QLatin1String("somafm.com"), Qt::CaseInsensitive)) { return Source::SomaFM; } if (url.host().endsWith(QLatin1String("somafm.com"), Qt::CaseInsensitive)) { return Source::SomaFM; }
if (url.host().endsWith(QLatin1String("radioparadise.com"), Qt::CaseInsensitive)) { return Source::RadioParadise; } if (url.host().endsWith(QLatin1String("radioparadise.com"), Qt::CaseInsensitive)) { return Source::RadioParadise; }
@@ -960,8 +962,9 @@ QString Song::TextForSource(const Source source) {
case Source::CDDA: return QStringLiteral("cd"); case Source::CDDA: return QStringLiteral("cd");
case Source::Device: return QStringLiteral("device"); case Source::Device: return QStringLiteral("device");
case Source::Stream: return QStringLiteral("stream"); case Source::Stream: return QStringLiteral("stream");
case Source::Tidal: return QStringLiteral("tidal");
case Source::Subsonic: return QStringLiteral("subsonic"); case Source::Subsonic: return QStringLiteral("subsonic");
case Source::Tidal: return QStringLiteral("tidal");
case Source::Spotify: return QStringLiteral("spotify");
case Source::Qobuz: return QStringLiteral("qobuz"); case Source::Qobuz: return QStringLiteral("qobuz");
case Source::SomaFM: return QStringLiteral("somafm"); case Source::SomaFM: return QStringLiteral("somafm");
case Source::RadioParadise: return QStringLiteral("radioparadise"); case Source::RadioParadise: return QStringLiteral("radioparadise");
@@ -979,8 +982,9 @@ QString Song::DescriptionForSource(const Source source) {
case Source::CDDA: return QStringLiteral("CD"); case Source::CDDA: return QStringLiteral("CD");
case Source::Device: return QStringLiteral("Device"); case Source::Device: return QStringLiteral("Device");
case Source::Stream: return QStringLiteral("Stream"); case Source::Stream: return QStringLiteral("Stream");
case Source::Tidal: return QStringLiteral("Tidal");
case Source::Subsonic: return QStringLiteral("Subsonic"); case Source::Subsonic: return QStringLiteral("Subsonic");
case Source::Tidal: return QStringLiteral("Tidal");
case Source::Spotify: return QStringLiteral("Spotify");
case Source::Qobuz: return QStringLiteral("Qobuz"); case Source::Qobuz: return QStringLiteral("Qobuz");
case Source::SomaFM: return QStringLiteral("SomaFM"); case Source::SomaFM: return QStringLiteral("SomaFM");
case Source::RadioParadise: return QStringLiteral("Radio Paradise"); case Source::RadioParadise: return QStringLiteral("Radio Paradise");
@@ -997,8 +1001,9 @@ Song::Source Song::SourceFromText(const QString &source) {
if (source.compare(QLatin1String("cd"), Qt::CaseInsensitive) == 0) return Source::CDDA; if (source.compare(QLatin1String("cd"), Qt::CaseInsensitive) == 0) return Source::CDDA;
if (source.compare(QLatin1String("device"), Qt::CaseInsensitive) == 0) return Source::Device; if (source.compare(QLatin1String("device"), Qt::CaseInsensitive) == 0) return Source::Device;
if (source.compare(QLatin1String("stream"), Qt::CaseInsensitive) == 0) return Source::Stream; if (source.compare(QLatin1String("stream"), Qt::CaseInsensitive) == 0) return Source::Stream;
if (source.compare(QLatin1String("tidal"), Qt::CaseInsensitive) == 0) return Source::Tidal;
if (source.compare(QLatin1String("subsonic"), Qt::CaseInsensitive) == 0) return Source::Subsonic; if (source.compare(QLatin1String("subsonic"), Qt::CaseInsensitive) == 0) return Source::Subsonic;
if (source.compare(QLatin1String("tidal"), Qt::CaseInsensitive) == 0) return Source::Tidal;
if (source.compare(QLatin1String("spotify"), Qt::CaseInsensitive) == 0) return Source::Spotify;
if (source.compare(QLatin1String("qobuz"), Qt::CaseInsensitive) == 0) return Source::Qobuz; if (source.compare(QLatin1String("qobuz"), Qt::CaseInsensitive) == 0) return Source::Qobuz;
if (source.compare(QLatin1String("somafm"), Qt::CaseInsensitive) == 0) return Source::SomaFM; if (source.compare(QLatin1String("somafm"), Qt::CaseInsensitive) == 0) return Source::SomaFM;
if (source.compare(QLatin1String("radioparadise"), Qt::CaseInsensitive) == 0) return Source::RadioParadise; if (source.compare(QLatin1String("radioparadise"), Qt::CaseInsensitive) == 0) return Source::RadioParadise;
@@ -1015,8 +1020,9 @@ QIcon Song::IconForSource(const Source source) {
case Source::CDDA: return IconLoader::Load(QStringLiteral("media-optical")); case Source::CDDA: return IconLoader::Load(QStringLiteral("media-optical"));
case Source::Device: return IconLoader::Load(QStringLiteral("device")); case Source::Device: return IconLoader::Load(QStringLiteral("device"));
case Source::Stream: return IconLoader::Load(QStringLiteral("applications-internet")); case Source::Stream: return IconLoader::Load(QStringLiteral("applications-internet"));
case Source::Tidal: return IconLoader::Load(QStringLiteral("tidal"));
case Source::Subsonic: return IconLoader::Load(QStringLiteral("subsonic")); case Source::Subsonic: return IconLoader::Load(QStringLiteral("subsonic"));
case Source::Tidal: return IconLoader::Load(QStringLiteral("tidal"));
case Source::Spotify: return IconLoader::Load(QStringLiteral("spotify"));
case Source::Qobuz: return IconLoader::Load(QStringLiteral("qobuz")); case Source::Qobuz: return IconLoader::Load(QStringLiteral("qobuz"));
case Source::SomaFM: return IconLoader::Load(QStringLiteral("somafm")); case Source::SomaFM: return IconLoader::Load(QStringLiteral("somafm"));
case Source::RadioParadise: return IconLoader::Load(QStringLiteral("radioparadise")); case Source::RadioParadise: return IconLoader::Load(QStringLiteral("radioparadise"));
@@ -1237,6 +1243,8 @@ QString Song::ImageCacheDir(const Source source) {
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/subsonicalbumcovers"); return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/subsonicalbumcovers");
case Source::Tidal: case Source::Tidal:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/tidalalbumcovers"); return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/tidalalbumcovers");
case Source::Spotify:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/spotifyalbumcovers");
case Source::Qobuz: case Source::Qobuz:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/qobuzalbumcovers"); return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + QStringLiteral("/qobuzalbumcovers");
case Source::Device: case Source::Device:

View File

@@ -77,7 +77,8 @@ class Song {
Subsonic = 7, Subsonic = 7,
Qobuz = 8, Qobuz = 8,
SomaFM = 9, 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. // Don't change these values - they're stored in the database, and defined in the tag reader protobuf.

View File

@@ -575,9 +575,10 @@ void AlbumCoverChoiceController::SaveArtManualToSong(Song *song, const QUrl &art
case Song::Source::SomaFM: case Song::Source::SomaFM:
case Song::Source::Unknown: case Song::Source::Unknown:
break; break;
case Song::Source::Tidal:
case Song::Source::Qobuz:
case Song::Source::Subsonic: case Song::Source::Subsonic:
case Song::Source::Tidal:
case Song::Source::Spotify:
case Song::Source::Qobuz:
StreamingServicePtr service = app_->streaming_services()->ServiceBySource(song->source()); StreamingServicePtr service = app_->streaming_services()->ServiceBySource(song->source());
if (!service) break; if (!service) break;
if (service->artists_collection_backend()) { if (service->artists_collection_backend()) {

View File

@@ -1,6 +1,6 @@
/* /*
* Strawberry Music Player * Strawberry Music Player
* Copyright 2020-2021, Jonas Kvinge <jonas@jkvinge.net> * Copyright 2020-2024, Jonas Kvinge <jonas@jkvinge.net>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -29,11 +29,8 @@
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
#include <QUrlQuery> #include <QUrlQuery>
#include <QDateTime>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QNetworkReply> #include <QNetworkReply>
#include <QSslError>
#include <QCryptographicHash>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonValue> #include <QJsonValue>
#include <QJsonObject> #include <QJsonObject>
@@ -49,46 +46,20 @@
#include "core/localredirectserver.h" #include "core/localredirectserver.h"
#include "utilities/randutils.h" #include "utilities/randutils.h"
#include "utilities/timeconstants.h" #include "utilities/timeconstants.h"
#include "streaming/streamingservices.h"
#include "spotify/spotifyservice.h"
#include "albumcoverfetcher.h" #include "albumcoverfetcher.h"
#include "jsoncoverprovider.h" #include "jsoncoverprovider.h"
#include "spotifycoverprovider.h" #include "spotifycoverprovider.h"
namespace { namespace {
constexpr char kSettingsGroup[] = "Spotify";
constexpr char kOAuthAuthorizeUrl[] = "https://accounts.spotify.com/authorize";
constexpr char kOAuthAccessTokenUrl[] = "https://accounts.spotify.com/api/token";
constexpr char kOAuthRedirectUrl[] = "http://localhost:63111/";
constexpr char kClientIDB64[] = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
constexpr char kClientSecretB64[] = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
constexpr char kApiUrl[] = "https://api.spotify.com/v1"; constexpr char kApiUrl[] = "https://api.spotify.com/v1";
constexpr int kLimit = 10; constexpr int kLimit = 10;
} // namespace } // namespace
SpotifyCoverProvider::SpotifyCoverProvider(Application *app, SharedPtr<NetworkAccessManager> network, QObject *parent) SpotifyCoverProvider::SpotifyCoverProvider(Application *app, SharedPtr<NetworkAccessManager> network, QObject *parent)
: JsonCoverProvider(QStringLiteral("Spotify"), true, true, 2.5, true, true, app, network, parent), : JsonCoverProvider(QStringLiteral("Spotify"), true, true, 2.5, true, true, app, network, parent),
server_(nullptr), service_(app->streaming_services()->Service<SpotifyService>()) {}
expires_in_(0),
login_time_(0) {
refresh_login_timer_.setSingleShot(true);
QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyCoverProvider::RequestNewAccessToken);
Settings s;
s.beginGroup(kSettingsGroup);
access_token_ = s.value("access_token").toString();
refresh_token_ = s.value("refresh_token").toString();
expires_in_ = s.value("expires_in").toLongLong();
login_time_ = s.value("login_time").toLongLong();
s.endGroup();
if (!refresh_token_.isEmpty()) {
qint64 time = static_cast<qint64>(expires_in_) - (QDateTime::currentDateTime().toSecsSinceEpoch() - static_cast<qint64>(login_time_));
if (time < 1) time = 1;
refresh_login_timer_.setInterval(static_cast<int>(time * kMsecPerSec));
refresh_login_timer_.start();
}
}
SpotifyCoverProvider::~SpotifyCoverProvider() { SpotifyCoverProvider::~SpotifyCoverProvider() {
@@ -101,265 +72,9 @@ SpotifyCoverProvider::~SpotifyCoverProvider() {
} }
void SpotifyCoverProvider::Authenticate() {
QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
if (!server_) {
server_ = new LocalRedirectServer(this);
int port = redirect_url.port();
int port_max = port + 10;
bool success = false;
forever {
server_->set_port(port);
if (server_->Listen()) {
success = true;
break;
}
++port;
if (port > port_max) break;
}
if (!success) {
AuthError(server_->error());
server_->deleteLater();
server_ = nullptr;
return;
}
QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyCoverProvider::RedirectArrived);
}
code_verifier_ = Utilities::CryptographicRandomString(44);
code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
if (code_challenge_.lastIndexOf(QLatin1Char('=')) == code_challenge_.length() - 1) {
code_challenge_.chop(1);
}
const ParamList params = ParamList() << Param(QStringLiteral("client_id"), QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
<< Param(QStringLiteral("response_type"), QStringLiteral("code"))
<< Param(QStringLiteral("redirect_uri"), redirect_url.toString())
<< Param(QStringLiteral("state"), code_challenge_);
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl url(QString::fromLatin1(kOAuthAuthorizeUrl));
url.setQuery(url_query);
const bool result = QDesktopServices::openUrl(url);
if (!result) {
QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox.setTextFormat(Qt::RichText);
messagebox.exec();
}
}
void SpotifyCoverProvider::Deauthenticate() {
access_token_.clear();
refresh_token_.clear();
expires_in_ = 0;
login_time_ = 0;
Settings s;
s.beginGroup(kSettingsGroup);
s.remove("access_token");
s.remove("refresh_token");
s.remove("expires_in");
s.remove("login_time");
s.endGroup();
refresh_login_timer_.stop();
}
void SpotifyCoverProvider::RedirectArrived() {
if (!server_) return;
if (server_->error().isEmpty()) {
QUrl url = server_->request_url();
if (url.isValid()) {
QUrlQuery url_query(url);
if (url_query.hasQueryItem(QStringLiteral("error"))) {
AuthError(QUrlQuery(url).queryItemValue(QStringLiteral("error")));
}
else if (url_query.hasQueryItem(QStringLiteral("code")) && url_query.hasQueryItem(QStringLiteral("state"))) {
qLog(Debug) << "Spotify: Authorization URL Received" << url;
QString code = url_query.queryItemValue(QStringLiteral("code"));
QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
redirect_url.setPort(server_->url().port());
RequestAccessToken(code, redirect_url);
}
else {
AuthError(tr("Redirect missing token code or state!"));
}
}
else {
AuthError(tr("Received invalid reply from web browser."));
}
}
else {
AuthError(server_->error());
}
server_->close();
server_->deleteLater();
server_ = nullptr;
}
void SpotifyCoverProvider::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
refresh_login_timer_.stop();
ParamList params = ParamList() << Param(QStringLiteral("client_id"), QLatin1String(kClientIDB64))
<< Param(QStringLiteral("client_secret"), QLatin1String(kClientSecretB64));
if (!code.isEmpty() && !redirect_url.isEmpty()) {
params << Param(QStringLiteral("grant_type"), QStringLiteral("authorization_code"));
params << Param(QStringLiteral("code"), code);
params << Param(QStringLiteral("redirect_uri"), redirect_url.toString());
}
else if (!refresh_token_.isEmpty() && is_enabled()) {
params << Param(QStringLiteral("grant_type"), QStringLiteral("refresh_token"));
params << Param(QStringLiteral("refresh_token"), refresh_token_);
}
else {
return;
}
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl new_url(QString::fromLatin1(kOAuthAccessTokenUrl));
QNetworkRequest req(new_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
QString auth_header_data = QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)) + QLatin1Char(':') + QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64));
req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64());
QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
QNetworkReply *reply = network_->post(req, query);
replies_ << reply;
QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyCoverProvider::HandleLoginSSLErrors);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); });
}
void SpotifyCoverProvider::HandleLoginSSLErrors(const QList<QSslError> &ssl_errors) {
for (const QSslError &ssl_error : ssl_errors) {
login_errors_ += ssl_error.errorString();
}
}
void SpotifyCoverProvider::AccessTokenRequestFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
AuthError(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
return;
}
else {
// See if there is Json data containing "error" and "error_description" then use that instead.
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains(QLatin1String("error")) && json_obj.contains(QLatin1String("error_description"))) {
QString error = json_obj[QLatin1String("error")].toString();
QString error_description = json_obj[QLatin1String("error_description")].toString();
login_errors_ << QStringLiteral("Authentication failure: %1 (%2)").arg(error, error_description);
}
}
if (login_errors_.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
login_errors_ << QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
login_errors_ << QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
AuthError();
return;
}
}
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error(QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
return;
}
if (json_doc.isEmpty()) {
AuthError(QStringLiteral("Authentication reply from server has empty Json document."));
return;
}
if (!json_doc.isObject()) {
AuthError(QStringLiteral("Authentication reply from server has Json document that is not an object."), json_doc);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
AuthError(QStringLiteral("Authentication reply from server has empty Json object."), json_doc);
return;
}
if (!json_obj.contains(QLatin1String("access_token")) || !json_obj.contains(QLatin1String("expires_in"))) {
AuthError(QStringLiteral("Authentication reply from server is missing access token or expires in."), json_obj);
return;
}
access_token_ = json_obj[QLatin1String("access_token")].toString();
if (json_obj.contains(QLatin1String("refresh_token"))) {
refresh_token_ = json_obj[QLatin1String("refresh_token")].toString();
}
expires_in_ = json_obj[QLatin1String("expires_in")].toInt();
login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("access_token", access_token_);
s.setValue("refresh_token", refresh_token_);
s.setValue("expires_in", expires_in_);
s.setValue("login_time", login_time_);
s.endGroup();
if (expires_in_ > 0) {
refresh_login_timer_.setInterval(static_cast<int>(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) { 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; if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
@@ -395,7 +110,7 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb
QNetworkRequest req(url); QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded")); req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
req.setRawHeader("Authorization", "Bearer " + access_token_.toUtf8()); req.setRawHeader("Authorization", "Bearer " + service_->access_token().toUtf8());
QNetworkReply *reply = network_->get(req); QNetworkReply *reply = network_->get(req);
replies_ << reply; replies_ << reply;
@@ -432,13 +147,13 @@ QByteArray SpotifyCoverProvider::GetReplyData(QNetworkReply *reply) {
int status = obj_error[QLatin1String("status")].toInt(); int status = obj_error[QLatin1String("status")].toInt();
QString message = obj_error[QLatin1String("message")].toString(); QString message = obj_error[QLatin1String("message")].toString();
error = QStringLiteral("%1 (%2)").arg(message).arg(status); error = QStringLiteral("%1 (%2)").arg(message).arg(status);
if (status == 401) access_token_.clear(); if (status == 401) Deauthenticate();
} }
} }
} }
if (error.isEmpty()) { if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) { if (reply->error() != QNetworkReply::NoError) {
if (reply->error() == 204) access_token_.clear(); if (reply->error() == 204) Deauthenticate();
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()); error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
} }
else { else {
@@ -541,20 +256,6 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id,
} }
void SpotifyCoverProvider::AuthError(const QString &error, const QVariant &debug) {
if (!error.isEmpty()) login_errors_ << error;
for (const QString &e : login_errors_) Error(e);
if (debug.isValid()) qLog(Debug) << debug;
emit AuthenticationFailure(login_errors_);
emit AuthenticationComplete(false, login_errors_);
login_errors_.clear();
}
void SpotifyCoverProvider::Error(const QString &error, const QVariant &debug) { void SpotifyCoverProvider::Error(const QString &error, const QVariant &debug) {
qLog(Error) << "Spotify:" << error; qLog(Error) << "Spotify:" << error;

View File

@@ -1,6 +1,6 @@
/* /*
* Strawberry Music Player * Strawberry Music Player
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net> * Copyright 2018-2024, Jonas Kvinge <jonas@jkvinge.net>
* *
* Strawberry is free software: you can redistribute it and/or modify * Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@@ -36,11 +36,11 @@
#include "core/shared_ptr.h" #include "core/shared_ptr.h"
#include "jsoncoverprovider.h" #include "jsoncoverprovider.h"
#include "spotify/spotifyservice.h"
class QNetworkReply; class QNetworkReply;
class Application; class Application;
class NetworkAccessManager; class NetworkAccessManager;
class LocalRedirectServer;
class SpotifyCoverProvider : public JsonCoverProvider { class SpotifyCoverProvider : public JsonCoverProvider {
Q_OBJECT Q_OBJECT
@@ -52,33 +52,20 @@ class SpotifyCoverProvider : public JsonCoverProvider {
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override; bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
void CancelSearch(const int id) override; void CancelSearch(const int id) override;
void Authenticate() override; bool IsAuthenticated() const override { return service_ && service_->authenticated(); }
void Deauthenticate() override; void Deauthenticate() override {
bool IsAuthenticated() const override { return !access_token_.isEmpty(); } if (service_) service_->Deauthenticate();
}
private slots: private slots:
void HandleLoginSSLErrors(const QList<QSslError> &ssl_errors);
void RedirectArrived();
void AccessTokenRequestFinished(QNetworkReply *reply);
void HandleSearchReply(QNetworkReply *reply, const int id, const QString &extract); void HandleSearchReply(QNetworkReply *reply, const int id, const QString &extract);
void RequestNewAccessToken() { RequestAccessToken(); }
private: private:
QByteArray GetReplyData(QNetworkReply *reply); 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 Error(const QString &error, const QVariant &debug = QVariant()) override;
void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl());
private: private:
LocalRedirectServer *server_; SharedPtr<SpotifyService> service_;
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_;
QList<QNetworkReply*> replies_; QList<QNetworkReply*> replies_;
}; };

View File

@@ -37,6 +37,9 @@
#include "enginebase.h" #include "enginebase.h"
#include "settings/backendsettingspage.h" #include "settings/backendsettingspage.h"
#include "settings/networkproxysettingspage.h" #include "settings/networkproxysettingspage.h"
#ifdef HAVE_SPOTIFY
# include "settings/spotifysettingspage.h"
#endif
EngineBase::EngineBase(QObject *parent) EngineBase::EngineBase(QObject *parent)
: QObject(parent), : QObject(parent),
@@ -239,6 +242,15 @@ void EngineBase::ReloadSettings() {
s.endGroup(); 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() { void EngineBase::EmitAboutToFinish() {

View File

@@ -247,6 +247,12 @@ class EngineBase : public QObject {
bool http2_enabled_; bool http2_enabled_;
bool strict_ssl_enabled_; bool strict_ssl_enabled_;
// Spotify
#ifdef HAVE_SPOTIFY
QString spotify_username_;
QString spotify_password_;
#endif
bool about_to_end_emitted_; bool about_to_end_emitted_;
Q_DISABLE_COPY(EngineBase) Q_DISABLE_COPY(EngineBase)

View File

@@ -173,7 +173,7 @@ void GstEngine::StartPreloading(const QUrl &media_url, const QUrl &stream_url, c
if (current_pipeline_) { if (current_pipeline_) {
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_nanosec, force_stop_at_end ? end_nanosec : 0); current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_nanosec, force_stop_at_end ? end_nanosec : 0);
// Add request to discover the stream // Add request to discover the stream
if (discoverer_) { if (discoverer_ && media_url.scheme() != QStringLiteral("spotify")) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) { if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url; qLog(Error) << "Failed to start stream discovery for" << gst_url;
} }
@@ -230,7 +230,7 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
} }
// Add request to discover the stream // Add request to discover the stream
if (discoverer_) { if (discoverer_ && media_url.scheme() != QStringLiteral("spotify")) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) { if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url; qLog(Error) << "Failed to start stream discovery for" << gst_url;
} }
@@ -816,6 +816,10 @@ SharedPtr<GstEnginePipeline> GstEngine::CreatePipeline() {
ret->set_strict_ssl_enabled(strict_ssl_enabled_); ret->set_strict_ssl_enabled(strict_ssl_enabled_);
ret->set_fading_enabled(fadeout_enabled_ || autocrossfade_enabled_ || fadeout_pause_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); ret->AddBufferConsumer(this);
for (GstBufferConsumer *consumer : std::as_const(buffer_consumers_)) { for (GstBufferConsumer *consumer : std::as_const(buffer_consumers_)) {
ret->AddBufferConsumer(consumer); ret->AddBufferConsumer(consumer);

View File

@@ -300,6 +300,15 @@ void GstEnginePipeline::set_fading_enabled(const bool enabled) {
fading_enabled_ = 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) { QString GstEnginePipeline::GstStateText(const GstState state) {
switch (state) { switch (state) {
@@ -996,6 +1005,17 @@ void GstEnginePipeline::SourceSetupCallback(GstElement *playbin, GstElement *sou
} }
} }
#ifdef HAVE_SPOTIFY
if (instance->media_url_.scheme() == QStringLiteral("spotify") &&
!instance->spotify_username_.isEmpty() &&
!instance->spotify_password_.isEmpty() &&
g_object_class_find_property(G_OBJECT_GET_CLASS(source), "username") &&
g_object_class_find_property(G_OBJECT_GET_CLASS(source), "password")) {
g_object_set(source, "username", instance->spotify_username_.toUtf8().constData(), nullptr);
g_object_set(source, "password", instance->spotify_password_.toUtf8().constData(), nullptr);
}
#endif
// If the pipeline was buffering we stop that now. // If the pipeline was buffering we stop that now.
if (instance->buffering_) { if (instance->buffering_) {
instance->buffering_ = false; instance->buffering_ = false;

View File

@@ -76,6 +76,9 @@ class GstEnginePipeline : public QObject {
void set_bs2b_enabled(const bool enabled); void set_bs2b_enabled(const bool enabled);
void set_strict_ssl_enabled(const bool enabled); void set_strict_ssl_enabled(const bool enabled);
void set_fading_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 // 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); bool InitFromUrl(const QUrl &media_url, const QUrl &stream_url, const QByteArray &gst_url, const qint64 end_nanosec, const double ebur128_loudness_normalizing_gain_db, QString &error);
@@ -249,6 +252,12 @@ class GstEnginePipeline : public QObject {
bool bs2b_enabled_; bool bs2b_enabled_;
bool strict_ssl_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 // These get called when there is a new audio buffer available
QList<GstBufferConsumer*> buffer_consumers_; QList<GstBufferConsumer*> buffer_consumers_;
QMutex buffer_consumers_mutex_; QMutex buffer_consumers_mutex_;

View File

@@ -46,6 +46,7 @@ PlaylistItemPtr PlaylistItem::NewFromSource(const Song::Source source) {
return make_shared<CollectionPlaylistItem>(); return make_shared<CollectionPlaylistItem>();
case Song::Source::Subsonic: case Song::Source::Subsonic:
case Song::Source::Tidal: case Song::Source::Tidal:
case Song::Source::Spotify:
case Song::Source::Qobuz: case Song::Source::Qobuz:
return make_shared<StreamPlaylistItem>(source); return make_shared<StreamPlaylistItem>(source);
case Song::Source::Stream: case Song::Source::Stream:
@@ -70,6 +71,7 @@ PlaylistItemPtr PlaylistItem::NewFromSong(const Song &song) {
return make_shared<CollectionPlaylistItem>(song); return make_shared<CollectionPlaylistItem>(song);
case Song::Source::Subsonic: case Song::Source::Subsonic:
case Song::Source::Tidal: case Song::Source::Tidal:
case Song::Source::Spotify:
case Song::Source::Qobuz: case Song::Source::Qobuz:
return make_shared<StreamPlaylistItem>(song); return make_shared<StreamPlaylistItem>(song);
case Song::Source::Stream: case Song::Source::Stream:

View File

@@ -226,6 +226,10 @@ void CoversSettingsPage::ProvidersCurrentItemChanged(QListWidgetItem *item_curre
DisableAuthentication(); DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate.")); ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate."));
} }
else if (provider->name() == QLatin1String("Spotify") && !provider->IsAuthenticated()) {
DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Spotify settings to authenticate."));
}
else if (provider->name() == QLatin1String("Qobuz") && !provider->IsAuthenticated()) { else if (provider->name() == QLatin1String("Qobuz") && !provider->IsAuthenticated()) {
DisableAuthentication(); DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate.")); ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate."));
@@ -339,6 +343,10 @@ void CoversSettingsPage::LogoutClicked() {
DisableAuthentication(); DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate.")); ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate."));
} }
else if (provider->name() == QLatin1String("Spotify")) {
DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Spotify settings to authenticate."));
}
else if (provider->name() == QLatin1String("Qobuz")) { else if (provider->name() == QLatin1String("Qobuz")) {
DisableAuthentication(); DisableAuthentication();
ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate.")); ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate."));

View File

@@ -78,6 +78,9 @@
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
# include "tidalsettingspage.h" # include "tidalsettingspage.h"
#endif #endif
#ifdef HAVE_SPOTIFY
# include "spotifysettingspage.h"
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
# include "qobuzsettingspage.h" # include "qobuzsettingspage.h"
#endif #endif
@@ -155,7 +158,7 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main
AddPage(Page::Moodbar, new MoodbarSettingsPage(this, this), iface); AddPage(Page::Moodbar, new MoodbarSettingsPage(this, this), iface);
#endif #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")); QTreeWidgetItem *streaming = AddCategory(tr("Streaming"));
#endif #endif
@@ -165,6 +168,9 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main
#ifdef HAVE_TIDAL #ifdef HAVE_TIDAL
AddPage(Page::Tidal, new TidalSettingsPage(this, this), streaming); AddPage(Page::Tidal, new TidalSettingsPage(this, this), streaming);
#endif #endif
#ifdef HAVE_SPOTIFY
AddPage(Page::Spotify, new SpotifySettingsPage(this, this), streaming);
#endif
#ifdef HAVE_QOBUZ #ifdef HAVE_QOBUZ
AddPage(Page::Qobuz, new QobuzSettingsPage(this, this), streaming); AddPage(Page::Qobuz, new QobuzSettingsPage(this, this), streaming);
#endif #endif

View File

@@ -92,6 +92,7 @@ class SettingsDialog : public QDialog {
Subsonic, Subsonic,
Tidal, Tidal,
Qobuz, Qobuz,
Spotify,
}; };
enum Role { enum Role {

View File

@@ -0,0 +1,170 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <gst/gst.h>
#include <gst/pbutils/pbutils.h>
#include <QObject>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QSettings>
#include <QCheckBox>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QSpinBox>
#include <QMessageBox>
#include <QEvent>
#include "settingsdialog.h"
#include "spotifysettingspage.h"
#include "ui_spotifysettingspage.h"
#include "core/application.h"
#include "core/iconloader.h"
#include "core/settings.h"
#include "streaming/streamingservices.h"
#include "spotify/spotifyservice.h"
#include "widgets/loginstatewidget.h"
const char *SpotifySettingsPage::kSettingsGroup = "Spotify";
SpotifySettingsPage::SpotifySettingsPage(SettingsDialog *dialog, QWidget *parent)
: SettingsPage(dialog, parent),
ui_(new Ui::SpotifySettingsPage),
service_(dialog->app()->streaming_services()->Service<SpotifyService>()) {
ui_->setupUi(this);
setWindowIcon(IconLoader::Load(QStringLiteral("spotify")));
QObject::connect(ui_->button_login, &QPushButton::clicked, this, &SpotifySettingsPage::LoginClicked);
QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &SpotifySettingsPage::LogoutClicked);
QObject::connect(this, &SpotifySettingsPage::Authorize, &*service_, &SpotifyService::Authenticate);
QObject::connect(&*service_, &StreamingService::LoginFailure, this, &SpotifySettingsPage::LoginFailure);
QObject::connect(&*service_, &StreamingService::LoginSuccess, this, &SpotifySettingsPage::LoginSuccess);
dialog->installEventFilter(this);
GstRegistry *reg = gst_registry_get();
if (reg) {
GstPluginFeature *spotifyaudiosrc = gst_registry_lookup_feature(reg, "spotifyaudiosrc");
if (spotifyaudiosrc) {
ui_->widget_warning->hide();
}
else {
ui_->widget_warning->show();
}
}
}
SpotifySettingsPage::~SpotifySettingsPage() { delete ui_; }
void SpotifySettingsPage::Load() {
Settings s;
s.beginGroup(kSettingsGroup);
ui_->enable->setChecked(s.value("enabled", false).toBool());
ui_->username->setText(s.value("username").toString());
QByteArray password = s.value("password").toByteArray();
if (password.isEmpty()) ui_->password->clear();
else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password)));
ui_->searchdelay->setValue(s.value("searchdelay", 1500).toInt());
ui_->artistssearchlimit->setValue(s.value("artistssearchlimit", 4).toInt());
ui_->albumssearchlimit->setValue(s.value("albumssearchlimit", 10).toInt());
ui_->songssearchlimit->setValue(s.value("songssearchlimit", 10).toInt());
ui_->checkbox_fetchalbums->setChecked(s.value("fetchalbums", false).toBool());
ui_->checkbox_download_album_covers->setChecked(s.value("downloadalbumcovers", true).toBool());
s.endGroup();
if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedIn);
Init(ui_->layout_spotifysettingspage->parentWidget());
if (!Settings().childGroups().contains(QLatin1String(kSettingsGroup))) set_changed();
}
void SpotifySettingsPage::Save() {
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("enabled", ui_->enable->isChecked());
s.setValue("username", ui_->username->text());
s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64()));
s.setValue("searchdelay", ui_->searchdelay->value());
s.setValue("artistssearchlimit", ui_->artistssearchlimit->value());
s.setValue("albumssearchlimit", ui_->albumssearchlimit->value());
s.setValue("songssearchlimit", ui_->songssearchlimit->value());
s.setValue("fetchalbums", ui_->checkbox_fetchalbums->isChecked());
s.setValue("downloadalbumcovers", ui_->checkbox_download_album_covers->isChecked());
s.endGroup();
}
void SpotifySettingsPage::LoginClicked() {
emit Authorize();
ui_->button_login->setEnabled(false);
}
bool SpotifySettingsPage::eventFilter(QObject *object, QEvent *event) {
if (object == dialog() && event->type() == QEvent::Enter) {
ui_->button_login->setEnabled(true);
}
return SettingsPage::eventFilter(object, event);
}
void SpotifySettingsPage::LogoutClicked() {
service_->Deauthenticate();
ui_->button_login->setEnabled(true);
ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedOut);
}
void SpotifySettingsPage::LoginSuccess() {
if (!isVisible()) return;
ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedIn);
ui_->button_login->setEnabled(true);
}
void SpotifySettingsPage::LoginFailure(const QString &failure_reason) {
if (!isVisible()) return;
QMessageBox::warning(this, tr("Authentication failed"), failure_reason);
ui_->button_login->setEnabled(true);
}

View File

@@ -0,0 +1,64 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef SPOTIFYSETTINGSPAGE_H
#define SPOTIFYSETTINGSPAGE_H
#include "config.h"
#include <QObject>
#include <QString>
#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<SpotifyService> service_;
};
#endif // SPOTIFYSETTINGSPAGE_H

View File

@@ -0,0 +1,321 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SpotifySettingsPage</class>
<widget class="QWidget" name="SpotifySettingsPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>505</width>
<height>853</height>
</rect>
</property>
<property name="windowTitle">
<string>Spotify</string>
</property>
<layout class="QVBoxLayout" name="layout_spotifysettingspage">
<item>
<widget class="QCheckBox" name="enable">
<property name="text">
<string>Enable</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupbox_basic_authentication">
<property name="title">
<string>Basic authentication</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="LoginStateWidget" name="login_state" native="true"/>
</item>
<item>
<widget class="QPushButton" name="button_login">
<property name="text">
<string>Authenticate</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="group_device_credentials">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Device credentials</string>
</property>
<layout class="QFormLayout" name="layout_credential_group">
<item row="1" column="0">
<widget class="QLabel" name="label_username">
<property name="text">
<string>Username</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_password">
<property name="text">
<string>Password</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="password">
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="username"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="widget_warning" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>2</number>
</property>
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="QLabel" name="label_warning_logo">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap resource="../../data/icons.qrc">:/icons/64x64/dialog-warning.png</pixmap>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_warning_text">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;The GStreamer Spotify plugin is not detected, you will not be able to stream songs from Spotify without it. See: &lt;a href=&quot;https://github.com/strawberrymusicplayer/strawberry/wiki/GStreamer-Spotify-plugin&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;https://github.com/strawberrymusicplayer/strawberry/wiki/GStreamer-Spotify-plugin&lt;/span&gt;&lt;/a&gt; for instructions on how to install the plugin.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="margin">
<number>10</number>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupbox_preferences">
<property name="title">
<string>Preferences</string>
</property>
<layout class="QFormLayout" name="layout_preferences">
<item row="0" column="0">
<widget class="QLabel" name="label_searchdelay">
<property name="text">
<string>Search delay</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="searchdelay">
<property name="suffix">
<string>ms</string>
</property>
<property name="minimum">
<number>500</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="singleStep">
<number>50</number>
</property>
<property name="value">
<number>1500</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_artistssearchlimit">
<property name="text">
<string>Artists search limit</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="artistssearchlimit">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>100</number>
</property>
<property name="value">
<number>50</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_albumssearchlimit">
<property name="text">
<string>Albums search limit</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="albumssearchlimit">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="value">
<number>50</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_songssearchlimit">
<property name="text">
<string>Songs search limit</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="songssearchlimit">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>1000</number>
</property>
<property name="value">
<number>50</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QCheckBox" name="checkbox_download_album_covers">
<property name="text">
<string>Download album covers</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="checkbox_fetchalbums">
<property name="text">
<string>Fetch entire albums when searching songs</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="spacer_middle">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>30</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="layout_bottom">
<item>
<spacer name="spacer_bottom">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label_spotify">
<property name="minimumSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="pixmap">
<pixmap resource="../../data/icons.qrc">:/icons/64x64/spotify.png</pixmap>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>LoginStateWidget</class>
<extends>QWidget</extends>
<header>widgets/loginstatewidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>enable</tabstop>
<tabstop>password</tabstop>
<tabstop>searchdelay</tabstop>
<tabstop>artistssearchlimit</tabstop>
<tabstop>albumssearchlimit</tabstop>
<tabstop>songssearchlimit</tabstop>
<tabstop>checkbox_download_album_covers</tabstop>
<tabstop>checkbox_fetchalbums</tabstop>
</tabstops>
<resources>
<include location="../../data/data.qrc"/>
<include location="../../data/icons.qrc"/>
</resources>
<connections/>
</ui>

View File

@@ -0,0 +1,184 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QObject>
#include <QByteArray>
#include <QPair>
#include <QList>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QSslError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#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 &params_provided) {
ParamList params = ParamList() << params_provided;
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl url(QLatin1String(SpotifyService::kApiUrl) + QLatin1Char('/') + ressource_name);
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
QNetworkReply *reply = network_->get(req);
QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyBaseRequest::HandleSSLErrors);
qLog(Debug) << "Spotify: Sending request" << url;
return reply;
}
void SpotifyBaseRequest::HandleSSLErrors(const QList<QSslError> &ssl_errors) {
for (const QSslError &ssl_error : ssl_errors) {
Error(ssl_error.errorString());
}
}
QByteArray SpotifyBaseRequest::GetReplyData(QNetworkReply *reply) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
data = reply->readAll();
}
else {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
Error(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
// See if there is Json data containing "error".
data = reply->readAll();
QString error;
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
int status = 0;
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains(QLatin1String("error")) && json_obj[QLatin1String("error")].isObject()) {
QJsonObject obj_error = json_obj[QLatin1String("error")].toObject();
if (!obj_error.isEmpty() && obj_error.contains(QLatin1String("status")) && obj_error.contains(QLatin1String("message"))) {
status = obj_error[QLatin1String("status")].toInt();
QString user_message = obj_error[QLatin1String("message")].toString();
error = QStringLiteral("%1 (%2)").arg(user_message).arg(status);
}
}
}
if (error.isEmpty()) {
if (reply->error() == QNetworkReply::NoError) {
error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
else {
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
}
Error(error);
}
return QByteArray();
}
return data;
}
QJsonObject SpotifyBaseRequest::ExtractJsonObj(const QByteArray &data) {
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error(QStringLiteral("Reply from server missing Json data."), data);
return QJsonObject();
}
if (json_doc.isEmpty()) {
Error(QStringLiteral("Received empty Json document."), data);
return QJsonObject();
}
if (!json_doc.isObject()) {
Error(QStringLiteral("Json document is not an object."), json_doc);
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
Error(QStringLiteral("Received empty Json object."), json_doc);
return QJsonObject();
}
return json_obj;
}
QJsonValue SpotifyBaseRequest::ExtractItems(const QByteArray &data) {
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) return QJsonValue();
return ExtractItems(json_obj);
}
QJsonValue SpotifyBaseRequest::ExtractItems(const QJsonObject &json_obj) {
if (!json_obj.contains(QLatin1String("items"))) {
Error(QStringLiteral("Json reply is missing items."), json_obj);
return QJsonArray();
}
QJsonValue json_items = json_obj[QLatin1String("items")];
return json_items;
}
QString SpotifyBaseRequest::ErrorsToHTML(const QStringList &errors) {
QString error_html;
for (const QString &error : errors) {
error_html += error + QLatin1String("<br />");
}
return error_html;
}

View File

@@ -0,0 +1,91 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef SPOTIFYBASEREQUEST_H
#define SPOTIFYBASEREQUEST_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QSet>
#include <QList>
#include <QPair>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QSslError>
#include <QJsonObject>
#include <QJsonValue>
#include "spotifyservice.h"
class QNetworkReply;
class NetworkAccessManager;
class SpotifyBaseRequest : public QObject {
Q_OBJECT
public:
explicit SpotifyBaseRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent = nullptr);
enum class QueryType {
None,
Artists,
Albums,
Songs,
SearchArtists,
SearchAlbums,
SearchSongs,
StreamURL,
};
protected:
typedef QPair<QString, QString> Param;
typedef QList<Param> ParamList;
QNetworkReply *CreateRequest(const QString &ressource_name, const ParamList &params_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<QSslError> &ssl_errors);
private:
SpotifyService *service_;
NetworkAccessManager *network_;
};
#endif // SPOTIFYBASEREQUEST_H

View File

@@ -0,0 +1,310 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QPair>
#include <QList>
#include <QMap>
#include <QMultiMap>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonArray>
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "core/song.h"
#include "spotifyservice.h"
#include "spotifybaserequest.h"
#include "spotifyfavoriterequest.h"
SpotifyFavoriteRequest::SpotifyFavoriteRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent)
: SpotifyBaseRequest(service, network, parent),
service_(service),
network_(network) {}
SpotifyFavoriteRequest::~SpotifyFavoriteRequest() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
QString SpotifyFavoriteRequest::FavoriteText(const FavoriteType type) {
switch (type) {
case FavoriteType_Artists:
return QStringLiteral("artists");
case FavoriteType_Albums:
return QStringLiteral("albums");
case FavoriteType_Songs:
return QStringLiteral("tracks");
}
return QString();
}
void SpotifyFavoriteRequest::AddArtists(const SongList &songs) {
AddFavorites(FavoriteType_Artists, songs);
}
void SpotifyFavoriteRequest::AddAlbums(const SongList &songs) {
AddFavorites(FavoriteType_Albums, songs);
}
void SpotifyFavoriteRequest::AddSongs(const SongList &songs) {
AddFavorites(FavoriteType_Songs, songs);
}
void SpotifyFavoriteRequest::AddSongs(const SongMap &songs) {
AddFavorites(FavoriteType_Songs, songs.values());
}
void SpotifyFavoriteRequest::AddFavorites(const FavoriteType type, const SongList &songs) {
QStringList list_ids;
QJsonArray array_ids;
for (const Song &song : songs) {
QString id;
switch (type) {
case FavoriteType_Artists:
id = song.artist_id();
break;
case FavoriteType_Albums:
id = song.album_id();
break;
case FavoriteType_Songs:
id = song.song_id();
break;
}
if (!id.isEmpty()) {
if (!list_ids.contains(id)) {
list_ids << id;
}
if (!array_ids.contains(id)) {
array_ids << id;
}
}
}
if (list_ids.isEmpty() || array_ids.isEmpty()) return;
QByteArray json_data = QJsonDocument(array_ids).toJson();
QString ids_list = list_ids.join(QLatin1Char(','));
AddFavoritesRequest(type, ids_list, json_data, songs);
}
void SpotifyFavoriteRequest::AddFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) {
QUrl url(QLatin1String(SpotifyService::kApiUrl) + (type == FavoriteType_Artists ? QStringLiteral("/me/following") : QStringLiteral("/me/") + FavoriteText(type)));
if (type == FavoriteType_Artists) {
QUrlQuery url_query;
url_query.addQueryItem(QStringLiteral("type"), QStringLiteral("artist"));
url_query.addQueryItem(QStringLiteral("ids"), ids_list);
url.setQuery(url_query);
}
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
QNetworkReply *reply = nullptr;
if (type == FavoriteType_Artists) {
reply = network_->put(req, "");
}
else {
reply = network_->put(req, json_data);
}
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { AddFavoritesReply(reply, type, songs); });
replies_ << reply;
}
void SpotifyFavoriteRequest::AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
GetReplyData(reply);
if (reply->error() != QNetworkReply::NoError) {
return;
}
if (type == FavoriteType_Artists) {
qLog(Debug) << "Spotify:" << songs.count() << "songs added to followed" << FavoriteText(type);
}
else {
qLog(Debug) << "Spotify:" << songs.count() << "songs added to saved" << FavoriteText(type);
}
switch (type) {
case FavoriteType_Artists:
emit ArtistsAdded(songs);
break;
case FavoriteType_Albums:
emit AlbumsAdded(songs);
break;
case FavoriteType_Songs:
emit SongsAdded(songs);
break;
}
}
void SpotifyFavoriteRequest::RemoveArtists(const SongList &songs) {
RemoveFavorites(FavoriteType_Artists, songs);
}
void SpotifyFavoriteRequest::RemoveAlbums(const SongList &songs) {
RemoveFavorites(FavoriteType_Albums, songs);
}
void SpotifyFavoriteRequest::RemoveSongs(const SongList &songs) {
RemoveFavorites(FavoriteType_Songs, songs);
}
void SpotifyFavoriteRequest::RemoveSongs(const SongMap &songs) {
RemoveFavorites(FavoriteType_Songs, songs.values());
}
void SpotifyFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongList &songs) {
QStringList list_ids;
QJsonArray array_ids;
for (const Song &song : songs) {
QString id;
switch (type) {
case FavoriteType_Artists:
id = song.artist_id();
break;
case FavoriteType_Albums:
id = song.album_id();
break;
case FavoriteType_Songs:
id = song.song_id();
break;
}
if (!id.isEmpty()) {
if (!list_ids.contains(id)) {
list_ids << id;
}
if (!array_ids.contains(id)) {
array_ids << id;
}
}
}
if (list_ids.isEmpty() || array_ids.isEmpty()) return;
QByteArray json_data = QJsonDocument(array_ids).toJson();
QString ids_list = list_ids.join(QLatin1Char(','));
RemoveFavoritesRequest(type, ids_list, json_data, songs);
}
void SpotifyFavoriteRequest::RemoveFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) {
Q_UNUSED(json_data)
QUrl url(QLatin1String(SpotifyService::kApiUrl) + (type == FavoriteType_Artists ? QStringLiteral("/me/following") : QStringLiteral("/me/") + FavoriteText(type)));
if (type == FavoriteType_Artists) {
QUrlQuery url_query;
url_query.addQueryItem(QStringLiteral("type"), QStringLiteral("artist"));
url_query.addQueryItem(QStringLiteral("ids"), ids_list);
url.setQuery(url_query);
}
QNetworkRequest req(url);
#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0)
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
#else
req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
#endif
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
QNetworkReply *reply = nullptr;
if (type == FavoriteType_Artists) {
reply = network_->deleteResource(req);
}
else {
// FIXME
reply = network_->deleteResource(req);
}
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { RemoveFavoritesReply(reply, type, songs); });
replies_ << reply;
}
void SpotifyFavoriteRequest::RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
GetReplyData(reply);
if (reply->error() != QNetworkReply::NoError) {
return;
}
if (type == FavoriteType_Artists) {
qLog(Debug) << "Spotify:" << songs.count() << "songs removed from followed" << FavoriteText(type);
}
else {
qLog(Debug) << "Spotify:" << songs.count() << "songs removed from saved" << FavoriteText(type);
}
switch (type) {
case FavoriteType_Artists:
emit ArtistsRemoved(songs);
break;
case FavoriteType_Albums:
emit AlbumsRemoved(songs);
break;
case FavoriteType_Songs:
emit SongsRemoved(songs);
break;
}
}
void SpotifyFavoriteRequest::Error(const QString &error, const QVariant &debug) {
qLog(Error) << "Spotify:" << error;
if (debug.isValid()) qLog(Debug) << debug;
}

View File

@@ -0,0 +1,90 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef SPOTIFYFAVORITEREQUEST_H
#define SPOTIFYFAVORITEREQUEST_H
#include "config.h"
#include <QObject>
#include <QList>
#include <QMap>
#include <QVariant>
#include <QByteArray>
#include <QString>
#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 <QNetworkReply*> replies_;
};
#endif // SPOTIFYFAVORITEREQUEST_H

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,233 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef SPOTIFYREQUEST_H
#define SPOTIFYREQUEST_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QPair>
#include <QSet>
#include <QList>
#include <QMap>
#include <QMultiMap>
#include <QQueue>
#include <QVariant>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QJsonObject>
#include <QTimer>
#include "core/song.h"
#include "spotifybaserequest.h"
class QNetworkReply;
class Application;
class NetworkAccessManager;
class SpotifyService;
class SpotifyRequest : public SpotifyBaseRequest {
Q_OBJECT
public:
explicit SpotifyRequest(SpotifyService *service, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent);
~SpotifyRequest() override;
void ReloadSettings();
void Process();
void Search(const int query_id, const QString &search_text);
private:
struct Artist {
QString artist_id;
QString artist;
};
struct Album {
QString album_id;
QString album;
QUrl cover_url;
};
struct Request {
Request() : offset(0), limit(0) {}
int offset;
int limit;
};
struct ArtistAlbumsRequest {
ArtistAlbumsRequest() : offset(0), limit(0) {}
Artist artist;
int offset;
int limit;
};
struct AlbumSongsRequest {
AlbumSongsRequest() : offset(0), limit(0) {}
Artist artist;
Album album;
int offset;
int limit;
};
struct AlbumCoverRequest {
QString artist_id;
QString album_id;
QUrl url;
QString filename;
};
signals:
void Results(int id, SongMap songs, QString error);
void UpdateStatus(int id, QString text);
void ProgressSetMaximum(int id, int max);
void UpdateProgress(int id, int max);
void StreamURLFinished(QUrl original_url, QUrl url, Song::FileType, QString error = QString());
private slots:
void FlushRequests();
void ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
void AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
void AlbumsReceived(QNetworkReply *reply, const Artist &artist_artist, const int limit_requested, const int offset_requested);
void SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
void SongsReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int limit_requested, const int offset_requested);
void ArtistAlbumsReplyReceived(QNetworkReply *reply, const Artist &artist, const int offset_requested);
void AlbumSongsReplyReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int offset_requested);
void AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename);
private:
void StartRequests();
bool IsQuery() const { return (type_ == QueryType::Artists || type_ == QueryType::Albums || type_ == QueryType::Songs); }
bool IsSearch() const { return (type_ == QueryType::SearchArtists || type_ == QueryType::SearchAlbums || type_ == QueryType::SearchSongs); }
void GetArtists();
void GetAlbums();
void GetSongs();
void ArtistsSearch();
void AlbumsSearch();
void SongsSearch();
void AddArtistsRequest(const int offset = 0, const int limit = 0);
void AddArtistsSearchRequest(const int offset = 0);
void FlushArtistsRequests();
void AddAlbumsRequest(const int offset = 0, const int limit = 0);
void AddAlbumsSearchRequest(const int offset = 0);
void FlushAlbumsRequests();
void AddSongsRequest(const int offset = 0, const int limit = 0);
void AddSongsSearchRequest(const int offset = 0);
void FlushSongsRequests();
void ArtistsFinishCheck(const int limit = 0, const int offset = 0, const int artists_received = 0);
void AlbumsFinishCheck(const Artist &artist, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0);
void SongsFinishCheck(const Artist &artist, const Album &album, const int limit = 0, const int offset = 0, const int songs_total = 0, const int songs_received = 0);
void AddArtistAlbumsRequest(const Artist &artist, const int offset = 0);
void FlushArtistAlbumsRequests();
void AddAlbumSongsRequest(const Artist &artist, const Album &album, const int offset = 0);
void FlushAlbumSongsRequests();
void ParseSong(Song &song, const QJsonObject &json_obj, const Artist &album_artist, const Album &album);
void GetAlbumCoversCheck();
void GetAlbumCovers();
void AddAlbumCoverRequest(const Song &song);
void FlushAlbumCoverRequests();
void AlbumCoverFinishCheck();
int GetProgress(const int count, const int total);
void FinishCheck();
static void Warn(const QString &error, const QVariant &debug = QVariant());
void Error(const QString &error, const QVariant &debug = QVariant()) override;
private:
SpotifyService *service_;
Application *app_;
NetworkAccessManager *network_;
QTimer *timer_flush_requests_;
QueryType type_;
bool fetchalbums_;
QString coversize_;
int query_id_;
QString search_text_;
bool finished_;
QQueue<Request> artists_requests_queue_;
QQueue<Request> albums_requests_queue_;
QQueue<Request> songs_requests_queue_;
QQueue<ArtistAlbumsRequest> artist_albums_requests_queue_;
QQueue<AlbumSongsRequest> album_songs_requests_queue_;
QQueue<AlbumCoverRequest> album_cover_requests_queue_;
QMap<QString, ArtistAlbumsRequest> artist_albums_requests_pending_;
QMap<QString, AlbumSongsRequest> album_songs_requests_pending_;
QMultiMap<QString, QString> 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<QNetworkReply*> replies_;
QList<QNetworkReply*> album_cover_replies_;
};
#endif // SPOTIFYREQUEST_H

View File

@@ -0,0 +1,749 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <memory>
#include <chrono>
#include <QObject>
#include <QDesktopServices>
#include <QCryptographicHash>
#include <QByteArray>
#include <QPair>
#include <QList>
#include <QMap>
#include <QString>
#include <QChar>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QSslError>
#include <QTimer>
#include <QJsonValue>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QMessageBox>
#include "core/application.h"
#include "core/player.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "core/database.h"
#include "core/song.h"
#include "core/settings.h"
#include "core/localredirectserver.h"
#include "utilities/timeconstants.h"
#include "utilities/randutils.h"
#include "streaming/streamingsearchview.h"
#include "collection/collectionbackend.h"
#include "collection/collectionmodel.h"
#include "spotifyservice.h"
#include "spotifybaserequest.h"
#include "spotifyrequest.h"
#include "spotifyfavoriterequest.h"
#include "settings/settingsdialog.h"
#include "settings/spotifysettingspage.h"
const Song::Source SpotifyService::kSource = Song::Source::Spotify;
const char SpotifyService::kApiUrl[] = "https://api.spotify.com/v1";
namespace {
constexpr char kOAuthAuthorizeUrl[] = "https://accounts.spotify.com/authorize";
constexpr char kOAuthAccessTokenUrl[] = "https://accounts.spotify.com/api/token";
constexpr char kOAuthRedirectUrl[] = "http://localhost:63111/";
constexpr char kClientIDB64[] = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
constexpr char kClientSecretB64[] = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
constexpr char kArtistsSongsTable[] = "spotify_artists_songs";
constexpr char kAlbumsSongsTable[] = "spotify_albums_songs";
constexpr char kSongsTable[] = "spotify_songs";
} // namespace
using std::make_shared;
using namespace std::chrono_literals;
SpotifyService::SpotifyService(Application *app, QObject *parent)
: StreamingService(Song::Source::Spotify, QStringLiteral("Spotify"), QStringLiteral("spotify"), QLatin1String(SpotifySettingsPage::kSettingsGroup), SettingsDialog::Page::Spotify, app, parent),
app_(app),
network_(new NetworkAccessManager(this)),
artists_collection_backend_(nullptr),
albums_collection_backend_(nullptr),
songs_collection_backend_(nullptr),
artists_collection_model_(nullptr),
albums_collection_model_(nullptr),
songs_collection_model_(nullptr),
timer_search_delay_(new QTimer(this)),
timer_refresh_login_(new QTimer(this)),
favorite_request_(new SpotifyFavoriteRequest(this, network_, this)),
enabled_(false),
artistssearchlimit_(1),
albumssearchlimit_(1),
songssearchlimit_(1),
fetchalbums_(true),
download_album_covers_(true),
expires_in_(0),
login_time_(0),
pending_search_id_(0),
next_pending_search_id_(1),
pending_search_type_(StreamingSearchView::SearchType::Artists),
search_id_(0),
server_(nullptr) {
// Backends
artists_collection_backend_ = make_shared<CollectionBackend>();
artists_collection_backend_->moveToThread(app_->database()->thread());
artists_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, QLatin1String(kArtistsSongsTable));
albums_collection_backend_ = make_shared<CollectionBackend>();
albums_collection_backend_->moveToThread(app_->database()->thread());
albums_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, QLatin1String(kAlbumsSongsTable));
songs_collection_backend_ = make_shared<CollectionBackend>();
songs_collection_backend_->moveToThread(app_->database()->thread());
songs_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, QLatin1String(kSongsTable));
// Models
artists_collection_model_ = new CollectionModel(artists_collection_backend_, app_, this);
albums_collection_model_ = new CollectionModel(albums_collection_backend_, app_, this);
songs_collection_model_ = new CollectionModel(songs_collection_backend_, app_, this);
timer_refresh_login_->setSingleShot(true);
QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken);
timer_search_delay_->setSingleShot(true);
QObject::connect(timer_search_delay_, &QTimer::timeout, this, &SpotifyService::StartSearch);
QObject::connect(this, &SpotifyService::AddArtists, favorite_request_, &SpotifyFavoriteRequest::AddArtists);
QObject::connect(this, &SpotifyService::AddAlbums, favorite_request_, &SpotifyFavoriteRequest::AddAlbums);
QObject::connect(this, &SpotifyService::AddSongs, favorite_request_, QOverload<const SongList&>::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<const SongList&>::of(&SpotifyFavoriteRequest::RemoveSongs));
QObject::connect(this, &SpotifyService::RemoveSongsByMap, favorite_request_, QOverload<const SongMap&>::of(&SpotifyFavoriteRequest::RemoveSongs));
QObject::connect(favorite_request_, &SpotifyFavoriteRequest::ArtistsAdded, &*artists_collection_backend_, &CollectionBackend::AddOrUpdateSongs);
QObject::connect(favorite_request_, &SpotifyFavoriteRequest::AlbumsAdded, &*albums_collection_backend_, &CollectionBackend::AddOrUpdateSongs);
QObject::connect(favorite_request_, &SpotifyFavoriteRequest::SongsAdded, &*songs_collection_backend_, &CollectionBackend::AddOrUpdateSongs);
QObject::connect(favorite_request_, &SpotifyFavoriteRequest::ArtistsRemoved, &*artists_collection_backend_, &CollectionBackend::DeleteSongs);
QObject::connect(favorite_request_, &SpotifyFavoriteRequest::AlbumsRemoved, &*albums_collection_backend_, &CollectionBackend::DeleteSongs);
QObject::connect(favorite_request_, &SpotifyFavoriteRequest::SongsRemoved, &*songs_collection_backend_, &CollectionBackend::DeleteSongs);
SpotifyService::ReloadSettings();
LoadSession();
}
SpotifyService::~SpotifyService() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
artists_collection_backend_->deleteLater();
albums_collection_backend_->deleteLater();
songs_collection_backend_->deleteLater();
}
void SpotifyService::Exit() {
wait_for_exit_ << &*artists_collection_backend_ << &*albums_collection_backend_ << &*songs_collection_backend_;
QObject::connect(&*artists_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived);
QObject::connect(&*albums_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived);
QObject::connect(&*songs_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived);
artists_collection_backend_->ExitAsync();
albums_collection_backend_->ExitAsync();
songs_collection_backend_->ExitAsync();
}
void SpotifyService::ExitReceived() {
QObject *obj = sender();
QObject::disconnect(obj, nullptr, this, nullptr);
qLog(Debug) << obj << "successfully exited.";
wait_for_exit_.removeAll(obj);
if (wait_for_exit_.isEmpty()) emit ExitFinished();
}
void SpotifyService::ShowConfig() {
app_->OpenSettingsDialogAtPage(SettingsDialog::Page::Spotify);
}
void SpotifyService::LoadSession() {
refresh_login_timer_.setSingleShot(true);
QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken);
Settings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
access_token_ = s.value("access_token").toString();
refresh_token_ = s.value("refresh_token").toString();
expires_in_ = s.value("expires_in").toLongLong();
login_time_ = s.value("login_time").toLongLong();
s.endGroup();
if (!refresh_token_.isEmpty()) {
qint64 time = static_cast<qint64>(expires_in_) - (QDateTime::currentDateTime().toSecsSinceEpoch() - static_cast<qint64>(login_time_));
if (time < 1) time = 1;
refresh_login_timer_.setInterval(static_cast<int>(time * kMsecPerSec));
refresh_login_timer_.start();
}
}
void SpotifyService::ReloadSettings() {
Settings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
enabled_ = s.value("enabled", false).toBool();
quint64 search_delay = std::max(s.value("searchdelay", 1500).toInt(), 500);
artistssearchlimit_ = s.value("artistssearchlimit", 4).toInt();
albumssearchlimit_ = s.value("albumssearchlimit", 10).toInt();
songssearchlimit_ = s.value("songssearchlimit", 10).toInt();
fetchalbums_ = s.value("fetchalbums", false).toBool();
download_album_covers_ = s.value("downloadalbumcovers", true).toBool();
s.endGroup();
timer_search_delay_->setInterval(static_cast<int>(search_delay));
}
void SpotifyService::Authenticate() {
QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
if (!server_) {
server_ = new LocalRedirectServer(this);
int port = redirect_url.port();
int port_max = port + 10;
bool success = false;
forever {
server_->set_port(port);
if (server_->Listen()) {
success = true;
break;
}
++port;
if (port > port_max) break;
}
if (!success) {
LoginError(server_->error());
server_->deleteLater();
server_ = nullptr;
return;
}
QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyService::RedirectArrived);
}
code_verifier_ = Utilities::CryptographicRandomString(44);
code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
if (code_challenge_.lastIndexOf(QLatin1Char('=')) == code_challenge_.length() - 1) {
code_challenge_.chop(1);
}
const ParamList params = ParamList() << Param(QStringLiteral("client_id"), QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
<< Param(QStringLiteral("response_type"), QStringLiteral("code"))
<< Param(QStringLiteral("redirect_uri"), redirect_url.toString())
<< Param(QStringLiteral("state"), code_challenge_)
<< Param(QStringLiteral("scope"), QStringLiteral("user-follow-read user-follow-modify user-library-read user-library-modify streaming"));
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl url(QString::fromLatin1(kOAuthAuthorizeUrl));
url.setQuery(url_query);
const bool result = QDesktopServices::openUrl(url);
if (!result) {
QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox.setTextFormat(Qt::RichText);
messagebox.exec();
}
}
void SpotifyService::Deauthenticate() {
access_token_.clear();
refresh_token_.clear();
expires_in_ = 0;
login_time_ = 0;
Settings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
s.remove("access_token");
s.remove("refresh_token");
s.remove("expires_in");
s.remove("login_time");
s.endGroup();
refresh_login_timer_.stop();
}
void SpotifyService::RedirectArrived() {
if (!server_) return;
if (server_->error().isEmpty()) {
QUrl url = server_->request_url();
if (url.isValid()) {
QUrlQuery url_query(url);
if (url_query.hasQueryItem(QStringLiteral("error"))) {
LoginError(QUrlQuery(url).queryItemValue(QStringLiteral("error")));
}
else if (url_query.hasQueryItem(QStringLiteral("code")) && url_query.hasQueryItem(QStringLiteral("state"))) {
qLog(Debug) << "Spotify: Authorization URL Received" << url;
QString code = url_query.queryItemValue(QStringLiteral("code"));
QUrl redirect_url(QString::fromLatin1(kOAuthRedirectUrl));
redirect_url.setPort(server_->url().port());
RequestAccessToken(code, redirect_url);
}
else {
LoginError(tr("Redirect missing token code or state!"));
}
}
else {
LoginError(tr("Received invalid reply from web browser."));
}
}
else {
LoginError(server_->error());
}
server_->close();
server_->deleteLater();
server_ = nullptr;
}
void SpotifyService::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
refresh_login_timer_.stop();
ParamList params = ParamList() << Param(QStringLiteral("client_id"), QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)))
<< Param(QStringLiteral("client_secret"), QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64)));
if (!code.isEmpty() && !redirect_url.isEmpty()) {
params << Param(QStringLiteral("grant_type"), QStringLiteral("authorization_code"));
params << Param(QStringLiteral("code"), code);
params << Param(QStringLiteral("redirect_uri"), redirect_url.toString());
}
else if (!refresh_token_.isEmpty() && enabled_) {
params << Param(QStringLiteral("grant_type"), QStringLiteral("refresh_token"));
params << Param(QStringLiteral("refresh_token"), refresh_token_);
}
else {
return;
}
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl new_url(QString::fromLatin1(kOAuthAccessTokenUrl));
QNetworkRequest req(new_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded"));
QString auth_header_data = QString::fromLatin1(QByteArray::fromBase64(kClientIDB64)) + QLatin1Char(':') + QString::fromLatin1(QByteArray::fromBase64(kClientSecretB64));
req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64());
QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
QNetworkReply *reply = network_->post(req, query);
replies_ << reply;
QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyService::HandleLoginSSLErrors);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); });
}
void SpotifyService::HandleLoginSSLErrors(const QList<QSslError> &ssl_errors) {
for (const QSslError &ssl_error : ssl_errors) {
login_errors_ += ssl_error.errorString();
}
}
void SpotifyService::AccessTokenRequestFinished(QNetworkReply *reply) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
LoginError(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
return;
}
else {
// See if there is Json data containing "error" and "error_description" then use that instead.
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains(QLatin1String("error")) && json_obj.contains(QLatin1String("error_description"))) {
QString error = json_obj[QLatin1String("error")].toString();
QString error_description = json_obj[QLatin1String("error_description")].toString();
login_errors_ << QStringLiteral("Authentication failure: %1 (%2)").arg(error, error_description);
}
}
if (login_errors_.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
login_errors_ << QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
login_errors_ << QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
LoginError();
return;
}
}
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error(QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
return;
}
if (json_doc.isEmpty()) {
LoginError(QStringLiteral("Authentication reply from server has empty Json document."));
return;
}
if (!json_doc.isObject()) {
LoginError(QStringLiteral("Authentication reply from server has Json document that is not an object."), json_doc);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
LoginError(QStringLiteral("Authentication reply from server has empty Json object."), json_doc);
return;
}
if (!json_obj.contains(QLatin1String("access_token")) || !json_obj.contains(QLatin1String("expires_in"))) {
LoginError(QStringLiteral("Authentication reply from server is missing access token or expires in."), json_obj);
return;
}
access_token_ = json_obj[QLatin1String("access_token")].toString();
if (json_obj.contains(QLatin1String("refresh_token"))) {
refresh_token_ = json_obj[QLatin1String("refresh_token")].toString();
}
expires_in_ = json_obj[QLatin1String("expires_in")].toInt();
login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
Settings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
s.setValue("access_token", access_token_);
s.setValue("refresh_token", refresh_token_);
s.setValue("expires_in", expires_in_);
s.setValue("login_time", login_time_);
s.endGroup();
if (expires_in_ > 0) {
refresh_login_timer_.setInterval(static_cast<int>(expires_in_ * kMsecPerSec));
refresh_login_timer_.start();
}
qLog(Debug) << "Spotify: Authentication was successful, login expires in" << expires_in_;
emit LoginComplete(true);
emit LoginSuccess();
}
void SpotifyService::ResetArtistsRequest() {
if (artists_request_) {
QObject::disconnect(&*artists_request_, nullptr, this, nullptr);
QObject::disconnect(this, nullptr, &*artists_request_, nullptr);
artists_request_.reset();
}
}
void SpotifyService::GetArtists() {
if (!authenticated()) {
emit ArtistsResults(SongMap(), tr("Not authenticated with Spotify."));
ShowConfig();
return;
}
ResetArtistsRequest();
artists_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType::Artists, this), [](SpotifyRequest *request) { request->deleteLater(); });
QObject::connect(&*artists_request_, &SpotifyRequest::Results, this, &SpotifyService::ArtistsResultsReceived);
QObject::connect(&*artists_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::ArtistsUpdateStatusReceived);
QObject::connect(&*artists_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::ArtistsProgressSetMaximumReceived);
QObject::connect(&*artists_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::ArtistsUpdateProgressReceived);
artists_request_->Process();
}
void SpotifyService::ArtistsResultsReceived(const int id, const SongMap &songs, const QString &error) {
Q_UNUSED(id);
emit ArtistsResults(songs, error);
ResetArtistsRequest();
}
void SpotifyService::ArtistsUpdateStatusReceived(const int id, const QString &text) {
Q_UNUSED(id);
emit ArtistsUpdateStatus(text);
}
void SpotifyService::ArtistsProgressSetMaximumReceived(const int id, const int max) {
Q_UNUSED(id);
emit ArtistsProgressSetMaximum(max);
}
void SpotifyService::ArtistsUpdateProgressReceived(const int id, const int progress) {
Q_UNUSED(id);
emit ArtistsUpdateProgress(progress);
}
void SpotifyService::ResetAlbumsRequest() {
if (albums_request_) {
QObject::disconnect(&*albums_request_, nullptr, this, nullptr);
QObject::disconnect(this, nullptr, &*albums_request_, nullptr);
albums_request_.reset();
}
}
void SpotifyService::GetAlbums() {
if (!authenticated()) {
emit AlbumsResults(SongMap(), tr("Not authenticated with Spotify."));
ShowConfig();
return;
}
ResetAlbumsRequest();
albums_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType::Albums, this), [](SpotifyRequest *request) { request->deleteLater(); });
QObject::connect(&*albums_request_, &SpotifyRequest::Results, this, &SpotifyService::AlbumsResultsReceived);
QObject::connect(&*albums_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::AlbumsUpdateStatusReceived);
QObject::connect(&*albums_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::AlbumsProgressSetMaximumReceived);
QObject::connect(&*albums_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::AlbumsUpdateProgressReceived);
albums_request_->Process();
}
void SpotifyService::AlbumsResultsReceived(const int id, const SongMap &songs, const QString &error) {
Q_UNUSED(id);
emit AlbumsResults(songs, error);
ResetAlbumsRequest();
}
void SpotifyService::AlbumsUpdateStatusReceived(const int id, const QString &text) {
Q_UNUSED(id);
emit AlbumsUpdateStatus(text);
}
void SpotifyService::AlbumsProgressSetMaximumReceived(const int id, const int max) {
Q_UNUSED(id);
emit AlbumsProgressSetMaximum(max);
}
void SpotifyService::AlbumsUpdateProgressReceived(const int id, const int progress) {
Q_UNUSED(id);
emit AlbumsUpdateProgress(progress);
}
void SpotifyService::ResetSongsRequest() {
if (songs_request_) {
QObject::disconnect(&*songs_request_, nullptr, this, nullptr);
QObject::disconnect(this, nullptr, &*songs_request_, nullptr);
songs_request_.reset();
}
}
void SpotifyService::GetSongs() {
if (!authenticated()) {
emit SongsResults(SongMap(), tr("Not authenticated with Spotify."));
ShowConfig();
return;
}
ResetSongsRequest();
songs_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType::Songs, this), [](SpotifyRequest *request) { request->deleteLater(); });
QObject::connect(&*songs_request_, &SpotifyRequest::Results, this, &SpotifyService::SongsResultsReceived);
QObject::connect(&*songs_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::SongsUpdateStatusReceived);
QObject::connect(&*songs_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SongsProgressSetMaximumReceived);
QObject::connect(&*songs_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::SongsUpdateProgressReceived);
songs_request_->Process();
}
void SpotifyService::SongsResultsReceived(const int id, const SongMap &songs, const QString &error) {
Q_UNUSED(id);
emit SongsResults(songs, error);
ResetSongsRequest();
}
void SpotifyService::SongsUpdateStatusReceived(const int id, const QString &text) {
Q_UNUSED(id);
emit SongsUpdateStatus(text);
}
void SpotifyService::SongsProgressSetMaximumReceived(const int id, const int max) {
Q_UNUSED(id);
emit SongsProgressSetMaximum(max);
}
void SpotifyService::SongsUpdateProgressReceived(const int id, const int progress) {
Q_UNUSED(id);
emit SongsUpdateProgress(progress);
}
int SpotifyService::Search(const QString &text, StreamingSearchView::SearchType type) {
pending_search_id_ = next_pending_search_id_;
pending_search_text_ = text;
pending_search_type_ = type;
next_pending_search_id_++;
if (text.isEmpty()) {
timer_search_delay_->stop();
return pending_search_id_;
}
timer_search_delay_->start();
return pending_search_id_;
}
void SpotifyService::StartSearch() {
if (!authenticated()) {
emit SearchResults(pending_search_id_, SongMap(), tr("Not authenticated with Spotify."));
ShowConfig();
return;
}
search_id_ = pending_search_id_;
search_text_ = pending_search_text_;
SendSearch();
}
void SpotifyService::CancelSearch() {
}
void SpotifyService::SendSearch() {
SpotifyBaseRequest::QueryType type = SpotifyBaseRequest::QueryType::None;
switch (pending_search_type_) {
case StreamingSearchView::SearchType::Artists:
type = SpotifyBaseRequest::QueryType::SearchArtists;
break;
case StreamingSearchView::SearchType::Albums:
type = SpotifyBaseRequest::QueryType::SearchAlbums;
break;
case StreamingSearchView::SearchType::Songs:
type = SpotifyBaseRequest::QueryType::SearchSongs;
break;
default:
//Error("Invalid search type.");
return;
}
search_request_.reset(new SpotifyRequest(this, app_, network_, type, this), [](SpotifyRequest *request) { request->deleteLater(); });
QObject::connect(search_request_.get(), &SpotifyRequest::Results, this, &SpotifyService::SearchResultsReceived);
QObject::connect(search_request_.get(), &SpotifyRequest::UpdateStatus, this, &SpotifyService::SearchUpdateStatus);
QObject::connect(search_request_.get(), &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SearchProgressSetMaximum);
QObject::connect(search_request_.get(), &SpotifyRequest::UpdateProgress, this, &SpotifyService::SearchUpdateProgress);
search_request_->Search(search_id_, search_text_);
search_request_->Process();
}
void SpotifyService::SearchResultsReceived(const int id, const SongMap &songs, const QString &error) {
emit SearchResults(id, songs, error);
search_request_.reset();
}
void SpotifyService::LoginError(const QString &error, const QVariant &debug) {
if (!error.isEmpty()) login_errors_ << error;
QString error_html;
for (const QString &e : login_errors_) {
qLog(Error) << "Spotify:" << e;
error_html += e + QLatin1String("<br />");
}
if (debug.isValid()) qLog(Debug) << debug;
emit LoginFailure(error_html);
emit LoginComplete(false);
login_errors_.clear();
}

View File

@@ -0,0 +1,192 @@
/*
* Strawberry Music Player
* Copyright 2022-2024, Jonas Kvinge <jonas@jkvinge.net>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
#ifndef SPOTIFYSERVICE_H
#define SPOTIFYSERVICE_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QPair>
#include <QSet>
#include <QList>
#include <QMap>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDateTime>
#include <QSslError>
#include <QTimer>
#include "core/shared_ptr.h"
#include "core/song.h"
#include "streaming/streamingservice.h"
#include "streaming/streamingsearchview.h"
#include "settings/spotifysettingspage.h"
class QNetworkReply;
class Application;
class NetworkAccessManager;
class SpotifyRequest;
class SpotifyFavoriteRequest;
class SpotifyStreamURLRequest;
class CollectionBackend;
class CollectionModel;
class CollectionFilter;
class LocalRedirectServer;
class SpotifyService : public StreamingService {
Q_OBJECT
public:
explicit SpotifyService(Application *app, QObject *parent = nullptr);
~SpotifyService() override;
static const Song::Source kSource;
static const char kApiUrl[];
void Exit() override;
void ReloadSettings() override;
int Search(const QString &text, StreamingSearchView::SearchType type) override;
void CancelSearch() override;
Application *app() { return app_; }
int artistssearchlimit() { return artistssearchlimit_; }
int albumssearchlimit() { return albumssearchlimit_; }
int songssearchlimit() { return songssearchlimit_; }
bool fetchalbums() { return fetchalbums_; }
bool download_album_covers() { return download_album_covers_; }
QString access_token() { return access_token_; }
bool authenticated() const override { return !access_token_.isEmpty(); }
SharedPtr<CollectionBackend> artists_collection_backend() override { return artists_collection_backend_; }
SharedPtr<CollectionBackend> albums_collection_backend() override { return albums_collection_backend_; }
SharedPtr<CollectionBackend> songs_collection_backend() override { return songs_collection_backend_; }
CollectionModel *artists_collection_model() override { return artists_collection_model_; }
CollectionModel *albums_collection_model() override { return albums_collection_model_; }
CollectionModel *songs_collection_model() override { return songs_collection_model_; }
CollectionFilter *artists_collection_filter_model() override { return artists_collection_model_->filter(); }
CollectionFilter *albums_collection_filter_model() override { return albums_collection_model_->filter(); }
CollectionFilter *songs_collection_filter_model() override { return songs_collection_model_->filter(); }
public slots:
void ShowConfig() override;
void Authenticate();
void Deauthenticate();
void GetArtists() override;
void GetAlbums() override;
void GetSongs() override;
void ResetArtistsRequest() override;
void ResetAlbumsRequest() override;
void ResetSongsRequest() override;
private slots:
void ExitReceived();
void RedirectArrived();
void RequestNewAccessToken() { RequestAccessToken(); }
void HandleLoginSSLErrors(const QList<QSslError> &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<QString, QString> Param;
typedef QList<Param> ParamList;
void LoadSession();
void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl());
void SendSearch();
void LoginError(const QString &error = QString(), const QVariant &debug = QVariant());
Application *app_;
NetworkAccessManager *network_;
SharedPtr<CollectionBackend> artists_collection_backend_;
SharedPtr<CollectionBackend> albums_collection_backend_;
SharedPtr<CollectionBackend> songs_collection_backend_;
CollectionModel *artists_collection_model_;
CollectionModel *albums_collection_model_;
CollectionModel *songs_collection_model_;
QTimer *timer_search_delay_;
QTimer *timer_refresh_login_;
SharedPtr<SpotifyRequest> artists_request_;
SharedPtr<SpotifyRequest> albums_request_;
SharedPtr<SpotifyRequest> songs_request_;
SharedPtr<SpotifyRequest> search_request_;
SpotifyFavoriteRequest *favorite_request_;
bool enabled_;
int artistssearchlimit_;
int albumssearchlimit_;
int songssearchlimit_;
bool fetchalbums_;
bool download_album_covers_;
QString access_token_;
QString refresh_token_;
quint64 expires_in_;
quint64 login_time_;
int pending_search_id_;
int next_pending_search_id_;
QString pending_search_text_;
StreamingSearchView::SearchType pending_search_type_;
int search_id_;
QString search_text_;
QString code_verifier_;
QString code_challenge_;
LocalRedirectServer *server_;
QStringList login_errors_;
QTimer refresh_login_timer_;
QList<QObject*> wait_for_exit_;
QList<QNetworkReply*> replies_;
};
using SpotifyServicePtr = SharedPtr<SpotifyService>;
#endif // SPOTIFYSERVICE_H

View File

@@ -123,6 +123,7 @@ QString CoverUtils::CoverFilenameFromSource(const Song::Source source, const QUr
} }
[[fallthrough]]; [[fallthrough]];
case Song::Source::Subsonic: case Song::Source::Subsonic:
case Song::Source::Spotify:
case Song::Source::Qobuz: case Song::Source::Qobuz:
if (!album_id.isEmpty()) { if (!album_id.isEmpty()) {
filename = album_id; filename = album_id;