Spotify WIP

This commit is contained in:
Jonas Kvinge 2022-06-04 15:51:35 +02:00
parent e1fbe9ae54
commit 6a51ee32b5
40 changed files with 4551 additions and 345 deletions

View File

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

View File

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

289
data/schema/schema-19.sql Normal file
View File

@ -0,0 +1,289 @@
CREATE TABLE IF NOT EXISTS spotify_artists_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS spotify_albums_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE TABLE IF NOT EXISTS spotify_songs (
title TEXT,
album TEXT,
artist TEXT,
albumartist TEXT,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT,
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT,
performer TEXT,
grouping TEXT,
comment TEXT,
lyrics TEXT,
artist_id TEXT,
album_id TEXT,
song_id TEXT,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_embedded INTEGER DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
art_unset INTEGER DEFAULT 0,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT,
rating INTEGER DEFAULT -1,
acoustid_id TEXT,
acoustid_fingerprint TEXT,
musicbrainz_album_artist_id TEXT,
musicbrainz_artist_id TEXT,
musicbrainz_original_artist_id TEXT,
musicbrainz_album_id TEXT,
musicbrainz_original_album_id TEXT,
musicbrainz_recording_id TEXT,
musicbrainz_track_id TEXT,
musicbrainz_disc_id TEXT,
musicbrainz_release_group_id TEXT,
musicbrainz_work_id TEXT,
ebur128_integrated_loudness_lufs REAL,
ebur128_loudness_range_lu REAL
);
CREATE VIRTUAL TABLE IF NOT EXISTS spotify_artists_songs_fts USING fts5(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize = "unicode61 remove_diacritics 1"
);
CREATE VIRTUAL TABLE IF NOT EXISTS spotify_albums_songs_fts USING fts5(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize = "unicode61 remove_diacritics 1"
);
CREATE VIRTUAL TABLE IF NOT EXISTS spotify_songs_fts USING fts5(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize = "unicode61 remove_diacritics 1"
);
UPDATE schema_version SET version=19;

View File

