From 6a51ee32b58492749c30ea4107970e68f20c1009 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Sat, 4 Jun 2022 15:51:35 +0200 Subject: [PATCH] Spotify WIP --- CMakeLists.txt | 1 + data/icons.qrc | 5 + data/icons/128x128/spotify.png | Bin 0 -> 4118 bytes data/icons/22x22/spotify.png | Bin 0 -> 1200 bytes data/icons/32x32/spotify.png | Bin 0 -> 1567 bytes data/icons/48x48/spotify.png | Bin 0 -> 2045 bytes data/icons/64x64/spotify.png | Bin 0 -> 2495 bytes data/icons/full/spotify.png | Bin 0 -> 16410 bytes data/schema/schema-19.sql | 289 ++++ data/schema/schema.sql | 231 +++ src/CMakeLists.txt | 21 +- src/config.h.in | 1 + src/core/application.cpp | 13 +- src/core/mainwindow.cpp | 36 + src/core/mainwindow.h | 9 + src/core/song.cpp | 22 +- src/core/song.h | 3 +- .../albumcoverchoicecontroller.cpp | 5 +- src/covermanager/spotifycoverprovider.cpp | 317 +--- src/covermanager/spotifycoverprovider.h | 25 +- src/engine/enginebase.cpp | 12 + src/engine/enginebase.h | 6 + src/engine/gstengine.cpp | 8 +- src/engine/gstenginepipeline.cpp | 16 + src/engine/gstenginepipeline.h | 9 + src/playlist/playlistitem.cpp | 2 + src/settings/settingsdialog.cpp | 8 +- src/settings/settingsdialog.h | 1 + src/settings/spotifysettingspage.cpp | 169 ++ src/settings/spotifysettingspage.h | 64 + src/settings/spotifysettingspage.ui | 321 ++++ src/spotify/spotifybaserequest.cpp | 185 +++ src/spotify/spotifybaserequest.h | 91 ++ src/spotify/spotifyfavoriterequest.cpp | 310 ++++ src/spotify/spotifyfavoriterequest.h | 90 ++ src/spotify/spotifyrequest.cpp | 1401 +++++++++++++++++ src/spotify/spotifyrequest.h | 241 +++ src/spotify/spotifyservice.cpp | 769 +++++++++ src/spotify/spotifyservice.h | 214 +++ src/utilities/coverutils.cpp | 1 + 40 files changed, 4551 insertions(+), 345 deletions(-) create mode 100644 data/icons/128x128/spotify.png create mode 100644 data/icons/22x22/spotify.png create mode 100644 data/icons/32x32/spotify.png create mode 100644 data/icons/48x48/spotify.png create mode 100644 data/icons/64x64/spotify.png create mode 100755 data/icons/full/spotify.png create mode 100644 data/schema/schema-19.sql create mode 100644 src/settings/spotifysettingspage.cpp create mode 100644 src/settings/spotifysettingspage.h create mode 100644 src/settings/spotifysettingspage.ui create mode 100644 src/spotify/spotifybaserequest.cpp create mode 100644 src/spotify/spotifybaserequest.h create mode 100644 src/spotify/spotifyfavoriterequest.cpp create mode 100644 src/spotify/spotifyfavoriterequest.h create mode 100644 src/spotify/spotifyrequest.cpp create mode 100644 src/spotify/spotifyrequest.h create mode 100644 src/spotify/spotifyservice.cpp create mode 100644 src/spotify/spotifyservice.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 07409145..e0d82e9d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -439,6 +439,7 @@ option(INSTALL_TRANSLATIONS "Install translations" OFF) optional_component(SUBSONIC ON "Streaming: Subsonic") optional_component(TIDAL ON "Streaming: Tidal") +optional_component(SPOTIFY ON "Streaming: Spotify" DEPENDS "gstreamer" GSTREAMER_FOUND) optional_component(QOBUZ ON "Streaming: Qobuz") optional_component(MOODBAR ON "Moodbar" diff --git a/data/icons.qrc b/data/icons.qrc index 847f5c96..ebbfe2f3 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -91,6 +91,7 @@ icons/128x128/love.png icons/128x128/subsonic.png icons/128x128/tidal.png + icons/128x128/spotify.png icons/128x128/qobuz.png icons/128x128/multimedia-player-ipod-standard-black.png icons/128x128/radio.png @@ -189,6 +190,7 @@ icons/64x64/love.png icons/64x64/subsonic.png icons/64x64/tidal.png + icons/64x64/spotify.png icons/64x64/qobuz.png icons/64x64/multimedia-player-ipod-standard-black.png icons/64x64/radio.png @@ -291,6 +293,7 @@ icons/48x48/love.png icons/48x48/subsonic.png icons/48x48/tidal.png + icons/48x48/spotify.png icons/48x48/qobuz.png icons/48x48/multimedia-player-ipod-standard-black.png icons/48x48/radio.png @@ -393,6 +396,7 @@ icons/32x32/love.png icons/32x32/subsonic.png icons/32x32/tidal.png + icons/32x32/spotify.png icons/32x32/qobuz.png icons/32x32/multimedia-player-ipod-standard-black.png icons/32x32/radio.png @@ -495,6 +499,7 @@ icons/22x22/love.png icons/22x22/subsonic.png icons/22x22/tidal.png + icons/22x22/spotify.png icons/22x22/qobuz.png icons/22x22/multimedia-player-ipod-standard-black.png icons/22x22/radio.png diff --git a/data/icons/128x128/spotify.png b/data/icons/128x128/spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..fb62bfe1ee5df3d51fd4cde743719d6b22259978 GIT binary patch literal 4118 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 literal 0 HcmV?d00001 diff --git a/data/icons/48x48/spotify.png b/data/icons/48x48/spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..4c1facad8655bded9fa8bded9b4ce2f97bc5f1f3 GIT binary patch literal 2045 zcmeH|cTm$=5XT=05CR4eq)HD+4JL$8!g&M;l1PV02NNJPMOs2}IFF`-NEb08Xohwm zaECM%MROr^f(nQTVm%N<6h!4jg!?)7*ZqHgI$fpH*e>?d1pM_oKTXgk^le{ zl|uG{6#LyJ;83JLL3TnS8foWh2f(!=sV#;Wlxu}iyj%e|u@8WZ8~`?ZomY!o=u9G^&Q9c^ zn4ZWWC_%T_sEA-uT^$h%Vw&<+>h)KB3jlx@Qpt8S%0L*M1k2ar z2}b$D{bPR)jr9q8M-YmkJZX~G7A(u2uT!nhBhg%f?7a@DUQpz=<~LVvA+ZHA>PDwW%}v(LIn;yeY45l22F7*glVL-WDL8gL9?1t`(btsMfaAR+7rw0fGkG1b)9_t%&kn ziga6hN<1quIf0$DvHFqE+GFd1K-ZY)o@_aG`JJfpm7(h-wB zhrn5M9dfrh+h5q3pO3MiCmX}}@{zIUS8l3T!L&O&wPC??!~=>J$sUbQ?`$JweRjib zT)vT)spmZ5WsT*Lc_TEM{xB|{$7%5kW6ZFe%M!H|+^@y2IPjssr;@UlrdICVbXm-NBP2^3${{ zpH6xlilsZ~zJMb#RcjRz#64%b)#$S7i5XHI*=+_jrK)b~`fsHN1j3B3Unoj9CO!IN z#SGp~sX6*-p$s{bo$j(?f;z6AW=)|8?#)UejxXsO>=0_+f!EH9Lwobz)D)o~%H)qO ztvv0}bU?ez2%^JXvS50DU*wK=pfB=3>RV!v#S*Qs% z7LDDmOH4n59;+V9(T^3*U@?hdiA+d<8P?1UgC$__I2z8HNFWe#1Y<0gh{b;3JE#8# zh>Z)6j!1(5oH+(-jltn*SOO7mMI@O03&aW4Iv_yh`wrf55v&wOA`{q!$AvPrsIiPl ZrWcbDo|Z7gg#KgzsE%&r-|gvH{{Y?UWPktw literal 0 HcmV?d00001 diff --git a/data/icons/64x64/spotify.png b/data/icons/64x64/spotify.png new file mode 100644 index 0000000000000000000000000000000000000000..2fcc5d6dfbbbed709a8d83fe3438f47f7f30e22b GIT binary patch literal 2495 zcmeHJ_fyk}68{3hgitQ@rh*a?r6qJD90U>)0^}e>2|Y*x2m&I3ho@pxilBfclI+oyR);i@eV|sI8+G=0D!m+ z9z)`z#V>^j@h!XV#u6VuerS6%08}N5?s*IIeN`Vk$sPdyR0jZNGyv@IL(F*q2uA?G zH!1+2G5|pSVs@jWDF6tv9PG$ge!}4)>Ob<&1pZGZz~k{|)4Lz=_i~g$B02$pfRwha zKQlS~x*tJP60GHu-8K97-c091?~?}((W>Wv&Q&U_-yGf_h*DGdb7C}HR>=NMJ6=#a zetqQ@Oyflb{8X>L`G`q>`LcD3% zmvlKv6r}ViN7&%1!;>=*iG%Xum}i^QWjZA<`zFUf))yvTq_|=8-Y-4BVW;A;%_=?o zW$NpEWS88-G@#h z@jlb7(;i1F1@D7O73eH2$DA3N8`sG2;#?x@VeJ4?Fbe;<_E7layN3v1kV5ZUQp0-w zd~e-zMG-`OU|rzpBC}IsAcgL0S!@-o@Z|`QLZqLrn|Xpdrw=mBn^?{F7kD8-*>xHE zJemO46WBEG`KdA z3F(0|xr}X)2Lq3w-^6w${!^}Z$J)dhMVfVJTlIvZs zOJtt&4ApgCdLg(2hrw=7>v)^xzFjhRdab-gS?!u+wfvD4YpMI2SHtLsvJYQiOC$Np zHAy273lp@7#kH+!_eX&{j=Pdh@`fHwYul9cm>Xst$F;nI1*0`pp_^%^ZC=H*bOlpc zXWI)}9wxfBt7TkUPZ0MG58?#ipX%HKjlgLdrE*4{#EC}POJfw9F}3cMt=2;cYIU)R z;kS!#yG{kSuotd~gGY~f)z-|cF0QqZXh2F$yx zhWEsTOo_E8_e*2)Kfw-n@ep1%?`fvb43e#5cNLV}5+hS5Q5V`UwivGdwV_XS5mu)5 z0$S636>(RhVm_->qH4EH*mfuPm*ekBs2Y2JzXBZQK8i3O`(f419m_ZGP?FM*YMkPR z(W1pu8nOujuPlsGJ1?7Ur>7$$2A<;Mmm@bcljc>9o=$_QNOoJ79iRA~Mn2G#(pnVS z?)(M{$Z{{VjkBx@GD3yE#+hj7lAd5|3=DMo!Z`Mf4 zGxAR;%|tRh15R3HVgSQ&R0xC!W?SSK%z`zAprG!f5qpx*6p+3)XN4l=WMR#^~fO1 zOmjk(M>GhOCVv;-Ssej~ccK;4xIL?QZI>S%1!N;o&Gx&FChm*{8vd6{L6u>VTlCq(yN(Q?RqAsb*L7$W|$esIJY9%ktlWV z-W-aJ|MX)_C5I+DLse!Vslzf-=5MRc>yf4uKG%e&DUDYPY+mBA7d($q5 TcG3A?6#&>^iI{Rr>dpTE72;rR literal 0 HcmV?d00001 diff --git a/data/icons/full/spotify.png b/data/icons/full/spotify.png new file mode 100755 index 0000000000000000000000000000000000000000..26410e2fe2ec1e88d81aad43aebdb40691e4db3b GIT binary patch literal 16410 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 L-Tx}otRwzE3yc;) literal 0 HcmV?d00001 diff --git a/data/schema/schema-19.sql b/data/schema/schema-19.sql new file mode 100644 index 00000000..216c2717 --- /dev/null +++ b/data/schema/schema-19.sql @@ -0,0 +1,289 @@ +CREATE TABLE IF NOT EXISTS spotify_artists_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT -1, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + fingerprint TEXT, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + lastseen INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_embedded INTEGER DEFAULT 0, + art_automatic TEXT, + art_manual TEXT, + art_unset INTEGER DEFAULT 0, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT, + + rating INTEGER DEFAULT -1, + + acoustid_id TEXT, + acoustid_fingerprint TEXT, + + musicbrainz_album_artist_id TEXT, + musicbrainz_artist_id TEXT, + musicbrainz_original_artist_id TEXT, + musicbrainz_album_id TEXT, + musicbrainz_original_album_id TEXT, + musicbrainz_recording_id TEXT, + musicbrainz_track_id TEXT, + musicbrainz_disc_id TEXT, + musicbrainz_release_group_id TEXT, + musicbrainz_work_id TEXT, + + ebur128_integrated_loudness_lufs REAL, + ebur128_loudness_range_lu REAL + +); + +CREATE TABLE IF NOT EXISTS spotify_albums_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT -1, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + fingerprint TEXT, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + lastseen INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_embedded INTEGER DEFAULT 0, + art_automatic TEXT, + art_manual TEXT, + art_unset INTEGER DEFAULT 0, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT, + + rating INTEGER DEFAULT -1, + + acoustid_id TEXT, + acoustid_fingerprint TEXT, + + musicbrainz_album_artist_id TEXT, + musicbrainz_artist_id TEXT, + musicbrainz_original_artist_id TEXT, + musicbrainz_album_id TEXT, + musicbrainz_original_album_id TEXT, + musicbrainz_recording_id TEXT, + musicbrainz_track_id TEXT, + musicbrainz_disc_id TEXT, + musicbrainz_release_group_id TEXT, + musicbrainz_work_id TEXT, + + ebur128_integrated_loudness_lufs REAL, + ebur128_loudness_range_lu REAL + +); + +CREATE TABLE IF NOT EXISTS spotify_songs ( + + title TEXT, + album TEXT, + artist TEXT, + albumartist TEXT, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT -1, + genre TEXT, + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT, + performer TEXT, + grouping TEXT, + comment TEXT, + lyrics TEXT, + + artist_id TEXT, + album_id TEXT, + song_id TEXT, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + fingerprint TEXT, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + lastseen INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_embedded INTEGER DEFAULT 0, + art_automatic TEXT, + art_manual TEXT, + art_unset INTEGER DEFAULT 0, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT, + + rating INTEGER DEFAULT -1, + + acoustid_id TEXT, + acoustid_fingerprint TEXT, + + musicbrainz_album_artist_id TEXT, + musicbrainz_artist_id TEXT, + musicbrainz_original_artist_id TEXT, + musicbrainz_album_id TEXT, + musicbrainz_original_album_id TEXT, + musicbrainz_recording_id TEXT, + musicbrainz_track_id TEXT, + musicbrainz_disc_id TEXT, + musicbrainz_release_group_id TEXT, + musicbrainz_work_id TEXT, + + ebur128_integrated_loudness_lufs REAL, + ebur128_loudness_range_lu REAL + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS spotify_artists_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 1" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS spotify_albums_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 1" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS spotify_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 1" + +); + +UPDATE schema_version SET version=19; diff --git a/data/schema/schema.sql b/data/schema/schema.sql index fb2e2417..350a5b41 100644 --- a/data/schema/schema.sql +++ b/data/schema/schema.sql @@ -422,6 +422,192 @@ CREATE TABLE IF NOT EXISTS tidal_songs ( ); +CREATE TABLE IF NOT EXISTS spotify_artists_songs ( + + title TEXT DEFAULT '', + album TEXT DEFAULT '', + artist TEXT DEFAULT '', + albumartist TEXT DEFAULT '', + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT -1, + genre TEXT DEFAULT '', + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT DEFAULT '', + performer TEXT DEFAULT '', + grouping TEXT DEFAULT '', + comment TEXT DEFAULT '', + lyrics TEXT DEFAULT '', + + artist_id TEXT DEFAULT '', + album_id TEXT DEFAULT '', + song_id TEXT DEFAULT '', + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL DEFAULT '', + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + fingerprint TEXT DEFAULT '', + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + lastseen INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT DEFAULT '', + art_manual TEXT DEFAULT '', + + effective_albumartist TEXT DEFAULT '', + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT DEFAULT '', + + rating INTEGER DEFAULT -1 + +); + +CREATE TABLE IF NOT EXISTS spotify_albums_songs ( + + title TEXT DEFAULT '', + album TEXT DEFAULT '', + artist TEXT DEFAULT '', + albumartist TEXT DEFAULT '', + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT -1, + genre TEXT DEFAULT '', + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT DEFAULT '', + performer TEXT DEFAULT '', + grouping TEXT DEFAULT '', + comment TEXT DEFAULT '', + lyrics TEXT DEFAULT '', + + artist_id TEXT DEFAULT '', + album_id TEXT DEFAULT '', + song_id TEXT DEFAULT '', + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL DEFAULT '', + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + fingerprint TEXT DEFAULT '', + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + lastseen INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT DEFAULT '', + art_manual TEXT DEFAULT '', + + effective_albumartist TEXT DEFAULT '', + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT DEFAULT '', + + rating INTEGER DEFAULT -1 + +); + +CREATE TABLE IF NOT EXISTS spotify_songs ( + + title TEXT DEFAULT '', + album TEXT DEFAULT '', + artist TEXT DEFAULT '', + albumartist TEXT DEFAULT '', + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT -1, + genre TEXT DEFAULT '', + compilation INTEGER NOT NULL DEFAULT 0, + composer TEXT DEFAULT '', + performer TEXT DEFAULT '', + grouping TEXT DEFAULT '', + comment TEXT DEFAULT '', + lyrics TEXT DEFAULT '', + + artist_id TEXT DEFAULT '', + album_id TEXT DEFAULT '', + song_id TEXT DEFAULT '', + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT -1, + samplerate INTEGER NOT NULL DEFAULT -1, + bitdepth INTEGER NOT NULL DEFAULT -1, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL DEFAULT -1, + url TEXT NOT NULL DEFAULT '', + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT -1, + mtime INTEGER NOT NULL DEFAULT -1, + ctime INTEGER NOT NULL DEFAULT -1, + unavailable INTEGER DEFAULT 0, + + fingerprint TEXT DEFAULT '', + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT -1, + lastseen INTEGER NOT NULL DEFAULT -1, + + compilation_detected INTEGER DEFAULT 0, + compilation_on INTEGER NOT NULL DEFAULT 0, + compilation_off INTEGER NOT NULL DEFAULT 0, + compilation_effective INTEGER NOT NULL DEFAULT 0, + + art_automatic TEXT DEFAULT '', + art_manual TEXT DEFAULT '', + + effective_albumartist TEXT DEFAULT '', + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT DEFAULT '', + + rating INTEGER DEFAULT -1 + +); + CREATE TABLE IF NOT EXISTS qobuz_artists_songs ( title TEXT, @@ -872,6 +1058,51 @@ CREATE VIRTUAL TABLE IF NOT EXISTS tidal_songs_fts USING fts5( ); +CREATE VIRTUAL TABLE IF NOT EXISTS spotify_artists_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 1" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS spotify_albums_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 1" + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS spotify_songs_fts USING fts5( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize = "unicode61 remove_diacritics 1" + +); + CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_artists_songs_fts USING fts5( ftstitle, diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ff638d63..60a89ee6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -170,7 +170,6 @@ set(SOURCES covermanager/deezercoverprovider.cpp covermanager/qobuzcoverprovider.cpp covermanager/musixmatchcoverprovider.cpp - covermanager/spotifycoverprovider.cpp 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 diff --git a/src/config.h.in b/src/config.h.in index 3be0c0f2..f481d829 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -30,6 +30,7 @@ #cmakedefine HAVE_SUBSONIC #cmakedefine HAVE_TIDAL +#cmakedefine HAVE_SPOTIFY #cmakedefine HAVE_QOBUZ #cmakedefine HAVE_MOODBAR diff --git a/src/core/application.cpp b/src/core/application.cpp index 0a813135..6444f2dd 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -56,7 +56,6 @@ #include "covermanager/musicbrainzcoverprovider.h" #include "covermanager/deezercoverprovider.h" #include "covermanager/musixmatchcoverprovider.h" -#include "covermanager/spotifycoverprovider.h" #include "covermanager/opentidalcoverprovider.h" #include "lyrics/lyricsproviders.h" @@ -90,6 +89,11 @@ # include "covermanager/tidalcoverprovider.h" #endif +#ifdef HAVE_SPOTIFY +# include "spotify/spotifyservice.h" +# include "covermanager/spotifycoverprovider.h" +#endif + #ifdef HAVE_QOBUZ # include "qobuz/qobuzservice.h" # include "covermanager/qobuzcoverprovider.h" @@ -143,11 +147,13 @@ class ApplicationImpl { cover_providers->AddProvider(new DiscogsCoverProvider(app, app->network())); cover_providers->AddProvider(new DeezerCoverProvider(app, app->network())); cover_providers->AddProvider(new MusixmatchCoverProvider(app, app->network())); - cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network())); cover_providers->AddProvider(new OpenTidalCoverProvider(app, app->network())); #ifdef HAVE_TIDAL cover_providers->AddProvider(new TidalCoverProvider(app, app->network())); #endif +#ifdef HAVE_SPOTIFY + cover_providers->AddProvider(new SpotifyCoverProvider(app, app->network())); +#endif #ifdef HAVE_QOBUZ cover_providers->AddProvider(new QobuzCoverProvider(app, app->network())); #endif @@ -183,6 +189,9 @@ class ApplicationImpl { #ifdef HAVE_TIDAL internet_services->AddService(make_shared(app)); #endif +#ifdef HAVE_SPOTIFY + internet_services->AddService(make_shared(app)); +#endif #ifdef HAVE_QOBUZ internet_services->AddService(make_shared(app)); #endif diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 027532be..cb0e3176 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -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 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 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 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(); diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 1f033f2d..a13004a9 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -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_; diff --git a/src/core/song.cpp b/src/core/song.cpp index 471d4135..225cb138 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -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: diff --git a/src/core/song.h b/src/core/song.h index fde0356e..3d2d96ae 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -76,7 +76,8 @@ class Song { Subsonic = 7, Qobuz = 8, SomaFM = 9, - RadioParadise = 10 + RadioParadise = 10, + Spotify = 11 }; // Don't change these values - they're stored in the database, and defined in the tag reader protobuf. diff --git a/src/covermanager/albumcoverchoicecontroller.cpp b/src/covermanager/albumcoverchoicecontroller.cpp index 246a69bb..c2d63fcd 100644 --- a/src/covermanager/albumcoverchoicecontroller.cpp +++ b/src/covermanager/albumcoverchoicecontroller.cpp @@ -574,9 +574,10 @@ void AlbumCoverChoiceController::SaveArtManualToSong(Song *song, const QUrl &art case Song::Source::SomaFM: case Song::Source::Unknown: break; - case Song::Source::Tidal: - case Song::Source::Qobuz: case Song::Source::Subsonic: + case Song::Source::Tidal: + case Song::Source::Spotify: + case Song::Source::Qobuz: InternetServicePtr service = app_->internet_services()->ServiceBySource(song->source()); if (!service) break; if (service->artists_collection_backend()) { diff --git a/src/covermanager/spotifycoverprovider.cpp b/src/covermanager/spotifycoverprovider.cpp index 375c7bc6..4a2c9fc8 100644 --- a/src/covermanager/spotifycoverprovider.cpp +++ b/src/covermanager/spotifycoverprovider.cpp @@ -29,11 +29,8 @@ #include #include #include -#include #include #include -#include -#include #include #include #include @@ -47,45 +44,17 @@ #include "core/logging.h" #include "utilities/randutils.h" #include "utilities/timeconstants.h" -#include "internet/localredirectserver.h" +#include "internet/internetservices.h" +#include "spotify/spotifyservice.h" #include "albumcoverfetcher.h" #include "jsoncoverprovider.h" #include "spotifycoverprovider.h" -const char *SpotifyCoverProvider::kSettingsGroup = "Spotify"; -const char *SpotifyCoverProvider::kOAuthAuthorizeUrl = "https://accounts.spotify.com/authorize"; -const char *SpotifyCoverProvider::kOAuthAccessTokenUrl = "https://accounts.spotify.com/api/token"; -const char *SpotifyCoverProvider::kOAuthRedirectUrl = "http://localhost:63111/"; -const char *SpotifyCoverProvider::kClientIDB64 = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc="; -const char *SpotifyCoverProvider::kClientSecretB64 = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE="; -const char *SpotifyCoverProvider::kApiUrl = "https://api.spotify.com/v1"; const int SpotifyCoverProvider::kLimit = 10; SpotifyCoverProvider::SpotifyCoverProvider(Application *app, SharedPtr network, QObject *parent) : JsonCoverProvider("Spotify", true, true, 2.5, true, true, app, network, parent), - server_(nullptr), - expires_in_(0), - login_time_(0) { - - refresh_login_timer_.setSingleShot(true); - QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyCoverProvider::RequestNewAccessToken); - - QSettings s; - s.beginGroup(kSettingsGroup); - access_token_ = s.value("access_token").toString(); - refresh_token_ = s.value("refresh_token").toString(); - expires_in_ = s.value("expires_in").toLongLong(); - login_time_ = s.value("login_time").toLongLong(); - s.endGroup(); - - if (!refresh_token_.isEmpty()) { - qint64 time = static_cast(expires_in_) - (QDateTime::currentDateTime().toSecsSinceEpoch() - static_cast(login_time_)); - if (time < 1) time = 1; - refresh_login_timer_.setInterval(static_cast(time * kMsecPerSec)); - refresh_login_timer_.start(); - } - -} + service_(app->internet_services()->Service()) {} SpotifyCoverProvider::~SpotifyCoverProvider() { @@ -98,265 +67,9 @@ SpotifyCoverProvider::~SpotifyCoverProvider() { } -void SpotifyCoverProvider::Authenticate() { - - QUrl redirect_url(kOAuthRedirectUrl); - - if (!server_) { - server_ = new LocalRedirectServer(this); - int port = redirect_url.port(); - int port_max = port + 10; - bool success = false; - forever { - server_->set_port(port); - if (server_->Listen()) { - success = true; - break; - } - ++port; - if (port > port_max) break; - } - if (!success) { - AuthError(server_->error()); - server_->deleteLater(); - server_ = nullptr; - return; - } - QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyCoverProvider::RedirectArrived); - } - - code_verifier_ = Utilities::CryptographicRandomString(44); - code_challenge_ = QString(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding)); - if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) { - code_challenge_.chop(1); - } - - const ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64)) - << Param("response_type", "code") - << Param("redirect_uri", redirect_url.toString()) - << Param("state", code_challenge_); - - QUrlQuery url_query; - for (const Param ¶m : params) { - url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); - } - - QUrl url(kOAuthAuthorizeUrl); - url.setQuery(url_query); - - const bool result = QDesktopServices::openUrl(url); - if (!result) { - QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QString(":
%1").arg(url.toString()), QMessageBox::Ok); - messagebox.setTextFormat(Qt::RichText); - messagebox.exec(); - } - -} - -void SpotifyCoverProvider::Deauthenticate() { - - access_token_.clear(); - refresh_token_.clear(); - expires_in_ = 0; - login_time_ = 0; - - QSettings s; - s.beginGroup(kSettingsGroup); - s.remove("access_token"); - s.remove("refresh_token"); - s.remove("expires_in"); - s.remove("login_time"); - s.endGroup(); - - refresh_login_timer_.stop(); - -} - -void SpotifyCoverProvider::RedirectArrived() { - - if (!server_) return; - - if (server_->error().isEmpty()) { - QUrl url = server_->request_url(); - if (url.isValid()) { - QUrlQuery url_query(url); - if (url_query.hasQueryItem("error")) { - AuthError(QUrlQuery(url).queryItemValue("error")); - } - else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) { - qLog(Debug) << "Spotify: Authorization URL Received" << url; - QString code = url_query.queryItemValue("code"); - QUrl redirect_url(kOAuthRedirectUrl); - redirect_url.setPort(server_->url().port()); - RequestAccessToken(code, redirect_url); - } - else { - AuthError(tr("Redirect missing token code or state!")); - } - } - else { - AuthError(tr("Received invalid reply from web browser.")); - } - } - else { - AuthError(server_->error()); - } - - server_->close(); - server_->deleteLater(); - server_ = nullptr; - -} - -void SpotifyCoverProvider::RequestAccessToken(const QString &code, const QUrl &redirect_url) { - - refresh_login_timer_.stop(); - - ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64)) - << Param("client_secret", QByteArray::fromBase64(kClientSecretB64)); - - if (!code.isEmpty() && !redirect_url.isEmpty()) { - params << Param("grant_type", "authorization_code"); - params << Param("code", code); - params << Param("redirect_uri", redirect_url.toString()); - } - else if (!refresh_token_.isEmpty() && is_enabled()) { - params << Param("grant_type", "refresh_token"); - params << Param("refresh_token", refresh_token_); - } - else { - return; - } - - QUrlQuery url_query; - for (const Param ¶m : params) { - url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); - } - - QUrl new_url(kOAuthAccessTokenUrl); - QNetworkRequest req(new_url); - req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - QString auth_header_data = QByteArray::fromBase64(kClientIDB64) + QString(":") + QByteArray::fromBase64(kClientSecretB64); - req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64()); - - QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); - - QNetworkReply *reply = network_->post(req, query); - replies_ << reply; - QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyCoverProvider::HandleLoginSSLErrors); - QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); }); - -} - -void SpotifyCoverProvider::HandleLoginSSLErrors(const QList &ssl_errors) { - - for (const QSslError &ssl_error : ssl_errors) { - login_errors_ += ssl_error.errorString(); - } - -} - -void SpotifyCoverProvider::AccessTokenRequestFinished(QNetworkReply *reply) { - - if (!replies_.contains(reply)) return; - replies_.removeAll(reply); - QObject::disconnect(reply, nullptr, this, nullptr); - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { - if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { - // This is a network error, there is nothing more to do. - AuthError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); - return; - } - else { - // See if there is Json data containing "error" and "error_description" then use that instead. - QByteArray data = reply->readAll(); - QJsonParseError json_error; - QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); - if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) { - QJsonObject json_obj = json_doc.object(); - if (!json_obj.isEmpty() && json_obj.contains("error") && json_obj.contains("error_description")) { - QString error = json_obj["error"].toString(); - QString error_description = json_obj["error_description"].toString(); - login_errors_ << QString("Authentication failure: %1 (%2)").arg(error, error_description); - } - } - if (login_errors_.isEmpty()) { - if (reply->error() != QNetworkReply::NoError) { - login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); - } - else { - login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); - } - } - AuthError(); - return; - } - } - - QByteArray data = reply->readAll(); - - QJsonParseError json_error; - QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); - - if (json_error.error != QJsonParseError::NoError) { - Error(QString("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString())); - return; - } - - if (json_doc.isEmpty()) { - AuthError("Authentication reply from server has empty Json document."); - return; - } - - if (!json_doc.isObject()) { - AuthError("Authentication reply from server has Json document that is not an object.", json_doc); - return; - } - - QJsonObject json_obj = json_doc.object(); - if (json_obj.isEmpty()) { - AuthError("Authentication reply from server has empty Json object.", json_doc); - return; - } - - if (!json_obj.contains("access_token") || !json_obj.contains("expires_in")) { - AuthError("Authentication reply from server is missing access token or expires in.", json_obj); - return; - } - - access_token_ = json_obj["access_token"].toString(); - if (json_obj.contains("refresh_token")) { - refresh_token_ = json_obj["refresh_token"].toString(); - } - expires_in_ = json_obj["expires_in"].toInt(); - login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch(); - - QSettings s; - s.beginGroup(kSettingsGroup); - s.setValue("access_token", access_token_); - s.setValue("refresh_token", refresh_token_); - s.setValue("expires_in", expires_in_); - s.setValue("login_time", login_time_); - s.endGroup(); - - if (expires_in_ > 0) { - refresh_login_timer_.setInterval(static_cast(expires_in_ * kMsecPerSec)); - refresh_login_timer_.start(); - } - - qLog(Debug) << "Spotify: Authentication was successful, login expires in" << expires_in_; - - emit AuthenticationComplete(true); - emit AuthenticationSuccess(); - -} - bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) { - if (access_token_.isEmpty()) return false; + if (!IsAuthenticated()) return false; if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false; @@ -387,12 +100,12 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); } - QUrl url(kApiUrl + QString("/search")); + QUrl url(SpotifyService::kApiUrl + QString("/search")); url.setQuery(url_query); QNetworkRequest req(url); req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("Authorization", "Bearer " + access_token_.toUtf8()); + req.setRawHeader("Authorization", "Bearer " + service_->access_token().toUtf8()); QNetworkReply *reply = network_->get(req); replies_ << reply; @@ -429,13 +142,13 @@ QByteArray SpotifyCoverProvider::GetReplyData(QNetworkReply *reply) { int status = obj_error["status"].toInt(); QString message = obj_error["message"].toString(); error = QString("%1 (%2)").arg(message).arg(status); - if (status == 401) access_token_.clear(); + if (status == 401) Deauthenticate(); } } } if (error.isEmpty()) { if (reply->error() != QNetworkReply::NoError) { - if (reply->error() == 204) access_token_.clear(); + if (reply->error() == 204) Deauthenticate(); error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); } else { @@ -538,20 +251,6 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id, } -void SpotifyCoverProvider::AuthError(const QString &error, const QVariant &debug) { - - if (!error.isEmpty()) login_errors_ << error; - - for (const QString &e : login_errors_) Error(e); - if (debug.isValid()) qLog(Debug) << debug; - - emit AuthenticationFailure(login_errors_); - emit AuthenticationComplete(false, login_errors_); - - login_errors_.clear(); - -} - void SpotifyCoverProvider::Error(const QString &error, const QVariant &debug) { qLog(Error) << "Spotify:" << error; diff --git a/src/covermanager/spotifycoverprovider.h b/src/covermanager/spotifycoverprovider.h index a0a5bd10..e4719a45 100644 --- a/src/covermanager/spotifycoverprovider.h +++ b/src/covermanager/spotifycoverprovider.h @@ -36,11 +36,11 @@ #include "core/shared_ptr.h" #include "jsoncoverprovider.h" +#include "spotify/spotifyservice.h" class QNetworkReply; class Application; class NetworkAccessManager; -class LocalRedirectServer; class SpotifyCoverProvider : public JsonCoverProvider { Q_OBJECT @@ -52,22 +52,17 @@ class SpotifyCoverProvider : public JsonCoverProvider { bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override; void CancelSearch(const int id) override; - void Authenticate() override; - void Deauthenticate() override; - bool IsAuthenticated() const override { return !access_token_.isEmpty(); } + bool IsAuthenticated() const override { return service_ && service_->authenticated(); } + void Deauthenticate() override { + if (service_) service_->Deauthenticate(); + } private slots: - void HandleLoginSSLErrors(const QList &ssl_errors); - void RedirectArrived(); - void AccessTokenRequestFinished(QNetworkReply *reply); void HandleSearchReply(QNetworkReply *reply, const int id, const QString &extract); - void RequestNewAccessToken() { RequestAccessToken(); } private: QByteArray GetReplyData(QNetworkReply *reply); - void AuthError(const QString &error = QString(), const QVariant &debug = QVariant()); void Error(const QString &error, const QVariant &debug = QVariant()) override; - void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl()); private: static const char *kSettingsGroup; @@ -79,15 +74,7 @@ class SpotifyCoverProvider : public JsonCoverProvider { static const char *kApiUrl; static const int kLimit; - LocalRedirectServer *server_; - QStringList login_errors_; - QString code_verifier_; - QString code_challenge_; - QString access_token_; - QString refresh_token_; - quint64 expires_in_; - quint64 login_time_; - QTimer refresh_login_timer_; + SharedPtr service_; QList replies_; }; diff --git a/src/engine/enginebase.cpp b/src/engine/enginebase.cpp index fdd93017..0260128f 100644 --- a/src/engine/enginebase.cpp +++ b/src/engine/enginebase.cpp @@ -36,6 +36,9 @@ #include "enginebase.h" #include "settings/backendsettingspage.h" #include "settings/networkproxysettingspage.h" +#ifdef HAVE_SPOTIFY +# include "settings/spotifysettingspage.h" +#endif EngineBase::EngineBase(QObject *parent) : QObject(parent), @@ -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() { diff --git a/src/engine/enginebase.h b/src/engine/enginebase.h index bf7a5de5..a7ecd9ad 100644 --- a/src/engine/enginebase.h +++ b/src/engine/enginebase.h @@ -247,6 +247,12 @@ class EngineBase : public QObject { bool http2_enabled_; bool strict_ssl_enabled_; + // Spotify +#ifdef HAVE_SPOTIFY + QString spotify_username_; + QString spotify_password_; +#endif + bool about_to_end_emitted_; Q_DISABLE_COPY(EngineBase) diff --git a/src/engine/gstengine.cpp b/src/engine/gstengine.cpp index 980187cd..0e7d8703 100644 --- a/src/engine/gstengine.cpp +++ b/src/engine/gstengine.cpp @@ -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 GstEngine::CreatePipeline() { ret->set_strict_ssl_enabled(strict_ssl_enabled_); ret->set_fading_enabled(fadeout_enabled_ || autocrossfade_enabled_ || fadeout_pause_enabled_); +#ifdef HAVE_SPOTIFY + ret->set_spotify_login(spotify_username_, spotify_password_); +#endif + ret->AddBufferConsumer(this); for (GstBufferConsumer *consumer : buffer_consumers_) { ret->AddBufferConsumer(consumer); diff --git a/src/engine/gstenginepipeline.cpp b/src/engine/gstenginepipeline.cpp index ceed691e..2692913e 100644 --- a/src/engine/gstenginepipeline.cpp +++ b/src/engine/gstenginepipeline.cpp @@ -300,6 +300,15 @@ void GstEnginePipeline::set_fading_enabled(const bool enabled) { fading_enabled_ = enabled; } +#ifdef HAVE_SPOTIFY +void GstEnginePipeline::set_spotify_login(const QString &spotify_username, const QString &spotify_password) { + + spotify_username_ = spotify_username; + spotify_password_ = spotify_password; + +} +#endif // HAVE_SPOTIFY + QString GstEnginePipeline::GstStateText(const GstState state) { switch (state) { @@ -996,6 +1005,13 @@ void GstEnginePipeline::SourceSetupCallback(GstElement *playbin, GstElement *sou } } +#ifdef HAVE_SPOTIFY + if (instance->media_url_.scheme() == "spotify" && !instance->spotify_username_.isEmpty() && !instance->spotify_password_.isEmpty()) { + g_object_set(source, "username", instance->spotify_username_.toUtf8().constData(), nullptr); + g_object_set(source, "password", instance->spotify_password_.toUtf8().constData(), nullptr); + } +#endif + // If the pipeline was buffering we stop that now. if (instance->buffering_) { instance->buffering_ = false; diff --git a/src/engine/gstenginepipeline.h b/src/engine/gstenginepipeline.h index 6e3bb502..03ef0eeb 100644 --- a/src/engine/gstenginepipeline.h +++ b/src/engine/gstenginepipeline.h @@ -76,6 +76,9 @@ class GstEnginePipeline : public QObject { void set_bs2b_enabled(const bool enabled); void set_strict_ssl_enabled(const bool enabled); void set_fading_enabled(const bool enabled); +#ifdef HAVE_SPOTIFY + void set_spotify_login(const QString &spotify_username, const QString &spotify_password); +#endif // Creates the pipeline, returns false on error bool InitFromUrl(const QUrl &media_url, const QUrl &stream_url, const QByteArray &gst_url, const qint64 end_nanosec, const double ebur128_loudness_normalizing_gain_db, QString &error); @@ -249,6 +252,12 @@ class GstEnginePipeline : public QObject { bool bs2b_enabled_; bool strict_ssl_enabled_; + // Spotify +#ifdef HAVE_SPOTIFY + QString spotify_username_; + QString spotify_password_; +#endif + // These get called when there is a new audio buffer available QList buffer_consumers_; QMutex buffer_consumers_mutex_; diff --git a/src/playlist/playlistitem.cpp b/src/playlist/playlistitem.cpp index 1a8e4dd9..a6413db4 100644 --- a/src/playlist/playlistitem.cpp +++ b/src/playlist/playlistitem.cpp @@ -46,6 +46,7 @@ PlaylistItemPtr PlaylistItem::NewFromSource(const Song::Source source) { return make_shared(); case Song::Source::Subsonic: case Song::Source::Tidal: + case Song::Source::Spotify: case Song::Source::Qobuz: return make_shared(source); case Song::Source::Stream: @@ -70,6 +71,7 @@ PlaylistItemPtr PlaylistItem::NewFromSong(const Song &song) { return make_shared(song); case Song::Source::Subsonic: case Song::Source::Tidal: + case Song::Source::Spotify: case Song::Source::Qobuz: return make_shared(song); case Song::Source::Stream: diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index b87a88fb..aa78b0d0 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -77,6 +77,9 @@ #ifdef HAVE_TIDAL # include "tidalsettingspage.h" #endif +#ifdef HAVE_SPOTIFY +# include "spotifysettingspage.h" +#endif #ifdef HAVE_QOBUZ # include "qobuzsettingspage.h" #endif @@ -154,7 +157,7 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main AddPage(Page::Moodbar, new MoodbarSettingsPage(this, this), iface); #endif -#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_QOBUZ) +#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_SPOTIFY) || defined(HAVE_QOBUZ) QTreeWidgetItem *streaming = AddCategory(tr("Streaming")); #endif @@ -164,6 +167,9 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main #ifdef HAVE_TIDAL AddPage(Page::Tidal, new TidalSettingsPage(this, this), streaming); #endif +#ifdef HAVE_SPOTIFY + AddPage(Page::Spotify, new SpotifySettingsPage(this, this), streaming); +#endif #ifdef HAVE_QOBUZ AddPage(Page::Qobuz, new QobuzSettingsPage(this, this), streaming); #endif diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 013890dd..2581c635 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -93,6 +93,7 @@ class SettingsDialog : public QDialog { Subsonic, Tidal, Qobuz, + Spotify, }; enum Role { diff --git a/src/settings/spotifysettingspage.cpp b/src/settings/spotifysettingspage.cpp new file mode 100644 index 00000000..d552cb78 --- /dev/null +++ b/src/settings/spotifysettingspage.cpp @@ -0,0 +1,169 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "settingsdialog.h" +#include "spotifysettingspage.h" +#include "ui_spotifysettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "internet/internetservices.h" +#include "spotify/spotifyservice.h" +#include "widgets/loginstatewidget.h" + +const char *SpotifySettingsPage::kSettingsGroup = "Spotify"; + +SpotifySettingsPage::SpotifySettingsPage(SettingsDialog *dialog, QWidget *parent) + : SettingsPage(dialog, parent), + ui_(new Ui::SpotifySettingsPage), + service_(dialog->app()->internet_services()->Service()) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("spotify")); + + QObject::connect(ui_->button_login, &QPushButton::clicked, this, &SpotifySettingsPage::LoginClicked); + QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &SpotifySettingsPage::LogoutClicked); + + QObject::connect(this, &SpotifySettingsPage::Authorize, &*service_, &SpotifyService::Authenticate); + + QObject::connect(&*service_, &InternetService::LoginFailure, this, &SpotifySettingsPage::LoginFailure); + QObject::connect(&*service_, &InternetService::LoginSuccess, this, &SpotifySettingsPage::LoginSuccess); + + dialog->installEventFilter(this); + + GstRegistry *reg = gst_registry_get(); + if (reg) { + GstPluginFeature *spotifyaudiosrc = gst_registry_lookup_feature(reg, "spotifyaudiosrc"); + if (spotifyaudiosrc) { + ui_->widget_warning->hide(); + } + else { + ui_->widget_warning->show(); + } + } + +} + +SpotifySettingsPage::~SpotifySettingsPage() { delete ui_; } + +void SpotifySettingsPage::Load() { + + QSettings s; + s.beginGroup(kSettingsGroup); + ui_->enable->setChecked(s.value("enabled", false).toBool()); + + ui_->username->setText(s.value("username").toString()); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) ui_->password->clear(); + else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password))); + + ui_->searchdelay->setValue(s.value("searchdelay", 1500).toInt()); + ui_->artistssearchlimit->setValue(s.value("artistssearchlimit", 4).toInt()); + ui_->albumssearchlimit->setValue(s.value("albumssearchlimit", 10).toInt()); + ui_->songssearchlimit->setValue(s.value("songssearchlimit", 10).toInt()); + ui_->checkbox_fetchalbums->setChecked(s.value("fetchalbums", false).toBool()); + ui_->checkbox_download_album_covers->setChecked(s.value("downloadalbumcovers", true).toBool()); + + s.endGroup(); + + if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedIn); + + Init(ui_->layout_spotifysettingspage->parentWidget()); + + if (!QSettings().childGroups().contains(kSettingsGroup)) set_changed(); + +} + +void SpotifySettingsPage::Save() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("enabled", ui_->enable->isChecked()); + + s.setValue("username", ui_->username->text()); + s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); + + s.setValue("searchdelay", ui_->searchdelay->value()); + s.setValue("artistssearchlimit", ui_->artistssearchlimit->value()); + s.setValue("albumssearchlimit", ui_->albumssearchlimit->value()); + s.setValue("songssearchlimit", ui_->songssearchlimit->value()); + s.setValue("fetchalbums", ui_->checkbox_fetchalbums->isChecked()); + s.setValue("downloadalbumcovers", ui_->checkbox_download_album_covers->isChecked()); + s.endGroup(); + +} + +void SpotifySettingsPage::LoginClicked() { + + emit Authorize(); + + ui_->button_login->setEnabled(false); + +} + +bool SpotifySettingsPage::eventFilter(QObject *object, QEvent *event) { + + if (object == dialog() && event->type() == QEvent::Enter) { + ui_->button_login->setEnabled(true); + } + + return SettingsPage::eventFilter(object, event); + +} + +void SpotifySettingsPage::LogoutClicked() { + + service_->Deauthenticate(); + ui_->button_login->setEnabled(true); + ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedOut); + +} + +void SpotifySettingsPage::LoginSuccess() { + + if (!isVisible()) return; + ui_->login_state->SetLoggedIn(LoginStateWidget::State::LoggedIn); + ui_->button_login->setEnabled(true); + +} + +void SpotifySettingsPage::LoginFailure(const QString &failure_reason) { + + if (!isVisible()) return; + QMessageBox::warning(this, tr("Authentication failed"), failure_reason); + ui_->button_login->setEnabled(true); + +} diff --git a/src/settings/spotifysettingspage.h b/src/settings/spotifysettingspage.h new file mode 100644 index 00000000..88409ad3 --- /dev/null +++ b/src/settings/spotifysettingspage.h @@ -0,0 +1,64 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef SPOTIFYSETTINGSPAGE_H +#define SPOTIFYSETTINGSPAGE_H + +#include "config.h" + +#include +#include + +#include "core/shared_ptr.h" +#include "settings/settingspage.h" + +class QEvent; +class SpotifyService; +class SettingsDialog; +class Ui_SpotifySettingsPage; + +class SpotifySettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit SpotifySettingsPage(SettingsDialog *dialog, QWidget *parent = nullptr); + ~SpotifySettingsPage() override; + + static const char *kSettingsGroup; + + void Load() override; + void Save() override; + + bool eventFilter(QObject *object, QEvent *event) override; + + signals: + void Authorize(); + + private slots: + void LoginClicked(); + void LogoutClicked(); + void LoginSuccess(); + void LoginFailure(const QString &failure_reason); + + private: + Ui_SpotifySettingsPage *ui_; + SharedPtr service_; +}; + +#endif // SPOTIFYSETTINGSPAGE_H diff --git a/src/settings/spotifysettingspage.ui b/src/settings/spotifysettingspage.ui new file mode 100644 index 00000000..7ed6b689 --- /dev/null +++ b/src/settings/spotifysettingspage.ui @@ -0,0 +1,321 @@ + + + SpotifySettingsPage + + + + 0 + 0 + 505 + 853 + + + + Spotify + + + + + + Enable + + + + + + + Basic authentication + + + + + + + + + Authenticate + + + + + + + + + + + 0 + 0 + + + + Device credentials + + + + + + Username + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + + + + + + + + 2 + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + 0 + 0 + + + + + + + :/icons/64x64/dialog-warning.png + + + + + + + <html><head/><body><p>The GStreamer Spotify plugin is not detected, you will not be able to stream songs from Spotify without it. See: <a href="https://github.com/strawberrymusicplayer/strawberry/wiki/GStreamer-Spotify-plugin"><span style=" text-decoration: underline; color:#0000ff;">https://github.com/strawberrymusicplayer/strawberry/wiki/GStreamer-Spotify-plugin</span></a> for instructions on how to install the plugin.</p></body></html> + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + true + + + 10 + + + true + + + + + + + + + + Preferences + + + + + + Search delay + + + + + + + ms + + + 500 + + + 10000 + + + 50 + + + 1500 + + + + + + + Artists search limit + + + + + + + 1 + + + 100 + + + 50 + + + + + + + Albums search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Songs search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Download album covers + + + + + + + Fetch entire albums when searching songs + + + + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + :/icons/64x64/spotify.png + + + + + + + + + + LoginStateWidget + QWidget +
widgets/loginstatewidget.h
+ 1 +
+
+ + enable + password + searchdelay + artistssearchlimit + albumssearchlimit + songssearchlimit + checkbox_download_album_covers + checkbox_fetchalbums + + + + + + +
diff --git a/src/spotify/spotifybaserequest.cpp b/src/spotify/spotifybaserequest.cpp new file mode 100644 index 00000000..ad8eb8cd --- /dev/null +++ b/src/spotify/spotifybaserequest.cpp @@ -0,0 +1,185 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "spotifyservice.h" +#include "spotifybaserequest.h" + +SpotifyBaseRequest::SpotifyBaseRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent) + : QObject(parent), + service_(service), + network_(network) {} + +QNetworkReply *SpotifyBaseRequest::CreateRequest(const QString &ressource_name, const ParamList ¶ms_provided) { + + ParamList params = ParamList() << params_provided; + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QUrl url(SpotifyService::kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8()); + + QNetworkReply *reply = network_->get(req); + QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyBaseRequest::HandleSSLErrors); + + qLog(Debug) << "Spotify: Sending request" << url; + + return reply; + +} + +void SpotifyBaseRequest::HandleSSLErrors(const QList &ssl_errors) { + + for (const QSslError &ssl_error : ssl_errors) { + Error(ssl_error.errorString()); + } + +} + +QByteArray SpotifyBaseRequest::GetReplyData(QNetworkReply *reply) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + // See if there is Json data containing "error". + data = reply->readAll(); + QString error; + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + int status = 0; + if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("error") && json_obj["error"].isObject()) { + QJsonObject obj_error = json_obj["error"].toObject(); + if (!obj_error.isEmpty() && obj_error.contains("status") && obj_error.contains("message")) { + status = obj_error["status"].toInt(); + QString user_message = obj_error["message"].toString(); + error = QString("%1 (%2)").arg(user_message).arg(status); + } + } + } + if (error.isEmpty()) { + if (reply->error() == QNetworkReply::NoError) { + error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + else { + error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + } + Error(error); + } + return QByteArray(); + } + + return data; + +} + +QJsonObject SpotifyBaseRequest::ExtractJsonObj(const QByteArray &data) { + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isEmpty()) { + Error("Received empty Json document.", data); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + return json_obj; + +} + +QJsonValue SpotifyBaseRequest::ExtractItems(const QByteArray &data) { + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) return QJsonValue(); + return ExtractItems(json_obj); + +} + +QJsonValue SpotifyBaseRequest::ExtractItems(const QJsonObject &json_obj) { + + if (!json_obj.contains("items")) { + Error("Json reply is missing items.", json_obj); + return QJsonArray(); + } + QJsonValue json_items = json_obj["items"]; + return json_items; + +} + +QString SpotifyBaseRequest::ErrorsToHTML(const QStringList &errors) { + + QString error_html; + for (const QString &error : errors) { + error_html += error + "
"; + } + return error_html; + +} diff --git a/src/spotify/spotifybaserequest.h b/src/spotify/spotifybaserequest.h new file mode 100644 index 00000000..51a7da3b --- /dev/null +++ b/src/spotify/spotifybaserequest.h @@ -0,0 +1,91 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef SPOTIFYBASEREQUEST_H +#define SPOTIFYBASEREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "spotifyservice.h" + +class QNetworkReply; +class NetworkAccessManager; + +class SpotifyBaseRequest : public QObject { + Q_OBJECT + + public: + explicit SpotifyBaseRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent = nullptr); + + enum QueryType { + QueryType_None, + QueryType_Artists, + QueryType_Albums, + QueryType_Songs, + QueryType_SearchArtists, + QueryType_SearchAlbums, + QueryType_SearchSongs, + QueryType_StreamURL, + }; + + protected: + typedef QPair Param; + typedef QList ParamList; + + QNetworkReply *CreateRequest(const QString &ressource_name, const ParamList ¶ms_provided); + QByteArray GetReplyData(QNetworkReply *reply); + QJsonObject ExtractJsonObj(const QByteArray &data); + QJsonValue ExtractItems(const QByteArray &data); + QJsonValue ExtractItems(const QJsonObject &json_obj); + + virtual void Error(const QString &error, const QVariant &debug = QVariant()) = 0; + static QString ErrorsToHTML(const QStringList &errors); + + int artistssearchlimit() { return service_->artistssearchlimit(); } + int albumssearchlimit() { return service_->albumssearchlimit(); } + int songssearchlimit() { return service_->songssearchlimit(); } + + QString access_token() { return service_->access_token(); } + + bool authenticated() { return service_->authenticated(); } + + private slots: + void HandleSSLErrors(const QList &ssl_errors); + + private: + SpotifyService *service_; + NetworkAccessManager *network_; + +}; + +#endif // SPOTIFYBASEREQUEST_H diff --git a/src/spotify/spotifyfavoriterequest.cpp b/src/spotify/spotifyfavoriterequest.cpp new file mode 100644 index 00000000..613dcf7a --- /dev/null +++ b/src/spotify/spotifyfavoriterequest.cpp @@ -0,0 +1,310 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/song.h" +#include "spotifyservice.h" +#include "spotifybaserequest.h" +#include "spotifyfavoriterequest.h" + +SpotifyFavoriteRequest::SpotifyFavoriteRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent) + : SpotifyBaseRequest(service, network, parent), + service_(service), + network_(network) {} + +SpotifyFavoriteRequest::~SpotifyFavoriteRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + +} + +QString SpotifyFavoriteRequest::FavoriteText(const FavoriteType type) { + + switch (type) { + case FavoriteType_Artists: + return "artists"; + case FavoriteType_Albums: + return "albums"; + case FavoriteType_Songs: + return "tracks"; + } + + return QString(); + +} + +void SpotifyFavoriteRequest::AddArtists(const SongList &songs) { + AddFavorites(FavoriteType_Artists, songs); +} + +void SpotifyFavoriteRequest::AddAlbums(const SongList &songs) { + AddFavorites(FavoriteType_Albums, songs); +} + +void SpotifyFavoriteRequest::AddSongs(const SongList &songs) { + AddFavorites(FavoriteType_Songs, songs); +} + +void SpotifyFavoriteRequest::AddSongs(const SongMap &songs) { + AddFavorites(FavoriteType_Songs, songs.values()); +} + +void SpotifyFavoriteRequest::AddFavorites(const FavoriteType type, const SongList &songs) { + + QStringList list_ids; + QJsonArray array_ids; + for (const Song &song : songs) { + QString id; + switch (type) { + case FavoriteType_Artists: + id = song.artist_id(); + break; + case FavoriteType_Albums: + id = song.album_id(); + break; + case FavoriteType_Songs: + id = song.song_id(); + break; + } + if (!id.isEmpty()) { + if (!list_ids.contains(id)) { + list_ids << id; + } + if (!array_ids.contains(id)) { + array_ids << id; + } + } + } + + if (list_ids.isEmpty() || array_ids.isEmpty()) return; + + QByteArray json_data = QJsonDocument(array_ids).toJson(); + QString ids_list = list_ids.join(","); + + AddFavoritesRequest(type, ids_list, json_data, songs); + +} + +void SpotifyFavoriteRequest::AddFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) { + + QUrl url(SpotifyService::kApiUrl + (type == FavoriteType_Artists ? QString("/me/following") : QString("/me/") + FavoriteText(type))); + if (type == FavoriteType_Artists) { + QUrlQuery url_query; + url_query.addQueryItem("type", "artist"); + url_query.addQueryItem("ids", ids_list); + url.setQuery(url_query); + } + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8()); + QNetworkReply *reply = nullptr; + if (type == FavoriteType_Artists) { + reply = network_->put(req, ""); + } + else { + reply = network_->put(req, json_data); + } + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { AddFavoritesReply(reply, type, songs); }); + replies_ << reply; + +} + +void SpotifyFavoriteRequest::AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + GetReplyData(reply); + if (reply->error() != QNetworkReply::NoError) { + return; + } + + if (type == FavoriteType_Artists) { + qLog(Debug) << "Spotify:" << songs.count() << "songs added to followed" << FavoriteText(type); + } + else { + qLog(Debug) << "Spotify:" << songs.count() << "songs added to saved" << FavoriteText(type); + } + + switch (type) { + case FavoriteType_Artists: + emit ArtistsAdded(songs); + break; + case FavoriteType_Albums: + emit AlbumsAdded(songs); + break; + case FavoriteType_Songs: + emit SongsAdded(songs); + break; + } + +} + +void SpotifyFavoriteRequest::RemoveArtists(const SongList &songs) { + RemoveFavorites(FavoriteType_Artists, songs); +} + +void SpotifyFavoriteRequest::RemoveAlbums(const SongList &songs) { + RemoveFavorites(FavoriteType_Albums, songs); +} + +void SpotifyFavoriteRequest::RemoveSongs(const SongList &songs) { + RemoveFavorites(FavoriteType_Songs, songs); +} + +void SpotifyFavoriteRequest::RemoveSongs(const SongMap &songs) { + + RemoveFavorites(FavoriteType_Songs, songs.values()); + +} + +void SpotifyFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongList &songs) { + + QStringList list_ids; + QJsonArray array_ids; + for (const Song &song : songs) { + QString id; + switch (type) { + case FavoriteType_Artists: + id = song.artist_id(); + break; + case FavoriteType_Albums: + id = song.album_id(); + break; + case FavoriteType_Songs: + id = song.song_id(); + break; + } + if (!id.isEmpty()) { + if (!list_ids.contains(id)) { + list_ids << id; + } + if (!array_ids.contains(id)) { + array_ids << id; + } + } + } + + if (list_ids.isEmpty() || array_ids.isEmpty()) return; + + QByteArray json_data = QJsonDocument(array_ids).toJson(); + QString ids_list = list_ids.join(","); + + RemoveFavoritesRequest(type, ids_list, json_data, songs); + +} + +void SpotifyFavoriteRequest::RemoveFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs) { + + Q_UNUSED(json_data) + + QUrl url(SpotifyService::kApiUrl + (type == FavoriteType_Artists ? QString("/me/following") : QString("/me/") + FavoriteText(type))); + if (type == FavoriteType_Artists) { + QUrlQuery url_query; + url_query.addQueryItem("type", "artist"); + url_query.addQueryItem("ids", ids_list); + url.setQuery(url_query); + } + QNetworkRequest req(url); +#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); +#else + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); +#endif + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + if (!access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + access_token().toUtf8()); + QNetworkReply *reply = nullptr; + if (type == FavoriteType_Artists) { + reply = network_->deleteResource(req); + } + else { + // FIXME + reply = network_->deleteResource(req); + } + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, type, songs]() { RemoveFavoritesReply(reply, type, songs); }); + replies_ << reply; + +} + +void SpotifyFavoriteRequest::RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + GetReplyData(reply); + if (reply->error() != QNetworkReply::NoError) { + return; + } + + if (type == FavoriteType_Artists) { + qLog(Debug) << "Spotify:" << songs.count() << "songs removed from followed" << FavoriteText(type); + } + else { + qLog(Debug) << "Spotify:" << songs.count() << "songs removed from saved" << FavoriteText(type); + } + + switch (type) { + case FavoriteType_Artists: + emit ArtistsRemoved(songs); + break; + case FavoriteType_Albums: + emit AlbumsRemoved(songs); + break; + case FavoriteType_Songs: + emit SongsRemoved(songs); + break; + } + +} + +void SpotifyFavoriteRequest::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << "Spotify:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} diff --git a/src/spotify/spotifyfavoriterequest.h b/src/spotify/spotifyfavoriterequest.h new file mode 100644 index 00000000..389f5164 --- /dev/null +++ b/src/spotify/spotifyfavoriterequest.h @@ -0,0 +1,90 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef SPOTIFYFAVORITEREQUEST_H +#define SPOTIFYFAVORITEREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "spotifybaserequest.h" +#include "core/song.h" + +class QNetworkReply; +class SpotifyService; +class NetworkAccessManager; + +class SpotifyFavoriteRequest : public SpotifyBaseRequest { + Q_OBJECT + + public: + explicit SpotifyFavoriteRequest(SpotifyService *service, NetworkAccessManager *network, QObject *parent = nullptr); + ~SpotifyFavoriteRequest() override; + + enum FavoriteType { + FavoriteType_Artists, + FavoriteType_Albums, + FavoriteType_Songs + }; + + signals: + void ArtistsAdded(SongList); + void AlbumsAdded(SongList); + void SongsAdded(SongList); + void ArtistsRemoved(SongList); + void AlbumsRemoved(SongList); + void SongsRemoved(SongList); + + private slots: + void AddFavoritesReply(QNetworkReply *reply, const SpotifyFavoriteRequest::FavoriteType type, const SongList &songs); + void RemoveFavoritesReply(QNetworkReply *reply, const SpotifyFavoriteRequest::FavoriteType type, const SongList &songs); + + public slots: + void AddArtists(const SongList &songs); + void AddAlbums(const SongList &songs); + void AddSongs(const SongList &songs); + void AddSongs(const SongMap &songs); + + void RemoveArtists(const SongList &songs); + void RemoveAlbums(const SongList &songs); + void RemoveSongs(const SongList &songs); + void RemoveSongs(const SongMap &songs); + + private: + void Error(const QString &error, const QVariant &debug = QVariant()) override; + static QString FavoriteText(const FavoriteType type); + void AddFavorites(const FavoriteType type, const SongList &songs); + void AddFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs); + void RemoveFavorites(const FavoriteType type, const SongList &songs); + void RemoveFavorites(const FavoriteType type, const QString &id, const SongList &songs); + void RemoveFavoritesRequest(const FavoriteType type, const QString &ids_list, const QByteArray &json_data, const SongList &songs); + + SpotifyService *service_; + NetworkAccessManager *network_; + QList replies_; + +}; + +#endif // SPOTIFYFAVORITEREQUEST_H diff --git a/src/spotify/spotifyrequest.cpp b/src/spotify/spotifyrequest.cpp new file mode 100644 index 00000000..52b566be --- /dev/null +++ b/src/spotify/spotifyrequest.cpp @@ -0,0 +1,1401 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/song.h" +#include "core/application.h" +#include "utilities/timeconstants.h" +#include "utilities/imageutils.h" +#include "utilities/coverutils.h" +#include "spotifyservice.h" +#include "spotifybaserequest.h" +#include "spotifyrequest.h" + +const int SpotifyRequest::kMaxConcurrentArtistsRequests = 1; +const int SpotifyRequest::kMaxConcurrentAlbumsRequests = 1; +const int SpotifyRequest::kMaxConcurrentSongsRequests = 1; +const int SpotifyRequest::kMaxConcurrentArtistAlbumsRequests = 1; +const int SpotifyRequest::kMaxConcurrentAlbumSongsRequests = 1; +const int SpotifyRequest::kMaxConcurrentAlbumCoverRequests = 10; +const int SpotifyRequest::kFlushRequestsDelay = 200; + +SpotifyRequest::SpotifyRequest(SpotifyService *service, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent) + : SpotifyBaseRequest(service, network, parent), + service_(service), + app_(app), + network_(network), + timer_flush_requests_(new QTimer(this)), + type_(type), + fetchalbums_(service->fetchalbums()), + query_id_(-1), + finished_(false), + artists_requests_total_(0), + artists_requests_active_(0), + artists_requests_received_(0), + artists_total_(0), + artists_received_(0), + albums_requests_total_(0), + albums_requests_active_(0), + albums_requests_received_(0), + albums_total_(0), + albums_received_(0), + songs_requests_total_(0), + songs_requests_active_(0), + songs_requests_received_(0), + songs_total_(0), + songs_received_(0), + artist_albums_requests_total_(), + artist_albums_requests_active_(0), + artist_albums_requests_received_(0), + artist_albums_total_(0), + artist_albums_received_(0), + album_songs_requests_active_(0), + album_songs_requests_received_(0), + album_songs_requests_total_(0), + album_songs_total_(0), + album_songs_received_(0), + album_covers_requests_total_(0), + album_covers_requests_active_(0), + album_covers_requests_received_(0), + no_results_(false) { + + timer_flush_requests_->setInterval(kFlushRequestsDelay); + timer_flush_requests_->setSingleShot(false); + QObject::connect(timer_flush_requests_, &QTimer::timeout, this, &SpotifyRequest::FlushRequests); + +} + +SpotifyRequest::~SpotifyRequest() { + + if (timer_flush_requests_->isActive()) { + timer_flush_requests_->stop(); + } + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + + while (!album_cover_replies_.isEmpty()) { + QNetworkReply *reply = album_cover_replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +void SpotifyRequest::Process() { + + if (!service_->authenticated()) { + emit UpdateStatus(query_id_, tr("Authenticating...")); + return; + } + + switch (type_) { + case QueryType::QueryType_Artists: + GetArtists(); + break; + case QueryType::QueryType_Albums: + GetAlbums(); + break; + case QueryType::QueryType_Songs: + GetSongs(); + break; + case QueryType::QueryType_SearchArtists: + ArtistsSearch(); + break; + case QueryType::QueryType_SearchAlbums: + AlbumsSearch(); + break; + case QueryType::QueryType_SearchSongs: + SongsSearch(); + break; + default: + Error("Invalid query type."); + break; + } + +} + +void SpotifyRequest::StartRequests() { + + if (!timer_flush_requests_->isActive()) { + timer_flush_requests_->start(); + } + +} + +void SpotifyRequest::FlushRequests() { + + if (!artists_requests_queue_.isEmpty()) { + FlushArtistsRequests(); + return; + } + + if (!albums_requests_queue_.isEmpty()) { + FlushAlbumsRequests(); + return; + } + + if (!artist_albums_requests_queue_.isEmpty()) { + FlushArtistAlbumsRequests(); + return; + } + + if (!album_songs_requests_queue_.isEmpty()) { + FlushAlbumSongsRequests(); + return; + } + + if (!songs_requests_queue_.isEmpty()) { + FlushSongsRequests(); + return; + } + + if (!album_cover_requests_queue_.isEmpty()) { + FlushAlbumCoverRequests(); + return; + } + + timer_flush_requests_->stop(); + +} + +void SpotifyRequest::Search(const int query_id, const QString &search_text) { + + query_id_ = query_id; + search_text_ = search_text; + +} + +void SpotifyRequest::GetArtists() { + + emit UpdateStatus(query_id_, tr("Receiving artists...")); + emit UpdateProgress(query_id_, 0); + AddArtistsRequest(); + +} + +void SpotifyRequest::AddArtistsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + artists_requests_queue_.enqueue(request); + + ++artists_requests_total_; + + StartRequests(); + +} + +void SpotifyRequest::FlushArtistsRequests() { + + while (!artists_requests_queue_.isEmpty() && artists_requests_active_ < kMaxConcurrentArtistsRequests) { + + Request request = artists_requests_queue_.dequeue(); + + ParamList parameters = ParamList() << Param("type", "artist"); + if (type_ == QueryType_SearchArtists) { + parameters << Param("q", search_text_); + } + if (request.limit > 0) { + parameters << Param("limit", QString::number(request.limit)); + } + if (request.offset > 0) { + parameters << Param("offset", QString::number(request.offset)); + } + QNetworkReply *reply = nullptr; + if (type_ == QueryType_Artists) { + reply = CreateRequest("me/following", parameters); + } + if (type_ == QueryType_SearchArtists) { + reply = CreateRequest("search", parameters); + } + if (!reply) continue; + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { ArtistsReplyReceived(reply, request.limit, request.offset); }); + + ++artists_requests_active_; + + } + +} + +void SpotifyRequest::GetAlbums() { + + emit UpdateStatus(query_id_, tr("Receiving albums...")); + emit UpdateProgress(query_id_, 0); + AddAlbumsRequest(); + +} + +void SpotifyRequest::AddAlbumsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + albums_requests_queue_.enqueue(request); + + ++albums_requests_total_; + + StartRequests(); + +} + +void SpotifyRequest::FlushAlbumsRequests() { + + while (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) { + + Request request = albums_requests_queue_.dequeue(); + + ParamList parameters; + if (type_ == QueryType_SearchAlbums) { + parameters << Param("type", "album"); + parameters << Param("q", search_text_); + } + if (request.limit > 0) parameters << Param("limit", QString::number(request.limit)); + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = nullptr; + if (type_ == QueryType_Albums) { + reply = CreateRequest("me/albums", parameters); + } + if (type_ == QueryType_SearchAlbums) { + reply = CreateRequest("search", parameters); + } + if (!reply) continue; + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { AlbumsReplyReceived(reply, request.limit, request.offset); }); + + ++albums_requests_active_; + + } + +} + +void SpotifyRequest::GetSongs() { + + emit UpdateStatus(query_id_, tr("Receiving songs...")); + emit UpdateProgress(query_id_, 0); + AddSongsRequest(); + +} + +void SpotifyRequest::AddSongsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + songs_requests_queue_.enqueue(request); + + ++songs_requests_total_; + + StartRequests(); + +} + +void SpotifyRequest::FlushSongsRequests() { + + while (!songs_requests_queue_.isEmpty() && songs_requests_active_ < kMaxConcurrentSongsRequests) { + + Request request = songs_requests_queue_.dequeue(); + + ParamList parameters; + if (type_ == QueryType_SearchSongs) { + parameters << Param("type", "track"); + parameters << Param("q", search_text_); + } + if (request.limit > 0) { + parameters << Param("limit", QString::number(request.limit)); + } + if (request.offset > 0) { + parameters << Param("offset", QString::number(request.offset)); + } + QNetworkReply *reply = nullptr; + if (type_ == QueryType_Songs) { + reply = CreateRequest("me/tracks", parameters); + } + if (type_ == QueryType_SearchSongs) { + reply = CreateRequest("search", parameters); + } + if (!reply) continue; + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { SongsReplyReceived(reply, request.limit, request.offset); }); + + ++songs_requests_active_; + + } + +} + +void SpotifyRequest::ArtistsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddArtistsSearchRequest(); + +} + +void SpotifyRequest::AddArtistsSearchRequest(const int offset) { + + AddArtistsRequest(offset, service_->artistssearchlimit()); + +} + +void SpotifyRequest::AlbumsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddAlbumsSearchRequest(); + +} + +void SpotifyRequest::AddAlbumsSearchRequest(const int offset) { + + AddAlbumsRequest(offset, service_->albumssearchlimit()); + +} + +void SpotifyRequest::SongsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddSongsSearchRequest(); + +} + +void SpotifyRequest::AddSongsSearchRequest(const int offset) { + + AddSongsRequest(offset, service_->songssearchlimit()); + +} + +void SpotifyRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + + --artists_requests_active_; + ++artists_requests_received_; + + if (finished_) return; + + if (data.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + if (!json_obj.contains("artists") || !json_obj["artists"].isObject()) { + Error("Json object missing values.", json_obj); + ArtistsFinishCheck(); + return; + } + QJsonObject obj_artists = json_obj["artists"].toObject(); + + if (!obj_artists.contains("limit") || + !obj_artists.contains("total") || + !obj_artists.contains("items")) { + Error("Json object missing values.", obj_artists); + ArtistsFinishCheck(); + return; + } + + int offset = 0; + if (obj_artists.contains("offset")) { + offset = obj_artists["offset"].toInt(); + } + int artists_total = obj_artists["total"].toInt(); + + if (offset_requested == 0) { + artists_total_ = artists_total; + } + else if (artists_total != artists_total_) { + Error(QString("Total returned does not match previous total! %1 != %2").arg(artists_total).arg(artists_total_)); + ArtistsFinishCheck(); + return; + } + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + ArtistsFinishCheck(); + return; + } + + if (offset_requested == 0) { + emit UpdateProgress(query_id_, GetProgress(artists_received_, artists_total_)); + } + + QJsonValue value_items = ExtractItems(obj_artists); + if (!value_items.isArray()) { + ArtistsFinishCheck(); + return; + } + + QJsonArray array_items = value_items.toArray(); + if (array_items.isEmpty()) { // Empty array means no results + if (offset_requested == 0) no_results_ = true; + ArtistsFinishCheck(); + return; + } + + int artists_received = 0; + for (const QJsonValueRef value_item : array_items) { + + ++artists_received; + + if (!value_item.isObject()) { + Error("Invalid Json reply, item in array is not a object."); + continue; + } + QJsonObject obj_item = value_item.toObject(); + + if (obj_item.contains("item")) { + QJsonValue json_item = obj_item["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item in array is not a object.", json_item); + continue; + } + obj_item = json_item.toObject(); + } + + if (!obj_item.contains("id") || !obj_item.contains("name")) { + Error("Invalid Json reply, item missing id or album.", obj_item); + continue; + } + + QString artist_id = obj_item["id"].toString(); + QString artist = obj_item["name"].toString(); + + if (artist_albums_requests_pending_.contains(artist_id)) continue; + + ArtistAlbumsRequest request; + request.artist.artist_id = artist_id; + request.artist.artist = artist; + artist_albums_requests_pending_.insert(artist_id, request); + + } + artists_received_ += artists_received; + + if (offset_requested != 0) emit UpdateProgress(query_id_, GetProgress(artists_total_, artists_received_)); + + ArtistsFinishCheck(limit_requested, offset, artists_received); + +} + +void SpotifyRequest::ArtistsFinishCheck(const int limit, const int offset, const int artists_received) { + + if (finished_) return; + + if ((limit == 0 || limit > artists_received) && artists_received_ < artists_total_) { + int offset_next = offset + artists_received; + if (offset_next > 0 && offset_next < artists_total_) { + if (type_ == QueryType_Artists) AddArtistsRequest(offset_next); + else if (type_ == QueryType_SearchArtists) AddArtistsSearchRequest(offset_next); + } + } + + if (artists_requests_queue_.isEmpty() && artists_requests_active_ <= 0) { // Artist query is finished, get all albums for all artists. + + // Get artist albums + QList requests = artist_albums_requests_pending_.values(); + for (const ArtistAlbumsRequest &request : requests) { + AddArtistAlbumsRequest(request.artist); + } + artist_albums_requests_pending_.clear(); + + if (artist_albums_requests_total_ > 0) { + if (artist_albums_requests_total_ == 1) emit UpdateStatus(query_id_, tr("Receiving albums for %1 artist...").arg(artist_albums_requests_total_)); + else emit UpdateStatus(query_id_, tr("Receiving albums for %1 artists...").arg(artist_albums_requests_total_)); + emit UpdateProgress(query_id_, 0); + } + + } + + FinishCheck(); + +} + +void SpotifyRequest::AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + --albums_requests_active_; + ++albums_requests_received_; + AlbumsReceived(reply, Artist(), limit_requested, offset_requested); + +} + +void SpotifyRequest::AddArtistAlbumsRequest(const Artist &artist, const int offset) { + + ArtistAlbumsRequest request; + request.artist = artist; + request.offset = offset; + artist_albums_requests_queue_.enqueue(request); + + ++artist_albums_requests_total_; + + StartRequests(); + +} + +void SpotifyRequest::FlushArtistAlbumsRequests() { + + while (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) { + + ArtistAlbumsRequest request = artist_albums_requests_queue_.dequeue(); + + ParamList parameters; + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("artists/%1/albums").arg(request.artist.artist_id), parameters); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { ArtistAlbumsReplyReceived(reply, request.artist, request.offset); }); + replies_ << reply; + + ++artist_albums_requests_active_; + + } + +} + +void SpotifyRequest::ArtistAlbumsReplyReceived(QNetworkReply *reply, const Artist &artist, const int offset_requested) { + + --artist_albums_requests_active_; + ++artist_albums_requests_received_; + emit UpdateProgress(query_id_, GetProgress(artist_albums_requests_received_, artist_albums_requests_total_)); + AlbumsReceived(reply, artist, 0, offset_requested); + +} + +void SpotifyRequest::AlbumsReceived(QNetworkReply *reply, const Artist &artist_artist, const int limit_requested, const int offset_requested) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + + if (finished_) return; + + if (data.isEmpty()) { + AlbumsFinishCheck(artist_artist); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + AlbumsFinishCheck(artist_artist); + return; + } + + if (json_obj.contains("albums") && json_obj["albums"].isObject()) { + json_obj = json_obj["albums"].toObject(); + } + + if (json_obj.contains("tracks") && json_obj["tracks"].isObject()) { + json_obj = json_obj["tracks"].toObject(); + } + + if (!json_obj.contains("limit") || + !json_obj.contains("offset") || + !json_obj.contains("total") || + !json_obj.contains("items")) { + Error("Json object missing values.", json_obj); + AlbumsFinishCheck(artist_artist); + return; + } + + int offset = json_obj["offset"].toInt(); + int albums_total = json_obj["total"].toInt(); + + if (type_ == QueryType_Albums || type_ == QueryType_SearchAlbums) { + albums_total_ = albums_total; + } + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + AlbumsFinishCheck(artist_artist); + return; + } + + QJsonValue value_items = ExtractItems(json_obj); + if (!value_items.isArray()) { + AlbumsFinishCheck(artist_artist); + return; + } + QJsonArray array_items = value_items.toArray(); + if (array_items.isEmpty()) { + if ((type_ == QueryType_Albums || type_ == QueryType_SearchAlbums || (type_ == QueryType_SearchSongs && fetchalbums_)) && offset_requested == 0) { + no_results_ = true; + } + AlbumsFinishCheck(artist_artist); + return; + } + + int albums_received = 0; + for (const QJsonValueRef value_item : array_items) { + + ++albums_received; + + if (!value_item.isObject()) { + Error("Invalid Json reply, item in array is not a object."); + continue; + } + QJsonObject obj_item = value_item.toObject(); + + if (obj_item.contains("item")) { + QJsonValue json_item = obj_item["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item in array is not a object.", json_item); + continue; + } + obj_item = json_item.toObject(); + } + + if (obj_item.contains("album")) { + QJsonValue json_item = obj_item["album"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, album in array is not a object.", json_item); + continue; + } + obj_item = json_item.toObject(); + } + + Artist artist; + Album album; + + if (!obj_item.contains("id")) { + Error("Invalid Json reply, item is missing ID.", obj_item); + continue; + } + if (!obj_item.contains("name")) { + Error("Invalid Json reply, item is missing name.", obj_item); + continue; + } + if (!obj_item.contains("images")) { + Error("Invalid Json reply, item is missing images.", obj_item); + continue; + } + album.album_id = obj_item["id"].toString(); + album.album = obj_item["name"].toString(); + + if (artist_artist.artist_id.isEmpty() && obj_item.contains("artists") && obj_item["artists"].isArray()) { + QJsonArray array_artists = obj_item["artists"].toArray(); + for (const QJsonValueRef value : array_artists) { + if (!value.isObject()) { + continue; + } + QJsonObject obj_artist = value.toObject(); + if (obj_artist.isEmpty() || !obj_artist.contains("id") || !obj_artist.contains("name")) continue; + artist.artist_id = obj_artist["id"].toString(); + artist.artist = obj_artist["name"].toString(); + break; + } + } + else { + artist = artist_artist; + } + + if (obj_item.contains("images") && obj_item["images"].isArray()) { + QJsonArray array_images = obj_item["images"].toArray(); + for (const QJsonValueRef value : array_images) { + if (!value.isObject()) { + continue; + } + QJsonObject obj_image = value.toObject(); + if (obj_image.isEmpty() || !obj_image.contains("url") || !obj_image.contains("width") || !obj_image.contains("height")) continue; + int width = obj_image["width"].toInt(); + int height = obj_image["height"].toInt(); + if (width <= 300 || height <= 300) { + continue; + } + album.cover_url = QUrl(obj_image["url"].toString()); + } + } + + if (obj_item.contains("tracks") && obj_item["tracks"].isObject()) { + QJsonObject obj_tracks = obj_item["tracks"].toObject(); + if (obj_tracks.contains("items") && obj_tracks["items"].isArray()) { + QJsonArray array_tracks = obj_tracks["items"].toArray(); + bool compilation = false; + bool multidisc = false; + SongList songs; + int songs_received = 0; + for (const QJsonValueRef value : array_tracks) { + if (!value.isObject()) { + continue; + } + QJsonObject obj_track = value.toObject(); + if (obj_track.contains("track") && obj_track["track"].isObject()) { + obj_track = obj_track["track"].toObject(); + } + ++songs_received; + Song song(Song::Source::Spotify); + ParseSong(song, obj_track, artist, album); + if (!song.is_valid()) continue; + if (song.disc() >= 2) multidisc = true; + if (song.is_compilation()) compilation = true; + songs << song; + } + for (Song song : songs) { + if (compilation) song.set_compilation_detected(true); + if (!multidisc) song.set_disc(0); + songs_.insert(song.song_id(), song); + } + } + } + else if (!album_songs_requests_pending_.contains(album.album_id)) { + AlbumSongsRequest request; + request.artist = artist; + request.album = album; + album_songs_requests_pending_.insert(album.album_id, request); + } + + } + + if (type_ == QueryType_Albums || type_ == QueryType_SearchAlbums) { + albums_received_ += albums_received; + emit UpdateProgress(query_id_, GetProgress(albums_received_, albums_total_)); + } + + AlbumsFinishCheck(artist_artist, limit_requested, offset, albums_total, albums_received); + +} + +void SpotifyRequest::AlbumsFinishCheck(const Artist &artist, const int limit, const int offset, const int albums_total, const int albums_received) { + + if (finished_) return; + + if (limit == 0 || limit > albums_received) { + int offset_next = offset + albums_received; + if (offset_next > 0 && offset_next < albums_total) { + switch (type_) { + case QueryType_Albums: + AddAlbumsRequest(offset_next); + break; + case QueryType_SearchAlbums: + AddAlbumsSearchRequest(offset_next); + break; + case QueryType_Artists: + case QueryType_SearchArtists: + AddArtistAlbumsRequest(artist, offset_next); + break; + default: + break; + } + } + } + + if ( + artists_requests_queue_.isEmpty() && + artists_requests_active_ <= 0 && + albums_requests_queue_.isEmpty() && + albums_requests_active_ <= 0 && + artist_albums_requests_queue_.isEmpty() && + artist_albums_requests_active_ <= 0 + ) { // Artist albums query is finished, get all songs for all albums. + + // Get songs for all the albums. + + for (QMap ::iterator it = album_songs_requests_pending_.begin(); it != album_songs_requests_pending_.end(); ++it) { + AlbumSongsRequest request = it.value(); + AddAlbumSongsRequest(request.artist, request.album); + } + album_songs_requests_pending_.clear(); + + if (album_songs_requests_total_ > 0) { + if (album_songs_requests_total_ == 1) emit UpdateStatus(query_id_, tr("Receiving songs for %1 album...").arg(album_songs_requests_total_)); + else emit UpdateStatus(query_id_, tr("Receiving songs for %1 albums...").arg(album_songs_requests_total_)); + emit UpdateProgress(query_id_, 0); + } + } + + GetAlbumCoversCheck(); + + FinishCheck(); + +} + +void SpotifyRequest::SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + --songs_requests_active_; + ++songs_requests_received_; + if (type_ == QueryType_SearchSongs && fetchalbums_) { + AlbumsReceived(reply, Artist(), limit_requested, offset_requested); + } + else { + SongsReceived(reply, Artist(), Album(), limit_requested, offset_requested); + } + +} + +void SpotifyRequest::AddAlbumSongsRequest(const Artist &artist, const Album &album, const int offset) { + + AlbumSongsRequest request; + request.artist = artist; + request.album = album; + request.offset = offset; + album_songs_requests_queue_.enqueue(request); + + ++album_songs_requests_total_; + + StartRequests(); + +} + +void SpotifyRequest::FlushAlbumSongsRequests() { + + while (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) { + + AlbumSongsRequest request = album_songs_requests_queue_.dequeue(); + ++album_songs_requests_active_; + ParamList parameters; + if (request.offset > 0) parameters << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("albums/%1/tracks").arg(request.album.album_id), parameters); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { AlbumSongsReplyReceived(reply, request.artist, request.album, request.offset); }); + + } + +} + +void SpotifyRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int offset_requested) { + + --album_songs_requests_active_; + ++album_songs_requests_received_; + if (offset_requested == 0) { + emit UpdateProgress(query_id_, GetProgress(album_songs_requests_received_, album_songs_requests_total_)); + } + SongsReceived(reply, artist, album, 0, offset_requested); + +} + +void SpotifyRequest::SongsReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int limit_requested, const int offset_requested) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + + if (finished_) return; + + if (data.isEmpty()) { + SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0); + return; + } + + if (json_obj.contains("tracks") && json_obj["tracks"].isObject()) { + json_obj = json_obj["tracks"].toObject(); + } + + if (!json_obj.contains("limit") || + !json_obj.contains("offset") || + !json_obj.contains("total") || + !json_obj.contains("items")) { + Error("Json object missing values.", json_obj); + SongsFinishCheck(artist, album, limit_requested, offset_requested, 0, 0); + return; + } + + int offset = json_obj["offset"].toInt(); + int songs_total = json_obj["total"].toInt(); + + if (type_ == QueryType_Songs || type_ == QueryType_SearchSongs) { + songs_total_ = songs_total; + } + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, 0); + return; + } + + QJsonValue json_value = ExtractItems(json_obj); + if (!json_value.isArray()) { + SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, 0); + return; + } + + QJsonArray array_items = json_value.toArray(); + if (array_items.isEmpty()) { + if ((type_ == QueryType_Songs || type_ == QueryType_SearchSongs) && offset_requested == 0) { + no_results_ = true; + } + SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, 0); + return; + } + + bool compilation = false; + bool multidisc = false; + SongList songs; + int songs_received = 0; + for (const QJsonValueRef value_item : array_items) { + + if (!value_item.isObject()) { + Error("Invalid Json reply, track is not a object."); + continue; + } + QJsonObject obj_item = value_item.toObject(); + + if (obj_item.contains("item") && obj_item["item"].isObject()) { + obj_item = obj_item["item"].toObject(); + } + + if (obj_item.contains("track") && obj_item["track"].isObject()) { + obj_item = obj_item["track"].toObject(); + } + + ++songs_received; + Song song(Song::Source::Spotify); + ParseSong(song, obj_item, artist, album); + if (!song.is_valid()) continue; + if (song.disc() >= 2) multidisc = true; + if (song.is_compilation()) compilation = true; + songs << song; + } + + for (Song song : songs) { + if (compilation) song.set_compilation_detected(true); + if (!multidisc) song.set_disc(0); + songs_.insert(song.song_id(), song); + } + + if (type_ == QueryType_Songs || type_ == QueryType_SearchSongs) { + songs_received_ += songs_received; + emit UpdateProgress(query_id_, GetProgress(songs_received_, songs_total_)); + } + + SongsFinishCheck(artist, album, limit_requested, offset_requested, songs_total, songs_received); + +} + +void SpotifyRequest::SongsFinishCheck(const Artist &artist, const Album &album, const int limit, const int offset, const int songs_total, const int songs_received) { + + if (finished_) return; + + if (limit == 0 || limit > songs_received) { + int offset_next = offset + songs_received; + if (offset_next > 0 && offset_next < songs_total) { + switch (type_) { + case QueryType_Songs: + AddSongsRequest(offset_next); + break; + case QueryType_SearchSongs: + // If artist_id and album_id isn't zero it means that it's a songs search where we fetch all albums too. So fallthrough. + if (artist.artist_id.isEmpty() && album.album_id.isEmpty()) { + AddSongsSearchRequest(offset_next); + break; + } + // fallthrough + case QueryType_Artists: + case QueryType_SearchArtists: + case QueryType_Albums: + case QueryType_SearchAlbums: + AddAlbumSongsRequest(artist, album, offset_next); + break; + default: + break; + } + } + } + + GetAlbumCoversCheck(); + + FinishCheck(); + +} + +void SpotifyRequest::ParseSong(Song &song, const QJsonObject &json_obj, const Artist &album_artist, const Album &album) { + + if ( + !json_obj.contains("type") || + !json_obj.contains("id") || + !json_obj.contains("name") || + !json_obj.contains("uri") || + !json_obj.contains("duration_ms") || + !json_obj.contains("track_number") || + !json_obj.contains("disc_number") + ) { + Error("Invalid Json reply, track is missing one or more values.", json_obj); + return; + } + + QString artist_id; + QString artist_title; + if (json_obj.contains("artists") && json_obj["artists"].isArray()) { + QJsonArray array_artists = json_obj["artists"].toArray(); + for (const QJsonValueRef value_artist : array_artists) { + if (!value_artist.isObject()) continue; + QJsonObject obj_artist = value_artist.toObject(); + if (!obj_artist.contains("type") || !obj_artist.contains("id") || !obj_artist.contains("name")) { + continue; + } + artist_id = obj_artist["id"].toString(); + artist_title = obj_artist["name"].toString(); + break; + } + } + + QString album_id; + QString album_title; + QUrl cover_url; + if (json_obj.contains("album") && json_obj["album"].isObject()) { + QJsonObject obj_album = json_obj["album"].toObject(); + if (obj_album.contains("type") && obj_album.contains("id") && obj_album.contains("name")) { + album_id = obj_album["id"].toString(); + album_title = obj_album["name"].toString(); + if (obj_album.contains("images") && obj_album["images"].isArray()) { + QJsonArray array_images = obj_album["images"].toArray(); + for (const QJsonValueRef value : array_images) { + if (!value.isObject()) { + continue; + } + QJsonObject obj_image = value.toObject(); + if (obj_image.isEmpty() || !obj_image.contains("url") || !obj_image.contains("width") || !obj_image.contains("height")) continue; + int width = obj_image["width"].toInt(); + int height = obj_image["height"].toInt(); + if (width <= 300 || height <= 300) { + continue; + } + cover_url = QUrl(obj_image["url"].toString()); + } + } + } + } + + if (artist_id.isEmpty() || artist_title.isEmpty()) { + artist_id = album_artist.artist_id; + artist_title = album_artist.artist; + } + + if (album_id.isEmpty() || album_title.isEmpty() || cover_url.isEmpty()) { + album_id = album.album_id; + album_title = album.album; + cover_url = album.cover_url; + } + + QJsonValue json_duration = json_obj["duration"]; + + QString song_id = json_obj["id"].toString(); + QString title = json_obj["name"].toString(); + QString uri = json_obj["uri"].toString(); + qint64 duration = json_obj["duration_ms"].toVariant().toLongLong() * kNsecPerMsec; + int track = json_obj["track_number"].toInt(); + int disc = json_obj["disc_number"].toInt(); + + QUrl url(uri); + + title = Song::TitleRemoveMisc(title); + + song.set_source(Song::Source::Spotify); + song.set_song_id(song_id); + song.set_album_id(album_id); + song.set_artist_id(artist_id); + if (album_artist.artist != artist_title) { + song.set_albumartist(album_artist.artist); + } + song.set_album(album_title); + song.set_artist(artist_title); + song.set_title(title); + song.set_track(track); + song.set_disc(disc); + song.set_url(url); + song.set_length_nanosec(duration); + song.set_art_automatic(cover_url); + song.set_directory_id(0); + song.set_filetype(Song::FileType::Stream); + song.set_filesize(0); + song.set_mtime(0); + song.set_ctime(0); + song.set_valid(true); + +} + +void SpotifyRequest::GetAlbumCoversCheck() { + + if ( + !finished_ && + artists_requests_queue_.isEmpty() && + albums_requests_queue_.isEmpty() && + songs_requests_queue_.isEmpty() && + artist_albums_requests_queue_.isEmpty() && + album_songs_requests_queue_.isEmpty() && + album_cover_requests_queue_.isEmpty() && + artist_albums_requests_pending_.isEmpty() && + album_songs_requests_pending_.isEmpty() && + album_covers_requests_sent_.isEmpty() && + artists_requests_active_ <= 0 && + albums_requests_active_ <= 0 && + songs_requests_active_ <= 0 && + artist_albums_requests_active_ <= 0 && + album_songs_requests_active_ <= 0 && + album_covers_requests_active_ <= 0 + ) { + GetAlbumCovers(); + } + +} + +void SpotifyRequest::GetAlbumCovers() { + + const SongList songs = songs_.values(); + for (const Song &song : songs) { + AddAlbumCoverRequest(song); + } + + if (album_covers_requests_total_ == 1) emit UpdateStatus(query_id_, tr("Receiving album cover for %1 album...").arg(album_covers_requests_total_)); + else emit UpdateStatus(query_id_, tr("Receiving album covers for %1 albums...").arg(album_covers_requests_total_)); + emit UpdateProgress(query_id_, 0); + + StartRequests(); + +} + +void SpotifyRequest::AddAlbumCoverRequest(const Song &song) { + + if (album_covers_requests_sent_.contains(song.album_id())) { + album_covers_requests_sent_.insert(song.album_id(), song.song_id()); + return; + } + + AlbumCoverRequest request; + request.album_id = song.album_id(); + request.url = song.art_automatic(); + request.filename = CoverUtils::CoverFilePath(CoverOptions(), song.source(), song.effective_albumartist(), song.effective_album(), song.album_id(), QString(), request.url); + if (request.filename.isEmpty()) return; + + album_covers_requests_sent_.insert(song.album_id(), song.song_id()); + ++album_covers_requests_total_; + + album_cover_requests_queue_.enqueue(request); + +} + +void SpotifyRequest::FlushAlbumCoverRequests() { + + while (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) { + + AlbumCoverRequest request = album_cover_requests_queue_.dequeue(); + + QNetworkRequest req(request.url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + QNetworkReply *reply = network_->get(req); + album_cover_replies_ << reply; + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { AlbumCoverReceived(reply, request.album_id, request.url, request.filename); }); + + ++album_covers_requests_active_; + + } + +} + +void SpotifyRequest::AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename) { + + if (album_cover_replies_.contains(reply)) { + album_cover_replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + } + else { + AlbumCoverFinishCheck(); + return; + } + + --album_covers_requests_active_; + ++album_covers_requests_received_; + + if (finished_) return; + + emit UpdateProgress(query_id_, GetProgress(album_covers_requests_received_, album_covers_requests_total_)); + + if (!album_covers_requests_sent_.contains(album_id)) { + AlbumCoverFinishCheck(); + return; + } + + if (reply->error() != QNetworkReply::NoError) { + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + Error(QString("Received HTTP code %1 for %2.").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()).arg(url.toString())); + if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QString mimetype = reply->header(QNetworkRequest::ContentTypeHeader).toString(); + if (mimetype.contains(';')) { + mimetype = mimetype.left(mimetype.indexOf(';')); + } + if (!ImageUtils::SupportedImageMimeTypes().contains(mimetype, Qt::CaseInsensitive) && !ImageUtils::SupportedImageFormats().contains(mimetype, Qt::CaseInsensitive)) { + Error(QString("Unsupported mimetype for image reader %1 for %2").arg(mimetype, url.toString())); + if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QByteArray data = reply->readAll(); + if (data.isEmpty()) { + Error(QString("Received empty image data for %1").arg(url.toString())); + if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QList format_list = QImageReader::imageFormatsForMimeType(mimetype.toUtf8()); + char *format = nullptr; + if (!format_list.isEmpty()) { + format = format_list.first().data(); + } + + QImage image; + if (image.loadFromData(data, format)) { + if (image.save(filename, format)) { + while (album_covers_requests_sent_.contains(album_id)) { + const QString song_id = album_covers_requests_sent_.take(album_id); + if (songs_.contains(song_id)) { + songs_[song_id].set_art_automatic(QUrl::fromLocalFile(filename)); + } + } + } + else { + Error(QString("Error saving image data to %1").arg(filename)); + if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id); + } + } + else { + if (album_covers_requests_sent_.contains(album_id)) album_covers_requests_sent_.remove(album_id); + Error(QString("Error decoding image data from %1").arg(url.toString())); + } + + AlbumCoverFinishCheck(); + +} + +void SpotifyRequest::AlbumCoverFinishCheck() { + + FinishCheck(); + +} + +void SpotifyRequest::FinishCheck() { + + if ( + !finished_ && + artists_requests_queue_.isEmpty() && + albums_requests_queue_.isEmpty() && + songs_requests_queue_.isEmpty() && + artist_albums_requests_queue_.isEmpty() && + album_songs_requests_queue_.isEmpty() && + album_cover_requests_queue_.isEmpty() && + artist_albums_requests_pending_.isEmpty() && + album_songs_requests_pending_.isEmpty() && + album_covers_requests_sent_.isEmpty() && + artists_requests_active_ <= 0 && + albums_requests_active_ <= 0 && + songs_requests_active_ <= 0 && + artist_albums_requests_active_ <= 0 && + album_songs_requests_active_ <= 0 && + album_covers_requests_active_ <= 0 + ) { + if (timer_flush_requests_->isActive()) { + timer_flush_requests_->stop(); + } + finished_ = true; + if (no_results_ && songs_.isEmpty()) { + if (IsSearch()) + emit Results(query_id_, SongMap(), tr("No match.")); + else + emit Results(query_id_, SongMap(), QString()); + } + else { + if (songs_.isEmpty() && errors_.isEmpty()) { + emit Results(query_id_, songs_, tr("Data missing error")); + } + else { + emit Results(query_id_, songs_, ErrorsToHTML(errors_)); + } + } + } + +} + +int SpotifyRequest::GetProgress(const int count, const int total) { + + return static_cast((static_cast(count) / static_cast(total)) * 100.0F); + +} + +void SpotifyRequest::Error(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) { + errors_ << error; + qLog(Error) << "Spotify:" << error; + } + + if (debug.isValid()) qLog(Debug) << debug; + + FinishCheck(); + +} + +void SpotifyRequest::Warn(const QString &error, const QVariant &debug) { + + qLog(Error) << "Spotify:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} diff --git a/src/spotify/spotifyrequest.h b/src/spotify/spotifyrequest.h new file mode 100644 index 00000000..a26424cb --- /dev/null +++ b/src/spotify/spotifyrequest.h @@ -0,0 +1,241 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef SPOTIFYREQUEST_H +#define SPOTIFYREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "spotifybaserequest.h" + +class QNetworkReply; +class Application; +class NetworkAccessManager; +class SpotifyService; +class SpotifyUrlHandler; + +class SpotifyRequest : public SpotifyBaseRequest { + Q_OBJECT + + public: + explicit SpotifyRequest(SpotifyService *service, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent); + ~SpotifyRequest() override; + + void ReloadSettings(); + + void Process(); + void Search(const int query_id, const QString &search_text); + + private: + struct Artist { + QString artist_id; + QString artist; + }; + struct Album { + QString album_id; + QString album; + QUrl cover_url; + }; + struct Request { + Request() : offset(0), limit(0) {} + int offset; + int limit; + }; + struct ArtistAlbumsRequest { + ArtistAlbumsRequest() : offset(0), limit(0) {} + Artist artist; + int offset; + int limit; + }; + struct AlbumSongsRequest { + AlbumSongsRequest() : offset(0), limit(0) {} + Artist artist; + Album album; + int offset; + int limit; + }; + struct AlbumCoverRequest { + QString artist_id; + QString album_id; + QUrl url; + QString filename; + }; + + signals: + void Results(int id, SongMap songs, QString error); + void UpdateStatus(int id, QString text); + void ProgressSetMaximum(int id, int max); + void UpdateProgress(int id, int max); + void StreamURLFinished(QUrl original_url, QUrl url, Song::FileType, QString error = QString()); + + private slots: + void FlushRequests(); + + void ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested); + + void AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested); + void AlbumsReceived(QNetworkReply *reply, const Artist &artist_artist, const int limit_requested, const int offset_requested); + + void SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested); + void SongsReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int limit_requested, const int offset_requested); + + void ArtistAlbumsReplyReceived(QNetworkReply *reply, const Artist &artist, const int offset_requested); + void AlbumSongsReplyReceived(QNetworkReply *reply, const Artist &artist, const Album &album, const int offset_requested); + void AlbumCoverReceived(QNetworkReply *reply, const QString &album_id, const QUrl &url, const QString &filename); + + private: + void StartRequests(); + + bool IsQuery() { return (type_ == QueryType_Artists || type_ == QueryType_Albums || type_ == QueryType_Songs); } + bool IsSearch() { return (type_ == QueryType_SearchArtists || type_ == QueryType_SearchAlbums || type_ == QueryType_SearchSongs); } + + void GetArtists(); + void GetAlbums(); + void GetSongs(); + + void ArtistsSearch(); + void AlbumsSearch(); + void SongsSearch(); + + void AddArtistsRequest(const int offset = 0, const int limit = 0); + void AddArtistsSearchRequest(const int offset = 0); + void FlushArtistsRequests(); + void AddAlbumsRequest(const int offset = 0, const int limit = 0); + void AddAlbumsSearchRequest(const int offset = 0); + void FlushAlbumsRequests(); + void AddSongsRequest(const int offset = 0, const int limit = 0); + void AddSongsSearchRequest(const int offset = 0); + void FlushSongsRequests(); + + void ArtistsFinishCheck(const int limit = 0, const int offset = 0, const int artists_received = 0); + void AlbumsFinishCheck(const Artist &artist, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0); + void SongsFinishCheck(const Artist &artist, const Album &album, const int limit = 0, const int offset = 0, const int songs_total = 0, const int songs_received = 0); + + void AddArtistAlbumsRequest(const Artist &artist, const int offset = 0); + void FlushArtistAlbumsRequests(); + + void AddAlbumSongsRequest(const Artist &artist, const Album &album, const int offset = 0); + void FlushAlbumSongsRequests(); + + void ParseSong(Song &song, const QJsonObject &json_obj, const Artist &album_artist, const Album &album); + + void GetAlbumCoversCheck(); + void GetAlbumCovers(); + void AddAlbumCoverRequest(const Song &song); + void FlushAlbumCoverRequests(); + void AlbumCoverFinishCheck(); + + int GetProgress(const int count, const int total); + void FinishCheck(); + static void Warn(const QString &error, const QVariant &debug = QVariant()); + void Error(const QString &error, const QVariant &debug = QVariant()) override; + + static const int kMaxConcurrentArtistsRequests; + static const int kMaxConcurrentAlbumsRequests; + static const int kMaxConcurrentSongsRequests; + static const int kMaxConcurrentArtistAlbumsRequests; + static const int kMaxConcurrentAlbumSongsRequests; + static const int kMaxConcurrentAlbumCoverRequests; + static const int kFlushRequestsDelay; + + SpotifyService *service_; + Application *app_; + NetworkAccessManager *network_; + QTimer *timer_flush_requests_; + + QueryType type_; + bool fetchalbums_; + QString coversize_; + + int query_id_; + QString search_text_; + + bool finished_; + + QQueue artists_requests_queue_; + QQueue albums_requests_queue_; + QQueue songs_requests_queue_; + + QQueue artist_albums_requests_queue_; + QQueue album_songs_requests_queue_; + QQueue album_cover_requests_queue_; + + QMap artist_albums_requests_pending_; + QMap album_songs_requests_pending_; + QMultiMap album_covers_requests_sent_; + + int artists_requests_total_; + int artists_requests_active_; + int artists_requests_received_; + int artists_total_; + int artists_received_; + + int albums_requests_total_; + int albums_requests_active_; + int albums_requests_received_; + int albums_total_; + int albums_received_; + + int songs_requests_total_; + int songs_requests_active_; + int songs_requests_received_; + int songs_total_; + int songs_received_; + + int artist_albums_requests_total_; + int artist_albums_requests_active_; + int artist_albums_requests_received_; + int artist_albums_total_; + int artist_albums_received_; + + int album_songs_requests_active_; + int album_songs_requests_received_; + int album_songs_requests_total_; + int album_songs_total_; + int album_songs_received_; + + int album_covers_requests_total_; + int album_covers_requests_active_; + int album_covers_requests_received_; + + SongMap songs_; + QStringList errors_; + bool no_results_; + QList replies_; + QList album_cover_replies_; + +}; + +#endif // SPOTIFYREQUEST_H diff --git a/src/spotify/spotifyservice.cpp b/src/spotify/spotifyservice.cpp new file mode 100644 index 00000000..63b0c061 --- /dev/null +++ b/src/spotify/spotifyservice.cpp @@ -0,0 +1,769 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/player.h" +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/database.h" +#include "core/song.h" +#include "utilities/timeconstants.h" +#include "utilities/randutils.h" +#include "internet/localredirectserver.h" +#include "internet/internetsearchview.h" +#include "collection/collectionbackend.h" +#include "collection/collectionmodel.h" +#include "spotifyservice.h" +#include "spotifybaserequest.h" +#include "spotifyrequest.h" +#include "spotifyfavoriterequest.h" +#include "settings/settingsdialog.h" +#include "settings/spotifysettingspage.h" + +const Song::Source SpotifyService::kSource = Song::Source::Spotify; + +const char *SpotifyService::kOAuthAuthorizeUrl = "https://accounts.spotify.com/authorize"; +const char *SpotifyService::kOAuthAccessTokenUrl = "https://accounts.spotify.com/api/token"; +const char *SpotifyService::kOAuthRedirectUrl = "http://localhost:63111/"; +const char *SpotifyService::kClientIDB64 = "ZTZjY2Y2OTQ5NzY1NGE3NThjOTAxNWViYzdiMWQzMTc="; +const char *SpotifyService::kClientSecretB64 = "N2ZlMDMxODk1NTBlNDE3ZGI1ZWQ1MzE3ZGZlZmU2MTE="; +const char *SpotifyService::kApiUrl = "https://api.spotify.com/v1"; + +const char *SpotifyService::kArtistsSongsTable = "spotify_artists_songs"; +const char *SpotifyService::kAlbumsSongsTable = "spotify_albums_songs"; +const char *SpotifyService::kSongsTable = "spotify_songs"; + +const char *SpotifyService::kArtistsSongsFtsTable = "spotify_artists_songs_fts"; +const char *SpotifyService::kAlbumsSongsFtsTable = "spotify_albums_songs_fts"; +const char *SpotifyService::kSongsFtsTable = "spotify_songs_fts"; + +using std::make_shared; +using namespace std::chrono_literals; + +SpotifyService::SpotifyService(Application *app, QObject *parent) + : InternetService(Song::Source::Spotify, "Spotify", "spotify", SpotifySettingsPage::kSettingsGroup, SettingsDialog::Page::Spotify, app, parent), + app_(app), + network_(new NetworkAccessManager(this)), + artists_collection_backend_(nullptr), + albums_collection_backend_(nullptr), + songs_collection_backend_(nullptr), + artists_collection_model_(nullptr), + albums_collection_model_(nullptr), + songs_collection_model_(nullptr), + artists_collection_sort_model_(new QSortFilterProxyModel(this)), + albums_collection_sort_model_(new QSortFilterProxyModel(this)), + songs_collection_sort_model_(new QSortFilterProxyModel(this)), + timer_search_delay_(new QTimer(this)), + timer_refresh_login_(new QTimer(this)), + favorite_request_(new SpotifyFavoriteRequest(this, network_, this)), + enabled_(false), + artistssearchlimit_(1), + albumssearchlimit_(1), + songssearchlimit_(1), + fetchalbums_(true), + download_album_covers_(true), + expires_in_(0), + login_time_(0), + pending_search_id_(0), + next_pending_search_id_(1), + pending_search_type_(InternetSearchView::SearchType::Artists), + search_id_(0), + server_(nullptr) { + + // Backends + + artists_collection_backend_ = make_shared(); + artists_collection_backend_->moveToThread(app_->database()->thread()); + artists_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kArtistsSongsTable, kArtistsSongsFtsTable); + + albums_collection_backend_ = make_shared(); + albums_collection_backend_->moveToThread(app_->database()->thread()); + albums_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kAlbumsSongsTable, kAlbumsSongsFtsTable); + + songs_collection_backend_ = make_shared(); + songs_collection_backend_->moveToThread(app_->database()->thread()); + songs_collection_backend_->Init(app_->database(), app->task_manager(), Song::Source::Spotify, kSongsTable, kSongsFtsTable); + + artists_collection_model_ = new CollectionModel(artists_collection_backend_, app_, this); + albums_collection_model_ = new CollectionModel(albums_collection_backend_, app_, this); + songs_collection_model_ = new CollectionModel(songs_collection_backend_, app_, this); + + artists_collection_sort_model_->setSourceModel(artists_collection_model_); + artists_collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + artists_collection_sort_model_->setDynamicSortFilter(true); + artists_collection_sort_model_->setSortLocaleAware(true); + artists_collection_sort_model_->sort(0); + + albums_collection_sort_model_->setSourceModel(albums_collection_model_); + albums_collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + albums_collection_sort_model_->setDynamicSortFilter(true); + albums_collection_sort_model_->setSortLocaleAware(true); + albums_collection_sort_model_->sort(0); + + songs_collection_sort_model_->setSourceModel(songs_collection_model_); + songs_collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + songs_collection_sort_model_->setDynamicSortFilter(true); + songs_collection_sort_model_->setSortLocaleAware(true); + songs_collection_sort_model_->sort(0); + + timer_refresh_login_->setSingleShot(true); + QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken); + + timer_search_delay_->setSingleShot(true); + QObject::connect(timer_search_delay_, &QTimer::timeout, this, &SpotifyService::StartSearch); + + QObject::connect(this, &SpotifyService::AddArtists, favorite_request_, &SpotifyFavoriteRequest::AddArtists); + QObject::connect(this, &SpotifyService::AddAlbums, favorite_request_, &SpotifyFavoriteRequest::AddAlbums); + QObject::connect(this, &SpotifyService::AddSongs, favorite_request_, QOverload::of(&SpotifyFavoriteRequest::AddSongs)); + + QObject::connect(this, &SpotifyService::RemoveArtists, favorite_request_, &SpotifyFavoriteRequest::RemoveArtists); + QObject::connect(this, &SpotifyService::RemoveAlbums, favorite_request_, &SpotifyFavoriteRequest::RemoveAlbums); + QObject::connect(this, &SpotifyService::RemoveSongsByList, favorite_request_, QOverload::of(&SpotifyFavoriteRequest::RemoveSongs)); + QObject::connect(this, &SpotifyService::RemoveSongsByMap, favorite_request_, QOverload::of(&SpotifyFavoriteRequest::RemoveSongs)); + + QObject::connect(favorite_request_, &SpotifyFavoriteRequest::ArtistsAdded, &*artists_collection_backend_, &CollectionBackend::AddOrUpdateSongs); + QObject::connect(favorite_request_, &SpotifyFavoriteRequest::AlbumsAdded, &*albums_collection_backend_, &CollectionBackend::AddOrUpdateSongs); + QObject::connect(favorite_request_, &SpotifyFavoriteRequest::SongsAdded, &*songs_collection_backend_, &CollectionBackend::AddOrUpdateSongs); + + QObject::connect(favorite_request_, &SpotifyFavoriteRequest::ArtistsRemoved, &*artists_collection_backend_, &CollectionBackend::DeleteSongs); + QObject::connect(favorite_request_, &SpotifyFavoriteRequest::AlbumsRemoved, &*albums_collection_backend_, &CollectionBackend::DeleteSongs); + QObject::connect(favorite_request_, &SpotifyFavoriteRequest::SongsRemoved, &*songs_collection_backend_, &CollectionBackend::DeleteSongs); + + SpotifyService::ReloadSettings(); + LoadSession(); + +} + +SpotifyService::~SpotifyService() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); + } + + artists_collection_backend_->deleteLater(); + albums_collection_backend_->deleteLater(); + songs_collection_backend_->deleteLater(); + +} + +void SpotifyService::Exit() { + + wait_for_exit_ << &*artists_collection_backend_ << &*albums_collection_backend_ << &*songs_collection_backend_; + + QObject::connect(&*artists_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived); + QObject::connect(&*albums_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived); + QObject::connect(&*songs_collection_backend_, &CollectionBackend::ExitFinished, this, &SpotifyService::ExitReceived); + + artists_collection_backend_->ExitAsync(); + albums_collection_backend_->ExitAsync(); + songs_collection_backend_->ExitAsync(); + +} + +void SpotifyService::ExitReceived() { + + QObject *obj = sender(); + QObject::disconnect(obj, nullptr, this, nullptr); + qLog(Debug) << obj << "successfully exited."; + wait_for_exit_.removeAll(obj); + if (wait_for_exit_.isEmpty()) emit ExitFinished(); + +} + +void SpotifyService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page::Spotify); +} + +void SpotifyService::LoadSession() { + + refresh_login_timer_.setSingleShot(true); + QObject::connect(&refresh_login_timer_, &QTimer::timeout, this, &SpotifyService::RequestNewAccessToken); + + QSettings s; + s.beginGroup(SpotifySettingsPage::kSettingsGroup); + access_token_ = s.value("access_token").toString(); + refresh_token_ = s.value("refresh_token").toString(); + expires_in_ = s.value("expires_in").toLongLong(); + login_time_ = s.value("login_time").toLongLong(); + s.endGroup(); + + if (!refresh_token_.isEmpty()) { + qint64 time = static_cast(expires_in_) - (QDateTime::currentDateTime().toSecsSinceEpoch() - static_cast(login_time_)); + if (time < 1) time = 1; + refresh_login_timer_.setInterval(static_cast(time * kMsecPerSec)); + refresh_login_timer_.start(); + } + +} + +void SpotifyService::ReloadSettings() { + + QSettings s; + s.beginGroup(SpotifySettingsPage::kSettingsGroup); + + enabled_ = s.value("enabled", false).toBool(); + + quint64 search_delay = std::max(s.value("searchdelay", 1500).toInt(), 500); + artistssearchlimit_ = s.value("artistssearchlimit", 4).toInt(); + albumssearchlimit_ = s.value("albumssearchlimit", 10).toInt(); + songssearchlimit_ = s.value("songssearchlimit", 10).toInt(); + fetchalbums_ = s.value("fetchalbums", false).toBool(); + download_album_covers_ = s.value("downloadalbumcovers", true).toBool(); + + s.endGroup(); + + timer_search_delay_->setInterval(static_cast(search_delay)); + +} + +void SpotifyService::Authenticate() { + + QUrl redirect_url(kOAuthRedirectUrl); + + if (!server_) { + server_ = new LocalRedirectServer(this); + int port = redirect_url.port(); + int port_max = port + 10; + bool success = false; + forever { + server_->set_port(port); + if (server_->Listen()) { + success = true; + break; + } + ++port; + if (port > port_max) break; + } + if (!success) { + LoginError(server_->error()); + server_->deleteLater(); + server_ = nullptr; + return; + } + QObject::connect(server_, &LocalRedirectServer::Finished, this, &SpotifyService::RedirectArrived); + } + + code_verifier_ = Utilities::CryptographicRandomString(44); + code_challenge_ = QString(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding)); + if (code_challenge_.lastIndexOf(QChar('=')) == code_challenge_.length() - 1) { + code_challenge_.chop(1); + } + + const ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64)) + << Param("response_type", "code") + << Param("redirect_uri", redirect_url.toString()) + << Param("state", code_challenge_) + << Param("scope", "user-follow-read user-follow-modify user-library-read user-library-modify streaming"); + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QUrl url(kOAuthAuthorizeUrl); + url.setQuery(url_query); + + const bool result = QDesktopServices::openUrl(url); + if (!result) { + QMessageBox messagebox(QMessageBox::Information, tr("Spotify Authentication"), tr("Please open this URL in your browser") + QString(":
%1").arg(url.toString()), QMessageBox::Ok); + messagebox.setTextFormat(Qt::RichText); + messagebox.exec(); + } + +} + +void SpotifyService::Deauthenticate() { + + access_token_.clear(); + refresh_token_.clear(); + expires_in_ = 0; + login_time_ = 0; + + QSettings s; + s.beginGroup(SpotifySettingsPage::kSettingsGroup); + s.remove("access_token"); + s.remove("refresh_token"); + s.remove("expires_in"); + s.remove("login_time"); + s.endGroup(); + + refresh_login_timer_.stop(); + +} + +void SpotifyService::RedirectArrived() { + + if (!server_) return; + + if (server_->error().isEmpty()) { + QUrl url = server_->request_url(); + if (url.isValid()) { + QUrlQuery url_query(url); + if (url_query.hasQueryItem("error")) { + LoginError(QUrlQuery(url).queryItemValue("error")); + } + else if (url_query.hasQueryItem("code") && url_query.hasQueryItem("state")) { + qLog(Debug) << "Spotify: Authorization URL Received" << url; + QString code = url_query.queryItemValue("code"); + QUrl redirect_url(kOAuthRedirectUrl); + redirect_url.setPort(server_->url().port()); + RequestAccessToken(code, redirect_url); + } + else { + LoginError(tr("Redirect missing token code or state!")); + } + } + else { + LoginError(tr("Received invalid reply from web browser.")); + } + } + else { + LoginError(server_->error()); + } + + server_->close(); + server_->deleteLater(); + server_ = nullptr; + +} + +void SpotifyService::RequestAccessToken(const QString &code, const QUrl &redirect_url) { + + refresh_login_timer_.stop(); + + ParamList params = ParamList() << Param("client_id", QByteArray::fromBase64(kClientIDB64)) + << Param("client_secret", QByteArray::fromBase64(kClientSecretB64)); + + if (!code.isEmpty() && !redirect_url.isEmpty()) { + params << Param("grant_type", "authorization_code"); + params << Param("code", code); + params << Param("redirect_uri", redirect_url.toString()); + } + else if (!refresh_token_.isEmpty() && enabled_) { + params << Param("grant_type", "refresh_token"); + params << Param("refresh_token", refresh_token_); + } + else { + return; + } + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QUrl new_url(kOAuthAccessTokenUrl); + QNetworkRequest req(new_url); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + QString auth_header_data = QByteArray::fromBase64(kClientIDB64) + QString(":") + QByteArray::fromBase64(kClientSecretB64); + req.setRawHeader("Authorization", "Basic " + auth_header_data.toUtf8().toBase64()); + + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + + QNetworkReply *reply = network_->post(req, query); + replies_ << reply; + QObject::connect(reply, &QNetworkReply::sslErrors, this, &SpotifyService::HandleLoginSSLErrors); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { AccessTokenRequestFinished(reply); }); + +} + +void SpotifyService::HandleLoginSSLErrors(const QList &ssl_errors) { + + for (const QSslError &ssl_error : ssl_errors) { + login_errors_ += ssl_error.errorString(); + } + +} + +void SpotifyService::AccessTokenRequestFinished(QNetworkReply *reply) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + QObject::disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + LoginError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + return; + } + else { + // See if there is Json data containing "error" and "error_description" then use that instead. + QByteArray data = reply->readAll(); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("error") && json_obj.contains("error_description")) { + QString error = json_obj["error"].toString(); + QString error_description = json_obj["error_description"].toString(); + login_errors_ << QString("Authentication failure: %1 (%2)").arg(error, error_description); + } + } + if (login_errors_.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + LoginError(); + return; + } + } + + QByteArray data = reply->readAll(); + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + Error(QString("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString())); + return; + } + + if (json_doc.isEmpty()) { + LoginError("Authentication reply from server has empty Json document."); + return; + } + + if (!json_doc.isObject()) { + LoginError("Authentication reply from server has Json document that is not an object.", json_doc); + return; + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + LoginError("Authentication reply from server has empty Json object.", json_doc); + return; + } + + if (!json_obj.contains("access_token") || !json_obj.contains("expires_in")) { + LoginError("Authentication reply from server is missing access token or expires in.", json_obj); + return; + } + + access_token_ = json_obj["access_token"].toString(); + if (json_obj.contains("refresh_token")) { + refresh_token_ = json_obj["refresh_token"].toString(); + } + expires_in_ = json_obj["expires_in"].toInt(); + login_time_ = QDateTime::currentDateTime().toSecsSinceEpoch(); + + QSettings s; + s.beginGroup(SpotifySettingsPage::kSettingsGroup); + s.setValue("access_token", access_token_); + s.setValue("refresh_token", refresh_token_); + s.setValue("expires_in", expires_in_); + s.setValue("login_time", login_time_); + s.endGroup(); + + if (expires_in_ > 0) { + refresh_login_timer_.setInterval(static_cast(expires_in_ * kMsecPerSec)); + refresh_login_timer_.start(); + } + + qLog(Debug) << "Spotify: Authentication was successful, login expires in" << expires_in_; + + emit LoginComplete(true); + emit LoginSuccess(); + +} + +void SpotifyService::ResetArtistsRequest() { + + if (artists_request_) { + QObject::disconnect(&*artists_request_, nullptr, this, nullptr); + QObject::disconnect(this, nullptr, &*artists_request_, nullptr); + artists_request_.reset(); + } + +} + +void SpotifyService::GetArtists() { + + if (!authenticated()) { + emit ArtistsResults(SongMap(), tr("Not authenticated with Spotify.")); + ShowConfig(); + return; + } + + ResetArtistsRequest(); + artists_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType_Artists, this), [](SpotifyRequest *request) { request->deleteLater(); }); + QObject::connect(&*artists_request_, &SpotifyRequest::Results, this, &SpotifyService::ArtistsResultsReceived); + QObject::connect(&*artists_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::ArtistsUpdateStatusReceived); + QObject::connect(&*artists_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::ArtistsProgressSetMaximumReceived); + QObject::connect(&*artists_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::ArtistsUpdateProgressReceived); + + artists_request_->Process(); + +} + +void SpotifyService::ArtistsResultsReceived(const int id, const SongMap &songs, const QString &error) { + + Q_UNUSED(id); + emit ArtistsResults(songs, error); + ResetArtistsRequest(); + +} + +void SpotifyService::ArtistsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit ArtistsUpdateStatus(text); +} + +void SpotifyService::ArtistsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit ArtistsProgressSetMaximum(max); +} + +void SpotifyService::ArtistsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit ArtistsUpdateProgress(progress); +} + +void SpotifyService::ResetAlbumsRequest() { + + if (albums_request_) { + QObject::disconnect(&*albums_request_, nullptr, this, nullptr); + QObject::disconnect(this, nullptr, &*albums_request_, nullptr); + albums_request_.reset(); + } + +} + +void SpotifyService::GetAlbums() { + + if (!authenticated()) { + emit AlbumsResults(SongMap(), tr("Not authenticated with Spotify.")); + ShowConfig(); + return; + } + + ResetAlbumsRequest(); + albums_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType_Albums, this), [](SpotifyRequest *request) { request->deleteLater(); }); + QObject::connect(&*albums_request_, &SpotifyRequest::Results, this, &SpotifyService::AlbumsResultsReceived); + QObject::connect(&*albums_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::AlbumsUpdateStatusReceived); + QObject::connect(&*albums_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::AlbumsProgressSetMaximumReceived); + QObject::connect(&*albums_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::AlbumsUpdateProgressReceived); + + albums_request_->Process(); + +} + +void SpotifyService::AlbumsResultsReceived(const int id, const SongMap &songs, const QString &error) { + + Q_UNUSED(id); + emit AlbumsResults(songs, error); + ResetAlbumsRequest(); + +} + +void SpotifyService::AlbumsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit AlbumsUpdateStatus(text); +} + +void SpotifyService::AlbumsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit AlbumsProgressSetMaximum(max); +} + +void SpotifyService::AlbumsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit AlbumsUpdateProgress(progress); +} + +void SpotifyService::ResetSongsRequest() { + + if (songs_request_) { + QObject::disconnect(&*songs_request_, nullptr, this, nullptr); + QObject::disconnect(this, nullptr, &*songs_request_, nullptr); + songs_request_.reset(); + } + +} + +void SpotifyService::GetSongs() { + + if (!authenticated()) { + emit SongsResults(SongMap(), tr("Not authenticated with Spotify.")); + ShowConfig(); + return; + } + + ResetSongsRequest(); + songs_request_.reset(new SpotifyRequest(this, app_, network_, SpotifyBaseRequest::QueryType_Songs, this), [](SpotifyRequest *request) { request->deleteLater(); }); + QObject::connect(&*songs_request_, &SpotifyRequest::Results, this, &SpotifyService::SongsResultsReceived); + QObject::connect(&*songs_request_, &SpotifyRequest::UpdateStatus, this, &SpotifyService::SongsUpdateStatusReceived); + QObject::connect(&*songs_request_, &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SongsProgressSetMaximumReceived); + QObject::connect(&*songs_request_, &SpotifyRequest::UpdateProgress, this, &SpotifyService::SongsUpdateProgressReceived); + + songs_request_->Process(); + +} + +void SpotifyService::SongsResultsReceived(const int id, const SongMap &songs, const QString &error) { + + Q_UNUSED(id); + emit SongsResults(songs, error); + ResetSongsRequest(); + +} + +void SpotifyService::SongsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit SongsUpdateStatus(text); +} + +void SpotifyService::SongsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit SongsProgressSetMaximum(max); +} + +void SpotifyService::SongsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit SongsUpdateProgress(progress); +} + +int SpotifyService::Search(const QString &text, InternetSearchView::SearchType type) { + + pending_search_id_ = next_pending_search_id_; + pending_search_text_ = text; + pending_search_type_ = type; + + next_pending_search_id_++; + + if (text.isEmpty()) { + timer_search_delay_->stop(); + return pending_search_id_; + } + timer_search_delay_->start(); + + return pending_search_id_; + +} + +void SpotifyService::StartSearch() { + + if (!authenticated()) { + emit SearchResults(pending_search_id_, SongMap(), tr("Not authenticated with Spotify.")); + ShowConfig(); + return; + } + + search_id_ = pending_search_id_; + search_text_ = pending_search_text_; + + SendSearch(); + +} + +void SpotifyService::CancelSearch() { +} + +void SpotifyService::SendSearch() { + + SpotifyBaseRequest::QueryType type = SpotifyBaseRequest::QueryType_None; + + switch (pending_search_type_) { + case InternetSearchView::SearchType::Artists: + type = SpotifyBaseRequest::QueryType_SearchArtists; + break; + case InternetSearchView::SearchType::Albums: + type = SpotifyBaseRequest::QueryType_SearchAlbums; + break; + case InternetSearchView::SearchType::Songs: + type = SpotifyBaseRequest::QueryType_SearchSongs; + break; + default: + //Error("Invalid search type."); + return; + } + + search_request_.reset(new SpotifyRequest(this, app_, network_, type, this), [](SpotifyRequest *request) { request->deleteLater(); }); + + QObject::connect(search_request_.get(), &SpotifyRequest::Results, this, &SpotifyService::SearchResultsReceived); + QObject::connect(search_request_.get(), &SpotifyRequest::UpdateStatus, this, &SpotifyService::SearchUpdateStatus); + QObject::connect(search_request_.get(), &SpotifyRequest::ProgressSetMaximum, this, &SpotifyService::SearchProgressSetMaximum); + QObject::connect(search_request_.get(), &SpotifyRequest::UpdateProgress, this, &SpotifyService::SearchUpdateProgress); + + search_request_->Search(search_id_, search_text_); + search_request_->Process(); + +} + +void SpotifyService::SearchResultsReceived(const int id, const SongMap &songs, const QString &error) { + + emit SearchResults(id, songs, error); + search_request_.reset(); + +} + +void SpotifyService::LoginError(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) login_errors_ << error; + + QString error_html; + for (const QString &e : login_errors_) { + qLog(Error) << "Spotify:" << e; + error_html += e + "
"; + } + if (debug.isValid()) qLog(Debug) << debug; + + emit LoginFailure(error_html); + emit LoginComplete(false); + + login_errors_.clear(); + +} diff --git a/src/spotify/spotifyservice.h b/src/spotify/spotifyservice.h new file mode 100644 index 00000000..469e7d13 --- /dev/null +++ b/src/spotify/spotifyservice.h @@ -0,0 +1,214 @@ +/* + * Strawberry Music Player + * Copyright 2022, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#ifndef SPOTIFYSERVICE_H +#define SPOTIFYSERVICE_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/shared_ptr.h" +#include "core/song.h" +#include "internet/internetservice.h" +#include "internet/internetsearchview.h" +#include "settings/spotifysettingspage.h" + +class QSortFilterProxyModel; +class QNetworkReply; + +class Application; +class NetworkAccessManager; +class SpotifyUrlHandler; +class SpotifyRequest; +class SpotifyFavoriteRequest; +class SpotifyStreamURLRequest; +class CollectionBackend; +class CollectionModel; +class LocalRedirectServer; + +class SpotifyService : public InternetService { + Q_OBJECT + + public: + explicit SpotifyService(Application *app, QObject *parent = nullptr); + ~SpotifyService() override; + + static const Song::Source kSource; + static const char *kApiUrl; + static const char *kResourcesUrl; + + void Exit() override; + void ReloadSettings() override; + + int Search(const QString &text, InternetSearchView::SearchType type) override; + void CancelSearch() override; + + Application *app() { return app_; } + + int artistssearchlimit() { return artistssearchlimit_; } + int albumssearchlimit() { return albumssearchlimit_; } + int songssearchlimit() { return songssearchlimit_; } + bool fetchalbums() { return fetchalbums_; } + bool download_album_covers() { return download_album_covers_; } + + QString access_token() { return access_token_; } + + bool authenticated() const override { return !access_token_.isEmpty(); } + + SharedPtr artists_collection_backend() override { return artists_collection_backend_; } + SharedPtr albums_collection_backend() override { return albums_collection_backend_; } + SharedPtr songs_collection_backend() override { return songs_collection_backend_; } + + CollectionModel *artists_collection_model() override { return artists_collection_model_; } + CollectionModel *albums_collection_model() override { return albums_collection_model_; } + CollectionModel *songs_collection_model() override { return songs_collection_model_; } + + QSortFilterProxyModel *artists_collection_sort_model() override { return artists_collection_sort_model_; } + QSortFilterProxyModel *albums_collection_sort_model() override { return albums_collection_sort_model_; } + QSortFilterProxyModel *songs_collection_sort_model() override { return songs_collection_sort_model_; } + + public slots: + void ShowConfig() override; + void Authenticate(); + void Deauthenticate(); + void GetArtists() override; + void GetAlbums() override; + void GetSongs() override; + void ResetArtistsRequest() override; + void ResetAlbumsRequest() override; + void ResetSongsRequest() override; + + private slots: + void ExitReceived(); + void RedirectArrived(); + void RequestNewAccessToken() { RequestAccessToken(); } + void HandleLoginSSLErrors(const QList &ssl_errors); + void AccessTokenRequestFinished(QNetworkReply *reply); + void StartSearch(); + void ArtistsResultsReceived(const int id, const SongMap &songs, const QString &error); + void AlbumsResultsReceived(const int id, const SongMap &songs, const QString &error); + void SongsResultsReceived(const int id, const SongMap &songs, const QString &error); + void SearchResultsReceived(const int id, const SongMap &songs, const QString &error); + void ArtistsUpdateStatusReceived(const int id, const QString &text); + void AlbumsUpdateStatusReceived(const int id, const QString &text); + void SongsUpdateStatusReceived(const int id, const QString &text); + void ArtistsProgressSetMaximumReceived(const int id, const int max); + void AlbumsProgressSetMaximumReceived(const int id, const int max); + void SongsProgressSetMaximumReceived(const int id, const int max); + void ArtistsUpdateProgressReceived(const int id, const int progress); + void AlbumsUpdateProgressReceived(const int id, const int progress); + void SongsUpdateProgressReceived(const int id, const int progress); + + private: + typedef QPair Param; + typedef QList ParamList; + + void LoadSession(); + void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl()); + void SendSearch(); + void LoginError(const QString &error = QString(), const QVariant &debug = QVariant()); + + static const char *kOAuthAuthorizeUrl; + static const char *kOAuthAccessTokenUrl; + static const char *kOAuthRedirectUrl; + static const char *kAuthUrl; + static const char *kClientIDB64; + static const char *kClientSecretB64; + + static const char *kArtistsSongsTable; + static const char *kAlbumsSongsTable; + static const char *kSongsTable; + + static const char *kArtistsSongsFtsTable; + static const char *kAlbumsSongsFtsTable; + static const char *kSongsFtsTable; + + Application *app_; + NetworkAccessManager *network_; + SpotifyUrlHandler *url_handler_; + + SharedPtr artists_collection_backend_; + SharedPtr albums_collection_backend_; + SharedPtr songs_collection_backend_; + + CollectionModel *artists_collection_model_; + CollectionModel *albums_collection_model_; + CollectionModel *songs_collection_model_; + + QSortFilterProxyModel *artists_collection_sort_model_; + QSortFilterProxyModel *albums_collection_sort_model_; + QSortFilterProxyModel *songs_collection_sort_model_; + + QTimer *timer_search_delay_; + QTimer *timer_refresh_login_; + + SharedPtr artists_request_; + SharedPtr albums_request_; + SharedPtr songs_request_; + SharedPtr search_request_; + SpotifyFavoriteRequest *favorite_request_; + + bool enabled_; + int artistssearchlimit_; + int albumssearchlimit_; + int songssearchlimit_; + bool fetchalbums_; + bool download_album_covers_; + + QString access_token_; + QString refresh_token_; + quint64 expires_in_; + quint64 login_time_; + + int pending_search_id_; + int next_pending_search_id_; + QString pending_search_text_; + InternetSearchView::SearchType pending_search_type_; + + int search_id_; + QString search_text_; + + QString code_verifier_; + QString code_challenge_; + + LocalRedirectServer *server_; + QStringList login_errors_; + QTimer refresh_login_timer_; + + QList wait_for_exit_; + QList replies_; +}; + +using SpotifyServicePtr = SharedPtr; + +#endif // SPOTIFYSERVICE_H diff --git a/src/utilities/coverutils.cpp b/src/utilities/coverutils.cpp index 9a5bba5e..2fe0f204 100644 --- a/src/utilities/coverutils.cpp +++ b/src/utilities/coverutils.cpp @@ -123,6 +123,7 @@ QString CoverUtils::CoverFilenameFromSource(const Song::Source source, const QUr } [[fallthrough]]; case Song::Source::Subsonic: + case Song::Source::Spotify: case Song::Source::Qobuz: if (!album_id.isEmpty()) { filename = album_id;