From 89252d0dbacbc7a4b9d284deb1a56b2eea4845cd Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Wed, 19 Jun 2019 02:22:11 +0200 Subject: [PATCH] Add Qobuz support (#181) --- CMakeLists.txt | 1 + README.md | 2 +- data/data.qrc | 1 + data/icons.qrc | 15 +- data/icons/128x128/qobuz.png | Bin 0 -> 6410 bytes data/icons/22x22/qobuz.png | Bin 0 -> 964 bytes data/icons/32x32/qobuz.png | Bin 0 -> 1405 bytes data/icons/48x48/qobuz.png | Bin 0 -> 2205 bytes data/icons/64x64/qobuz.png | Bin 0 -> 3057 bytes data/icons/full/qobuz.png | Bin 0 -> 60300 bytes data/schema/device-schema.sql | 1 - data/schema/schema-7.sql | 217 +++ data/schema/schema.sql | 219 ++- debian/control | 2 +- debian/copyright | 4 +- dist/man/strawberry.1 | 2 +- dist/rpm/strawberry.spec.in | 2 +- dist/unix/org.strawbs.strawberry.appdata.xml | 2 +- src/CMakeLists.txt | 21 + src/config.h.in | 1 + src/core/application.cpp | 16 + src/core/application.h | 3 + src/core/database.cpp | 2 +- src/core/mainwindow.cpp | 29 + src/core/mainwindow.h | 1 + src/core/song.cpp | 5 +- src/core/song.h | 1 + src/qobuz/qobuzbaserequest.cpp | 194 +++ src/qobuz/qobuzbaserequest.h | 108 ++ src/qobuz/qobuzfavoriterequest.cpp | 281 ++++ src/qobuz/qobuzfavoriterequest.h | 78 ++ src/qobuz/qobuzrequest.cpp | 1273 ++++++++++++++++++ src/qobuz/qobuzrequest.h | 208 +++ src/qobuz/qobuzservice.cpp | 603 +++++++++ src/qobuz/qobuzservice.h | 212 +++ src/qobuz/qobuzstreamurlrequest.cpp | 213 +++ src/qobuz/qobuzstreamurlrequest.h | 69 + src/qobuz/qobuzurlhandler.cpp | 68 + src/qobuz/qobuzurlhandler.h | 55 + src/settings/qobuzsettingspage.cpp | 152 +++ src/settings/qobuzsettingspage.h | 61 + src/settings/qobuzsettingspage.ui | 297 ++++ src/settings/settingsdialog.cpp | 25 +- src/settings/settingsdialog.h | 6 +- 44 files changed, 4428 insertions(+), 22 deletions(-) create mode 100644 data/icons/128x128/qobuz.png create mode 100644 data/icons/22x22/qobuz.png create mode 100644 data/icons/32x32/qobuz.png create mode 100644 data/icons/48x48/qobuz.png create mode 100644 data/icons/64x64/qobuz.png create mode 100644 data/icons/full/qobuz.png create mode 100644 data/schema/schema-7.sql create mode 100644 src/qobuz/qobuzbaserequest.cpp create mode 100644 src/qobuz/qobuzbaserequest.h create mode 100644 src/qobuz/qobuzfavoriterequest.cpp create mode 100644 src/qobuz/qobuzfavoriterequest.h create mode 100644 src/qobuz/qobuzrequest.cpp create mode 100644 src/qobuz/qobuzrequest.h create mode 100644 src/qobuz/qobuzservice.cpp create mode 100644 src/qobuz/qobuzservice.h create mode 100644 src/qobuz/qobuzstreamurlrequest.cpp create mode 100644 src/qobuz/qobuzstreamurlrequest.h create mode 100644 src/qobuz/qobuzurlhandler.cpp create mode 100644 src/qobuz/qobuzurlhandler.h create mode 100644 src/settings/qobuzsettingspage.cpp create mode 100644 src/settings/qobuzsettingspage.h create mode 100644 src/settings/qobuzsettingspage.ui diff --git a/CMakeLists.txt b/CMakeLists.txt index 28c42078d..4035c6a58 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,6 +345,7 @@ optional_component(TRANSLATIONS ON "Translations" ) optional_component(TIDAL ON "Tidal support") +optional_component(QOBUZ ON "Qobuz support") optional_component(SUBSONIC ON "Subsonic support") optional_component(MOODBAR ON "Moodbar" diff --git a/README.md b/README.md index a2dc00cca..137a3f28a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Strawberry is a music player and music collection organizer. It is a fork of Cle * Audio analyzer * Audio equalizer * Transfer music to iPod, iPhone, MTP or mass-storage USB player - * Streaming support for Tidal and Subsonic + * Streaming support for Tidal, Qobuz and Subsonic * Scrobbler with support for Last.fm, Libre.fm and ListenBrainz It has so far been tested to work on Linux, OpenBSD, macOS and Windows. diff --git a/data/data.qrc b/data/data.qrc index a075d0c17..f85e63311 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -7,6 +7,7 @@ schema/schema-4.sql schema/schema-5.sql schema/schema-6.sql + schema/schema-7.sql schema/device-schema.sql style/strawberry.css html/playing-tooltip-plain.html diff --git a/data/icons.qrc b/data/icons.qrc index b6d184914..9566d728f 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -83,12 +83,13 @@ icons/128x128/xine.png icons/128x128/zoom-in.png icons/128x128/zoom-out.png - icons/128x128/tidal.png icons/128x128/scrobble.png icons/128x128/scrobble-disabled.png icons/128x128/moodbar.png icons/128x128/love.png icons/128x128/subsonic.png + icons/128x128/tidal.png + icons/128x128/qobuz.png icons/64x64/albums.png icons/64x64/alsa.png icons/64x64/application-exit.png @@ -172,12 +173,13 @@ icons/64x64/xine.png icons/64x64/zoom-in.png icons/64x64/zoom-out.png - icons/64x64/tidal.png icons/64x64/scrobble.png icons/64x64/scrobble-disabled.png icons/64x64/moodbar.png icons/64x64/love.png icons/64x64/subsonic.png + icons/64x64/tidal.png + icons/64x64/qobuz.png icons/48x48/albums.png icons/48x48/alsa.png icons/48x48/application-exit.png @@ -264,12 +266,13 @@ icons/48x48/xine.png icons/48x48/zoom-in.png icons/48x48/zoom-out.png - icons/48x48/tidal.png icons/48x48/scrobble.png icons/48x48/scrobble-disabled.png icons/48x48/moodbar.png icons/48x48/love.png icons/48x48/subsonic.png + icons/48x48/tidal.png + icons/48x48/qobuz.png icons/32x32/albums.png icons/32x32/alsa.png icons/32x32/application-exit.png @@ -357,12 +360,13 @@ icons/32x32/xine.png icons/32x32/zoom-in.png icons/32x32/zoom-out.png - icons/32x32/tidal.png icons/32x32/scrobble.png icons/32x32/scrobble-disabled.png icons/32x32/moodbar.png icons/32x32/love.png icons/32x32/subsonic.png + icons/32x32/tidal.png + icons/32x32/qobuz.png icons/22x22/albums.png icons/22x22/alsa.png icons/22x22/application-exit.png @@ -450,11 +454,12 @@ icons/22x22/xine.png icons/22x22/zoom-in.png icons/22x22/zoom-out.png - icons/22x22/tidal.png icons/22x22/scrobble.png icons/22x22/scrobble-disabled.png icons/22x22/moodbar.png icons/22x22/love.png icons/22x22/subsonic.png + icons/22x22/tidal.png + icons/22x22/qobuz.png diff --git a/data/icons/128x128/qobuz.png b/data/icons/128x128/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..4b6b1775654b6074ccab55e9091379d1760e48d8 GIT binary patch literal 6410 zcmV+l8TICgP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00002 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 YQe|Oed2z{QJOBUy07*qoM6N<$g2+560ssI2 literal 0 HcmV?d00001 diff --git a/data/icons/22x22/qobuz.png b/data/icons/22x22/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..09834a5f14d275c903069022d533220cd7fa80c2 GIT binary patch literal 964 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H0wnYHF4+L2SkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+ueoXe|!I#{Xiaj ziKnkC`(rjCZdI9^pHCG6<(GT9IEHAPPn}$wai&n_c>H&Rw%hsq=I3q%e%i5ZuE@1X zHm<^ASxcTIsy1Ph&ElXPTZ^ zzM$#XGAY^q-Fsxze#dXU^3rl~o+oow=-O9LLS2G(_o=)&Kf~5*?)R5FRra6DUTl^z zy)0v{R*A2p=k_W0Pxvr!KRC0$a_fp7E4~Mc^MC53u_eEJrdcYoX;x19-r^HyH%Lx? z`ruz{>Xps2zuyoDOgbWQtfiN&?7}g>ZPPdF@^W)~`OP{Pyy{#Kig~tkU-x&tW(I=99AL7R|6Y zzJq0ofvRV7%ZU`HKGTUF5o=~lu9zXHW-vWR!OLVSv$3*9;yZ4Z!Xgd+3^S2t*`kiKX4VgIU|CWWjC%t>oBDCu26LE=%#QXOueBMU=n{O8w zIw|Q@U}eYF{rBf5?k*1uTK?^XgzEB@4*e>7Cw|smcYkHKo2@VFnHWdQr707SS-d%u zAlPjX+J{qaYP>Y;!0&+d7m z>)g|6H_V+Po{#h7K5j&pUXO@geCxPai%Z; literal 0 HcmV?d00001 diff --git a/data/icons/32x32/qobuz.png b/data/icons/32x32/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c6b8a3258ad93eec9bb70f4c287cc4dda89409 GIT binary patch literal 1405 zcmV-@1%mpCP)kdg00004XF*Lt006O% z3;baP0000WV@Og>004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00002 zVoOIv0RM-N%)bBt00(qQO+^Re1`-P;5Qyd&qj+!)=GhLc%l(K{pq^d)LJ9?pB?pT(8fnI45o;I? zwDT4(|GlN=|XF}Rt{nbB7>jpOu# z4NSW}p7#76GKjE^4=~Bag##z4xQGxWNEc!3T;V8>P(&I?GkJ1vhC#2;h%>yuJ}fLE z^wY~tx(U(+w6l?afLAG*$WpMY#8F;}g9y7~M(e{*63K)(ivSxbbmJpHKLE8HWIZEU zM#EC}!G_{Uhop1L5(6t$52-{O^@a9pt@1TY&N%cjbxNw!FduW^Xr-c(cEk}7dUl}m zQmsl8FsDvP3TSGS4JL)f7(}T{a8&`@89T6>MOA`-L;L}xQ(#bP8dVp&6>5Qb^KQvM01ESJk?qh%9-vNp2B`XUHR|T@%8rhKlUo3zFJZGRO%@ znylqortva_UQ~^i#gN4cNjf5D8^w@AeU&6>yNp2+@nYXg>eLD$mySt_Xr(x0BmsO_ zrglj_&A+bzs*q1or(V+A3QIbztX>up`<-0eEJ-@6!UssaBA=w70+M>QLe!9Qr>Jjc zhEK&@U-kfrO8H{1W49a~%p7Qnk<8vwotGqC(#uM|PaPiCJMu~D*ILnbTi~_L);R;PGU?;=zvz~(&BJ1MqHH<1(*J^oW#m%HX`PHI29oAMYR;COuS1Qyg zsaXZWlVDK0ji))86{^&iYSc|xvH5gShxM+?IbHppXHUdHCk`*^W6e~VOJidOk^}^MDf!?1LtXv zqs(C|n>hym0WFT>IC_fqk^lezC3HntbYx+4WjbSWWnpw>05UK!IV~_YEipJ$F)=zb zHaajjD=;uRFfgpSQY!!e03~!qSaf7zbY(hiZ)9m^c>ppnF*z+THZ3tYR539+Gc`Ij zFe@-HIxsN1lwp1V000?uMObuGZ)S9NVRB^vL1b@YWgtmyVP|DhWnpA_ami&o00000 LNkvXXu0mjfjoVM_ literal 0 HcmV?d00001 diff --git a/data/icons/48x48/qobuz.png b/data/icons/48x48/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..08af18cebcbb8c22f69c3657e488fa3c5503e524 GIT binary patch literal 2205 zcmV;O2x9k%P)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00002 zVoOIv0RM-N%)bBt00(qQO+^Re1`-P;2*W8J3;+NJfJsC_R9M56n0s(k^%ciIdv~+h zykF!+c!nmB08*4mgtj~sk?;~6rDywZLv(P9klHz(~dI~7!{_rIt~t1 z9*SkG6sDF0BnlV^uK;<6Kwz_*>}Ger{UbkaHpwQ?bN{&K{(k42@A;kI@1Aq+b#Nz2 zXEOJ59}~$Z6*r=r>s;X^l~i((;PC2>Vdk7nWho0NCEaNylS;C&@8uRg2+~9q?{Sc( z{~y4joHZ;aCxfZXWIBaplZXol;-!hRRPkSG355BAH~1S3cZ@|8jpb$9P>G(^QF%2K zwP=r4DO-qYzJup(%`f2unxH@Es_sl7Rp|+(if(o?{?5kTJk9lZtyaw-Qvr3TP1kfw zJp*QXv|slNag47HZ$5z+2;^#ux}(&)RIP1Vr-yW}N;O$!TBh~dr8-5Tq#CqVapDq- zhx7ggLKF0Ulv=y~szn+hk4C6ebF@SinyCp&lSkvVMjtB}<;W%_ih33fb&7t znPVP%*vrdQl1woZ8BGpZq!Q)^?KD$I4Ht3q2v0N3XhirE8|XUEH>v77>wR0hIVX7= z^UY%m8`ww-CEUvdvPi;1Jn@9-CV)sFn;ZiCo4q766(``Jj0lG_HHi=C><=)OZSL_e zveG=(vWB5g@opf&U~25!@a?QX;hTh z(cjOjh6~b3rvV8NB#dz2K!ga=O_#lbFaSP&M;!o3Jc-B6Dm=X*T1Uk?mIKhiQ=GF8 zR+uoB*<#VcDJnU^6?_Eoa+90f#7oGQZzYKb0m$bNS916#4Uokr89HiSmcJ`b^t%#N zps6ZRy4=c9kqVWrIOXXcl_-0V^f;X}mi{Qj27tNTm~ApokiZ*ZOx!~dLeGAOs_PQ?t- z?G<+tf?F2XymXi)jfx>2WRF=Pq_CJ-wuem|j3Mt}wBcF_L9<1jmwGc)8q=e9_$cQ1 z_LwV3rpRy?A;blX&x4#cLo-N

my{aeTm-*umzQJ21`w^l%!yoPn>?^_b~&uIauZ z62Oy=jMu^|` zh4@Lr6?U6`aEPi9>sn?*T+*-XZoFCr;t~`405Ulgr>%Cbe@!WO4Y5@9M(cJf+@){X z<+6_!adW&rwMGA;jJt+dGT=L6tK8xUx%~m0{2t9$vn}?HitZZXQ8n6npQ=Cv`9`1N z+pp#crd%v)VV?OCD&YZca3QKGiA`?uXrq>LQUHi>oLVOd9Ol+Q*P1jhhwD+Q3ONH3qoAshgKs!~GRp*~$hAEORCb5FKi=UM)2I{RTLmu0-fAue-^AQ41_Fx|9M!*147Zknn-8p&>imml}W)^R5U z08$vq7z)V5LxfIRspkr05UK!IV~_YEipJ$F)=zbHaajjD=;uRFfgpSQY!!e03~!qSaf7zbY(hiZ)9m^ zc>ppnF*z+THZ3tYR539+Gc`IjFe@-HIxsN1lwp1V000?uMObuGZ)S9NVRB^vL1b@Y fWgtmyVP|DhWnpA_ami&o00000NkvXXu0mjf;r-P( literal 0 HcmV?d00001 diff --git a/data/icons/64x64/qobuz.png b/data/icons/64x64/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..4c7f31b4e4d613780a582d9b575d2935883fa9c9 GIT binary patch literal 3057 zcmV004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00002 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 literal 0 HcmV?d00001 diff --git a/data/icons/full/qobuz.png b/data/icons/full/qobuz.png new file mode 100644 index 0000000000000000000000000000000000000000..c7ad97fdb1ff70d0f6f873876269f30477d86bdf GIT binary patch literal 60300 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 R03PPCxL|X>+{7d1e*lR%7w!N6 literal 0 HcmV?d00001 diff --git a/data/schema/device-schema.sql b/data/schema/device-schema.sql index df18e7b77..886e706bc 100644 --- a/data/schema/device-schema.sql +++ b/data/schema/device-schema.sql @@ -76,4 +76,3 @@ CREATE VIRTUAL TABLE device_%deviceid_fts USING fts3( ); UPDATE devices SET schema_version=0 WHERE ROWID=%deviceid; - diff --git a/data/schema/schema-7.sql b/data/schema/schema-7.sql new file mode 100644 index 000000000..92a03b4a9 --- /dev/null +++ b/data/schema/schema-7.sql @@ -0,0 +1,217 @@ +CREATE TABLE IF NOT EXISTS qobuz_artists_songs ( + + title TEXT NOT NULL, + album TEXT NOT NULL, + artist TEXT NOT NULL, + albumartist TEXT NOT NULL, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT NOT NULL, + compilation INTEGER NOT NULL DEFAULT -1, + composer TEXT NOT NULL, + performer TEXT NOT NULL, + grouping TEXT NOT NULL, + comment TEXT NOT NULL, + lyrics TEXT NOT NULL, + + artist_id INTEGER NOT NULL DEFAULT -1, + album_id INTEGER NOT NULL DEFAULT -1, + song_id INTEGER NOT NULL DEFAULT -1, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT 0, + samplerate INTEGER NOT NULL DEFAULT 0, + bitdepth INTEGER NOT NULL DEFAULT 0, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + ctime INTEGER NOT NULL DEFAULT 0, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT 0, + + 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, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS qobuz_albums_songs ( + + title TEXT NOT NULL, + album TEXT NOT NULL, + artist TEXT NOT NULL, + albumartist TEXT NOT NULL, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT NOT NULL, + compilation INTEGER NOT NULL DEFAULT -1, + composer TEXT NOT NULL, + performer TEXT NOT NULL, + grouping TEXT NOT NULL, + comment TEXT NOT NULL, + lyrics TEXT NOT NULL, + + artist_id INTEGER NOT NULL DEFAULT -1, + album_id INTEGER NOT NULL DEFAULT -1, + song_id INTEGER NOT NULL DEFAULT -1, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT 0, + samplerate INTEGER NOT NULL DEFAULT 0, + bitdepth INTEGER NOT NULL DEFAULT 0, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + ctime INTEGER NOT NULL DEFAULT 0, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT 0, + + 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, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS qobuz_songs ( + + title TEXT NOT NULL, + album TEXT NOT NULL, + artist TEXT NOT NULL, + albumartist TEXT NOT NULL, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT NOT NULL, + compilation INTEGER NOT NULL DEFAULT -1, + composer TEXT NOT NULL, + performer TEXT NOT NULL, + grouping TEXT NOT NULL, + comment TEXT NOT NULL, + lyrics TEXT NOT NULL, + + artist_id INTEGER NOT NULL DEFAULT -1, + album_id INTEGER NOT NULL DEFAULT -1, + song_id INTEGER NOT NULL DEFAULT -1, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT 0, + samplerate INTEGER NOT NULL DEFAULT 0, + bitdepth INTEGER NOT NULL DEFAULT 0, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + ctime INTEGER NOT NULL DEFAULT 0, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT 0, + + 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, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_artists_songs_fts USING fts3( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize=unicode + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_albums_songs_fts USING fts3( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize=unicode + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_songs_fts USING fts3( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize=unicode + +); + +UPDATE schema_version SET version=7; diff --git a/data/schema/schema.sql b/data/schema/schema.sql index bcda861a9..67f0d0b87 100644 --- a/data/schema/schema.sql +++ b/data/schema/schema.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS schema_version ( DELETE FROM schema_version; -INSERT INTO schema_version (version) VALUES (6); +INSERT INTO schema_version (version) VALUES (7); CREATE TABLE IF NOT EXISTS directories ( path TEXT NOT NULL, @@ -302,6 +302,177 @@ CREATE TABLE IF NOT EXISTS subsonic_songs ( ); +CREATE TABLE IF NOT EXISTS qobuz_artists_songs ( + + title TEXT NOT NULL, + album TEXT NOT NULL, + artist TEXT NOT NULL, + albumartist TEXT NOT NULL, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT NOT NULL, + compilation INTEGER NOT NULL DEFAULT -1, + composer TEXT NOT NULL, + performer TEXT NOT NULL, + grouping TEXT NOT NULL, + comment TEXT NOT NULL, + lyrics TEXT NOT NULL, + + artist_id INTEGER NOT NULL DEFAULT -1, + album_id INTEGER NOT NULL DEFAULT -1, + song_id INTEGER NOT NULL DEFAULT -1, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT 0, + samplerate INTEGER NOT NULL DEFAULT 0, + bitdepth INTEGER NOT NULL DEFAULT 0, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + ctime INTEGER NOT NULL DEFAULT 0, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT 0, + + 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, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS qobuz_albums_songs ( + + title TEXT NOT NULL, + album TEXT NOT NULL, + artist TEXT NOT NULL, + albumartist TEXT NOT NULL, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT NOT NULL, + compilation INTEGER NOT NULL DEFAULT -1, + composer TEXT NOT NULL, + performer TEXT NOT NULL, + grouping TEXT NOT NULL, + comment TEXT NOT NULL, + lyrics TEXT NOT NULL, + + artist_id INTEGER NOT NULL DEFAULT -1, + album_id INTEGER NOT NULL DEFAULT -1, + song_id INTEGER NOT NULL DEFAULT -1, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT 0, + samplerate INTEGER NOT NULL DEFAULT 0, + bitdepth INTEGER NOT NULL DEFAULT 0, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + ctime INTEGER NOT NULL DEFAULT 0, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT 0, + + 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, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + +CREATE TABLE IF NOT EXISTS qobuz_songs ( + + title TEXT NOT NULL, + album TEXT NOT NULL, + artist TEXT NOT NULL, + albumartist TEXT NOT NULL, + track INTEGER NOT NULL DEFAULT -1, + disc INTEGER NOT NULL DEFAULT -1, + year INTEGER NOT NULL DEFAULT -1, + originalyear INTEGER NOT NULL DEFAULT 0, + genre TEXT NOT NULL, + compilation INTEGER NOT NULL DEFAULT -1, + composer TEXT NOT NULL, + performer TEXT NOT NULL, + grouping TEXT NOT NULL, + comment TEXT NOT NULL, + lyrics TEXT NOT NULL, + + artist_id INTEGER NOT NULL DEFAULT -1, + album_id INTEGER NOT NULL DEFAULT -1, + song_id INTEGER NOT NULL DEFAULT -1, + + beginning INTEGER NOT NULL DEFAULT 0, + length INTEGER NOT NULL DEFAULT 0, + + bitrate INTEGER NOT NULL DEFAULT 0, + samplerate INTEGER NOT NULL DEFAULT 0, + bitdepth INTEGER NOT NULL DEFAULT 0, + + source INTEGER NOT NULL DEFAULT 0, + directory_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filetype INTEGER NOT NULL DEFAULT 0, + filesize INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0, + ctime INTEGER NOT NULL DEFAULT 0, + unavailable INTEGER DEFAULT 0, + + playcount INTEGER NOT NULL DEFAULT 0, + skipcount INTEGER NOT NULL DEFAULT 0, + lastplayed INTEGER NOT NULL DEFAULT 0, + + 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, + art_manual TEXT, + + effective_albumartist TEXT, + effective_originalyear INTEGER NOT NULL DEFAULT 0, + + cue_path TEXT + +); + CREATE TABLE IF NOT EXISTS playlists ( name TEXT NOT NULL, @@ -470,6 +641,51 @@ CREATE VIRTUAL TABLE IF NOT EXISTS subsonic_songs_fts USING fts3( ); +CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_artists_songs_fts USING fts3( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize=unicode + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_albums_songs_fts USING fts3( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize=unicode + +); + +CREATE VIRTUAL TABLE IF NOT EXISTS qobuz_songs_fts USING fts3( + + ftstitle, + ftsalbum, + ftsartist, + ftsalbumartist, + ftscomposer, + ftsperformer, + ftsgrouping, + ftsgenre, + ftscomment, + tokenize=unicode + +); + CREATE VIRTUAL TABLE IF NOT EXISTS playlist_items_fts_ USING fts3( ftstitle, @@ -500,7 +716,6 @@ CREATE VIRTUAL TABLE IF NOT EXISTS %allsongstables_fts USING fts3( ); - INSERT INTO songs_fts (ROWID, ftstitle, ftsalbum, ftsartist, ftsalbumartist, ftscomposer, ftsperformer, ftsgrouping, ftsgenre, ftscomment) SELECT ROWID, title, album, artist, albumartist, composer, performer, grouping, genre, comment FROM songs; diff --git a/debian/control b/debian/control index 006283d45..234511613 100644 --- a/debian/control +++ b/debian/control @@ -66,7 +66,7 @@ Description: Audio player and music collection organizer - Audio analyzer - Audio equalizer - Transfer music to iPod, iPhone, MTP or mass-storage USB player - - Streaming support for Tidal and Subsonic + - Streaming support for Tidal, Qobuz and Subsonic - Scrobbler with support for Last.fm, Libre.fm and ListenBrainz . It is a fork of Clementine. The name is inspired by the band Strawbs. diff --git a/debian/copyright b/debian/copyright index 9bf3bb27e..0c18b14c0 100644 --- a/debian/copyright +++ b/debian/copyright @@ -58,9 +58,11 @@ Files: src/core/main.h src/lyrics/* src/scrobbler/* src/tidal/* + src/qobuz/* + src/subsonic/* src/transcoder/transcoderoptionswavpack.cpp src/transcoder/transcoderoptionswavpack.h -Copyright: 2012-2014, 2017-2018, Jonas Kvinge +Copyright: 2012-2014, 2017-2019, Jonas Kvinge License: GPL-3+ Files: src/core/main.cpp diff --git a/dist/man/strawberry.1 b/dist/man/strawberry.1 index 3aac59771..0010b01e8 100644 --- a/dist/man/strawberry.1 +++ b/dist/man/strawberry.1 @@ -37,7 +37,7 @@ Features: .br - Transfer music to iPod, iPhone, MTP or mass-storage USB player .br -- Integrated Tidal support +- Streaming from Tidal, Qobuz and Subsonic .TP It is a fork of Clementine. The name is inspired by the band Strawbs. .SH OPTIONS diff --git a/dist/rpm/strawberry.spec.in b/dist/rpm/strawberry.spec.in index 49b509f63..430a4327b 100644 --- a/dist/rpm/strawberry.spec.in +++ b/dist/rpm/strawberry.spec.in @@ -106,7 +106,7 @@ Features: - Audio analyzer - Audio equalizer - Transfer music to iPod, iPhone, MTP or mass-storage USB player - - Streaming support for Tidal and Subsonic + - Streaming support for Tidal, Qobuz and Subsonic - Scrobbler with support for Last.fm, Libre.fm and ListenBrainz %prep diff --git a/dist/unix/org.strawbs.strawberry.appdata.xml b/dist/unix/org.strawbs.strawberry.appdata.xml index 82e33102a..145c4b6bc 100644 --- a/dist/unix/org.strawbs.strawberry.appdata.xml +++ b/dist/unix/org.strawbs.strawberry.appdata.xml @@ -34,7 +34,7 @@

  • Audio analyzer
  • Audio equalizer
  • Transfer music to iPod, iPhone, MTP or mass-storage USB player
  • -
  • Streaming support for Tidal and Subsonic
  • +
  • Streaming support for Tidal, Qobuz and Subsonic
  • Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
  • diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 641ebcfe7..6bee17d3a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -911,6 +911,27 @@ optional_source(HAVE_TIDAL settings/tidalsettingspage.ui ) +optional_source(HAVE_QOBUZ + SOURCES + qobuz/qobuzservice.cpp + qobuz/qobuzurlhandler.cpp + qobuz/qobuzbaserequest.cpp + qobuz/qobuzrequest.cpp + qobuz/qobuzstreamurlrequest.cpp + qobuz/qobuzfavoriterequest.cpp + settings/qobuzsettingspage.cpp + HEADERS + qobuz/qobuzservice.h + qobuz/qobuzurlhandler.h + qobuz/qobuzbaserequest.h + qobuz/qobuzrequest.h + qobuz/qobuzstreamurlrequest.h + qobuz/qobuzfavoriterequest.h + settings/qobuzsettingspage.h + UI + settings/qobuzsettingspage.ui +) + optional_source(HAVE_SUBSONIC SOURCES subsonic/subsonicservice.cpp diff --git a/src/config.h.in b/src/config.h.in index c017d1554..d8d27c670 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -51,6 +51,7 @@ #cmakedefine HAVE_TIDAL #cmakedefine HAVE_SUBSONIC +#cmakedefine HAVE_QOBUZ #cmakedefine HAVE_MOODBAR diff --git a/src/core/application.cpp b/src/core/application.cpp index 83fc8399e..cbd143a26 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -71,6 +71,10 @@ # include "covermanager/tidalcoverprovider.h" #endif +#ifdef HAVE_QOBUZ +# include "qobuz/qobuzservice.h" +#endif + #ifdef HAVE_SUBSONIC # include "subsonic/subsonicservice.h" #endif @@ -140,6 +144,9 @@ class ApplicationImpl { #ifdef HAVE_TIDAL internet_services->AddService(new TidalService(app, internet_services)); #endif +#ifdef HAVE_QOBUZ + internet_services->AddService(new QobuzService(app, internet_services)); +#endif #ifdef HAVE_SUBSONIC internet_services->AddService(new SubsonicService(app, internet_services)); #endif @@ -147,6 +154,9 @@ class ApplicationImpl { }), #ifdef HAVE_TIDAL tidal_search_([=]() { return new InternetSearch(app, Song::Source_Tidal, app); }), +#endif +#ifdef HAVE_QOBUZ + qobuz_search_([=]() { return new InternetSearch(app, Song::Source_Qobuz, app); }), #endif scrobbler_([=]() { return new AudioScrobbler(app, app); }), @@ -177,6 +187,9 @@ class ApplicationImpl { Lazy internet_services_; #ifdef HAVE_TIDAL Lazy tidal_search_; +#endif +#ifdef HAVE_QOBUZ + Lazy qobuz_search_; #endif Lazy scrobbler_; #ifdef HAVE_MOODBAR @@ -254,6 +267,9 @@ InternetServices *Application::internet_services() const { return p_->internet_s #ifdef HAVE_TIDAL InternetSearch *Application::tidal_search() const { return p_->tidal_search_.get(); } #endif +#ifdef HAVE_QOBUZ +InternetSearch *Application::qobuz_search() const { return p_->qobuz_search_.get(); } +#endif AudioScrobbler *Application::scrobbler() const { return p_->scrobbler_.get(); } #ifdef HAVE_MOODBAR MoodbarController *Application::moodbar_controller() const { return p_->moodbar_controller_.get(); } diff --git a/src/core/application.h b/src/core/application.h index ade650653..2d39446c0 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -102,6 +102,9 @@ class Application : public QObject { #ifdef HAVE_TIDAL InternetSearch *tidal_search() const; #endif +#ifdef HAVE_QOBUZ + InternetSearch *qobuz_search() const; +#endif #ifdef HAVE_MOODBAR MoodbarController *moodbar_controller() const; diff --git a/src/core/database.cpp b/src/core/database.cpp index 249596150..7364f5684 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -52,7 +52,7 @@ #include "scopedtransaction.h" const char *Database::kDatabaseFilename = "strawberry.db"; -const int Database::kSchemaVersion = 6; +const int Database::kSchemaVersion = 7; const char *Database::kMagicAllSongsTables = "%allsongstables"; int Database::sNextConnectionId = 1; diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 64740a90d..b9009b212 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -138,6 +138,10 @@ # include "tidal/tidalservice.h" # include "settings/tidalsettingspage.h" #endif +#ifdef HAVE_QOBUZ +# include "qobuz/qobuzservice.h" +# include "settings/qobuzsettingspage.h" +#endif #ifdef HAVE_SUBSONIC # include "subsonic/subsonicservice.h" # include "settings/subsonicsettingspage.h" @@ -216,6 +220,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co #ifdef HAVE_TIDAL tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Tidal), app_->tidal_search(), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, this)), #endif +#ifdef HAVE_QOBUZ + qobuz_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Qobuz), app_->qobuz_search(), QobuzSettingsPage::kSettingsGroup, SettingsDialog::Page_Qobuz, this)), +#endif #ifdef HAVE_SUBSONIC subsonic_view_(new InternetSongsView(app_, app->internet_services()->ServiceBySource(Song::Source_Subsonic), SubsonicSettingsPage::kSettingsGroup, SettingsDialog::Page_Subsonic, this)), #endif @@ -273,6 +280,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co #ifdef HAVE_TIDAL ui_->tabs->AddTab(tidal_view_, "tidal", IconLoader::Load("tidal"), tr("Tidal")); #endif +#ifdef HAVE_QOBUZ + ui_->tabs->AddTab(qobuz_view_, "qobuz", IconLoader::Load("qobuz"), tr("Qobuz")); +#endif #ifdef HAVE_SUBSONIC ui_->tabs->AddTab(subsonic_view_, "subsonic", IconLoader::Load("subsonic"), tr("Subsonic")); #endif @@ -566,7 +576,13 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co TidalService *tidalservice = qobject_cast (app_->internet_services()->ServiceBySource(Song::Source_Tidal)); if (tidalservice) connect(this, SIGNAL(AuthorisationUrlReceived(const QUrl&)), tidalservice, SLOT(AuthorisationUrlReceived(const QUrl&))); +#endif +#ifdef HAVE_QOBUZ + connect(qobuz_view_->artists_collection_view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + connect(qobuz_view_->albums_collection_view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + connect(qobuz_view_->songs_collection_view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + connect(qobuz_view_->search_view(), SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); #endif #ifdef HAVE_SUBSONIC @@ -889,6 +905,16 @@ void MainWindow::ReloadSettings() { ui_->tabs->DisableTab(tidal_view_); #endif +#ifdef HAVE_QOBUZ + settings.beginGroup(QobuzSettingsPage::kSettingsGroup); + bool enable_qobuz = settings.value("enabled", false).toBool(); + settings.endGroup(); + if (enable_qobuz) + ui_->tabs->EnableTab(qobuz_view_); + else + ui_->tabs->DisableTab(qobuz_view_); +#endif + #ifdef HAVE_SUBSONIC settings.beginGroup(SubsonicSettingsPage::kSettingsGroup); bool enable_subsonic = settings.value("enabled", false).toBool(); @@ -917,6 +943,9 @@ void MainWindow::ReloadAllSettings() { #ifdef HAVE_TIDAL tidal_view_->ReloadSettings(); #endif +#ifdef HAVE_QOBUZ + qobuz_view_->ReloadSettings(); +#endif #ifdef HAVE_SUBSONIC subsonic_view_->ReloadSettings(); #endif diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 416852f41..94ab4d503 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -314,6 +314,7 @@ signals: #endif InternetTabsView *tidal_view_; + InternetTabsView *qobuz_view_; InternetSongsView *subsonic_view_; QAction *collection_show_all_; diff --git a/src/core/song.cpp b/src/core/song.cpp index a2d0eb150..c8ea4fd0f 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -333,7 +333,7 @@ bool Song::has_cue() const { return !d->cue_path_.isEmpty(); } bool Song::is_collection_song() const { return !is_cdda() && !is_stream() && id() != -1; } bool Song::is_metadata_good() const { return !d->title_.isEmpty() && !d->album_.isEmpty() && !d->artist_.isEmpty() && !d->url_.isEmpty() && d->end_ > 0; } -bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal || d->source_ == Source_Subsonic; } +bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal || d->source_ == Source_Subsonic || d->source_ == Source_Qobuz; } bool Song::is_cdda() const { return d->source_ == Source_CDDA; } const QString &Song::error() const { return d->error_; } @@ -411,6 +411,7 @@ Song::Source Song::SourceFromURL(const QUrl &url) { 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() == "qobuz") return Source_Qobuz; else if (url.scheme() == "http" || url.scheme() == "https" || url.scheme() == "rtsp") return Source_Stream; else return Source_Unknown; @@ -426,6 +427,7 @@ QString Song::TextForSource(Source source) { case Song::Source_Stream: return QObject::tr("Stream"); case Song::Source_Tidal: return QObject::tr("Tidal"); case Song::Source_Subsonic: return QObject::tr("subsonic"); + case Song::Source_Qobuz: return QObject::tr("qobuz"); case Song::Source_Unknown: return QObject::tr("Unknown"); } return QObject::tr("Unknown"); @@ -442,6 +444,7 @@ QIcon Song::IconForSource(Source source) { case Song::Source_Stream: return IconLoader::Load("applications-internet"); case Song::Source_Tidal: return IconLoader::Load("tidal"); case Song::Source_Subsonic: return IconLoader::Load("subsonic"); + case Song::Source_Qobuz: return IconLoader::Load("qobuz"); case Song::Source_Unknown: return IconLoader::Load("edit-delete"); } return IconLoader::Load("edit-delete"); diff --git a/src/core/song.h b/src/core/song.h index 0bd09e158..7958c3cdd 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -75,6 +75,7 @@ class Song { Source_Stream = 5, Source_Tidal = 6, Source_Subsonic = 7, + Source_Qobuz = 8, }; // Don't change these values - they're stored in the database, and defined in the tag reader protobuf. diff --git a/src/qobuz/qobuzbaserequest.cpp b/src/qobuz/qobuzbaserequest.cpp new file mode 100644 index 000000000..29b45c581 --- /dev/null +++ b/src/qobuz/qobuzbaserequest.cpp @@ -0,0 +1,194 @@ +/* + * Strawberry Music Player + * Copyright 2019, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/network.h" +#include "qobuzservice.h" +#include "qobuzbaserequest.h" + +const char *QobuzBaseRequest::kApiUrl = "http://www.qobuz.com/api.json/0.2"; + +QobuzBaseRequest::QobuzBaseRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent) : + QObject(parent), + service_(service), + network_(network) + {} + +QobuzBaseRequest::~QobuzBaseRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, 0, nullptr, 0); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +QNetworkReply *QobuzBaseRequest::CreateRequest(const QString &ressource_name, const QList ¶ms_provided) { + + ParamList params = ParamList() << params_provided + << Param("app_id", app_id()); + + std::sort(params.begin(), params.end()); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QNetworkReply *reply = network_->get(req); + replies_ << reply; + + //qLog(Debug) << "Qobuz: Sending request" << url; + + return reply; + +} + +QByteArray QobuzBaseRequest::GetReplyData(QNetworkReply *reply, QString &error) { + + if (replies_.contains(reply)) { + replies_.removeAll(reply); + reply->deleteLater(); + } + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError) { + int http_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (http_code == 200) { + data = reply->readAll(); + } + else { + error = Error(QString("Received HTTP code %1").arg(http_code)); + } + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + // See if there is Json data containing "status", "code" and "message" - then use that instead. + data = reply->readAll(); + QJsonParseError parse_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error); + QString failure_reason; + if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("code") && json_obj.contains("message")) { + QString status = json_obj["status"].toString(); + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + failure_reason = QString("%1 (%2)").arg(message).arg(code); + } + } + if (failure_reason.isEmpty()) { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + error = Error(failure_reason); + } + return QByteArray(); + } + + return data; + +} + +QJsonObject QobuzBaseRequest::ExtractJsonObj(QByteArray &data, QString &error) { + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + error = Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + error = Error("Received empty Json document.", data); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + error = Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + error = Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + return json_obj; + +} + +QJsonValue QobuzBaseRequest::ExtractItems(QByteArray &data, QString &error) { + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) return QJsonValue(); + return ExtractItems(json_obj, error); + +} + +QJsonValue QobuzBaseRequest::ExtractItems(QJsonObject &json_obj, QString &error) { + + if (!json_obj.contains("items")) { + error = Error("Json reply is missing items.", json_obj); + return QJsonArray(); + } + QJsonValue json_items = json_obj["items"]; + return json_items; + +} + +QString QobuzBaseRequest::Error(QString error, QVariant debug) { + + qLog(Error) << "Qobuz:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + return error; + +} diff --git a/src/qobuz/qobuzbaserequest.h b/src/qobuz/qobuzbaserequest.h new file mode 100644 index 000000000..e2089c208 --- /dev/null +++ b/src/qobuz/qobuzbaserequest.h @@ -0,0 +1,108 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 QOBUZBASEREQUEST_H +#define QOBUZBASEREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetservices.h" +#include "internet/internetservice.h" +#include "internet/internetsearch.h" +#include "qobuzservice.h" + +class Application; +class NetworkAccessManager; +class QobuzUrlHandler; +class CollectionBackend; +class CollectionModel; + +class QobuzBaseRequest : public QObject { + Q_OBJECT + + public: + + enum QueryType { + QueryType_None, + QueryType_Artists, + QueryType_Albums, + QueryType_Songs, + QueryType_SearchArtists, + QueryType_SearchAlbums, + QueryType_SearchSongs, + QueryType_StreamURL, + }; + + QobuzBaseRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent); + ~QobuzBaseRequest(); + + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + QNetworkReply *CreateRequest(const QString &ressource_name, const QList ¶ms_provided); + QByteArray GetReplyData(QNetworkReply *reply, QString &error); + QJsonObject ExtractJsonObj(QByteArray &data, QString &error); + QJsonValue ExtractItems(QByteArray &data, QString &error); + QJsonValue ExtractItems(QJsonObject &json_obj, QString &error); + + virtual QString Error(QString error, QVariant debug = QVariant()); + + QString api_url() { return QString(kApiUrl); } + QString app_id() { return service_->app_id(); } + QString app_secret() { return service_->app_secret(); } + QString username() { return service_->username(); } + QString password() { return service_->password(); } + int format() { return service_->format(); } + 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(); } + bool login_sent() { return service_->login_sent(); } + int max_login_attempts() { return service_->max_login_attempts(); } + int login_attempts() { return service_->login_attempts(); } + + private: + + static const char *kApiUrl; + + QobuzService *service_; + NetworkAccessManager *network_; + QList replies_; + +}; + +#endif // QOBUZBASEREQUEST_H diff --git a/src/qobuz/qobuzfavoriterequest.cpp b/src/qobuz/qobuzfavoriterequest.cpp new file mode 100644 index 000000000..7db24a487 --- /dev/null +++ b/src/qobuz/qobuzfavoriterequest.cpp @@ -0,0 +1,281 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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/network.h" +#include "core/closure.h" +#include "core/song.h" +#include "qobuzservice.h" +#include "qobuzbaserequest.h" +#include "qobuzfavoriterequest.h" + +QobuzFavoriteRequest::QobuzFavoriteRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent) + : QobuzBaseRequest(service, network, parent), + service_(service), + network_(network) {} + +QobuzFavoriteRequest::~QobuzFavoriteRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, 0, nullptr, 0); + reply->abort(); + reply->deleteLater(); + } + +} + +QString QobuzFavoriteRequest::FavoriteText(const FavoriteType type) { + + switch (type) { + case FavoriteType_Artists: + return "artists"; + case FavoriteType_Albums: + return "albums"; + case FavoriteType_Songs: + default: + return "tracks"; + } + +} + +void QobuzFavoriteRequest::AddArtists(const SongList &songs) { + AddFavorites(FavoriteType_Artists, songs); +} + +void QobuzFavoriteRequest::AddAlbums(const SongList &songs) { + AddFavorites(FavoriteType_Albums, songs); +} + +void QobuzFavoriteRequest::AddSongs(const SongList &songs) { + AddFavorites(FavoriteType_Songs, songs); +} + +void QobuzFavoriteRequest::AddFavorites(const FavoriteType type, const SongList &songs) { + + if (songs.isEmpty()) return; + + QString text; + switch (type) { + case FavoriteType_Artists: + text = "artist_ids"; + break; + case FavoriteType_Albums: + text = "album_ids"; + break; + case FavoriteType_Songs: + text = "track_ids"; + break; + } + + QStringList ids_list; + for (const Song &song : songs) { + QString id; + switch (type) { + case FavoriteType_Artists: + if (song.artist_id() <= 0) continue; + id = QString::number(song.artist_id()); + break; + case FavoriteType_Albums: + if (song.album_id() <= 0) continue; + id = QString::number(song.album_id()); + break; + case FavoriteType_Songs: + if (song.song_id() <= 0) continue; + id = QString::number(song.song_id()); + break; + } + if (id.isEmpty()) continue; + if (!ids_list.contains(id)) { + ids_list << id; + } + } + if (ids_list.isEmpty()) return; + + QString ids = ids_list.join(','); + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + ParamList params = ParamList() << Param("app_id", app_id()) + << Param("user_auth_token", access_token()) + << Param(text, ids); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QNetworkReply *reply = CreateRequest("favorite/create", params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AddFavoritesReply(QNetworkReply*, const FavoriteType, const SongList&)), reply, type, songs); + replies_ << reply; + +} + +void QobuzFavoriteRequest::AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) { + + if (replies_.contains(reply)) { + replies_.removeAll(reply); + reply->deleteLater(); + } + else { + return; + } + + QString error; + QByteArray data = GetReplyData(reply, error); + + if (reply->error() != QNetworkReply::NoError) { + return; + } + + qLog(Debug) << "Qobuz:" << songs.count() << "songs added to" << FavoriteText(type) << "favorites."; + + switch (type) { + case FavoriteType_Artists: + emit ArtistsAdded(songs); + break; + case FavoriteType_Albums: + emit AlbumsAdded(songs); + break; + case FavoriteType_Songs: + emit SongsAdded(songs); + break; + } + +} + +void QobuzFavoriteRequest::RemoveArtists(const SongList &songs) { + RemoveFavorites(FavoriteType_Artists, songs); +} + +void QobuzFavoriteRequest::RemoveAlbums(const SongList &songs) { + RemoveFavorites(FavoriteType_Albums, songs); +} + +void QobuzFavoriteRequest::RemoveSongs(const SongList &songs) { + RemoveFavorites(FavoriteType_Songs, songs); +} + +void QobuzFavoriteRequest::RemoveFavorites(const FavoriteType type, const SongList &songs) { + + if (songs.isEmpty()) return; + + QString text; + switch (type) { + case FavoriteType_Artists: + text = "artist_ids"; + break; + case FavoriteType_Albums: + text = "album_ids"; + break; + case FavoriteType_Songs: + text = "track_ids"; + break; + } + + QStringList ids_list; + for (const Song &song : songs) { + QString id; + switch (type) { + case FavoriteType_Artists: + if (song.artist_id() <= 0) continue; + id = QString::number(song.artist_id()); + break; + case FavoriteType_Albums: + if (song.album_id() <= 0) continue; + id = QString::number(song.album_id()); + break; + case FavoriteType_Songs: + if (song.song_id() <= 0) continue; + id = QString::number(song.song_id()); + break; + } + if (id.isEmpty()) continue; + if (!ids_list.contains(id)) { + ids_list << id; + } + } + if (ids_list.isEmpty()) return; + + QString ids = ids_list.join(','); + + ParamList params = ParamList() << Param("app_id", app_id()) + << Param("user_auth_token", access_token()) + << Param(text, ids); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QNetworkReply *reply = CreateRequest("favorite/delete", params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(RemoveFavoritesReply(QNetworkReply*, const FavoriteType, const SongList&)), reply, type, songs); + replies_ << reply; + +} + +void QobuzFavoriteRequest::RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs) { + + if (replies_.contains(reply)) { + replies_.removeAll(reply); + reply->deleteLater(); + } + else { + return; + } + + QString error; + QByteArray data = GetReplyData(reply, error); + if (reply->error() != QNetworkReply::NoError) { + return; + } + + qLog(Debug) << "Qobuz:" << songs.count() << "songs removed from" << FavoriteText(type) << "favorites."; + + switch (type) { + case FavoriteType_Artists: + emit ArtistsRemoved(songs); + break; + case FavoriteType_Albums: + emit AlbumsRemoved(songs); + break; + case FavoriteType_Songs: + emit SongsRemoved(songs); + break; + } + +} diff --git a/src/qobuz/qobuzfavoriterequest.h b/src/qobuz/qobuzfavoriterequest.h new file mode 100644 index 000000000..a2d182a5e --- /dev/null +++ b/src/qobuz/qobuzfavoriterequest.h @@ -0,0 +1,78 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 QOBUZFAVORITEREQUEST_H +#define QOBUZFAVORITEREQUEST_H + +#include "config.h" + +#include + +#include "qobuzbaserequest.h" +#include "core/song.h" + +class QNetworkReply; +class QobuzService; +class NetworkAccessManager; + +class QobuzFavoriteRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + QobuzFavoriteRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent); + ~QobuzFavoriteRequest(); + + enum FavoriteType { + FavoriteType_Artists, + FavoriteType_Albums, + FavoriteType_Songs + }; + + signals: + void ArtistsAdded(const SongList &songs); + void AlbumsAdded(const SongList &songs); + void SongsAdded(const SongList &songs); + void ArtistsRemoved(const SongList &songs); + void AlbumsRemoved(const SongList &songs); + void SongsRemoved(const SongList &songs); + + private slots: + void AddArtists(const SongList &songs); + void AddAlbums(const SongList &songs); + void AddSongs(const SongList &songs); + + void RemoveArtists(const SongList &songs); + void RemoveAlbums(const SongList &songs); + void RemoveSongs(const SongList &songs); + + void AddFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs); + void RemoveFavoritesReply(QNetworkReply *reply, const FavoriteType type, const SongList &songs); + + private: + QString FavoriteText(const FavoriteType type); + void AddFavorites(const FavoriteType type, const SongList &songs); + void RemoveFavorites(const FavoriteType type, const SongList &songs); + + QobuzService *service_; + NetworkAccessManager *network_; + QList replies_; + +}; + +#endif // QOBUZFAVORITEREQUEST_H diff --git a/src/qobuz/qobuzrequest.cpp b/src/qobuz/qobuzrequest.cpp new file mode 100644 index 000000000..f0ea5ac31 --- /dev/null +++ b/src/qobuz/qobuzrequest.cpp @@ -0,0 +1,1273 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "organise/organiseformat.h" +#include "qobuzservice.h" +#include "qobuzurlhandler.h" +#include "qobuzrequest.h" + +const int QobuzRequest::kMaxConcurrentArtistsRequests = 3; +const int QobuzRequest::kMaxConcurrentAlbumsRequests = 3; +const int QobuzRequest::kMaxConcurrentSongsRequests = 3; +const int QobuzRequest::kMaxConcurrentArtistAlbumsRequests = 3; +const int QobuzRequest::kMaxConcurrentAlbumSongsRequests = 3; +const int QobuzRequest::kMaxConcurrentAlbumCoverRequests = 1; + +QobuzRequest::QobuzRequest(QobuzService *service, QobuzUrlHandler *url_handler, NetworkAccessManager *network, QueryType type, QObject *parent) + : QobuzBaseRequest(service, network, parent), + service_(service), + url_handler_(url_handler), + network_(network), + type_(type), + search_id_(-1), + finished_(false), + artists_requests_active_(0), + artists_total_(0), + artists_received_(0), + albums_requests_active_(0), + songs_requests_active_(0), + artist_albums_requests_active_(0), + artist_albums_requested_(0), + artist_albums_received_(0), + album_songs_requests_active_(0), + album_songs_requested_(0), + album_songs_received_(0), + album_covers_requests_active_(), + album_covers_requested_(0), + album_covers_received_(0), + no_results_(false) {} + +QobuzRequest::~QobuzRequest() { + + while (!album_cover_replies_.isEmpty()) { + QNetworkReply *reply = album_cover_replies_.takeFirst(); + disconnect(reply, 0, nullptr, 0); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +void QobuzRequest::Process() { + + 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 QobuzRequest::Search(const int search_id, const QString &search_text) { + search_id_ = search_id; + search_text_ = search_text; +} + +void QobuzRequest::GetArtists() { + + emit UpdateStatus(tr("Retrieving artists...")); + emit UpdateProgress(0); + AddArtistsRequest(); + +} + +void QobuzRequest::AddArtistsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + artists_requests_queue_.enqueue(request); + if (artists_requests_active_ < kMaxConcurrentArtistsRequests) FlushArtistsRequests(); + +} + +void QobuzRequest::FlushArtistsRequests() { + + while (!artists_requests_queue_.isEmpty() && artists_requests_active_ < kMaxConcurrentArtistsRequests) { + + Request request = artists_requests_queue_.dequeue(); + ++artists_requests_active_; + + ParamList params; + if (type_ == QueryType_Artists) { + params << Param("type", "artists"); + params << Param("user_auth_token", access_token()); + } + else if (type_ == QueryType_SearchArtists) params << Param("query", search_text_); + if (request.limit > 0) params << Param("limit", QString::number(request.limit)); + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = nullptr; + if (type_ == QueryType_Artists) { + reply = CreateRequest(QString("favorite/getUserFavorites"), params); + } + else if (type_ == QueryType_SearchArtists) { + reply = CreateRequest("artist/search", params); + } + if (!reply) continue; + NewClosure(reply, SIGNAL(finished()), this, SLOT(ArtistsReplyReceived(QNetworkReply*, int, int)), reply, request.limit, request.offset); + + } + +} + +void QobuzRequest::GetAlbums() { + + emit UpdateStatus(tr("Retrieving albums...")); + emit UpdateProgress(0); + AddAlbumsRequest(); + +} + +void QobuzRequest::AddAlbumsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + albums_requests_queue_.enqueue(request); + if (albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); + +} + +void QobuzRequest::FlushAlbumsRequests() { + + while (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) { + + Request request = albums_requests_queue_.dequeue(); + ++albums_requests_active_; + + ParamList params; + if (type_ == QueryType_Albums) { + params << Param("type", "albums"); + params << Param("user_auth_token", access_token()); + } + else if (type_ == QueryType_SearchAlbums) params << Param("query", search_text_); + if (request.limit > 0) params << Param("limit", QString::number(request.limit)); + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = nullptr; + if (type_ == QueryType_Albums) { + reply = CreateRequest(QString("favorite/getUserFavorites"), params); + } + else if (type_ == QueryType_SearchAlbums) { + reply = CreateRequest("album/search", params); + } + if (!reply) continue; + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumsReplyReceived(QNetworkReply*, int, int)), reply, request.limit, request.offset); + + } + +} + +void QobuzRequest::GetSongs() { + + emit UpdateStatus(tr("Retrieving songs...")); + emit UpdateProgress(0); + AddSongsRequest(); + +} + +void QobuzRequest::AddSongsRequest(const int offset, const int limit) { + + Request request; + request.limit = limit; + request.offset = offset; + songs_requests_queue_.enqueue(request); + if (songs_requests_active_ < kMaxConcurrentSongsRequests) FlushSongsRequests(); + +} + +void QobuzRequest::FlushSongsRequests() { + + while (!songs_requests_queue_.isEmpty() && songs_requests_active_ < kMaxConcurrentSongsRequests) { + + Request request = songs_requests_queue_.dequeue(); + ++songs_requests_active_; + + ParamList params; + if (type_ == QueryType_Songs) { + params << Param("type", "tracks"); + params << Param("user_auth_token", access_token()); + } + else if (type_ == QueryType_SearchSongs) params << Param("query", search_text_); + if (request.limit > 0) params << Param("limit", QString::number(request.limit)); + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = nullptr; + if (type_ == QueryType_Songs) { + reply = CreateRequest(QString("favorite/getUserFavorites"), params); + } + else if (type_ == QueryType_SearchSongs) { + reply = CreateRequest("track/search", params); + } + if (!reply) continue; + NewClosure(reply, SIGNAL(finished()), this, SLOT(SongsReplyReceived(QNetworkReply*, int, int)), reply, request.limit, request.offset); + + } + +} + +void QobuzRequest::ArtistsSearch() { + + emit UpdateStatus(tr("Searching...")); + emit UpdateProgress(0); + AddArtistsSearchRequest(); + +} + +void QobuzRequest::AddArtistsSearchRequest(const int offset) { + + AddArtistsRequest(offset, service_->artistssearchlimit()); + +} + +void QobuzRequest::AlbumsSearch() { + + emit UpdateStatus(tr("Searching...")); + emit UpdateProgress(0); + AddAlbumsSearchRequest(); + +} + +void QobuzRequest::AddAlbumsSearchRequest(const int offset) { + + AddAlbumsRequest(offset, service_->albumssearchlimit()); + +} + +void QobuzRequest::SongsSearch() { + + emit UpdateStatus(tr("Searching...")); + emit UpdateProgress(0); + AddSongsSearchRequest(); + +} + +void QobuzRequest::AddSongsSearchRequest(const int offset) { + + AddSongsRequest(offset, service_->songssearchlimit()); + +} + +void QobuzRequest::ArtistsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + QString error; + QByteArray data = GetReplyData(reply, error); + + --artists_requests_active_; + + if (finished_) return; + + if (data.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + if (!json_obj.contains("artists")) { + ArtistsFinishCheck(); + Error("Json object is missing artists.", json_obj); + return; + } + QJsonValue json_artists = json_obj["artists"]; + if (!json_artists.isObject()) { + Error("Json artists is not an object.", json_obj); + ArtistsFinishCheck(); + return; + } + QJsonObject json_obj_artists = json_artists.toObject(); + + if (!json_obj_artists.contains("limit") || + !json_obj_artists.contains("offset") || + !json_obj_artists.contains("total") || + !json_obj_artists.contains("items")) { + ArtistsFinishCheck(); + Error("Json artists object is missing values.", json_obj); + return; + } + //int limit = json_obj_artists["limit"].toInt(); + int offset = json_obj_artists["offset"].toInt(); + int artists_total = json_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 ProgressSetMaximum(artists_total_); + emit UpdateProgress(artists_received_); + } + + QJsonValue json_value = ExtractItems(json_obj_artists, error); + if (!json_value.isArray()) { + ArtistsFinishCheck(); + return; + } + + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { // Empty array means no results + no_results_ = true; + ArtistsFinishCheck(); + return; + } + + int artists_received = 0; + for (const QJsonValue &value : json_items) { + + ++artists_received; + + if (!value.isObject()) { + Error("Invalid Json reply, item not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (json_obj.contains("item")) { + QJsonValue json_item = json_obj["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item not a object.", json_item); + continue; + } + json_obj = json_item.toObject(); + } + + if (!json_obj.contains("id") || !json_obj.contains("name")) { + Error("Invalid Json reply, item missing id or album.", json_obj); + continue; + } + + int artist_id = json_obj["id"].toInt(); + if (artist_albums_requests_pending_.contains(artist_id)) continue; + artist_albums_requests_pending_.append(artist_id); + + } + artists_received_ += artists_received; + + if (offset_requested != 0) emit UpdateProgress(artists_received_); + + ArtistsFinishCheck(limit_requested, offset, artists_received); + +} + +void QobuzRequest::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_ < kMaxConcurrentArtistsRequests) FlushArtistsRequests(); + + if (artists_requests_queue_.isEmpty() && artists_requests_active_ <= 0) { // Artist query is finished, get all albums for all artists. + + // Get artist albums + for (int artist_id : artist_albums_requests_pending_) { + AddArtistAlbumsRequest(artist_id); + ++artist_albums_requested_; + } + artist_albums_requests_pending_.clear(); + + if (artist_albums_requested_ > 0) { + if (artist_albums_requested_ == 1) emit UpdateStatus(tr("Retrieving albums for %1 artist...").arg(artist_albums_requested_)); + else emit UpdateStatus(tr("Retrieving albums for %1 artists...").arg(artist_albums_requested_)); + emit ProgressSetMaximum(artist_albums_requested_); + emit UpdateProgress(0); + } + + } + + FinishCheck(); + +} + +void QobuzRequest::AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + --albums_requests_active_; + AlbumsReceived(reply, 0, limit_requested, offset_requested); + if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); +} + +void QobuzRequest::AddArtistAlbumsRequest(const int artist_id, const int offset) { + + Request request; + request.artist_id = artist_id; + request.offset = offset; + artist_albums_requests_queue_.enqueue(request); + if (artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) FlushArtistAlbumsRequests(); + +} + +void QobuzRequest::FlushArtistAlbumsRequests() { + + while (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) { + + Request request = artist_albums_requests_queue_.dequeue(); + ++artist_albums_requests_active_; + + ParamList params = ParamList() << Param("artist_id", QString::number(request.artist_id)) + << Param("extra", "albums"); + + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("artist/get"), params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(ArtistAlbumsReplyReceived(QNetworkReply*, int, int)), reply, request.artist_id, request.offset); + + } + +} + +void QobuzRequest::ArtistAlbumsReplyReceived(QNetworkReply *reply, const int artist_id, const int offset_requested) { + + --artist_albums_requests_active_; + ++artist_albums_received_; + emit UpdateProgress(artist_albums_received_); + AlbumsReceived(reply, artist_id, 0, offset_requested); + if (!artist_albums_requests_queue_.isEmpty() && artist_albums_requests_active_ < kMaxConcurrentArtistAlbumsRequests) FlushArtistAlbumsRequests(); + +} + +void QobuzRequest::AlbumsReceived(QNetworkReply *reply, const int artist_id_requested, const int limit_requested, const int offset_requested) { + + QString error; + QByteArray data = GetReplyData(reply, error); + + if (finished_) return; + + if (data.isEmpty()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + + int artist_id = 0; + if (json_obj.contains("id")) { + artist_id = json_obj["id"].toInt(); + } + + if (!json_obj.contains("albums")) { + AlbumsFinishCheck(artist_id_requested); + Error("Json object is missing albums.", json_obj); + return; + } + QJsonValue json_albums = json_obj["albums"]; + if (!json_albums.isObject()) { + Error("Json albums is not an object.", json_obj); + AlbumsFinishCheck(artist_id_requested); + return; + } + QJsonObject json_obj_albums = json_albums.toObject(); + + if (!json_obj_albums.contains("limit") || + !json_obj_albums.contains("offset") || + !json_obj_albums.contains("total") || + !json_obj_albums.contains("items")) { + AlbumsFinishCheck(artist_id_requested); + Error("Json albums object is missing values.", json_obj); + return; + } + + //int limit = json_obj["limit"].toInt(); + int offset = json_obj["offset"].toInt(); + int albums_total = json_obj["total"].toInt(); + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + AlbumsFinishCheck(artist_id_requested); + return; + } + + QJsonValue json_value = ExtractItems(json_obj_albums, error); + if (!json_value.isArray()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { + no_results_ = true; + AlbumsFinishCheck(artist_id_requested); + return; + } + + int albums_received = 0; + for (const QJsonValue &value : json_items) { + + ++albums_received; + + if (!value.isObject()) { + Error("Invalid Json reply, item not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (!json_obj.contains("artist") || !json_obj.contains("title") || !json_obj.contains("id")) { + Error("Invalid Json reply, item missing artist, title or id.", json_obj); + continue; + } + + QString album_id = json_obj["id"].toString(); + if (album_songs_requests_pending_.contains(album_id)) continue; + + QString album = json_obj["title"].toString(); + + QJsonValue json_value_artist = json_obj["artist"]; + if (!json_value_artist.isObject()) { + Error("Invalid Json reply, item artist is not a object.", json_value_artist); + continue; + } + QJsonObject json_artist = json_value_artist.toObject(); + if (!json_artist.contains("id") || !json_artist.contains("name")) { + Error("Invalid Json reply, item artist missing id or name.", json_artist); + continue; + } + + artist_id = json_artist["id"].toInt(); + QString artist = json_artist["name"].toString(); + + Request request; + if (artist_id_requested == 0) { + request.artist_id = artist_id; + } + else { + request.artist_id = artist_id_requested; + } + request.album_id = album_id; + request.album_artist = artist; + request.album = album; + album_songs_requests_pending_.insert(album_id, request); + + } + + AlbumsFinishCheck(artist_id_requested, limit_requested, offset, albums_total, albums_received); + +} + +void QobuzRequest::AlbumsFinishCheck(const int artist_id, 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_id, offset_next); + break; + default: + break; + } + } + } + + if ( + 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. + + QHash ::iterator i; + for (i = album_songs_requests_pending_.begin() ; i != album_songs_requests_pending_.end() ; ++i) { + Request request = i.value(); + AddAlbumSongsRequest(request.artist_id, request.album_id, request.album_artist); + } + album_songs_requests_pending_.clear(); + + if (album_songs_requested_ > 0) { + if (album_songs_requested_ == 1) emit UpdateStatus(tr("Retrieving songs for %1 album...").arg(album_songs_requested_)); + else emit UpdateStatus(tr("Retrieving songs for %1 albums...").arg(album_songs_requested_)); + emit ProgressSetMaximum(album_songs_requested_); + emit UpdateProgress(0); + } + } + + FinishCheck(); + +} + +void QobuzRequest::SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + --songs_requests_active_; + SongsReceived(reply, 0, 0, limit_requested, offset_requested); + +} + +void QobuzRequest::AddAlbumSongsRequest(const int artist_id, const QString &album_id, const QString &album_artist, const int offset) { + + Request request; + request.artist_id = artist_id; + request.album_id = album_id; + request.album_artist = album_artist; + request.offset = offset; + album_songs_requests_queue_.enqueue(request); + ++album_songs_requested_; + if (album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + +} + +void QobuzRequest::FlushAlbumSongsRequests() { + + while (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) { + + Request request = album_songs_requests_queue_.dequeue(); + ++album_songs_requests_active_; + ParamList params = ParamList() << Param("album_id", request.album_id); + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("album/get"), params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumSongsReplyReceived(QNetworkReply*, int, const QString&, int, const QString&)), reply, request.artist_id, request.album_id, request.offset, request.album_artist); + + } + +} + +void QobuzRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const int artist_id, const QString &album_id, const int offset_requested, const QString &album_artist) { + + --album_songs_requests_active_; + ++album_songs_received_; + if (offset_requested == 0) { + emit UpdateProgress(album_songs_received_); + } + SongsReceived(reply, artist_id, album_id, 0, offset_requested, album_artist); + +} + +void QobuzRequest::SongsReceived(QNetworkReply *reply, const int artist_id_requested, const QString &album_id_requested, const int limit_requested, const int offset_requested, const QString &album_artist_requested) { + + QString error; + QByteArray data = GetReplyData(reply, error); + + if (finished_) return; + + if (data.isEmpty()) { + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + + //qLog(Debug) << json_obj; + + if (!json_obj.contains("tracks")) { + Error("Json object is missing tracks.", json_obj); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + + int artist_id = 0; + QString album_artist; + QString album_id; + QString album; + QUrl cover_url; + + if (json_obj.contains("id")) { + album_id = json_obj["id"].toString(); + } + + if (json_obj.contains("title")) { + album = json_obj["title"].toString(); + } + + if (json_obj.contains("artist")) { + QJsonValue json_artist = json_obj["artist"]; + if (!json_artist.isObject()) { + Error("Invalid Json reply, album artist is not a object.", json_artist); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + QJsonObject json_obj_artist = json_artist.toObject(); + if (!json_obj_artist.contains("id") || !json_obj_artist.contains("name")) { + Error("Invalid Json reply, album artist is missing id or name.", json_obj_artist); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + artist_id = json_obj_artist["id"].toInt(); + album_artist = json_obj_artist["name"].toString(); + } + + if (json_obj.contains("image")) { + QJsonValue json_image = json_obj["image"]; + if (!json_image.isObject()) { + Error("Invalid Json reply, album image is not a object.", json_image); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + QJsonObject json_obj_image = json_image.toObject(); + if (!json_obj_image.contains("large")) { + Error("Invalid Json reply, album image is missing large.", json_obj_image); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + QString album_image = json_obj_image["large"].toString(); + if (!album_image.isEmpty()) { + cover_url = QUrl(album_image); + } + } + + QJsonValue json_tracks = json_obj["tracks"]; + if (!json_tracks.isObject()) { + Error("Json tracks is not an object.", json_obj); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + return; + } + QJsonObject json_obj_tracks = json_tracks.toObject(); + + if (!json_obj_tracks.contains("limit") || + !json_obj_tracks.contains("offset") || + !json_obj_tracks.contains("total") || + !json_obj_tracks.contains("items")) { + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested); + Error("Json songs object is missing values.", json_obj); + return; + } + + //int limit = json_obj_tracks["limit"].toInt(); + int offset = json_obj_tracks["offset"].toInt(); + int songs_total = json_obj_tracks["total"].toInt(); + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist); + return; + } + + QJsonValue json_value = ExtractItems(json_obj_tracks, error); + if (!json_value.isArray()) { + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist); + return; + } + + QJsonArray json_items = json_value.toArray(); + if (json_items.isEmpty()) { + no_results_ = true; + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist); + return; + } + + bool compilation = false; + bool multidisc = false; + SongList songs; + int songs_received = 0; + for (const QJsonValue &value : json_items) { + + if (!value.isObject()) { + Error("Invalid Json reply, track is not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + ++songs_received; + Song song; + ParseSong(song, json_obj, artist_id, album_id, album_artist, album, cover_url); + 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) { + QString album_full(QString("%1 - (Disc %2)").arg(song.album()).arg(song.disc())); + song.set_album(album_full); + } + songs_ << song; + } + + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, songs_received, album_artist); + +} + +void QobuzRequest::SongsFinishCheck(const int artist_id, const QString &album_id, const int limit, const int offset, const int songs_total, const int songs_received, const QString &album_artist) { + + 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 pass through. + if (artist_id == 0 && album_id == 0) { + AddSongsSearchRequest(offset_next); + break; + } + case QueryType_Artists: + case QueryType_SearchArtists: + case QueryType_Albums: + case QueryType_SearchAlbums: + AddAlbumSongsRequest(artist_id, album_id, album_artist, offset_next); + break; + default: + break; + } + } + } + + if (!songs_requests_queue_.isEmpty() && songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + if (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + + if ( + service_->cache_album_covers() && + songs_requests_queue_.isEmpty() && + songs_requests_active_ <= 0 && + album_songs_requests_queue_.isEmpty() && + album_songs_requests_active_ <= 0 && + album_cover_requests_queue_.isEmpty() && + album_covers_received_ <= 0 && + album_covers_requests_sent_.isEmpty() && + album_songs_received_ >= album_songs_requested_ + ) { + GetAlbumCovers(); + } + + FinishCheck(); + +} + +int QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, int artist_id, QString album_id, QString album_artist, QString album, QUrl cover_url) { + + if ( + !json_obj.contains("id") || + !json_obj.contains("title") || + !json_obj.contains("track_number") || + !json_obj.contains("duration") || + !json_obj.contains("copyright") || + !json_obj.contains("streamable") + ) { + Error("Invalid Json reply, track is missing one or more values.", json_obj); + return -1; + } + + int song_id = json_obj["id"].toInt(); + QString title = json_obj["title"].toString(); + int track = json_obj["track_number"].toInt(); + QString copyright = json_obj["copyright"].toString(); + quint64 duration = json_obj["duration"].toInt() * kNsecPerSec; + //bool streamable = json_obj["streamable"].toBool(); + QString composer; + QString performer; + + if (json_obj.contains("album")) { + + QJsonValue json_album = json_obj["album"]; + if (!json_album.isObject()) { + Error("Invalid Json reply, album is not an object.", json_album); + return -1; + } + QJsonObject json_obj_album = json_album.toObject(); + + if (json_obj_album.contains("id")) { + album_id = json_obj_album["id"].toString(); + } + + if (json_obj_album.contains("title")) { + album = json_obj_album["title"].toString(); + } + + if (json_obj_album.contains("artist")) { + QJsonValue json_artist = json_obj_album["artist"]; + if (!json_artist.isObject()) { + Error("Invalid Json reply, album artist is not a object.", json_artist); + return -1; + } + QJsonObject json_obj_artist = json_artist.toObject(); + if (!json_obj_artist.contains("id") || !json_obj_artist.contains("name")) { + Error("Invalid Json reply, album artist is missing id or name.", json_obj_artist); + return -1; + } + artist_id = json_obj_artist["id"].toInt(); + album_artist = json_obj_artist["name"].toString(); + } + + if (json_obj_album.contains("image")) { + QJsonValue json_image = json_obj_album["image"]; + if (!json_image.isObject()) { + Error("Invalid Json reply, album image is not a object.", json_image); + return -1; + } + QJsonObject json_obj_image = json_image.toObject(); + if (!json_obj_image.contains("large")) { + Error("Invalid Json reply, album image is missing large.", json_obj_image); + return -1; + } + QString album_image = json_obj_image["large"].toString(); + if (!album_image.isEmpty()) { + cover_url = QUrl(album_image); + } + } + } + + if (json_obj.contains("composer")) { + QJsonValue json_composer = json_obj["composer"]; + if (!json_composer.isObject()) { + Error("Invalid Json reply, track composer is not a object.", json_composer); + return -1; + } + QJsonObject json_obj_composer = json_composer.toObject(); + if (!json_obj_composer.contains("id") || !json_obj_composer.contains("name")) { + Error("Invalid Json reply, track composer is missing id or name.", json_obj_composer); + return -1; + } + QString composer = json_obj_composer["name"].toString(); + } + + if (json_obj.contains("performer")) { + QJsonValue json_performer = json_obj["performer"]; + if (!json_performer.isObject()) { + Error("Invalid Json reply, track performer is not a object.", json_performer); + return -1; + } + QJsonObject json_obj_performer = json_performer.toObject(); + if (!json_obj_performer.contains("id") || !json_obj_performer.contains("name")) { + Error("Invalid Json reply, track performer is missing id or name.", json_obj_performer); + return -1; + } + performer = json_obj_performer["name"].toString(); + } + + //if (!streamable) { + //Warn(QString("Song %1 %2 %3 is not streamable").arg(album_artist).arg(album).arg(title)); + //} + + QUrl url; + url.setScheme(url_handler_->scheme()); + url.setPath(QString::number(song_id)); + + title.remove(Song::kTitleRemoveMisc); + + //qLog(Debug) << "id" << song_id << "track" << track << "title" << title << "album" << album << "album artist" << album_artist << cover_url << streamable << url; + + song.set_source(Song::Source_Qobuz); + song.set_song_id(song_id); + song.set_artist_id(artist_id); + song.set_album(album); + song.set_artist(album_artist); + song.set_title(title); + song.set_track(track); + song.set_url(url); + song.set_length_nanosec(duration); + song.set_art_automatic(cover_url.toEncoded()); + song.set_comment(copyright); + 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); + + return song_id; + +} + +void QobuzRequest::GetAlbumCovers() { + + for (Song &song : songs_) { + AddAlbumCoverRequest(song); + } + FlushAlbumCoverRequests(); + + if (album_covers_requested_ == 1) emit UpdateStatus(tr("Retrieving album cover for %1 album...").arg(album_covers_requested_)); + else emit UpdateStatus(tr("Retrieving album covers for %1 albums...").arg(album_covers_requested_)); + emit ProgressSetMaximum(album_covers_requested_); + emit UpdateProgress(0); + +} + +void QobuzRequest::AddAlbumCoverRequest(Song &song) { + + QUrl cover_url(song.art_automatic()); + if (!cover_url.isValid()) return; + + if (album_covers_requests_sent_.contains(cover_url)) { + album_covers_requests_sent_.insertMulti(cover_url, &song); + return; + } + + album_covers_requests_sent_.insertMulti(cover_url, &song); + ++album_covers_requested_; + + AlbumCoverRequest request; + request.url = cover_url; + request.filename = AlbumCoverFileName(song); + + album_cover_requests_queue_.enqueue(request); + +} + +void QobuzRequest::FlushAlbumCoverRequests() { + + while (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) { + + AlbumCoverRequest request = album_cover_requests_queue_.dequeue(); + ++album_covers_requests_active_; + + QNetworkRequest req(request.url); + QNetworkReply *reply = network_->get(req); + album_cover_replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumCoverReceived(QNetworkReply*, const QUrl&, const QString&)), reply, request.url, request.filename); + + } + +} + +void QobuzRequest::AlbumCoverReceived(QNetworkReply *reply, const QUrl &cover_url, const QString &filename) { + + if (album_cover_replies_.contains(reply)) { + album_cover_replies_.removeAll(reply); + reply->deleteLater(); + } + else { + AlbumCoverFinishCheck(); + return; + } + + --album_covers_requests_active_; + ++album_covers_received_; + + if (finished_) return; + + emit UpdateProgress(album_covers_received_); + + if (!album_covers_requests_sent_.contains(cover_url)) { + AlbumCoverFinishCheck(); + return; + } + + QString error; + if (reply->error() != QNetworkReply::NoError) { + error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + album_covers_requests_sent_.remove(cover_url); + AlbumCoverFinishCheck(); + return; + } + + QByteArray data = reply->readAll(); + if (data.isEmpty()) { + error = Error(QString("Received empty image data for %1").arg(cover_url.toString())); + album_covers_requests_sent_.remove(cover_url); + AlbumCoverFinishCheck(); + return; + } + + QImage image; + if (image.loadFromData(data)) { + + QDir dir; + if (dir.mkpath(service_->CoverCacheDir())) { + QString filepath(service_->CoverCacheDir() + "/" + filename); + if (image.save(filepath, "JPG")) { + while (album_covers_requests_sent_.contains(cover_url)) { + Song *song = album_covers_requests_sent_.take(cover_url); + song->set_art_automatic(filepath); + } + } + } + + } + else { + album_covers_requests_sent_.remove(cover_url); + error = Error(QString("Error decoding image data from %1").arg(cover_url.toString())); + } + + AlbumCoverFinishCheck(); + +} + +QString QobuzRequest::AlbumCoverFileName(const Song &song) { + + QString artist = song.effective_albumartist(); + QString album = song.effective_album(); + QString title = song.title(); + + artist.remove('/'); + album.remove('/'); + title.remove('/'); + + QString filename = artist + "-" + album + ".jpg"; + filename = filename.toLower(); + filename.replace(' ', '-'); + filename.replace("--", "-"); + filename.replace(230, "ae"); + filename.replace(198, "AE"); + filename.replace(246, 'o'); + filename.replace(248, 'o'); + filename.replace(214, 'O'); + filename.replace(216, 'O'); + filename.replace(228, 'a'); + filename.replace(229, 'a'); + filename.replace(196, 'A'); + filename.replace(197, 'A'); + filename.remove(OrganiseFormat::kValidFatCharacters); + + return filename; + +} + +void QobuzRequest::AlbumCoverFinishCheck() { + + if (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) + FlushAlbumCoverRequests(); + + FinishCheck(); + +} + +void QobuzRequest::FinishCheck() { + + if ( + !finished_ && + albums_requests_queue_.isEmpty() && + artists_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 && + artist_albums_received_ >= artist_albums_requested_ && + album_songs_requests_active_ <= 0 && + album_songs_received_ >= album_songs_requested_ && + album_covers_requests_active_ <= 0 && + album_covers_received_ >= album_covers_requested_ + ) { + finished_ = true; + if (songs_.isEmpty()) { + if (IsSearch()) { + if (no_results_) emit ErrorSignal(search_id_, tr("No match")); + else if (errors_.isEmpty()) emit ErrorSignal(search_id_, tr("Unknown error")); + else emit ErrorSignal(search_id_, errors_); + } + else { + if (no_results_) emit Results(songs_); + else if (errors_.isEmpty()) emit ErrorSignal(tr("Unknown error")); + else emit ErrorSignal(errors_); + } + } + else { + if (IsSearch()) { + emit SearchResults(search_id_, songs_); + } + else { + emit Results(songs_); + } + } + + } + +} + +QString QobuzRequest::Error(QString error, QVariant debug) { + + qLog(Error) << "Qobuz:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + if (!error.isEmpty()) { + errors_ += error; + errors_ += "
    "; + } + FinishCheck(); + + return error; + +} + +void QobuzRequest::Warn(QString error, QVariant debug) { + + qLog(Error) << "Qobuz:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} + diff --git a/src/qobuz/qobuzrequest.h b/src/qobuz/qobuzrequest.h new file mode 100644 index 000000000..f63b77a07 --- /dev/null +++ b/src/qobuz/qobuzrequest.h @@ -0,0 +1,208 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 QOBUZREQUEST_H +#define QOBUZREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "qobuzbaserequest.h" + +class NetworkAccessManager; +class QobuzService; +class QobuzUrlHandler; + +class QobuzRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + + QobuzRequest(QobuzService *service, QobuzUrlHandler *url_handler, NetworkAccessManager *network, QueryType type, QObject *parent); + ~QobuzRequest(); + + void ReloadSettings(); + + void Process(); + void Search(const int search_id, const QString &search_text); + + signals: + void Login(); + void Login(const QString &username, const QString &password, const QString &token); + void LoginSuccess(); + void LoginFailure(QString failure_reason); + void Results(SongList songs); + void SearchResults(int id, SongList songs); + void ErrorSignal(QString message); + void ErrorSignal(int id, QString message); + void UpdateStatus(QString text); + void ProgressSetMaximum(int max); + void UpdateProgress(int max); + void StreamURLFinished(const QUrl original_url, const QUrl url, const Song::FileType, QString error = QString()); + + private slots: + //void LoginComplete(bool success, QString error = QString()); + + 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 int artist_id_requested, 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 int artist_id_requested, const QString &album_id_requested, const int limit_requested, const int offset_requested, const QString &album_artist_requested = QString()); + + void ArtistAlbumsReplyReceived(QNetworkReply *reply, const int artist_id, const int offset_requested); + void AlbumSongsReplyReceived(QNetworkReply *reply, const int artist_id, const QString &album_id, const int offset_requested, const QString &album_artist); + void AlbumCoverReceived(QNetworkReply *reply, const QUrl &cover_url, const QString &filename); + + private: + typedef QPair Param; + typedef QList ParamList; + + struct Request { + int artist_id = 0; + QString album_id = 0; + int song_id = 0; + int offset = 0; + int limit = 0; + QString album_artist; + QString album; + }; + struct AlbumCoverRequest { + //int artist_id = 0; + QUrl url; + QString filename; + }; + + const bool IsQuery() { return (type_ == QueryType_Artists || type_ == QueryType_Albums || type_ == QueryType_Songs); } + const 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 int artist_id, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0); + void SongsFinishCheck(const int artist_id, const QString &album_id, const int limit, const int offset, const int songs_total, const int songs_received, const QString &album_artist); + + void AddArtistAlbumsRequest(const int artist_id, const int offset = 0); + void FlushArtistAlbumsRequests(); + + void AddAlbumSongsRequest(const int artist_id, const QString &album_id, const QString &album_artist, const int offset = 0); + void FlushAlbumSongsRequests(); + + int ParseSong(Song &song, const QJsonObject &json_obj, int artist_id, QString album_id, QString album_artist, QString album, QUrl cover_url); + + QString AlbumCoverFileName(const Song &song); + + void GetAlbumCovers(); + void AddAlbumCoverRequest(Song &song); + void FlushAlbumCoverRequests(); + void AlbumCoverFinishCheck(); + + void FinishCheck(); + void Warn(QString error, QVariant debug = QVariant()); + QString Error(QString error, QVariant debug = QVariant()); + + static const int kMaxConcurrentArtistsRequests; + static const int kMaxConcurrentAlbumsRequests; + static const int kMaxConcurrentSongsRequests; + static const int kMaxConcurrentArtistAlbumsRequests; + static const int kMaxConcurrentAlbumSongsRequests; + static const int kMaxConcurrentAlbumCoverRequests; + + QobuzService *service_; + QobuzUrlHandler *url_handler_; + NetworkAccessManager *network_; + + QueryType type_; + + int search_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_; + + QList artist_albums_requests_pending_; + QHash album_songs_requests_pending_; + QMultiMap album_covers_requests_sent_; + + int artists_requests_active_; + int artists_total_; + int artists_received_; + + int albums_requests_active_; + int songs_requests_active_; + + int artist_albums_requests_active_; + int artist_albums_requested_; + int artist_albums_received_; + + int album_songs_requests_active_; + int album_songs_requested_; + int album_songs_received_; + + int album_covers_requests_active_; + int album_covers_requested_; + int album_covers_received_; + + SongList songs_; + QString errors_; + bool no_results_; + QList album_cover_replies_; + +}; + +#endif // QOBUZREQUEST_H diff --git a/src/qobuz/qobuzservice.cpp b/src/qobuz/qobuzservice.cpp new file mode 100644 index 000000000..af34088c5 --- /dev/null +++ b/src/qobuz/qobuzservice.cpp @@ -0,0 +1,603 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/database.h" +#include "core/song.h" +#include "core/utilities.h" +#include "internet/internetsearch.h" +#include "collection/collectionbackend.h" +#include "collection/collectionmodel.h" +#include "qobuzservice.h" +#include "qobuzurlhandler.h" +#include "qobuzrequest.h" +#include "qobuzfavoriterequest.h" +#include "qobuzstreamurlrequest.h" +#include "settings/qobuzsettingspage.h" + +using std::shared_ptr; + +const Song::Source QobuzService::kSource = Song::Source_Qobuz; +const char *QobuzService::kAuthUrl = "http://www.qobuz.com/api.json/0.2/user/login"; +const int QobuzService::kLoginAttempts = 2; +const int QobuzService::kTimeResetLoginAttempts = 60000; + +const char *QobuzService::kArtistsSongsTable = "qobuz_artists_songs"; +const char *QobuzService::kAlbumsSongsTable = "qobuz_albums_songs"; +const char *QobuzService::kSongsTable = "qobuz_songs"; + +const char *QobuzService::kArtistsSongsFtsTable = "qobuz_artists_songs_fts"; +const char *QobuzService::kAlbumsSongsFtsTable = "qobuz_albums_songs_fts"; +const char *QobuzService::kSongsFtsTable = "qobuz_songs_fts"; + +QobuzService::QobuzService(Application *app, QObject *parent) + : InternetService(Song::Source_Qobuz, "Qobuz", "qobuz", app, parent), + app_(app), + network_(new NetworkAccessManager(this)), + url_handler_(new QobuzUrlHandler(app, 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_login_attempt_(new QTimer(this)), + favorite_request_(new QobuzFavoriteRequest(this, network_, this)), + format_(0), + search_delay_(1500), + artistssearchlimit_(1), + albumssearchlimit_(1), + songssearchlimit_(1), + cache_album_covers_(true), + pending_search_id_(0), + next_pending_search_id_(1), + search_id_(0), + login_sent_(false), + login_attempts_(0) + { + + app->player()->RegisterUrlHandler(url_handler_); + + // Backends + + artists_collection_backend_ = new CollectionBackend(); + artists_collection_backend_->moveToThread(app_->database()->thread()); + artists_collection_backend_->Init(app_->database(), kArtistsSongsTable, QString(), QString(), kArtistsSongsFtsTable); + + albums_collection_backend_ = new CollectionBackend(); + albums_collection_backend_->moveToThread(app_->database()->thread()); + albums_collection_backend_->Init(app_->database(), kAlbumsSongsTable, QString(), QString(), kAlbumsSongsFtsTable); + + songs_collection_backend_ = new CollectionBackend(); + songs_collection_backend_->moveToThread(app_->database()->thread()); + songs_collection_backend_->Init(app_->database(), kSongsTable, QString(), QString(), 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); + + // Search + + timer_search_delay_->setSingleShot(true); + connect(timer_search_delay_, SIGNAL(timeout()), SLOT(StartSearch())); + + timer_login_attempt_->setSingleShot(true); + connect(timer_login_attempt_, SIGNAL(timeout()), SLOT(ResetLoginAttempts())); + + connect(this, SIGNAL(Login()), SLOT(SendLogin())); + connect(this, SIGNAL(Login(QString, QString, QString)), SLOT(SendLogin(QString, QString, QString))); + + connect(this, SIGNAL(AddArtists(const SongList&)), favorite_request_, SLOT(AddArtists(const SongList&))); + connect(this, SIGNAL(AddAlbums(const SongList&)), favorite_request_, SLOT(AddAlbums(const SongList&))); + connect(this, SIGNAL(AddSongs(const SongList&)), favorite_request_, SLOT(AddSongs(const SongList&))); + + connect(this, SIGNAL(RemoveArtists(const SongList&)), favorite_request_, SLOT(RemoveArtists(const SongList&))); + connect(this, SIGNAL(RemoveAlbums(const SongList&)), favorite_request_, SLOT(RemoveAlbums(const SongList&))); + connect(this, SIGNAL(RemoveSongs(const SongList&)), favorite_request_, SLOT(RemoveSongs(const SongList&))); + + connect(favorite_request_, SIGNAL(ArtistsAdded(const SongList&)), artists_collection_backend_, SLOT(AddOrUpdateSongs(const SongList&))); + connect(favorite_request_, SIGNAL(AlbumsAdded(const SongList&)), albums_collection_backend_, SLOT(AddOrUpdateSongs(const SongList&))); + connect(favorite_request_, SIGNAL(SongsAdded(const SongList&)), songs_collection_backend_, SLOT(AddOrUpdateSongs(const SongList&))); + + connect(favorite_request_, SIGNAL(ArtistsRemoved(const SongList&)), artists_collection_backend_, SLOT(DeleteSongs(const SongList&))); + connect(favorite_request_, SIGNAL(AlbumsRemoved(const SongList&)), albums_collection_backend_, SLOT(DeleteSongs(const SongList&))); + connect(favorite_request_, SIGNAL(SongsRemoved(const SongList&)), songs_collection_backend_, SLOT(DeleteSongs(const SongList&))); + + ReloadSettings(); + +} + +QobuzService::~QobuzService() { + + while (!stream_url_requests_.isEmpty()) { + QobuzStreamURLRequest *stream_url_req = stream_url_requests_.takeFirst(); + disconnect(stream_url_req, 0, nullptr, 0); + stream_url_req->deleteLater(); + } + +} + +void QobuzService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Qobuz); +} + +void QobuzService::ReloadSettings() { + + QSettings s; + s.beginGroup(QobuzSettingsPage::kSettingsGroup); + + app_id_ = s.value("app_id").toString(); + app_secret_ = s.value("app_secret").toString(); + + username_ = s.value("username").toString(); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) password_.clear(); + else password_ = QString::fromUtf8(QByteArray::fromBase64(password)); + + format_ = s.value("format", 27).toInt(); + search_delay_ = s.value("searchdelay", 1500).toInt(); + artistssearchlimit_ = s.value("artistssearchlimit", 5).toInt(); + albumssearchlimit_ = s.value("albumssearchlimit", 100).toInt(); + songssearchlimit_ = s.value("songssearchlimit", 100).toInt(); + cache_album_covers_ = s.value("cachealbumcovers", true).toBool(); + + access_token_ = s.value("access_token").toString(); + + s.endGroup(); + +} + +QString QobuzService::CoverCacheDir() { + return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/qobuzalbumcovers"; +} + +void QobuzService::SendLogin() { + SendLogin(app_id_, username_, password_); +} + +void QobuzService::SendLogin(const QString &app_id, const QString &username, const QString &password) { + + emit UpdateStatus(tr("Authenticating...")); + + login_sent_ = true; + ++login_attempts_; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + timer_login_attempt_->setInterval(kTimeResetLoginAttempts); + timer_login_attempt_->start(); + + const ParamList params = ParamList() << Param("app_id", app_id) + << Param("username", username) + << Param("password", password); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url(kAuthUrl); + QNetworkRequest req(url); + + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network_->post(req, query); + NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*)), reply); + + qLog(Debug) << "Qobuz: Sending request" << url << query; + +} + +void QobuzService::HandleAuthReply(QNetworkReply *reply) { + + reply->deleteLater(); + + login_sent_ = false; + + if (reply->error() != QNetworkReply::NoError) { + if (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 "status", "code" and "message" - then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + QString failure_reason; + if (json_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("code") && json_obj.contains("message")) { + QString status = json_obj["status"].toString(); + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + failure_reason = QString("%1 (%2)").arg(message).arg(code); + } + } + if (failure_reason.isEmpty()) { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + LoginError(failure_reason); + return; + } + } + + int http_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (http_code != 200) { + LoginError(QString("Received HTTP code %1").arg(http_code)); + return; + } + + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + LoginError("Authentication reply from server missing Json data."); + return; + } + + if (json_doc.isNull() || 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("user_auth_token")) { + LoginError("Authentication reply from server is missing user_auth_token", json_obj); + return; + } + + access_token_ = json_obj["user_auth_token"].toString(); + + QSettings s; + s.beginGroup(QobuzSettingsPage::kSettingsGroup); + s.setValue("access_token", access_token_); + s.endGroup(); + + qLog(Debug) << "Qobuz: Login successful" << "access token" << access_token_; + + login_attempts_ = 0; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + + emit LoginComplete(true); + emit LoginSuccess(); + +} + +void QobuzService::Logout() { + + access_token_.clear(); + + QSettings s; + s.beginGroup(QobuzSettingsPage::kSettingsGroup); + s.remove("user_auth_token"); + s.endGroup(); + +} + +void QobuzService::ResetLoginAttempts() { + login_attempts_ = 0; +} + +void QobuzService::TryLogin() { + + if (authenticated() || login_sent_) return; + + if (login_attempts_ >= kLoginAttempts) { + emit LoginComplete(false, "Maximum number of login attempts reached."); + return; + } + if (app_id_.isEmpty()) { + emit LoginComplete(false, "Missing Qobuz app ID."); + return; + } + if (username_.isEmpty()) { + emit LoginComplete(false, "Missing Qobuz username."); + return; + } + if (password_.isEmpty()) { + emit LoginComplete(false, "Missing Qobuz password."); + return; + } + + emit Login(); + +} + +void QobuzService::ResetArtistsRequest() { + + if (artists_request_.get()) { + disconnect(artists_request_.get(), 0, nullptr, 0); + disconnect(this, 0, artists_request_.get(), 0); + artists_request_.reset(); + } + +} + +void QobuzService::GetArtists() { + + ResetArtistsRequest(); + + artists_request_.reset(new QobuzRequest(this, url_handler_, network_, QobuzBaseRequest::QueryType_Artists, this)); + + connect(artists_request_.get(), SIGNAL(ErrorSignal(QString)), SLOT(ArtistsErrorReceived(QString))); + connect(artists_request_.get(), SIGNAL(Results(SongList)), SLOT(ArtistsResultsReceived(SongList))); + connect(artists_request_.get(), SIGNAL(UpdateStatus(QString)), SIGNAL(ArtistsUpdateStatus(QString))); + connect(artists_request_.get(), SIGNAL(ProgressSetMaximum(int)), SIGNAL(ArtistsProgressSetMaximum(int))); + connect(artists_request_.get(), SIGNAL(UpdateProgress(int)), SIGNAL(ArtistsUpdateProgress(int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), artists_request_.get(), SLOT(LoginComplete(bool, QString))); + + artists_request_->Process(); + +} + +void QobuzService::ArtistsResultsReceived(SongList songs) { + + emit ArtistsResults(songs); + +} + +void QobuzService::ArtistsErrorReceived(QString error) { + + emit ArtistsError(error); + +} + +void QobuzService::ResetAlbumsRequest() { + + if (albums_request_.get()) { + disconnect(albums_request_.get(), 0, nullptr, 0); + disconnect(this, 0, albums_request_.get(), 0); + albums_request_.reset(); + } + +} + +void QobuzService::GetAlbums() { + + ResetAlbumsRequest(); + albums_request_.reset(new QobuzRequest(this, url_handler_, network_, QobuzBaseRequest::QueryType_Albums, this)); + connect(albums_request_.get(), SIGNAL(ErrorSignal(QString)), SLOT(AlbumsErrorReceived(QString))); + connect(albums_request_.get(), SIGNAL(Results(SongList)), SLOT(AlbumsResultsReceived(SongList))); + connect(albums_request_.get(), SIGNAL(UpdateStatus(QString)), SIGNAL(AlbumsUpdateStatus(QString))); + connect(albums_request_.get(), SIGNAL(ProgressSetMaximum(int)), SIGNAL(AlbumsProgressSetMaximum(int))); + connect(albums_request_.get(), SIGNAL(UpdateProgress(int)), SIGNAL(AlbumsUpdateProgress(int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), albums_request_.get(), SLOT(LoginComplete(bool, QString))); + + albums_request_->Process(); + +} + +void QobuzService::AlbumsResultsReceived(SongList songs) { + + emit AlbumsResults(songs); + +} + +void QobuzService::AlbumsErrorReceived(QString error) { + + emit AlbumsError(error); + +} + +void QobuzService::ResetSongsRequest() { + + if (songs_request_.get()) { + disconnect(songs_request_.get(), 0, nullptr, 0); + disconnect(this, 0, songs_request_.get(), 0); + songs_request_.reset(); + } + +} + +void QobuzService::GetSongs() { + + ResetSongsRequest(); + songs_request_.reset(new QobuzRequest(this, url_handler_, network_, QobuzBaseRequest::QueryType_Songs, this)); + connect(songs_request_.get(), SIGNAL(ErrorSignal(QString)), SLOT(SongsErrorReceived(QString))); + connect(songs_request_.get(), SIGNAL(Results(SongList)), SLOT(SongsResultsReceived(SongList))); + connect(songs_request_.get(), SIGNAL(UpdateStatus(QString)), SIGNAL(SongsUpdateStatus(QString))); + connect(songs_request_.get(), SIGNAL(ProgressSetMaximum(int)), SIGNAL(SongsProgressSetMaximum(int))); + connect(songs_request_.get(), SIGNAL(UpdateProgress(int)), SIGNAL(SongsUpdateProgress(int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), songs_request_.get(), SLOT(LoginComplete(bool, QString))); + + songs_request_->Process(); + +} + +void QobuzService::SongsResultsReceived(SongList songs) { + + emit SongsResults(songs); + +} + +void QobuzService::SongsErrorReceived(QString error) { + + emit SongsError(error); + +} + +int QobuzService::Search(const QString &text, InternetSearch::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_->setInterval(search_delay_); + timer_search_delay_->start(); + + return pending_search_id_; + +} + +void QobuzService::StartSearch() { + + if (app_id_.isEmpty() || username_.isEmpty() || password_.isEmpty()) { + emit SearchError(pending_search_id_, tr("Not authenticated.")); + next_pending_search_id_ = 1; + ShowConfig(); + return; + } + + search_id_ = pending_search_id_; + search_text_ = pending_search_text_; + + SendSearch(); + +} + +void QobuzService::CancelSearch() { +} + +void QobuzService::SendSearch() { + + QobuzBaseRequest::QueryType type; + + switch (pending_search_type_) { + case InternetSearch::SearchType_Artists: + type = QobuzBaseRequest::QueryType_SearchArtists; + break; + case InternetSearch::SearchType_Albums: + type = QobuzBaseRequest::QueryType_SearchAlbums; + break; + case InternetSearch::SearchType_Songs: + type = QobuzBaseRequest::QueryType_SearchSongs; + break; + default: + //Error("Invalid search type."); + return; + } + + search_request_.reset(new QobuzRequest(this, url_handler_, network_, type, this)); + + connect(search_request_.get(), SIGNAL(SearchResults(int, SongList)), SIGNAL(SearchResults(int, SongList))); + connect(search_request_.get(), SIGNAL(ErrorSignal(int, QString)), SIGNAL(SearchError(int, QString))); + connect(search_request_.get(), SIGNAL(UpdateStatus(QString)), SIGNAL(SearchUpdateStatus(QString))); + connect(search_request_.get(), SIGNAL(ProgressSetMaximum(int)), SIGNAL(SearchProgressSetMaximum(int))); + connect(search_request_.get(), SIGNAL(UpdateProgress(int)), SIGNAL(SearchUpdateProgress(int))); + connect(this, SIGNAL(LoginComplete(bool, QString)), search_request_.get(), SLOT(LoginComplete(bool, QString))); + + search_request_->Search(search_id_, search_text_); + search_request_->Process(); + +} + +void QobuzService::GetStreamURL(const QUrl &url) { + + QobuzStreamURLRequest *stream_url_req = new QobuzStreamURLRequest(this, network_, url, this); + stream_url_requests_ << stream_url_req; + + connect(stream_url_req, SIGNAL(TryLogin()), this, SLOT(TryLogin())); + connect(stream_url_req, SIGNAL(StreamURLFinished(QUrl, QUrl, Song::FileType, QString)), this, SLOT(HandleStreamURLFinished(QUrl, QUrl, Song::FileType, QString))); + connect(this, SIGNAL(LoginComplete(bool, QString)), stream_url_req, SLOT(LoginComplete(bool, QString))); + + stream_url_req->Process(); + +} + +void QobuzService::HandleStreamURLFinished(const QUrl original_url, const QUrl stream_url, const Song::FileType filetype, QString error) { + + QobuzStreamURLRequest *stream_url_req = qobject_cast(sender()); + if (!stream_url_req || !stream_url_requests_.contains(stream_url_req)) return; + stream_url_req->deleteLater(); + stream_url_requests_.removeAll(stream_url_req); + + emit StreamURLFinished(original_url, stream_url, filetype, error); + +} + +QString QobuzService::LoginError(QString error, QVariant debug) { + + qLog(Error) << "Qobuz:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + emit LoginFailure(error); + emit LoginComplete(false, error); + + return error; + +} diff --git a/src/qobuz/qobuzservice.h b/src/qobuz/qobuzservice.h new file mode 100644 index 000000000..7f17f05a6 --- /dev/null +++ b/src/qobuz/qobuzservice.h @@ -0,0 +1,212 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 QOBUZSERVICE_H +#define QOBUZSERVICE_H + +#include "config.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetservice.h" +#include "internet/internetsearch.h" +#include "settings/qobuzsettingspage.h" + +class Application; +class NetworkAccessManager; +class QobuzUrlHandler; +class QobuzRequest; +class QobuzFavoriteRequest; +class QobuzStreamURLRequest; +class CollectionBackend; +class CollectionModel; + +using std::shared_ptr; + +class QobuzService : public InternetService { + Q_OBJECT + + public: + QobuzService(Application *app, QObject *parent); + ~QobuzService(); + + static const Song::Source kSource; + + void ReloadSettings(); + QString CoverCacheDir(); + + void Logout(); + int Search(const QString &query, InternetSearch::SearchType type); + void CancelSearch(); + + const int max_login_attempts() { return kLoginAttempts; } + + QString app_id() { return app_id_; } + QString app_secret() { return app_secret_; } + QString username() { return username_; } + QString password() { return password_; } + int format() { return format_; } + int search_delay() { return search_delay_; } + int artistssearchlimit() { return artistssearchlimit_; } + int albumssearchlimit() { return albumssearchlimit_; } + int songssearchlimit() { return songssearchlimit_; } + bool cache_album_covers() { return cache_album_covers_; } + + QString access_token() { return access_token_; } + + const bool authenticated() { return (!app_id_.isEmpty() && !app_secret_.isEmpty() && !access_token_.isEmpty()); } + const bool login_sent() { return login_sent_; } + const bool login_attempts() { return login_attempts_; } + + void GetStreamURL(const QUrl &url); + + CollectionBackend *artists_collection_backend() { return artists_collection_backend_; } + CollectionBackend *albums_collection_backend() { return albums_collection_backend_; } + CollectionBackend *songs_collection_backend() { return songs_collection_backend_; } + + CollectionModel *artists_collection_model() { return artists_collection_model_; } + CollectionModel *albums_collection_model() { return albums_collection_model_; } + CollectionModel *songs_collection_model() { return songs_collection_model_; } + + QSortFilterProxyModel *artists_collection_sort_model() { return artists_collection_sort_model_; } + QSortFilterProxyModel *albums_collection_sort_model() { return albums_collection_sort_model_; } + QSortFilterProxyModel *songs_collection_sort_model() { return songs_collection_sort_model_; } + + enum QueryType { + QueryType_Artists, + QueryType_Albums, + QueryType_Songs, + QueryType_SearchArtists, + QueryType_SearchAlbums, + QueryType_SearchSongs, + }; + + signals: + + public slots: + void ShowConfig(); + void TryLogin(); + void SendLogin(const QString &username, const QString &password, const QString &token); + void GetArtists(); + void GetAlbums(); + void GetSongs(); + void ResetArtistsRequest(); + void ResetAlbumsRequest(); + void ResetSongsRequest(); + + private slots: + void SendLogin(); + void HandleAuthReply(QNetworkReply *reply); + void ResetLoginAttempts(); + void StartSearch(); + void ArtistsResultsReceived(SongList songs); + void ArtistsErrorReceived(QString error); + void AlbumsResultsReceived(SongList songs); + void AlbumsErrorReceived(QString error); + void SongsResultsReceived(SongList songs); + void SongsErrorReceived(QString error); + void HandleStreamURLFinished(const QUrl original_url, const QUrl stream_url, const Song::FileType filetype, QString error = QString()); + + private: + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + void SendSearch(); + QString LoginError(QString error, QVariant debug = QVariant()); + + static const char *kAuthUrl; + static const int kLoginAttempts; + static const int kTimeResetLoginAttempts; + + 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_; + QobuzUrlHandler *url_handler_; + + CollectionBackend *artists_collection_backend_; + CollectionBackend *albums_collection_backend_; + CollectionBackend *songs_collection_backend_; + + CollectionModel *artists_collection_model_; + CollectionModel *albums_collection_model_; + CollectionModel *songs_collection_model_; + + QSortFilterProxyModel *artists_collection_sort_model_; + QSortFilterProxyModel *albums_collection_sort_model_; + QSortFilterProxyModel *songs_collection_sort_model_; + + QTimer *timer_search_delay_; + QTimer *timer_login_attempt_; + + std::shared_ptr artists_request_; + std::shared_ptr albums_request_; + std::shared_ptr songs_request_; + std::shared_ptr search_request_; + QobuzFavoriteRequest *favorite_request_; + + QString app_id_; + QString app_secret_; + QString username_; + QString password_; + int format_; + int search_delay_; + int artistssearchlimit_; + int albumssearchlimit_; + int songssearchlimit_; + bool cache_album_covers_; + + QString access_token_; + + int pending_search_id_; + int next_pending_search_id_; + QString pending_search_text_; + InternetSearch::SearchType pending_search_type_; + + int search_id_; + QString search_text_; + bool login_sent_; + int login_attempts_; + + QList stream_url_requests_; + +}; + +#endif // QOBUZSERVICE_H diff --git a/src/qobuz/qobuzstreamurlrequest.cpp b/src/qobuz/qobuzstreamurlrequest.cpp new file mode 100644 index 000000000..ad55a9d11 --- /dev/null +++ b/src/qobuz/qobuzstreamurlrequest.cpp @@ -0,0 +1,213 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 "core/logging.h" +#include "core/network.h" +#include "core/song.h" +#include "settings/qobuzsettingspage.h" +#include "qobuzservice.h" +#include "qobuzbaserequest.h" +#include "qobuzstreamurlrequest.h" + +QobuzStreamURLRequest::QobuzStreamURLRequest(QobuzService *service, NetworkAccessManager *network, const QUrl &original_url, QObject *parent) + : QobuzBaseRequest(service, network, parent), + service_(service), + reply_(nullptr), + original_url_(original_url), + song_id_(original_url.path().toInt()), + tries_(0), + need_login_(false) {} + +QobuzStreamURLRequest::~QobuzStreamURLRequest() { + + if (reply_) { + disconnect(reply_, 0, nullptr, 0); + if (reply_->isRunning()) reply_->abort(); + reply_->deleteLater(); + } + +} + +void QobuzStreamURLRequest::LoginComplete(bool success, QString error) { + + if (!need_login_) return; + need_login_ = false; + + if (!success) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + + Process(); + +} + +void QobuzStreamURLRequest::Process() { + + if (app_id().isEmpty() || app_secret().isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, tr("Missing app ID or secret.")); + return; + } + + if (!authenticated()) { + need_login_ = true; + emit TryLogin(); + return; + } + GetStreamURL(); + +} + +void QobuzStreamURLRequest::Cancel() { + + if (reply_ && reply_->isRunning()) { + reply_->abort(); + } + else { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, tr("Cancelled.")); + } + +} + +void QobuzStreamURLRequest::GetStreamURL() { + + ++tries_; + + if (reply_) { + disconnect(reply_, 0, nullptr, 0); + if (reply_->isRunning()) reply_->abort(); + reply_->deleteLater(); + } + + quint64 timestamp = QDateTime::currentDateTime().toTime_t(); + + ParamList params_to_sign = ParamList() << Param("format_id", QString::number(format())) + << Param("track_id", QString::number(song_id_)); + + std::sort(params_to_sign.begin(), params_to_sign.end()); + + QString data_to_sign; + data_to_sign += "trackgetFileUrl"; + for (const Param ¶m : params_to_sign) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + data_to_sign += param.first; + data_to_sign += param.second; + } + data_to_sign += QString::number(timestamp); + data_to_sign += app_secret(); + + QByteArray const digest = QCryptographicHash::hash(data_to_sign.toUtf8(), QCryptographicHash::Md5); + QString signature = QString::fromLatin1(digest.toHex()).rightJustified(32, '0').toLower(); + + ParamList params = params_to_sign; + params << Param("request_ts", QString::number(timestamp)); + params << Param("request_sig", signature); + params << Param("user_auth_token", access_token()); + + std::sort(params.begin(), params.end()); + + reply_ = CreateRequest(QString("track/getFileUrl"), params); + connect(reply_, SIGNAL(finished()), this, SLOT(StreamURLReceived())); + +} + +void QobuzStreamURLRequest::StreamURLReceived() { + + if (!reply_) return; + disconnect(reply_, 0, nullptr, 0); + reply_->deleteLater(); + + QString error; + + QByteArray data = GetReplyData(reply_, error); + if (data.isEmpty()) { + reply_ = nullptr; + if (!authenticated() && login_sent() && tries_ <= 1) { + need_login_ = true; + return; + } + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + reply_ = nullptr; + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + + if (!json_obj.contains("track_id")) { + error = Error("Invalid Json reply, stream url is missing track_id.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + + int track_id = json_obj["track_id"].toInt(); + if (track_id != song_id_) { + error = Error("Incorrect track ID returned.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + + if (!json_obj.contains("mime_type") || !json_obj.contains("url")) { + error = Error("Invalid Json reply, stream url is missing url or mime_type.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, error); + return; + } + + QUrl url(json_obj["url"].toString()); + QString mimetype = json_obj["mime_type"].toString(); + + Song::FileType filetype(Song::FileType_Unknown); + QMimeDatabase mimedb; + for (QString suffix : mimedb.mimeTypeForName(mimetype.toUtf8()).suffixes()) { + filetype = Song::FiletypeByExtension(suffix); + if (filetype != Song::FileType_Unknown) break; + } + if (filetype == Song::FileType_Unknown) { + qLog(Debug) << "Qobuz: Unknown mimetype" << mimetype; + filetype = Song::FileType_Stream; + } + + if (!url.isValid()) { + error = Error("Returned stream url is invalid.", json_obj); + emit StreamURLFinished(original_url_, original_url_, filetype, error); + return; + } + + emit StreamURLFinished(original_url_, url, filetype); + +} diff --git a/src/qobuz/qobuzstreamurlrequest.h b/src/qobuz/qobuzstreamurlrequest.h new file mode 100644 index 000000000..00b6d08bd --- /dev/null +++ b/src/qobuz/qobuzstreamurlrequest.h @@ -0,0 +1,69 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 QOBUZSTREAMURLREQUEST_H +#define QOBUZSTREAMURLREQUEST_H + +#include "config.h" + +#include +#include + +#include "core/song.h" +#include "qobuzbaserequest.h" + +class QNetworkReply; +class NetworkAccessManager; +class QobuzService; + +class QobuzStreamURLRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + QobuzStreamURLRequest(QobuzService *service, NetworkAccessManager *network, const QUrl &original_url, QObject *parent); + ~QobuzStreamURLRequest(); + + void GetStreamURL(); + void Process(); + void NeedLogin() { need_login_ = true; } + void Cancel(); + + QUrl original_url() { return original_url_; } + int song_id() { return song_id_; } + bool need_login() { return need_login_; } + + signals: + void TryLogin(); + void StreamURLFinished(const QUrl original_url, const QUrl stream_url, const Song::FileType filetype, QString error = QString()); + + private slots: + void LoginComplete(bool success, QString error = QString()); + void StreamURLReceived(); + + private: + QobuzService *service_; + QNetworkReply *reply_; + QUrl original_url_; + int song_id_; + int tries_; + bool need_login_; + +}; + +#endif // QOBUZSTREAMURLREQUEST_H diff --git a/src/qobuz/qobuzurlhandler.cpp b/src/qobuz/qobuzurlhandler.cpp new file mode 100644 index 000000000..375ba249f --- /dev/null +++ b/src/qobuz/qobuzurlhandler.cpp @@ -0,0 +1,68 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 +#include +#include + +#include "core/application.h" +#include "core/taskmanager.h" +#include "core/iconloader.h" +#include "core/logging.h" +#include "core/song.h" +#include "qobuz/qobuzservice.h" +#include "qobuzurlhandler.h" + +QobuzUrlHandler::QobuzUrlHandler(Application *app, QobuzService *service) : + UrlHandler(service), + app_(app), + service_(service), + task_id_(-1) + { + + connect(service, SIGNAL(StreamURLFinished(QUrl, QUrl, Song::FileType, QString)), this, SLOT(GetStreamURLFinished(QUrl, QUrl, Song::FileType, QString))); + +} + +UrlHandler::LoadResult QobuzUrlHandler::StartLoading(const QUrl &url) { + + LoadResult ret(url); + if (task_id_ != -1) return ret; + task_id_ = app_->task_manager()->StartTask(QString("Loading %1 stream...").arg(url.scheme())); + service_->GetStreamURL(url); + ret.type_ = LoadResult::WillLoadAsynchronously; + return ret; + +} + +void QobuzUrlHandler::GetStreamURLFinished(QUrl original_url, QUrl url, Song::FileType filetype, QString error) { + + if (task_id_ == -1) return; + CancelTask(); + if (error.isEmpty()) + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::TrackAvailable, url, filetype)); + else + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::Error, url, filetype, -1, error)); + +} + +void QobuzUrlHandler::CancelTask() { + app_->task_manager()->SetTaskFinished(task_id_); + task_id_ = -1; +} diff --git a/src/qobuz/qobuzurlhandler.h b/src/qobuz/qobuzurlhandler.h new file mode 100644 index 000000000..746ea4523 --- /dev/null +++ b/src/qobuz/qobuzurlhandler.h @@ -0,0 +1,55 @@ +/* + * Strawberry Music Player + * Copyright 2018, 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 QOBUZURLHANDLER_H +#define QOBUZURLHANDLER_H + +#include +#include +#include + +#include "core/urlhandler.h" +#include "core/song.h" +#include "qobuz/qobuzservice.h" + +class Application; +class QobuzService; + +class QobuzUrlHandler : public UrlHandler { + Q_OBJECT + + public: + QobuzUrlHandler(Application *app, QobuzService *service); + + QString scheme() const { return service_->url_scheme(); } + LoadResult StartLoading(const QUrl &url); + + void CancelTask(); + + private slots: + void GetStreamURLFinished(QUrl original_url, QUrl url, Song::FileType filetype, QString error = QString()); + + private: + Application *app_; + QobuzService *service_; + int task_id_; + +}; + +#endif diff --git a/src/settings/qobuzsettingspage.cpp b/src/settings/qobuzsettingspage.cpp new file mode 100644 index 000000000..698690830 --- /dev/null +++ b/src/settings/qobuzsettingspage.cpp @@ -0,0 +1,152 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 "qobuzsettingspage.h" +#include "ui_qobuzsettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "internet/internetservices.h" +#include "qobuz/qobuzservice.h" +#include "qobuz/qobuzstreamurlrequest.h" + +const char *QobuzSettingsPage::kSettingsGroup = "Qobuz"; + +QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *parent) + : SettingsPage(parent), + ui_(new Ui::QobuzSettingsPage), + service_(dialog()->app()->internet_services()->Service()) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("qobuz")); + + connect(ui_->button_login, SIGNAL(clicked()), SLOT(LoginClicked())); + connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked())); + + connect(this, SIGNAL(Login(QString, QString, QString)), service_, SLOT(SendLogin(QString, QString, QString))); + + connect(service_, SIGNAL(LoginFailure(QString)), SLOT(LoginFailure(QString))); + connect(service_, SIGNAL(LoginSuccess()), SLOT(LoginSuccess())); + + dialog()->installEventFilter(this); + + ui_->format->addItem("MP3 320", 5); + ui_->format->addItem("FLAC Lossless", 6); + ui_->format->addItem("FLAC Hi-Res <= 96kHz", 7); + ui_->format->addItem("FLAC Hi-Res > 96kHz", 27); + +} + +QobuzSettingsPage::~QobuzSettingsPage() { delete ui_; } + +void QobuzSettingsPage::Load() { + + QSettings s; + + s.beginGroup(kSettingsGroup); + ui_->enable->setChecked(s.value("enabled", false).toBool()); + ui_->app_id->setText(s.value("app_id").toString()); + ui_->app_secret->setText(s.value("app_secret").toString()); + + 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))); + + dialog()->ComboBoxLoadFromSettings(s, ui_->format, "format", 27); + ui_->searchdelay->setValue(s.value("searchdelay", 1500).toInt()); + ui_->artistssearchlimit->setValue(s.value("artistssearchlimit", 5).toInt()); + ui_->albumssearchlimit->setValue(s.value("albumssearchlimit", 100).toInt()); + ui_->songssearchlimit->setValue(s.value("songssearchlimit", 100).toInt()); + ui_->checkbox_cache_album_covers->setChecked(s.value("cachealbumcovers", true).toBool()); + + s.endGroup(); + + if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + +} + +void QobuzSettingsPage::Save() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("enabled", ui_->enable->isChecked()); + s.setValue("app_id", ui_->app_id->text()); + s.setValue("app_secret", ui_->app_secret->text()); + + s.setValue("username", ui_->username->text()); + s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); + + s.setValue("format", ui_->format->itemData(ui_->format->currentIndex())); + 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("cachealbumcovers", ui_->checkbox_cache_album_covers->isChecked()); + s.endGroup(); + + service_->ReloadSettings(); + +} + +void QobuzSettingsPage::LoginClicked() { + + if (ui_->app_id->text().isEmpty() || ui_->username->text().isEmpty() || ui_->password->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing app id, username or password.")); + return; + } + emit Login(ui_->app_id->text(), ui_->username->text(), ui_->password->text()); + ui_->button_login->setEnabled(false); + +} + +bool QobuzSettingsPage::eventFilter(QObject *object, QEvent *event) { + + if (object == dialog() && event->type() == QEvent::Enter) { + ui_->button_login->setEnabled(true); + return false; + } + + return SettingsPage::eventFilter(object, event); + +} + +void QobuzSettingsPage::LogoutClicked() { + service_->Logout(); + ui_->button_login->setEnabled(true); + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); +} + +void QobuzSettingsPage::LoginSuccess() { + if (!this->isVisible()) return; + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + ui_->button_login->setEnabled(false); +} + +void QobuzSettingsPage::LoginFailure(QString failure_reason) { + if (!this->isVisible()) return; + QMessageBox::warning(this, tr("Authentication failed"), failure_reason); +} diff --git a/src/settings/qobuzsettingspage.h b/src/settings/qobuzsettingspage.h new file mode 100644 index 000000000..1b4baa926 --- /dev/null +++ b/src/settings/qobuzsettingspage.h @@ -0,0 +1,61 @@ +/* + * Strawberry Music Player + * Copyright 2019, 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 QOBUZSETTINGSPAGE_H +#define QOBUZSETTINGSPAGE_H + +#include +#include +#include + +#include "settings/settingspage.h" + +class QobuzService; +class Ui_QobuzSettingsPage; + +class QobuzSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit QobuzSettingsPage(SettingsDialog* parent = nullptr); + ~QobuzSettingsPage(); + + static const char *kSettingsGroup; + + void Load(); + void Save(); + + bool eventFilter(QObject *object, QEvent *event); + + signals: + void Login(); + void Login(const QString &username, const QString &password, const QString &token); + + private slots: + void LoginClicked(); + void LogoutClicked(); + void LoginSuccess(); + void LoginFailure(QString failure_reason); + + private: + Ui_QobuzSettingsPage* ui_; + QobuzService *service_; +}; + +#endif diff --git a/src/settings/qobuzsettingspage.ui b/src/settings/qobuzsettingspage.ui new file mode 100644 index 000000000..546aeebd1 --- /dev/null +++ b/src/settings/qobuzsettingspage.ui @@ -0,0 +1,297 @@ + + + QobuzSettingsPage + + + + 0 + 0 + 715 + 836 + + + + Qobuz + + + + + + Enable + + + + + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + Authentication + + + + + + + 150 + 0 + + + + App ID + + + + + + + + + + Username + + + + + + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + App Secret + + + + + + + + + + + + + Login + + + + + + + + + + Qt::Horizontal + + + + + + + Preferences + + + + + + Audio format + + + + + + + + + + Search delay + + + + + + + ms + + + 0 + + + 10000 + + + 50 + + + 1500 + + + + + + + Artists search limit + + + + + + + 1 + + + 100 + + + 50 + + + + + + + Albums search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Songs search limit + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + Cache album covers + + + + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + :/icons/64x64/qobuz.png + + + + + + + + + + LoginStateWidget + QWidget +
    widgets/loginstatewidget.h
    + 1 +
    +
    + + username + password + + + + + + +
    diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index a7ac3aa66..32b2eceff 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -62,11 +62,14 @@ #include "transcodersettingspage.h" #include "networkproxysettingspage.h" #include "scrobblersettingspage.h" +#ifdef HAVE_MOODBAR +# include "moodbarsettingspage.h" +#endif #ifdef HAVE_TIDAL # include "tidalsettingspage.h" #endif -#ifdef HAVE_MOODBAR -# include "moodbarsettingspage.h" +#ifdef HAVE_QOBUZ +# include "qobuzsettingspage.h" #endif #ifdef HAVE_SUBSONIC # include "subsonicsettingspage.h" @@ -143,12 +146,15 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) AddPage(Page_Moodbar, new MoodbarSettingsPage(this), iface); #endif -#if defined(HAVE_TIDAL) || defined(HAVE_SUBSONIC) +#if defined(HAVE_TIDAL) || defined(HAVE_SUBSONIC) || defined(HAVE_QOBUZ) QTreeWidgetItem *streaming = AddCategory(tr("Streaming")); #endif #ifdef HAVE_TIDAL AddPage(Page_Tidal, new TidalSettingsPage(this), streaming); #endif +#ifdef HAVE_QOBUZ + AddPage(Page_Qobuz, new QobuzSettingsPage(this), streaming); +#endif #ifdef HAVE_SUBSONIC AddPage(Page_Subsonic, new SubsonicSettingsPage(this), streaming); #endif @@ -320,9 +326,20 @@ void SettingsDialog::CurrentItemChanged(QTreeWidgetItem *item) { } -void SettingsDialog::ComboBoxLoadFromSettings(QSettings &s, QComboBox *combobox, QString setting, QString default_value) { +void SettingsDialog::ComboBoxLoadFromSettings(const QSettings &s, QComboBox *combobox, const QString &setting, const QString &default_value) { + QString value = s.value(setting, default_value).toString(); int i = combobox->findData(value); if (i == -1) i = combobox->findData(default_value); combobox->setCurrentIndex(i); + +} + +void SettingsDialog::ComboBoxLoadFromSettings(const QSettings &s, QComboBox *combobox, const QString &setting, const int default_value) { + + int value = s.value(setting, default_value).toInt(); + int i = combobox->findData(value); + if (i == -1) i = combobox->findData(default_value); + combobox->setCurrentIndex(i); + } diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 6eae7329f..1f25faf2b 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -82,9 +82,10 @@ class SettingsDialog : public QDialog { Page_Transcoding, Page_Proxy, Page_Scrobbler, + Page_Moodbar, Page_Tidal, Page_Subsonic, - Page_Moodbar, + Page_Qobuz, }; enum Role { @@ -111,7 +112,8 @@ class SettingsDialog : public QDialog { // QWidget void showEvent(QShowEvent *e); - void ComboBoxLoadFromSettings(QSettings &s, QComboBox *combobox, QString setting, QString default_value); + void ComboBoxLoadFromSettings(const QSettings &s, QComboBox *combobox, const QString &setting, const QString &default_value); + void ComboBoxLoadFromSettings(const QSettings &s, QComboBox *combobox, const QString &setting, const int default_value); signals: void ReloadSettings();