@ -422,6 +422,192 @@ CREATE TABLE IF NOT EXISTS tidal_songs (
);
CREATE TABLE IF NOT EXISTS spotify_artists_songs (
title TEXT DEFAULT '',
album TEXT DEFAULT '',
artist TEXT DEFAULT '',
albumartist TEXT DEFAULT '',
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT DEFAULT '',
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT DEFAULT '',
performer TEXT DEFAULT '',
grouping TEXT DEFAULT '',
comment TEXT DEFAULT '',
lyrics TEXT DEFAULT '',
artist_id TEXT DEFAULT '',
album_id TEXT DEFAULT '',
song_id TEXT DEFAULT '',
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL DEFAULT '',
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT DEFAULT '',
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_automatic TEXT DEFAULT '',
art_manual TEXT DEFAULT '',
effective_albumartist TEXT DEFAULT '',
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT DEFAULT '',
rating INTEGER DEFAULT -1
);
CREATE TABLE IF NOT EXISTS spotify_albums_songs (
title TEXT DEFAULT '',
album TEXT DEFAULT '',
artist TEXT DEFAULT '',
albumartist TEXT DEFAULT '',
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT DEFAULT '',
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT DEFAULT '',
performer TEXT DEFAULT '',
grouping TEXT DEFAULT '',
comment TEXT DEFAULT '',
lyrics TEXT DEFAULT '',
artist_id TEXT DEFAULT '',
album_id TEXT DEFAULT '',
song_id TEXT DEFAULT '',
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL DEFAULT '',
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT DEFAULT '',
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_automatic TEXT DEFAULT '',
art_manual TEXT DEFAULT '',
effective_albumartist TEXT DEFAULT '',
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT DEFAULT '',
rating INTEGER DEFAULT -1
);
CREATE TABLE IF NOT EXISTS spotify_songs (
title TEXT DEFAULT '',
album TEXT DEFAULT '',
artist TEXT DEFAULT '',
albumartist TEXT DEFAULT '',
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT -1,
genre TEXT DEFAULT '',
compilation INTEGER NOT NULL DEFAULT 0,
composer TEXT DEFAULT '',
performer TEXT DEFAULT '',
grouping TEXT DEFAULT '',
comment TEXT DEFAULT '',
lyrics TEXT DEFAULT '',
artist_id TEXT DEFAULT '',
album_id TEXT DEFAULT '',
song_id TEXT DEFAULT '',
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT -1,
samplerate INTEGER NOT NULL DEFAULT -1,
bitdepth INTEGER NOT NULL DEFAULT -1,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL DEFAULT -1,
url TEXT NOT NULL DEFAULT '',
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT -1,
mtime INTEGER NOT NULL DEFAULT -1,
ctime INTEGER NOT NULL DEFAULT -1,
unavailable INTEGER DEFAULT 0,
fingerprint TEXT DEFAULT '',
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT -1,
lastseen INTEGER NOT NULL DEFAULT -1,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_automatic TEXT DEFAULT '',
art_manual TEXT DEFAULT '',
effective_albumartist TEXT DEFAULT '',
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT DEFAULT '',
rating INTEGER DEFAULT -1
);
CREATE TABLE IF NOT EXISTS qobuz_artists_songs (
title TEXT,
@ -872,6 +1058,51 @@ CREATE VIRTUAL TABLE IF NOT EXISTS tidal_songs_fts USING fts5(
);
CREATE VIRTUAL TABLE IF NOT EXISTS spotify_artists_songs_fts USING fts5(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize = "unicode61 remove_diacritics 1"
);
CREATE VIRTUAL TABLE IF NOT EXISTS spotify_albums_songs_fts USING fts5(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize = "unicode61 remove_diacritics 1"
);
CREATE VIRTUAL TABLE IF NOT EXISTS spotify_songs_fts USING fts5(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize = "unicode61 remove_diacritics 1"
);
CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_artists_songs_fts USING fts5(
ftstitle,

View File

@ -170,7 +170,6 @@ set(SOURCES
covermanager/deezercoverprovider.cpp
covermanager/qobuzcoverprovider.cpp
covermanager/musixmatchcoverprovider.cpp
covermanager/spotifycoverprovider.cpp
covermanager/opentidalcoverprovider.cpp
lyrics/lyricsproviders.cpp
@ -416,7 +415,6 @@ set(HEADERS
covermanager/deezercoverprovider.h
covermanager/qobuzcoverprovider.h
covermanager/musixmatchcoverprovider.h
covermanager/spotifycoverprovider.h
covermanager/opentidalcoverprovider.h
lyrics/lyricsproviders.h
@ -898,6 +896,25 @@ optional_source(HAVE_TIDAL
settings/tidalsettingspage.ui
)
optional_source(HAVE_SPOTIFY
SOURCES
spotify/spotifyservice.cpp
spotify/spotifybaserequest.cpp
spotify/spotifyrequest.cpp
spotify/spotifyfavoriterequest.cpp
settings/spotifysettingspage.cpp
covermanager/spotifycoverprovider.cpp
HEADERS
spotify/spotifyservice.h
spotify/spotifybaserequest.h
spotify/spotifyrequest.h
spotify/spotifyfavoriterequest.h
settings/spotifysettingspage.h
covermanager/spotifycoverprovider.h
UI
settings/spotifysettingspage.ui
)
optional_source(HAVE_QOBUZ
SOURCES
qobuz/qobuzservice.cpp

View File

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

View File

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

View File

@ -176,6 +176,9 @@
# include "tidal/tidalservice.h"
# include "settings/tidalsettingspage.h"
#endif
#ifdef HAVE_SPOTIFY
# include "settings/spotifysettingspage.h"
#endif
#ifdef HAVE_QOBUZ
# include "settings/qobuzsettingspage.h"
#endif
@ -306,6 +309,9 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
#ifdef HAVE_TIDAL
tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source::Tidal), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page::Tidal, this)),
#endif
#ifdef HAVE_SPOTIFY
spotify_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source::Spotify), SpotifySettingsPage::kSettingsGroup, SettingsDialog::Page::Spotify, this)),
#endif
#ifdef HAVE_QOBUZ
qobuz_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source::Qobuz), QobuzSettingsPage::kSettingsGroup, SettingsDialog::Page::Qobuz, this)),
#endif
@ -391,6 +397,9 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
#ifdef HAVE_TIDAL
ui_->tabs->AddTab(tidal_view_, "tidal", IconLoader::Load("tidal", true, 0, 32), tr("Tidal"));
#endif
#ifdef HAVE_SPOTIFY
ui_->tabs->AddTab(spotify_view_, "spotify", IconLoader::Load("spotify"), tr("Spotify"));
#endif
#ifdef HAVE_QOBUZ
ui_->tabs->AddTab(qobuz_view_, "qobuz", IconLoader::Load("qobuz", true, 0, 32), tr("Qobuz"));
#endif
@ -723,6 +732,13 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(qobuz_view_->search_view(), &InternetSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
#endif
#ifdef HAVE_SPOTIFY
QObject::connect(spotify_view_->artists_collection_view(), &InternetCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
QObject::connect(spotify_view_->albums_collection_view(), &InternetCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
QObject::connect(spotify_view_->songs_collection_view(), &InternetCollectionView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
QObject::connect(spotify_view_->search_view(), &InternetSearchView::AddToPlaylist, this, &MainWindow::AddToPlaylist);
#endif
QObject::connect(radio_view_, &RadioViewContainer::Refresh, &*app_->radio_services(), &RadioServices::RefreshChannels);
QObject::connect(radio_view_->view(), &RadioView::GetChannels, &*app_->radio_services(), &RadioServices::GetChannels);
QObject::connect(radio_view_->view(), &RadioView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
@ -1187,6 +1203,18 @@ void MainWindow::ReloadSettings() {
}
#endif
#ifdef HAVE_SPOTIFY
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
bool enable_spotify = s.value("enabled", false).toBool();
s.endGroup();
if (enable_spotify) {
ui_->tabs->EnableTab(spotify_view_);
}
else {
ui_->tabs->DisableTab(spotify_view_);
}
#endif
#ifdef HAVE_QOBUZ
s.beginGroup(QobuzSettingsPage::kSettingsGroup);
bool enable_qobuz = s.value("enabled", false).toBool();
@ -1235,6 +1263,9 @@ void MainWindow::ReloadAllSettings() {
#ifdef HAVE_TIDAL
tidal_view_->ReloadSettings();
#endif
#ifdef HAVE_SPOTIFY
spotify_view_->ReloadSettings();
#endif
#ifdef HAVE_QOBUZ
qobuz_view_->ReloadSettings();
#endif
@ -3294,6 +3325,11 @@ void MainWindow::FocusSearchField() {
tidal_view_->FocusSearchField();
}
#endif
#ifdef HAVE_SPOTIFY
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(spotify_view_) && !spotify_view_->SearchFieldHasFocus()) {
spotify_view_->FocusSearchField();
}
#endif
#ifdef HAVE_QOBUZ
else if (ui_->tabs->currentIndex() == ui_->tabs->IndexOfTab(qobuz_view_) && !qobuz_view_->SearchFieldHasFocus()) {
qobuz_view_->FocusSearchField();

View File

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

View File

@ -531,11 +531,11 @@ const QString &Song::playlist_albumartist_sortable() const { return is_compilati
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); }
bool Song::is_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz; }
bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
bool Song::is_compilation() const { return (d->compilation_ || d->compilation_detected_ || d->compilation_on_) && !d->compilation_off_; }
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz; }
bool Song::stream_url_can_expire() const { return d->source_ == Source::Tidal || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_module_music() const { return d->filetype_ == FileType::MOD || d->filetype_ == FileType::S3M || d->filetype_ == FileType::XM || d->filetype_ == FileType::IT; }
bool Song::has_cue() const { return !d->cue_path_.isEmpty(); }
@ -895,11 +895,13 @@ Song::Source Song::SourceFromURL(const QUrl &url) {
if (url.isLocalFile()) return Source::LocalFile;
else if (url.scheme() == "cdda") return Source::CDDA;
else if (url.scheme() == "tidal") return Source::Tidal;
else if (url.scheme() == "subsonic") return Source::Subsonic;
else if (url.scheme() == "tidal") return Source::Tidal;
else if (url.scheme() == "spotify") return Source::Spotify;
else if (url.scheme() == "qobuz") return Source::Qobuz;
else if (url.scheme() == "http" || url.scheme() == "https" || url.scheme() == "rtsp") {
if (url.host().endsWith("tidal.com", Qt::CaseInsensitive)) { return Source::Tidal; }
if (url.host().endsWith("spotify.com", Qt::CaseInsensitive)) { return Source::Spotify; }
if (url.host().endsWith("qobuz.com", Qt::CaseInsensitive)) { return Source::Qobuz; }
if (url.host().endsWith("somafm.com", Qt::CaseInsensitive)) { return Source::SomaFM; }
if (url.host().endsWith("radioparadise.com", Qt::CaseInsensitive)) { return Source::RadioParadise; }
@ -917,8 +919,9 @@ QString Song::TextForSource(const Source source) {
case Source::CDDA: return "cd";
case Source::Device: return "device";
case Source::Stream: return "stream";
case Source::Tidal: return "tidal";
case Source::Subsonic: return "subsonic";
case Source::Tidal: return "tidal";
case Source::Spotify: return "spotify";
case Source::Qobuz: return "qobuz";
case Source::SomaFM: return "somafm";
case Source::RadioParadise: return "radioparadise";
@ -936,8 +939,9 @@ QString Song::DescriptionForSource(const Source source) {
case Source::CDDA: return "CD";
case Source::Device: return "Device";
case Source::Stream: return "Stream";
case Source::Tidal: return "Tidal";
case Source::Subsonic: return "Subsonic";
case Source::Tidal: return "Tidal";
case Source::Spotify: return "Spotify";
case Source::Qobuz: return "Qobuz";
case Source::SomaFM: return "SomaFM";
case Source::RadioParadise: return "Radio Paradise";
@ -954,8 +958,9 @@ Song::Source Song::SourceFromText(const QString &source) {
if (source.compare("cd", Qt::CaseInsensitive) == 0) return Source::CDDA;
if (source.compare("device", Qt::CaseInsensitive) == 0) return Source::Device;
if (source.compare("stream", Qt::CaseInsensitive) == 0) return Source::Stream;
if (source.compare("tidal", Qt::CaseInsensitive) == 0) return Source::Tidal;
if (source.compare("subsonic", Qt::CaseInsensitive) == 0) return Source::Subsonic;
if (source.compare("tidal", Qt::CaseInsensitive) == 0) return Source::Tidal;
if (source.compare("spotify", Qt::CaseInsensitive) == 0) return Source::Spotify;
if (source.compare("qobuz", Qt::CaseInsensitive) == 0) return Source::Qobuz;
if (source.compare("somafm", Qt::CaseInsensitive) == 0) return Source::SomaFM;
if (source.compare("radioparadise", Qt::CaseInsensitive) == 0) return Source::RadioParadise;
@ -972,8 +977,9 @@ QIcon Song::IconForSource(const Source source) {
case Source::CDDA: return IconLoader::Load("media-optical");
case Source::Device: return IconLoader::Load("device");
case Source::Stream: return IconLoader::Load("applications-internet");
case Source::Tidal: return IconLoader::Load("tidal");
case Source::Subsonic: return IconLoader::Load("subsonic");
case Source::Tidal: return IconLoader::Load("tidal");
case Source::Spotify: return IconLoader::Load("spotify");
case Source::Qobuz: return IconLoader::Load("qobuz");
case Source::SomaFM: return IconLoader::Load("somafm");
case Source::RadioParadise: return IconLoader::Load("radioparadise");
@ -1194,6 +1200,8 @@ QString Song::ImageCacheDir(const Source source) {
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/subsonicalbumcovers";
case Source::Tidal:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/tidalalbumcovers";
case Source::Spotify:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/spotifyalbumcovers";
case Source::Qobuz:
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/qobuzalbumcovers";
case Source::Device:

View File

@ -76,7 +76,8 @@ class Song {
Subsonic = 7,
Qobuz = 8,
SomaFM = 9,
RadioParadise = 10
RadioParadise = 10,
Spotify = 11
};
// Don't change these values - they're stored in the database, and defined in the tag reader protobuf.

View File

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

View File

@ -29,11 +29,8 @@
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QSslError>
#include <QCryptographicHash>
#include <QJsonDocument>
#include <QJsonValue>
#include <QJsonObject>
@ -47,45 +44,17 @@
#include "core/logging.h"
#include "utilities/randutils.h"
#include "utilities/timeconstants.h"
#include "internet/localredirectserver.h"
#include "internet/internetservices.h"
#include "spotify/spotifyservice.h"
#include "albumcoverfetcher.h"
#include "jsoncoverprovider.h"
#include "spotifycoverprovider.h"
const char *SpotifyCoverProvider::kSettingsGroup = "Spotify";
const char *SpotifyCoverProvider::kOAuthAuthorizeUrl = "https://accounts.spotify.com/authorize";
const char *SpotifyCoverProvider::kOAuthAccessTokenUrl = "https://accounts.spotify.com/api/token";
const char *SpotifyCoverProvider::kOAuthRedirectUrl = "http://localhost:63111/";
const char *SpotifyCoverProvider::kClientIDB64 = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
const char *SpotifyCoverProvider::kClientSecretB64 = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
const char *SpotifyCoverProvider::kApiUrl = "https://api.spotify.com/v1";
const int SpotifyCoverProvider::kLimit = 10;
SpotifyCoverProvider::SpotifyCoverProvider(Application *app, SharedPtr<NetworkAccessManager> network, QObject *parent)
: JsonCoverProvider("Spotify", true, true, 2.5, true, true, app, network, parent),
server_(nullptr),
expires_in_(0),
login_time_(0) {
refresh_login_timer_.setSingleShot(true);
QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyCoverProvider::RequestNewAccessToken);
QSettings s;
s.beginGroup(kSettingsGroup);
access_token_ = s.value("access_token").toString();
refresh_token_ = s.value("refresh_token").toString();
expires_in_ = s.value("expires_in").toLongLong();
login_time_ = s.value("login_time").toLongLong();
s.endGroup();
if (!refresh_token_.isEmpty()) {
qint64 time = static_cast<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();
}
}
service_(app->internet_services()->Service<SpotifyService>()) {}
SpotifyCoverProvider::~SpotifyCoverProvider() {
@ -98,265 +67,9 @@ SpotifyCoverProvider::~SpotifyCoverProvider() {
}
void SpotifyCoverProvider::Authenticate() {
QUrl redirect_url(kOAuthRedirectUrl);
if (!server_) {
server_ = new LocalRedirectServer(this);
int port = redirect_url.port();
int port_max = port + 10;
bool success = false;
forever {
server_->set_port(port);
if (server_->Listen()) {
success = true;
break;
}
++port;
if (port > port_max) break;
}
if (!success) {
AuthError(server_->error());
server_->deleteLater();
server_ = nullptr;
return;
}
QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyCoverProvider::RedirectArrived);
}
code_verifier_ = Utilities::CryptographicRandomString(44);
code_challenge_ = QString(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) {
code_challenge_.chop(1);
}
const ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64))
<< Param("response_type", "code")
<< Param("redirect_uri", redirect_url.toString())
<< Param("state", code_challenge_);
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
QUrl url(kOAuthAuthorizeUrl);
url.setQuery(url_query);
const bool result = QDesktopServices::openUrl(url);
if (!result) {
QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QString(":<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;
QSettings s;
s.beginGroup(kSettingsGroup);
s.remove("access_token");
s.remove("refresh_token");
s.remove("expires_in");
s.remove("login_time");
s.endGroup();
refresh_login_timer_.stop();
}
void SpotifyCoverProvider::RedirectArrived() {
if (!server_) return;
if (server_->error().isEmpty()) {
QUrl url = server_->request_url();
if (url.isValid()) {
QUrlQuery url_query(url);
if (url_query.hasQueryItem("error")) {
AuthError(QUrlQuery(url).queryItemValue("error"));
}
else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) {
qLog(Debug) << "Spotify: Authorization URL Received" << url;
QString code = url_query.queryItemValue("code");
QUrl redirect_url(kOAuthRedirectUrl);
redirect_url.setPort(server_->url().port());
RequestAccessToken(code, redirect_url);
}
else {
AuthError(tr("Redirect missing token code or state!"));
}
}
else {
AuthError(tr("Received invalid reply from web browser."));
}
}
else {
AuthError(server_->error());
}
server_->close();
server_->deleteLater();
server_ = nullptr;
}
void SpotifyCoverProvider::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
refresh_login_timer_.stop();
ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64))
<< Param("client_secret", QByteArray::fromBase64(kClientSecretB64));
if (!code.isEmpty() && !redirect_url.isEmpty()) {
params << Param("grant_type", "authorization_code");
params << Param("code", code);
params << Param("redirect_uri", redirect_url.toString());
}
else if (!refresh_token_.isEmpty() && is_enabled()) {
params << Param("grant_type", "refresh_token");
params << Param("refresh_token", refresh_token_);
}
else {
return;
}
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
QUrl new_url(kOAuthAccessTokenUrl);
QNetworkRequest req(new_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QString auth_header_data = QByteArray::fromBase64(kClientIDB64) + QString(":") + QByteArray::fromBase64(kClientSecretB64);
req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64());
QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
QNetworkReply *reply = network_->post(req, query);
replies_ << reply;
QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyCoverProvider::HandleLoginSSLErrors);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); });
}
void SpotifyCoverProvider::HandleLoginSSLErrors(const QList<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(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
return;
}
else {
// See if there is Json data containing "error" and "error_description" then use that instead.
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains("error") && json_obj.contains("error_description")) {
QString error = json_obj["error"].toString();
QString error_description = json_obj["error_description"].toString();
login_errors_ << QString("Authentication failure: %1 (%2)").arg(error, error_description);
}
}
if (login_errors_.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
AuthError();
return;
}
}
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error(QString("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
return;
}
if (json_doc.isEmpty()) {
AuthError("Authentication reply from server has empty Json document.");
return;
}
if (!json_doc.isObject()) {
AuthError("Authentication reply from server has Json document that is not an object.", json_doc);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
AuthError("Authentication reply from server has empty Json object.", json_doc);
return;
}
if (!json_obj.contains("access_token") || !json_obj.contains("expires_in")) {
AuthError("Authentication reply from server is missing access token or expires in.", json_obj);
return;
}
access_token_ = json_obj["access_token"].toString();
if (json_obj.contains("refresh_token")) {
refresh_token_ = json_obj["refresh_token"].toString();
}
expires_in_ = json_obj["expires_in"].toInt();
login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
QSettings s;
s.beginGroup(kSettingsGroup);
s.setValue("access_token", access_token_);
s.setValue("refresh_token", refresh_token_);
s.setValue("expires_in", expires_in_);
s.setValue("login_time", login_time_);
s.endGroup();
if (expires_in_ > 0) {
refresh_login_timer_.setInterval(static_cast<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) {
if (access_token_.isEmpty()) return false;
if (!IsAuthenticated()) return false;
if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
@ -387,12 +100,12 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb
url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
QUrl url(kApiUrl + QString("/search"));
QUrl url(SpotifyService::kApiUrl + QString("/search"));
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
req.setRawHeader("Authorization", "Bearer " + access_token_.toUtf8());
req.setRawHeader("Authorization", "Bearer " + service_->access_token().toUtf8());
QNetworkReply *reply = network_->get(req);
replies_ << reply;
@ -429,13 +142,13 @@ QByteArray SpotifyCoverProvider::GetReplyData(QNetworkReply *reply) {
int status = obj_error["status"].toInt();
QString message = obj_error["message"].toString();
error = QString("%1 (%2)").arg(message).arg(status);
if (status == 401) access_token_.clear();
if (status == 401) Deauthenticate();
}
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
if (reply->error() == 204) access_token_.clear();
if (reply->error() == 204) Deauthenticate();
error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
@ -538,20 +251,6 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id,
}
void SpotifyCoverProvider::AuthError(const QString &error, const QVariant &debug) {
if (!error.isEmpty()) login_errors_ << error;
for (const QString &e : login_errors_) Error(e);
if (debug.isValid()) qLog(Debug) << debug;
emit AuthenticationFailure(login_errors_);
emit AuthenticationComplete(false, login_errors_);
login_errors_.clear();
}
void SpotifyCoverProvider::Error(const QString &error, const QVariant &debug) {
qLog(Error) << "Spotify:" << error;

View File

@ -36,11 +36,11 @@
#include "core/shared_ptr.h"
#include "jsoncoverprovider.h"
#include "spotify/spotifyservice.h"
class QNetworkReply;
class Application;
class NetworkAccessManager;
class LocalRedirectServer;
class SpotifyCoverProvider : public JsonCoverProvider {
Q_OBJECT
@ -52,22 +52,17 @@ class SpotifyCoverProvider : public JsonCoverProvider {
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
void CancelSearch(const int id) override;
void Authenticate() override;
void Deauthenticate() override;
bool IsAuthenticated() const override { return !access_token_.isEmpty(); }
bool IsAuthenticated() const override { return service_ && service_->authenticated(); }
void Deauthenticate() override {
if (service_) service_->Deauthenticate();
}
private slots:
void HandleLoginSSLErrors(const QList<QSslError> &ssl_errors);
void RedirectArrived();
void AccessTokenRequestFinished(QNetworkReply *reply);
void HandleSearchReply(QNetworkReply *reply, const int id, const QString &extract);
void RequestNewAccessToken() { RequestAccessToken(); }
private:
QByteArray GetReplyData(QNetworkReply *reply);
void AuthError(const QString &error = QString(), const QVariant &debug = QVariant());
void Error(const QString &error, const QVariant &debug = QVariant()) override;
void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl());
private:
static const char *kSettingsGroup;
@ -79,15 +74,7 @@ class SpotifyCoverProvider : public JsonCoverProvider {
static const char *kApiUrl;
static const int kLimit;
LocalRedirectServer *server_;
QStringList login_errors_;
QString code_verifier_;
QString code_challenge_;
QString access_token_;
QString refresh_token_;
quint64 expires_in_;
quint64 login_time_;
QTimer refresh_login_timer_;
SharedPtr<SpotifyService> service_;
QList<QNetworkReply*> replies_;
};

View File

@ -36,6 +36,9 @@
#include "enginebase.h"
#include "settings/backendsettingspage.h"
#include "settings/networkproxysettingspage.h"
#ifdef HAVE_SPOTIFY
# include "settings/spotifysettingspage.h"
#endif
EngineBase::EngineBase(QObject *parent)
: QObject(parent),
@ -238,6 +241,15 @@ void EngineBase::ReloadSettings() {
s.endGroup();
#ifdef HAVE_SPOTIFY
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
spotify_username_ = s.value("username").toString();
QByteArray password = s.value("password").toByteArray();
if (password.isEmpty()) spotify_password_.clear();
else spotify_password_ = QString::fromUtf8(QByteArray::fromBase64(password));
s.endGroup();
#endif
}
void EngineBase::EmitAboutToFinish() {

View File

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

View File

@ -172,7 +172,7 @@ void GstEngine::StartPreloading(const QUrl &media_url, const QUrl &stream_url, c
if (current_pipeline_) {
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_nanosec, force_stop_at_end ? end_nanosec : 0);
// Add request to discover the stream
if (discoverer_) {
if (discoverer_ && media_url.scheme() != "spotify") {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
}
@ -229,7 +229,7 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
}
// Add request to discover the stream
if (discoverer_) {
if (discoverer_ && media_url.scheme() != "spotify") {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
}
@ -814,6 +814,10 @@ SharedPtr<GstEnginePipeline> GstEngine::CreatePipeline() {
ret->set_strict_ssl_enabled(strict_ssl_enabled_);
ret->set_fading_enabled(fadeout_enabled_ || autocrossfade_enabled_ || fadeout_pause_enabled_);
#ifdef HAVE_SPOTIFY
ret->set_spotify_login(spotify_username_, spotify_password_);
#endif
ret->AddBufferConsumer(this);
for (GstBufferConsumer *consumer : buffer_consumers_) {
ret->AddBufferConsumer(consumer);

View File

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

View File

@ -76,6 +76,9 @@ class GstEnginePipeline : public QObject {
void set_bs2b_enabled(const bool enabled);
void set_strict_ssl_enabled(const bool enabled);
void set_fading_enabled(const bool enabled);
#ifdef HAVE_SPOTIFY
void set_spotify_login(const QString &spotify_username, const QString &spotify_password);
#endif
// Creates the pipeline, returns false on error
bool InitFromUrl(const QUrl &media_url, const QUrl &stream_url, const QByteArray &gst_url, const qint64 end_nanosec, const double ebur128_loudness_normalizing_gain_db, QString &error);
@ -249,6 +252,12 @@ class GstEnginePipeline : public QObject {
bool bs2b_enabled_;
bool strict_ssl_enabled_;
// Spotify
#ifdef HAVE_SPOTIFY
QString spotify_username_;
QString spotify_password_;
#endif
// These get called when there is a new audio buffer available
QList<GstBufferConsumer*> buffer_consumers_;
QMutex buffer_consumers_mutex_;

View File

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

View File

@ -77,6 +77,9 @@
#ifdef HAVE_TIDAL
# include "tidalsettingspage.h"
#endif
#ifdef HAVE_SPOTIFY
# include "spotifysettingspage.h"
#endif
#ifdef HAVE_QOBUZ
# include "qobuzsettingspage.h"
#endif
@ -154,7 +157,7 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main
AddPage(Page::Moodbar, new MoodbarSettingsPage(this, this), iface);
#endif
#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_QOBUZ)
#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_SPOTIFY) || defined(HAVE_QOBUZ)
QTreeWidgetItem *streaming = AddCategory(tr("Streaming"));
#endif
@ -164,6 +167,9 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main
#ifdef HAVE_TIDAL
AddPage(Page::Tidal, new TidalSettingsPage(this, this), streaming);
#endif
#ifdef HAVE_SPOTIFY
AddPage(Page::Spotify, new SpotifySettingsPage(this, this), streaming);
#endif
#ifdef HAVE_QOBUZ
AddPage(Page::Qobuz, new QobuzSettingsPage(this, this), streaming);
#endif

View File

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

View File

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

View File

@ -0,0 +1,64 @@
/*
* Strawberry Music Player
* Copyright 2022, 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,185 @@
/*
* Strawberry Music Player
* Copyright 2022, 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 <QtDebug>
#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(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
QUrl url(SpotifyService::kApiUrl + QString("/") + ressource_name);
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
QNetworkReply *reply = network_->get(req);
QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyBaseRequest::HandleSSLErrors);
qLog(Debug) << "Spotify: Sending request" << url;
return reply;
}
void SpotifyBaseRequest::HandleSSLErrors(const QList<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(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
// See if there is Json data containing "error".
data = reply->readAll();
QString error;
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
int status = 0;
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains("error") && json_obj["error"].isObject()) {
QJsonObject obj_error = json_obj["error"].toObject();
if (!obj_error.isEmpty() && obj_error.contains("status") && obj_error.contains("message")) {
status = obj_error["status"].toInt();
QString user_message = obj_error["message"].toString();
error = QString("%1 (%2)").arg(user_message).arg(status);
}
}
}
if (error.isEmpty()) {
if (reply->error() == QNetworkReply::NoError) {
error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
else {
error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
}
Error(error);
}
return QByteArray();
}
return data;
}
QJsonObject SpotifyBaseRequest::ExtractJsonObj(const QByteArray &data) {
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error("Reply from server missing Json data.", data);
return QJsonObject();
}
if (json_doc.isEmpty()) {
Error("Received empty Json document.", data);
return QJsonObject();
}
if (!json_doc.isObject()) {
Error("Json document is not an object.", json_doc);
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
Error("Received empty Json object.", json_doc);
return QJsonObject();
}
return json_obj;
}
QJsonValue SpotifyBaseRequest::ExtractItems(const QByteArray &data) {
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) return QJsonValue();
return ExtractItems(json_obj);
}
QJsonValue SpotifyBaseRequest::ExtractItems(const QJsonObject &json_obj) {
if (!json_obj.contains("items")) {
Error("Json reply is missing items.", json_obj);
return QJsonArray();
}
QJsonValue json_items = json_obj["items"];
return json_items;
}
QString SpotifyBaseRequest::ErrorsToHTML(const QStringList &errors) {
QString error_html;
for (const QString &error : errors) {
error_html += error + "<br />";
}
return error_html;
}

View File

@ -0,0 +1,91 @@
/*
* Strawberry Music Player
* Copyright 2022, 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 QueryType {
QueryType_None,
QueryType_Artists,
QueryType_Albums,
QueryType_Songs,
QueryType_SearchArtists,
QueryType_SearchAlbums,
QueryType_SearchSongs,
QueryType_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, 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 "artists";
case FavoriteType_Albums:
return "albums";
case FavoriteType_Songs:
return "tracks";
}
return QString();
}
void SpotifyFavoriteRequest::AddArtists(const SongList &songs) {
AddFavorites(FavoriteType_Artists, songs);
}
void SpotifyFavoriteRequest::AddAlbums(const SongList &songs) {
AddFavorites(FavoriteType_Albums, songs);
}
void SpotifyFavoriteRequest::AddSongs(const SongList &songs) {
AddFavorites(FavoriteType_Songs, songs);
}
void SpotifyFavoriteRequest::AddSongs(const SongMap &songs) {
AddFavorites(FavoriteType_Songs, songs.values());
}
void SpotifyFavoriteRequest::AddFavorites(const FavoriteType type, const SongList &songs) {
QStringList list_ids;
QJsonArray array_ids;
for (const Song &song : songs) {
QString id;
switch (type) {
case FavoriteType_Artists:
id = song.artist_id();
break;
case FavoriteType_Albums:
id = song.album_id();
break;
case FavoriteType_Songs:
id = song.song_id();
break;
}
if (!id.isEmpty()) {
if (!list_ids.contains(id)) {
list_ids << id;
}
if (!array_ids.contains(id)) {
array_ids << id;
}
}
}
if (list_ids.isEmpty() || array_ids.isEmpty()) return;
QByteArray json_data = QJsonDocument(array_ids).toJson();
QString ids_list = list_ids.join(",");
AddFavoritesRequest(type, ids_list, json_data, songs);
}
void SpotifyFavoriteRequest::AddFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) {
QUrl url(SpotifyService::kApiUrl + (type == FavoriteType_Artists ? QString("/me/following") : QString("/me/") + FavoriteText(type)));
if (type == FavoriteType_Artists) {
QUrlQuery url_query;
url_query.addQueryItem("type", "artist");
url_query.addQueryItem("ids", ids_list);
url.setQuery(url_query);
}
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
QNetworkReply *reply = nullptr;
if (type == FavoriteType_Artists) {
reply = network_->put(req, "");
}
else {
reply = network_->put(req, json_data);
}
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { AddFavoritesReply(reply, type, songs); });
replies_ << reply;
}
void SpotifyFavoriteRequest::AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
GetReplyData(reply);
if (reply->error() != QNetworkReply::NoError) {
return;
}
if (type == FavoriteType_Artists) {
qLog(Debug) << "Spotify:" << songs.count() << "songs added to followed" << FavoriteText(type);
}
else {
qLog(Debug) << "Spotify:" << songs.count() << "songs added to saved" << FavoriteText(type);
}
switch (type) {
case FavoriteType_Artists:
emit ArtistsAdded(songs);
break;
case FavoriteType_Albums:
emit AlbumsAdded(songs);
break;
case FavoriteType_Songs:
emit SongsAdded(songs);
break;
}
}
void SpotifyFavoriteRequest::RemoveArtists(const SongList &songs) {
RemoveFavorites(FavoriteType_Artists, songs);
}
void SpotifyFavoriteRequest::RemoveAlbums(const SongList &songs) {
RemoveFavorites(FavoriteType_Albums, songs);
}
void SpotifyFavoriteRequest::RemoveSongs(const SongList &songs) {
RemoveFavorites(FavoriteType_Songs, songs);
}
void SpotifyFavoriteRequest::RemoveSongs(const SongMap &songs) {
RemoveFavorites(FavoriteType_Songs, songs.values());
}
void SpotifyFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongList &songs) {
QStringList list_ids;
QJsonArray array_ids;
for (const Song &song : songs) {
QString id;
switch (type) {
case FavoriteType_Artists:
id = song.artist_id();
break;
case FavoriteType_Albums:
id = song.album_id();
break;
case FavoriteType_Songs:
id = song.song_id();
break;
}
if (!id.isEmpty()) {
if (!list_ids.contains(id)) {
list_ids << id;
}
if (!array_ids.contains(id)) {
array_ids << id;
}
}
}
if (list_ids.isEmpty() || array_ids.isEmpty()) return;
QByteArray json_data = QJsonDocument(array_ids).toJson();
QString ids_list = list_ids.join(",");
RemoveFavoritesRequest(type, ids_list, json_data, songs);
}
void SpotifyFavoriteRequest::RemoveFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) {
Q_UNUSED(json_data)
QUrl url(SpotifyService::kApiUrl + (type == FavoriteType_Artists ? QString("/me/following") : QString("/me/") + FavoriteText(type)));
if (type == FavoriteType_Artists) {
QUrlQuery url_query;
url_query.addQueryItem("type", "artist");
url_query.addQueryItem("ids", ids_list);
url.setQuery(url_query);
}
QNetworkRequest req(url);
#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0)
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
#else
req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
#endif
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8());
QNetworkReply *reply = nullptr;
if (type == FavoriteType_Artists) {
reply = network_->deleteResource(req);
}
else {
// FIXME
reply = network_->deleteResource(req);
}
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { RemoveFavoritesReply(reply, type, songs); });
replies_ << reply;
}
void SpotifyFavoriteRequest::RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
GetReplyData(reply);
if (reply->error() != QNetworkReply::NoError) {
return;
}
if (type == FavoriteType_Artists) {
qLog(Debug) << "Spotify:" << songs.count() << "songs removed from followed" << FavoriteText(type);
}
else {
qLog(Debug) << "Spotify:" << songs.count() << "songs removed from saved" << FavoriteText(type);
}
switch (type) {
case FavoriteType_Artists:
emit ArtistsRemoved(songs);
break;
case FavoriteType_Albums:
emit AlbumsRemoved(songs);
break;
case FavoriteType_Songs:
emit SongsRemoved(songs);
break;
}
}
void SpotifyFavoriteRequest::Error(const QString &error, const QVariant &debug) {
qLog(Error) << "Spotify:" << error;
if (debug.isValid()) qLog(Debug) << debug;
}

View File

@ -0,0 +1,90 @@
/*
* Strawberry Music Player
* Copyright 2022, 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,241 @@
/*
* Strawberry Music Player
* Copyright 2022, 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 SpotifyUrlHandler;
class SpotifyRequest : public SpotifyBaseRequest {
Q_OBJECT
public:
explicit SpotifyRequest(SpotifyService *service, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent);
~SpotifyRequest() override;
void ReloadSettings();
void Process();
void Search(const int query_id, const QString &search_text);
private:
struct Artist {
QString artist_id;
QString artist;
};
struct Album {
QString album_id;
QString album;
QUrl cover_url;
};
struct Request {
Request() : offset(0), limit(0) {}
int offset;
int limit;
};
struct ArtistAlbumsRequest {
ArtistAlbumsRequest() : offset(0), limit(0) {}
Artist artist;
int offset;
int limit;
};
struct AlbumSongsRequest {
AlbumSongsRequest() : offset(0), limit(0) {}
Artist artist;
Album album;
int offset;
int limit;
};
struct AlbumCoverRequest {
QString artist_id;
QString album_id;
QUrl url;
QString filename;
};
signals:
void Results(int id, SongMap songs, QString error);
void UpdateStatus(int id, QString text);
void ProgressSetMaximum(int id, int max);
void UpdateProgress(int id, int max);
void StreamURLFinished(QUrl original_url, QUrl url, Song::FileType, QString error = QString());
private slots:
void FlushRequests();
void ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
void AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
void AlbumsReceived(QNetworkReply *reply, const Artist &artist_artist, const int limit_requested, const int offset_requested);
void SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested);
void SongsReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int limit_requested, const int offset_requested);
void ArtistAlbumsReplyReceived(QNetworkReply *reply, const Artist &artist, const int offset_requested);
void AlbumSongsReplyReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int offset_requested);
void AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename);
private:
void StartRequests();
bool IsQuery() { return (type_ == QueryType_Artists || type_ == QueryType_Albums || type_ == QueryType_Songs); }
bool IsSearch() { return (type_ == QueryType_SearchArtists || type_ == QueryType_SearchAlbums || type_ == QueryType_SearchSongs); }
void GetArtists();
void GetAlbums();
void GetSongs();
void ArtistsSearch();
void AlbumsSearch();
void SongsSearch();
void AddArtistsRequest(const int offset = 0, const int limit = 0);
void AddArtistsSearchRequest(const int offset = 0);
void FlushArtistsRequests();
void AddAlbumsRequest(const int offset = 0, const int limit = 0);
void AddAlbumsSearchRequest(const int offset = 0);
void FlushAlbumsRequests();
void AddSongsRequest(const int offset = 0, const int limit = 0);
void AddSongsSearchRequest(const int offset = 0);
void FlushSongsRequests();
void ArtistsFinishCheck(const int limit = 0, const int offset = 0, const int artists_received = 0);
void AlbumsFinishCheck(const Artist &artist, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0);
void SongsFinishCheck(const Artist &artist, const Album &album, const int limit = 0, const int offset = 0, const int songs_total = 0, const int songs_received = 0);
void AddArtistAlbumsRequest(const Artist &artist, const int offset = 0);
void FlushArtistAlbumsRequests();
void AddAlbumSongsRequest(const Artist &artist, const Album &album, const int offset = 0);
void FlushAlbumSongsRequests();
void ParseSong(Song &song, const QJsonObject &json_obj, const Artist &album_artist, const Album &album);
void GetAlbumCoversCheck();
void GetAlbumCovers();
void AddAlbumCoverRequest(const Song &song);
void FlushAlbumCoverRequests();
void AlbumCoverFinishCheck();
int GetProgress(const int count, const int total);
void FinishCheck();
static void Warn(const QString &error, const QVariant &debug = QVariant());
void Error(const QString &error, const QVariant &debug = QVariant()) override;
static const int kMaxConcurrentArtistsRequests;
static const int kMaxConcurrentAlbumsRequests;
static const int kMaxConcurrentSongsRequests;
static const int kMaxConcurrentArtistAlbumsRequests;
static const int kMaxConcurrentAlbumSongsRequests;
static const int kMaxConcurrentAlbumCoverRequests;
static const int kFlushRequestsDelay;
SpotifyService *service_;
Application *app_;
NetworkAccessManager *network_;
QTimer *timer_flush_requests_;
QueryType type_;
bool fetchalbums_;
QString coversize_;
int query_id_;
QString search_text_;
bool finished_;
QQueue<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,769 @@
/*
* Strawberry Music Player
* Copyright 2022, 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 <QSortFilterProxyModel>
#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 "utilities/timeconstants.h"
#include "utilities/randutils.h"
#include "internet/localredirectserver.h"
#include "internet/internetsearchview.h"
#include "collection/collectionbackend.h"
#include "collection/collectionmodel.h"
#include "spotifyservice.h"
#include "spotifybaserequest.h"
#include "spotifyrequest.h"
#include "spotifyfavoriterequest.h"
#include "settings/settingsdialog.h"
#include "settings/spotifysettingspage.h"
const Song::Source SpotifyService::kSource = Song::Source::Spotify;
const char *SpotifyService::kOAuthAuthorizeUrl = "https://accounts.spotify.com/authorize";
const char *SpotifyService::kOAuthAccessTokenUrl = "https://accounts.spotify.com/api/token";
const char *SpotifyService::kOAuthRedirectUrl = "http://localhost:63111/";
const char *SpotifyService::kClientIDB64 = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc=";
const char *SpotifyService::kClientSecretB64 = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE=";
const char *SpotifyService::kApiUrl = "https://api.spotify.com/v1";
const char *SpotifyService::kArtistsSongsTable = "spotify_artists_songs";
const char *SpotifyService::kAlbumsSongsTable = "spotify_albums_songs";
const char *SpotifyService::kSongsTable = "spotify_songs";
const char *SpotifyService::kArtistsSongsFtsTable = "spotify_artists_songs_fts";
const char *SpotifyService::kAlbumsSongsFtsTable = "spotify_albums_songs_fts";
const char *SpotifyService::kSongsFtsTable = "spotify_songs_fts";
using std::make_shared;
using namespace std::chrono_literals;
SpotifyService::SpotifyService(Application *app, QObject *parent)
: InternetService(Song::Source::Spotify, "Spotify", "spotify", SpotifySettingsPage::kSettingsGroup, SettingsDialog::Page::Spotify, app, parent),
app_(app),
network_(new NetworkAccessManager(this)),
artists_collection_backend_(nullptr),
albums_collection_backend_(nullptr),
songs_collection_backend_(nullptr),
artists_collection_model_(nullptr),
albums_collection_model_(nullptr),
songs_collection_model_(nullptr),
artists_collection_sort_model_(new QSortFilterProxyModel(this)),
albums_collection_sort_model_(new QSortFilterProxyModel(this)),
songs_collection_sort_model_(new QSortFilterProxyModel(this)),
timer_search_delay_(new QTimer(this)),
timer_refresh_login_(new QTimer(this)),
favorite_request_(new SpotifyFavoriteRequest(this, network_, this)),
enabled_(false),
artistssearchlimit_(1),
albumssearchlimit_(1),
songssearchlimit_(1),
fetchalbums_(true),
download_album_covers_(true),
expires_in_(0),
login_time_(0),
pending_search_id_(0),
next_pending_search_id_(1),
pending_search_type_(InternetSearchView::SearchType::Artists),
search_id_(0),
server_(nullptr) {
// Backends
artists_collection_backend_ = make_shared<CollectionBackend>();
artists_collection_backend_->moveToThread(app_->database()->thread());
artists_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kArtistsSongsTable, kArtistsSongsFtsTable);
albums_collection_backend_ = make_shared<CollectionBackend>();
albums_collection_backend_->moveToThread(app_->database()->thread());
albums_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kAlbumsSongsTable, kAlbumsSongsFtsTable);
songs_collection_backend_ = make_shared<CollectionBackend>();
songs_collection_backend_->moveToThread(app_->database()->thread());
songs_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kSongsTable, kSongsFtsTable);
artists_collection_model_ = new CollectionModel(artists_collection_backend_, app_, this);
albums_collection_model_ = new CollectionModel(albums_collection_backend_, app_, this);
songs_collection_model_ = new CollectionModel(songs_collection_backend_, app_, this);
artists_collection_sort_model_->setSourceModel(artists_collection_model_);
artists_collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
artists_collection_sort_model_->setDynamicSortFilter(true);
artists_collection_sort_model_->setSortLocaleAware(true);
artists_collection_sort_model_->sort(0);
albums_collection_sort_model_->setSourceModel(albums_collection_model_);
albums_collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
albums_collection_sort_model_->setDynamicSortFilter(true);
albums_collection_sort_model_->setSortLocaleAware(true);
albums_collection_sort_model_->sort(0);
songs_collection_sort_model_->setSourceModel(songs_collection_model_);
songs_collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
songs_collection_sort_model_->setDynamicSortFilter(true);
songs_collection_sort_model_->setSortLocaleAware(true);
songs_collection_sort_model_->sort(0);
timer_refresh_login_->setSingleShot(true);
QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken);
timer_search_delay_->setSingleShot(true);
QObject::connect(timer_search_delay_, &QTimer::timeout, this, &SpotifyService::StartSearch);
QObject::connect(this, &SpotifyService::AddArtists, favorite_request_, &SpotifyFavoriteRequest::AddArtists);
QObject::connect(this, &SpotifyService::AddAlbums, favorite_request_, &SpotifyFavoriteRequest::AddAlbums);
QObject::connect(this, &SpotifyService::AddSongs, favorite_request_, QOverload<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);
QSettings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
access_token_ = s.value("access_token").toString();
refresh_token_ = s.value("refresh_token").toString();
expires_in_ = s.value("expires_in").toLongLong();
login_time_ = s.value("login_time").toLongLong();
s.endGroup();
if (!refresh_token_.isEmpty()) {
qint64 time = static_cast<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() {
QSettings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
enabled_ = s.value("enabled", false).toBool();
quint64 search_delay = std::max(s.value("searchdelay", 1500).toInt(), 500);
artistssearchlimit_ = s.value("artistssearchlimit", 4).toInt();
albumssearchlimit_ = s.value("albumssearchlimit", 10).toInt();
songssearchlimit_ = s.value("songssearchlimit", 10).toInt();
fetchalbums_ = s.value("fetchalbums", false).toBool();
download_album_covers_ = s.value("downloadalbumcovers", true).toBool();
s.endGroup();
timer_search_delay_->setInterval(static_cast<int>(search_delay));
}
void SpotifyService::Authenticate() {
QUrl redirect_url(kOAuthRedirectUrl);
if (!server_) {
server_ = new LocalRedirectServer(this);
int port = redirect_url.port();
int port_max = port + 10;
bool success = false;
forever {
server_->set_port(port);
if (server_->Listen()) {
success = true;
break;
}
++port;
if (port > port_max) break;
}
if (!success) {
LoginError(server_->error());
server_->deleteLater();
server_ = nullptr;
return;
}
QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyService::RedirectArrived);
}
code_verifier_ = Utilities::CryptographicRandomString(44);
code_challenge_ = QString(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) {
code_challenge_.chop(1);
}
const ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64))
<< Param("response_type", "code")
<< Param("redirect_uri", redirect_url.toString())
<< Param("state", code_challenge_)
<< Param("scope", "user-follow-read user-follow-modify user-library-read user-library-modify streaming");
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
QUrl url(kOAuthAuthorizeUrl);
url.setQuery(url_query);
const bool result = QDesktopServices::openUrl(url);
if (!result) {
QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QString(":<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;
QSettings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
s.remove("access_token");
s.remove("refresh_token");
s.remove("expires_in");
s.remove("login_time");
s.endGroup();
refresh_login_timer_.stop();
}
void SpotifyService::RedirectArrived() {
if (!server_) return;
if (server_->error().isEmpty()) {
QUrl url = server_->request_url();
if (url.isValid()) {
QUrlQuery url_query(url);
if (url_query.hasQueryItem("error")) {
LoginError(QUrlQuery(url).queryItemValue("error"));
}
else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) {
qLog(Debug) << "Spotify: Authorization URL Received" << url;
QString code = url_query.queryItemValue("code");
QUrl redirect_url(kOAuthRedirectUrl);
redirect_url.setPort(server_->url().port());
RequestAccessToken(code, redirect_url);
}
else {
LoginError(tr("Redirect missing token code or state!"));
}
}
else {
LoginError(tr("Received invalid reply from web browser."));
}
}
else {
LoginError(server_->error());
}
server_->close();
server_->deleteLater();
server_ = nullptr;
}
void SpotifyService::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
refresh_login_timer_.stop();
ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64))
<< Param("client_secret", QByteArray::fromBase64(kClientSecretB64));
if (!code.isEmpty() && !redirect_url.isEmpty()) {
params << Param("grant_type", "authorization_code");
params << Param("code", code);
params << Param("redirect_uri", redirect_url.toString());
}
else if (!refresh_token_.isEmpty() && enabled_) {
params << Param("grant_type", "refresh_token");
params << Param("refresh_token", refresh_token_);
}
else {
return;
}
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
}
QUrl new_url(kOAuthAccessTokenUrl);
QNetworkRequest req(new_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QString auth_header_data = QByteArray::fromBase64(kClientIDB64) + QString(":") + QByteArray::fromBase64(kClientSecretB64);
req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64());
QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8();
QNetworkReply *reply = network_->post(req, query);
replies_ << reply;
QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyService::HandleLoginSSLErrors);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); });
}
void SpotifyService::HandleLoginSSLErrors(const QList<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(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
return;
}
else {
// See if there is Json data containing "error" and "error_description" then use that instead.
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains("error") && json_obj.contains("error_description")) {
QString error = json_obj["error"].toString();
QString error_description = json_obj["error_description"].toString();
login_errors_ << QString("Authentication failure: %1 (%2)").arg(error, error_description);
}
}
if (login_errors_.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
LoginError();
return;
}
}
QByteArray data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error(QString("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
return;
}
if (json_doc.isEmpty()) {
LoginError("Authentication reply from server has empty Json document.");
return;
}
if (!json_doc.isObject()) {
LoginError("Authentication reply from server has Json document that is not an object.", json_doc);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
LoginError("Authentication reply from server has empty Json object.", json_doc);
return;
}
if (!json_obj.contains("access_token") || !json_obj.contains("expires_in")) {
LoginError("Authentication reply from server is missing access token or expires in.", json_obj);
return;
}
access_token_ = json_obj["access_token"].toString();
if (json_obj.contains("refresh_token")) {
refresh_token_ = json_obj["refresh_token"].toString();
}
expires_in_ = json_obj["expires_in"].toInt();
login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch();
QSettings s;
s.beginGroup(SpotifySettingsPage::kSettingsGroup);
s.setValue("access_token", access_token_);
s.setValue("refresh_token", refresh_token_);
s.setValue("expires_in", expires_in_);
s.setValue("login_time", login_time_);
s.endGroup();
if (expires_in_ > 0) {
refresh_login_timer_.setInterval(static_cast<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, InternetSearchView::SearchType type) {
pending_search_id_ = next_pending_search_id_;
pending_search_text_ = text;
pending_search_type_ = type;
next_pending_search_id_++;
if (text.isEmpty()) {
timer_search_delay_->stop();
return pending_search_id_;
}
timer_search_delay_->start();
return pending_search_id_;
}
void SpotifyService::StartSearch() {
if (!authenticated()) {
emit SearchResults(pending_search_id_, SongMap(), tr("Not authenticated with Spotify."));
ShowConfig();
return;
}
search_id_ = pending_search_id_;
search_text_ = pending_search_text_;
SendSearch();
}
void SpotifyService::CancelSearch() {
}
void SpotifyService::SendSearch() {
SpotifyBaseRequest::QueryType type = SpotifyBaseRequest::QueryType_None;
switch (pending_search_type_) {
case InternetSearchView::SearchType::Artists:
type = SpotifyBaseRequest::QueryType_SearchArtists;
break;
case InternetSearchView::SearchType::Albums:
type = SpotifyBaseRequest::QueryType_SearchAlbums;
break;
case InternetSearchView::SearchType::Songs:
type = SpotifyBaseRequest::QueryType_SearchSongs;
break;
default:
//Error("Invalid search type.");
return;
}
search_request_.reset(new SpotifyRequest(this, app_, network_, type, this), [](SpotifyRequest *request) { request->deleteLater(); });
QObject::connect(search_request_.get(), &SpotifyRequest::Results, this, &SpotifyService::SearchResultsReceived);
QObject::connect(search_request_.get(), &SpotifyRequest::UpdateStatus, this, &SpotifyService::SearchUpdateStatus);
QObject::connect(search_request_.get(), &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SearchProgressSetMaximum);
QObject::connect(search_request_.get(), &SpotifyRequest::UpdateProgress, this, &SpotifyService::SearchUpdateProgress);
search_request_->Search(search_id_, search_text_);
search_request_->Process();
}
void SpotifyService::SearchResultsReceived(const int id, const SongMap &songs, const QString &error) {
emit SearchResults(id, songs, error);
search_request_.reset();
}
void SpotifyService::LoginError(const QString &error, const QVariant &debug) {
if (!error.isEmpty()) login_errors_ << error;
QString error_html;
for (const QString &e : login_errors_) {
qLog(Error) << "Spotify:" << e;
error_html += e + "<br />";
}
if (debug.isValid()) qLog(Debug) << debug;
emit LoginFailure(error_html);
emit LoginComplete(false);
login_errors_.clear();
}

View File

@ -0,0 +1,214 @@
/*
* Strawberry Music Player
* Copyright 2022, 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 "internet/internetservice.h"
#include "internet/internetsearchview.h"
#include "settings/spotifysettingspage.h"
class QSortFilterProxyModel;
class QNetworkReply;
class Application;
class NetworkAccessManager;
class SpotifyUrlHandler;
class SpotifyRequest;
class SpotifyFavoriteRequest;
class SpotifyStreamURLRequest;
class CollectionBackend;
class CollectionModel;
class LocalRedirectServer;
class SpotifyService : public InternetService {
Q_OBJECT
public:
explicit SpotifyService(Application *app, QObject *parent = nullptr);
~SpotifyService() override;
static const Song::Source kSource;
static const char *kApiUrl;
static const char *kResourcesUrl;
void Exit() override;
void ReloadSettings() override;
int Search(const QString &text, InternetSearchView::SearchType type) override;
void CancelSearch() override;
Application *app() { return app_; }
int artistssearchlimit() { return artistssearchlimit_; }
int albumssearchlimit() { return albumssearchlimit_; }
int songssearchlimit() { return songssearchlimit_; }
bool fetchalbums() { return fetchalbums_; }
bool download_album_covers() { return download_album_covers_; }
QString access_token() { return access_token_; }
bool authenticated() const override { return !access_token_.isEmpty(); }
SharedPtr<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_; }
QSortFilterProxyModel *artists_collection_sort_model() override { return artists_collection_sort_model_; }
QSortFilterProxyModel *albums_collection_sort_model() override { return albums_collection_sort_model_; }
QSortFilterProxyModel *songs_collection_sort_model() override { return songs_collection_sort_model_; }
public slots:
void ShowConfig() override;
void Authenticate();
void Deauthenticate();
void GetArtists() override;
void GetAlbums() override;
void GetSongs() override;
void ResetArtistsRequest() override;
void ResetAlbumsRequest() override;
void ResetSongsRequest() override;
private slots:
void ExitReceived();
void RedirectArrived();
void RequestNewAccessToken() { RequestAccessToken(); }
void HandleLoginSSLErrors(const QList<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());
static const char *kOAuthAuthorizeUrl;
static const char *kOAuthAccessTokenUrl;
static const char *kOAuthRedirectUrl;
static const char *kAuthUrl;
static const char *kClientIDB64;
static const char *kClientSecretB64;
static const char *kArtistsSongsTable;
static const char *kAlbumsSongsTable;
static const char *kSongsTable;
static const char *kArtistsSongsFtsTable;
static const char *kAlbumsSongsFtsTable;
static const char *kSongsFtsTable;
Application *app_;
NetworkAccessManager *network_;
SpotifyUrlHandler *url_handler_;
SharedPtr<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_;
QSortFilterProxyModel *artists_collection_sort_model_;
QSortFilterProxyModel *albums_collection_sort_model_;
QSortFilterProxyModel *songs_collection_sort_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_;
InternetSearchView::SearchType pending_search_type_;
int search_id_;
QString search_text_;
QString code_verifier_;
QString code_challenge_;
LocalRedirectServer *server_;
QStringList login_errors_;
QTimer refresh_login_timer_;
QList<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]];
case Song::Source::Subsonic:
case Song::Source::Spotify:
case Song::Source::Qobuz:
if (!album_id.isEmpty()) {
filename = album_id;