From 89d6b7cec0e87d3b69b77ad5def4b1f4dc11b305 Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Thu, 17 Sep 2020 17:50:17 +0200 Subject: [PATCH] Add smart playlists, ratings and Qobuz Fixes #259 Fixes #264 --- CMakeLists.txt | 1 + data/data.qrc | 4 + data/icons.qrc | 5 + 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/pictures/star-off.png | Bin 0 -> 351 bytes data/pictures/star-on.png | Bin 0 -> 284 bytes data/schema/device-schema.sql | 6 +- data/schema/schema-13.sql | 231 +++ data/schema/schema.sql | 577 ++++--- data/style/smartplaylistsearchterm.css | 43 + src/CMakeLists.txt | 59 + src/collection/collectionbackend.cpp | 74 + src/collection/collectionbackend.h | 29 +- src/collection/collectionmodel.cpp | 1 + src/config.h.in | 1 + src/core/application.cpp | 13 +- src/core/database.cpp | 2 +- src/core/mainwindow.cpp | 53 +- src/core/mainwindow.h | 4 + src/core/metatypes.cpp | 4 + src/core/song.cpp | 27 + src/core/song.h | 6 + src/covermanager/qobuzcoverprovider.cpp | 263 +--- src/covermanager/qobuzcoverprovider.h | 17 +- src/device/devicedatabasebackend.cpp | 2 +- src/playlist/dynamicplaylistcontrols.cpp | 31 + src/playlist/dynamicplaylistcontrols.h | 41 + src/playlist/dynamicplaylistcontrols.ui | 99 ++ src/playlist/playlist.cpp | 174 ++- src/playlist/playlist.h | 21 +- src/playlist/playlistbackend.cpp | 29 +- src/playlist/playlistbackend.h | 8 +- src/playlist/playlistdelegates.cpp | 37 + src/playlist/playlistdelegates.h | 26 + src/playlist/playlistheader.cpp | 71 +- src/playlist/playlistheader.h | 15 +- src/playlist/playlistmanager.cpp | 24 + src/playlist/playlistmanager.h | 16 +- src/playlist/playlistsequence.cpp | 22 +- src/playlist/playlistsequence.h | 11 +- src/playlist/playlistview.cpp | 182 ++- src/playlist/playlistview.h | 14 + src/qobuz/qobuzbaserequest.cpp | 196 +++ src/qobuz/qobuzbaserequest.h | 105 ++ src/qobuz/qobuzfavoriterequest.cpp | 277 ++++ src/qobuz/qobuzfavoriterequest.h | 82 + src/qobuz/qobuzrequest.cpp | 1343 +++++++++++++++++ src/qobuz/qobuzrequest.h | 205 +++ src/qobuz/qobuzservice.cpp | 761 ++++++++++ src/qobuz/qobuzservice.h | 231 +++ src/qobuz/qobuzstreamurlrequest.cpp | 249 +++ src/qobuz/qobuzstreamurlrequest.h | 76 + src/qobuz/qobuzurlhandler.cpp | 68 + src/qobuz/qobuzurlhandler.h | 55 + src/settings/coverssettingspage.cpp | 8 + src/settings/qobuzsettingspage.cpp | 171 +++ src/settings/qobuzsettingspage.h | 62 + src/settings/qobuzsettingspage.ui | 306 ++++ src/settings/settingsdialog.cpp | 8 +- src/settings/settingsdialog.h | 1 + src/smartplaylists/playlistgenerator.cpp | 43 + src/smartplaylists/playlistgenerator.h | 100 ++ src/smartplaylists/playlistgenerator_fwd.h | 32 + .../playlistgeneratorinserter.cpp | 90 ++ .../playlistgeneratorinserter.h | 71 + .../playlistgeneratormimedata.h | 41 + src/smartplaylists/playlistquerygenerator.cpp | 101 ++ src/smartplaylists/playlistquerygenerator.h | 61 + .../smartplaylistquerysearchpage.ui | 134 ++ .../smartplaylistquerysortpage.ui | 130 ++ .../smartplaylistquerywizardplugin.cpp | 327 ++++ .../smartplaylistquerywizardplugin.h | 80 + src/smartplaylists/smartplaylistsearch.cpp | 148 ++ src/smartplaylists/smartplaylistsearch.h | 69 + .../smartplaylistsearchpreview.cpp | 151 ++ .../smartplaylistsearchpreview.h | 74 + .../smartplaylistsearchpreview.ui | 99 ++ .../smartplaylistsearchterm.cpp | 466 ++++++ src/smartplaylists/smartplaylistsearchterm.h | 141 ++ .../smartplaylistsearchtermwidget.cpp | 511 +++++++ .../smartplaylistsearchtermwidget.h | 94 ++ .../smartplaylistsearchtermwidget.ui | 350 +++++ src/smartplaylists/smartplaylistsitem.h | 45 + src/smartplaylists/smartplaylistsmodel.cpp | 312 ++++ src/smartplaylists/smartplaylistsmodel.h | 94 ++ src/smartplaylists/smartplaylistsview.cpp | 53 + src/smartplaylists/smartplaylistsview.h | 47 + .../smartplaylistsviewcontainer.cpp | 283 ++++ .../smartplaylistsviewcontainer.h | 101 ++ .../smartplaylistsviewcontainer.ui | 136 ++ src/smartplaylists/smartplaylistwizard.cpp | 179 +++ src/smartplaylists/smartplaylistwizard.h | 68 + .../smartplaylistwizardfinishpage.ui | 69 + .../smartplaylistwizardplugin.cpp | 32 + .../smartplaylistwizardplugin.h | 60 + src/widgets/ratingwidget.cpp | 164 ++ src/widgets/ratingwidget.h | 71 + 102 files changed, 10949 insertions(+), 525 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/pictures/star-off.png create mode 100644 data/pictures/star-on.png create mode 100644 data/schema/schema-13.sql create mode 100644 data/style/smartplaylistsearchterm.css create mode 100644 src/playlist/dynamicplaylistcontrols.cpp create mode 100644 src/playlist/dynamicplaylistcontrols.h create mode 100644 src/playlist/dynamicplaylistcontrols.ui 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 create mode 100644 src/smartplaylists/playlistgenerator.cpp create mode 100644 src/smartplaylists/playlistgenerator.h create mode 100644 src/smartplaylists/playlistgenerator_fwd.h create mode 100644 src/smartplaylists/playlistgeneratorinserter.cpp create mode 100644 src/smartplaylists/playlistgeneratorinserter.h create mode 100644 src/smartplaylists/playlistgeneratormimedata.h create mode 100644 src/smartplaylists/playlistquerygenerator.cpp create mode 100644 src/smartplaylists/playlistquerygenerator.h create mode 100644 src/smartplaylists/smartplaylistquerysearchpage.ui create mode 100644 src/smartplaylists/smartplaylistquerysortpage.ui create mode 100644 src/smartplaylists/smartplaylistquerywizardplugin.cpp create mode 100644 src/smartplaylists/smartplaylistquerywizardplugin.h create mode 100644 src/smartplaylists/smartplaylistsearch.cpp create mode 100644 src/smartplaylists/smartplaylistsearch.h create mode 100644 src/smartplaylists/smartplaylistsearchpreview.cpp create mode 100644 src/smartplaylists/smartplaylistsearchpreview.h create mode 100644 src/smartplaylists/smartplaylistsearchpreview.ui create mode 100644 src/smartplaylists/smartplaylistsearchterm.cpp create mode 100644 src/smartplaylists/smartplaylistsearchterm.h create mode 100644 src/smartplaylists/smartplaylistsearchtermwidget.cpp create mode 100644 src/smartplaylists/smartplaylistsearchtermwidget.h create mode 100644 src/smartplaylists/smartplaylistsearchtermwidget.ui create mode 100644 src/smartplaylists/smartplaylistsitem.h create mode 100644 src/smartplaylists/smartplaylistsmodel.cpp create mode 100644 src/smartplaylists/smartplaylistsmodel.h create mode 100644 src/smartplaylists/smartplaylistsview.cpp create mode 100644 src/smartplaylists/smartplaylistsview.h create mode 100644 src/smartplaylists/smartplaylistsviewcontainer.cpp create mode 100644 src/smartplaylists/smartplaylistsviewcontainer.h create mode 100644 src/smartplaylists/smartplaylistsviewcontainer.ui create mode 100644 src/smartplaylists/smartplaylistwizard.cpp create mode 100644 src/smartplaylists/smartplaylistwizard.h create mode 100644 src/smartplaylists/smartplaylistwizardfinishpage.ui create mode 100644 src/smartplaylists/smartplaylistwizardplugin.cpp create mode 100644 src/smartplaylists/smartplaylistwizardplugin.h create mode 100644 src/widgets/ratingwidget.cpp create mode 100644 src/widgets/ratingwidget.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c95f811..5e723195 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -367,6 +367,7 @@ option(INSTALL_TRANSLATIONS "Install translations" OFF) optional_component(SUBSONIC ON "Subsonic support") optional_component(TIDAL ON "Tidal support") +optional_component(QOBUZ ON "Qobuz support") optional_component(MOODBAR ON "Moodbar" DEPENDS "fftw3" FFTW3_FOUND diff --git a/data/data.qrc b/data/data.qrc index 3c6eb01f..828cb76b 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -13,8 +13,10 @@ schema/schema-10.sql schema/schema-11.sql schema/schema-12.sql + schema/schema-13.sql schema/device-schema.sql style/strawberry.css + style/smartplaylistsearchterm.css html/playing-tooltip.html html/oauthsuccess.html pictures/strawberry.png @@ -40,6 +42,8 @@ pictures/osd_shadow_edge.png pictures/nyancat.png pictures/rainbowdash.png + pictures/star-on.png + pictures/star-off.png fonts/HumongousofEternitySt.ttf mood/sample.mood text/ghosts.txt diff --git a/data/icons.qrc b/data/icons.qrc index c7212d4c..21307b33 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -89,6 +89,7 @@ icons/128x128/love.png icons/128x128/subsonic.png icons/128x128/tidal.png + icons/128x128/qobuz.png icons/128x128/multimedia-player-ipod-standard-black.png icons/64x64/albums.png icons/64x64/alsa.png @@ -180,6 +181,7 @@ icons/64x64/love.png icons/64x64/subsonic.png icons/64x64/tidal.png + icons/64x64/qobuz.png icons/64x64/multimedia-player-ipod-standard-black.png icons/48x48/albums.png icons/48x48/alsa.png @@ -275,6 +277,7 @@ icons/48x48/love.png icons/48x48/subsonic.png icons/48x48/tidal.png + icons/48x48/qobuz.png icons/48x48/multimedia-player-ipod-standard-black.png icons/32x32/albums.png icons/32x32/alsa.png @@ -370,6 +373,7 @@ icons/32x32/love.png icons/32x32/subsonic.png icons/32x32/tidal.png + icons/32x32/qobuz.png icons/32x32/multimedia-player-ipod-standard-black.png icons/22x22/albums.png icons/22x22/alsa.png @@ -465,6 +469,7 @@ icons/22x22/love.png icons/22x22/subsonic.png icons/22x22/tidal.png + icons/22x22/qobuz.png icons/22x22/multimedia-player-ipod-standard-black.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/pictures/star-off.png b/data/pictures/star-off.png new file mode 100644 index 0000000000000000000000000000000000000000..e624631766cafe72101807d8c9acc35eaaa61560 GIT binary patch literal 351 zcmV-l0igbgP)ZGmrxSozNv%WMLG5;dS4QYd8gWXe8X74iVQ(Lkre$?*>!QI{6U4K5@ z#=|>v?>)~^60LBKQ|PNwpaN$&N3OETx;R8B%CHA@MH2tR2Kf*$gCzv=U<+M*d76}k z3e=(t6A-AvJG@6d1ZL2Ge_Rw@7Z`%T5sHzCcv!qRXatgwjXekqUIzm)h9gAcqntHE zae#h!+ReGBiS7z7=+i$U;w5u1PuwvY8b^de57?qgMgKUm;nSbWbK!UBfQ5K z7{d(T@8a7ilvSV+Lr_OBMlpjhXrUE-(vsg{6@6%izzF(qj&5|~6rUv(_yd76Y#;%j x@gC7wz$pYQB^9v81@iGQc002ovPDHLkV1g0QjRF7w literal 0 HcmV?d00001 diff --git a/data/pictures/star-on.png b/data/pictures/star-on.png new file mode 100644 index 0000000000000000000000000000000000000000..5d586b3cf4ad302853defe18caf0c638944c89bc GIT binary patch literal 284 zcmV+%0ptFOP)Yvk?g<(s+^yjbm4>^2kN>*w$UEmZe(zXVsOj$R zUcqFTnIV{kAABL*Ou;ym!+_DS1#b|C7o_P4rXUAH5Fdlcg#~e*p>|AQ1{>JLGZe%@ z;TZ?m#sa1=W-T~}m~?6_sKS+r@G%OUuThCJgW)3I4_Kk|buLg6>@PG!5K3#P-`EBf zBCIePs$dMutex()); + QSqlDatabase db(db_->Connect()); + + // Build the query + QString sql = search.ToSql(songs_table()); + + // Run the query + SongList ret; + QSqlQuery query(db); + query.prepare(sql); + query.exec(); + if (db_->CheckErrors(query)) return ret; + + // Read the results + while (query.next()) { + Song song; + song.InitFromQuery(query, true); + ret << song; + } + return ret; + +} + +SongList CollectionBackend::GetAllSongs() { + + // Get all the songs! + return FindSongs(SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_FieldAsc, SmartPlaylistSearchTerm::Field_Artist, -1)); + +} + SongList CollectionBackend::GetSongsBy(const QString &artist, const QString &album, const QString &title) { QMutexLocker l(db_->Mutex()); @@ -1378,3 +1411,44 @@ void CollectionBackend::UpdatePlayCount(const QString &artist, const QString &ti emit SongsStatisticsChanged(SongList() << songs); } + +void CollectionBackend::UpdateSongRating(const int id, const float rating) { + + if (id == -1) return; + + QList id_list; + id_list << id; + UpdateSongsRating(id_list, rating); + +} + +void CollectionBackend::UpdateSongsRating(const QList &id_list, const float rating) { + + if (id_list.isEmpty()) return; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QStringList id_str_list; + for (int i : id_list) { + id_str_list << QString::number(i); + } + QString ids = id_str_list.join(","); + QSqlQuery q(db); + q.prepare(QString("UPDATE %1 SET rating = :rating WHERE ROWID IN (%2)").arg(songs_table_, ids)); + q.bindValue(":rating", rating); + q.exec(); + if (db_->CheckErrors(q)) return; + SongList new_song_list = GetSongsById(id_str_list, db); + emit SongsRatingChanged(new_song_list); + +} + +void CollectionBackend::UpdateSongRatingAsync(const int id, const float rating) { + metaObject()->invokeMethod(this, "UpdateSongRating", Qt::QueuedConnection, Q_ARG(int, id), Q_ARG(float, rating)); +} + +void CollectionBackend::UpdateSongsRatingAsync(const QList& ids, const float rating) { + metaObject()->invokeMethod(this, "UpdateSongsRating", Qt::QueuedConnection, Q_ARG(QList, ids), Q_ARG(float, rating)); +} + diff --git a/src/collection/collectionbackend.h b/src/collection/collectionbackend.h index c96e4cfd..6ba823da 100644 --- a/src/collection/collectionbackend.h +++ b/src/collection/collectionbackend.h @@ -40,6 +40,7 @@ class QThread; class Database; +class SmartPlaylistSearch; class CollectionBackendInterface : public QObject { Q_OBJECT @@ -182,10 +183,16 @@ class CollectionBackend : public CollectionBackendInterface { Song GetSongBySongId(const QString &song_id); SongList GetSongsBySongId(const QStringList &song_ids); + SongList GetAllSongs(); + SongList FindSongs(const SmartPlaylistSearch &search); + Song::Source Source() const; void AddOrUpdateSongsAsync(const SongList &songs); + void UpdateSongRatingAsync(const int id, const float rating); + void UpdateSongsRatingAsync(const QList &ids, const float rating); + public slots: void Exit(); void LoadDirectories(); @@ -209,19 +216,23 @@ class CollectionBackend : public CollectionBackendInterface { void UpdateLastPlayed(const QString &artist, const QString &album, const QString &title, const int lastplayed); void UpdatePlayCount(const QString &artist, const QString &title, const int playcount); - signals: - void DirectoryDiscovered(const Directory &dir, const SubdirectoryList &subdirs); - void DirectoryDeleted(const Directory &dir); + void UpdateSongRating(const int id, const float rating); + void UpdateSongsRating(const QList &id_list, const float rating); - void SongsDiscovered(const SongList &songs); - void SongsDeleted(const SongList &songs); - void SongsStatisticsChanged(const SongList& songs); + signals: + void DirectoryDiscovered(Directory, SubdirectoryList); + void DirectoryDeleted(Directory); + + void SongsDiscovered(SongList); + void SongsDeleted(SongList); + void SongsStatisticsChanged(SongList); void DatabaseReset(); - void TotalSongCountUpdated(const int total); - void TotalArtistCountUpdated(const int total); - void TotalAlbumCountUpdated(const int total); + void TotalSongCountUpdated(int); + void TotalArtistCountUpdated(int); + void TotalAlbumCountUpdated(int); + void SongsRatingChanged(SongList); void ExitFinished(); diff --git a/src/collection/collectionmodel.cpp b/src/collection/collectionmodel.cpp index 23b6b470..792e76f2 100644 --- a/src/collection/collectionmodel.cpp +++ b/src/collection/collectionmodel.cpp @@ -127,6 +127,7 @@ CollectionModel::CollectionModel(CollectionBackend *backend, Application *app, Q connect(backend_, SIGNAL(TotalArtistCountUpdated(int)), SLOT(TotalArtistCountUpdatedSlot(int))); connect(backend_, SIGNAL(TotalAlbumCountUpdated(int)), SLOT(TotalAlbumCountUpdatedSlot(int))); connect(backend_, SIGNAL(SongsStatisticsChanged(SongList)), SLOT(SongsSlightlyChanged(SongList))); + connect(backend_, SIGNAL(SongsRatingChanged(SongList)), SLOT(SongsSlightlyChanged(SongList))); backend_->UpdateTotalSongCountAsync(); backend_->UpdateTotalArtistCountAsync(); diff --git a/src/config.h.in b/src/config.h.in index 90c2d21d..86cdafe7 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -46,6 +46,7 @@ #cmakedefine HAVE_SUBSONIC #cmakedefine HAVE_TIDAL +#cmakedefine HAVE_QOBUZ #cmakedefine HAVE_MOODBAR diff --git a/src/core/application.cpp b/src/core/application.cpp index 70110b4e..22de1f03 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -56,7 +56,6 @@ #include "covermanager/discogscoverprovider.h" #include "covermanager/musicbrainzcoverprovider.h" #include "covermanager/deezercoverprovider.h" -#include "covermanager/qobuzcoverprovider.h" #include "covermanager/musixmatchcoverprovider.h" #include "covermanager/spotifycoverprovider.h" @@ -83,6 +82,11 @@ # include "covermanager/tidalcoverprovider.h" #endif +#ifdef HAVE_QOBUZ +# include "qobuz/qobuzservice.h" +# include "covermanager/qobuzcoverprovider.h" +#endif + #ifdef HAVE_MOODBAR # include "moodbar/moodbarcontroller.h" # include "moodbar/moodbarloader.h" @@ -124,11 +128,13 @@ class ApplicationImpl { cover_providers->AddProvider(new MusicbrainzCoverProvider(app, app)); cover_providers->AddProvider(new DiscogsCoverProvider(app, app)); cover_providers->AddProvider(new DeezerCoverProvider(app, app)); - cover_providers->AddProvider(new QobuzCoverProvider(app, app)); cover_providers->AddProvider(new MusixmatchCoverProvider(app, app)); cover_providers->AddProvider(new SpotifyCoverProvider(app, app)); #ifdef HAVE_TIDAL cover_providers->AddProvider(new TidalCoverProvider(app, app)); +#endif +#ifdef HAVE_QOBUZ + cover_providers->AddProvider(new QobuzCoverProvider(app, app)); #endif cover_providers->ReloadSettings(); return cover_providers; @@ -159,6 +165,9 @@ class ApplicationImpl { #endif #ifdef HAVE_TIDAL internet_services->AddService(new TidalService(app, internet_services)); +#endif +#ifdef HAVE_QOBUZ + internet_services->AddService(new QobuzService(app, internet_services)); #endif return internet_services; }), diff --git a/src/core/database.cpp b/src/core/database.cpp index 5c31213e..6399bcd1 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -54,7 +54,7 @@ #include "scopedtransaction.h" const char *Database::kDatabaseFilename = "strawberry.db"; -const int Database::kSchemaVersion = 12; +const int Database::kSchemaVersion = 13; const char *Database::kMagicAllSongsTables = "%allsongstables"; int Database::sNextConnectionId = 1; diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 99cec680..ecce3480 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -161,6 +161,9 @@ # include "tidal/tidalservice.h" # include "settings/tidalsettingspage.h" #endif +#ifdef HAVE_QOBUZ +# include "settings/qobuzsettingspage.h" +#endif #include "internet/internetservices.h" #include "internet/internetservice.h" @@ -185,6 +188,9 @@ # include "moodbar/moodbarproxystyle.h" #endif +#include "smartplaylists/smartplaylistsviewcontainer.h" +#include "smartplaylists/smartplaylistsview.h" + #ifdef Q_OS_WIN # include "windows7thumbbar.h" #endif @@ -257,11 +263,15 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd connect(add_stream_dialog, SIGNAL(accepted()), this, SLOT(AddStreamAccepted())); return add_stream_dialog; }), + smartplaylists_view_(new SmartPlaylistsViewContainer(app, this)), #ifdef HAVE_SUBSONIC subsonic_view_(new InternetSongsView(app_, app->internet_services()->ServiceBySource(Song::Source_Subsonic), SubsonicSettingsPage::kSettingsGroup, SettingsDialog::Page_Subsonic, this)), #endif #ifdef HAVE_TIDAL tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Tidal), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, this)), +#endif +#ifdef HAVE_QOBUZ + qobuz_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Qobuz), QobuzSettingsPage::kSettingsGroup, SettingsDialog::Page_Qobuz, this)), #endif lastfm_import_dialog_(new LastFMImportDialog(app_->lastfm_import(), this)), collection_show_all_(nullptr), @@ -296,6 +306,7 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd playing_widget_(true), doubleclick_addmode_(BehaviourSettingsPage::AddBehaviour_Append), doubleclick_playmode_(BehaviourSettingsPage::PlayBehaviour_Never), + doubleclick_playlist_addmode_(BehaviourSettingsPage::PlaylistAddBehaviour_Play), menu_playmode_(BehaviourSettingsPage::PlayBehaviour_Never), exit_count_(0), delete_files_(false) @@ -321,9 +332,10 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd // Add tabs to the fancy tab widget ui_->tabs->AddTab(context_view_, "context", IconLoader::Load("strawberry"), tr("Context")); ui_->tabs->AddTab(collection_view_, "collection", IconLoader::Load("library-music"), tr("Collection")); - ui_->tabs->AddTab(file_view_, "files", IconLoader::Load("document-open"), tr("Files")); - ui_->tabs->AddTab(playlist_list_, "playlists", IconLoader::Load("view-media-playlist"), tr("Playlists")); ui_->tabs->AddTab(queue_view_, "queue", IconLoader::Load("footsteps"), tr("Queue")); + ui_->tabs->AddTab(playlist_list_, "playlists", IconLoader::Load("view-media-playlist"), tr("Playlists")); + ui_->tabs->AddTab(smartplaylists_view_, "smartplaylists", IconLoader::Load("view-media-playlist"), tr("Smart playlists")); + ui_->tabs->AddTab(file_view_, "files", IconLoader::Load("document-open"), tr("Files")); #ifndef Q_OS_WIN ui_->tabs->AddTab(device_view_, "devices", IconLoader::Load("device"), tr("Devices")); #endif @@ -333,6 +345,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd #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 // Add the playing widget to the fancy tab widget ui_->tabs->addBottomWidget(ui_->widget_playing); @@ -644,6 +659,13 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd connect(this, SIGNAL(AuthorizationUrlReceived(QUrl)), tidalservice, SLOT(AuthorizationUrlReceived(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 + // Playlist menu connect(playlist_menu_, SIGNAL(aboutToHide()), SLOT(PlaylistMenuHidden())); playlist_play_pause_ = playlist_menu_->addAction(tr("Play"), this, SLOT(PlaylistPlay())); @@ -829,6 +851,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd connect(app_->playlist_manager()->sequence(), SIGNAL(RepeatModeChanged(PlaylistSequence::RepeatMode)), osd_, SLOT(RepeatModeChanged(PlaylistSequence::RepeatMode))); connect(app_->playlist_manager()->sequence(), SIGNAL(ShuffleModeChanged(PlaylistSequence::ShuffleMode)), osd_, SLOT(ShuffleModeChanged(PlaylistSequence::ShuffleMode))); + // Smart playlists + connect(smartplaylists_view_, SIGNAL(AddToPlaylist(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); + ScrobbleButtonVisibilityChanged(app_->scrobbler()->ScrobbleButton()); LoveButtonVisibilityChanged(app_->scrobbler()->LoveButton()); ScrobblingEnabledChanged(app_->scrobbler()->IsEnabled()); @@ -849,8 +874,8 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSDBase *osd restoreGeometry(settings_.value("geometry").toByteArray()); } - if (!ui_->splitter->restoreState(settings_.value("splitter_state").toByteArray())) { - ui_->splitter->setSizes(QList() << 250 << width() - 250); + if (!settings_.contains("splitter_state") || !ui_->splitter->restoreState(settings_.value("splitter_state").toByteArray())) { + ui_->splitter->setSizes(QList() << 20 << (width() - 20)); } ui_->tabs->setCurrentIndex(settings_.value("current_tab", 1).toInt()); @@ -981,9 +1006,9 @@ void MainWindow::ReloadSettings() { playing_widget_ = s.value("playing_widget", true).toBool(); if (playing_widget_ != ui_->widget_playing->IsEnabled()) TabSwitched(); doubleclick_addmode_ = BehaviourSettingsPage::AddBehaviour(s.value("doubleclick_addmode", BehaviourSettingsPage::AddBehaviour_Append).toInt()); - doubleclick_playmode_ = BehaviourSettingsPage::PlayBehaviour(s.value("doubleclick_playmode", BehaviourSettingsPage::PlayBehaviour_IfStopped).toInt()); - doubleclick_playlist_addmode_ = BehaviourSettingsPage::PlaylistAddBehaviour(s.value("doubleclick_playlist_addmode", BehaviourSettingsPage::PlaylistAddBehaviour_Play).toInt()); - menu_playmode_ = BehaviourSettingsPage::PlayBehaviour(s.value("menu_playmode", BehaviourSettingsPage::PlayBehaviour_IfStopped).toInt()); + doubleclick_playmode_ = BehaviourSettingsPage::PlayBehaviour(s.value("doubleclick_playmode", BehaviourSettingsPage::PlayBehaviour_Never).toInt()); + doubleclick_playlist_addmode_ = BehaviourSettingsPage::PlaylistAddBehaviour(s.value("doubleclick_playlist_addmode", BehaviourSettingsPage::PlayBehaviour_Never).toInt()); + menu_playmode_ = BehaviourSettingsPage::PlayBehaviour(s.value("menu_playmode", BehaviourSettingsPage::PlayBehaviour_Never).toInt()); s.endGroup(); s.beginGroup(AppearanceSettingsPage::kSettingsGroup); @@ -1039,6 +1064,16 @@ void MainWindow::ReloadSettings() { ui_->tabs->DisableTab(tidal_view_); #endif +#ifdef HAVE_QOBUZ + s.beginGroup(QobuzSettingsPage::kSettingsGroup); + bool enable_qobuz = s.value("enabled", false).toBool(); + s.endGroup(); + if (enable_qobuz) + ui_->tabs->EnableTab(qobuz_view_); + else + ui_->tabs->DisableTab(qobuz_view_); +#endif + ui_->tabs->ReloadSettings(); } @@ -1061,6 +1096,7 @@ void MainWindow::ReloadAllSettings() { file_view_->ReloadSettings(); queue_view_->ReloadSettings(); playlist_list_->ReloadSettings(); + smartplaylists_view_->ReloadSettings(); app_->cover_providers()->ReloadSettings(); app_->lyrics_providers()->ReloadSettings(); #ifdef HAVE_SUBSONIC @@ -1069,6 +1105,9 @@ void MainWindow::ReloadAllSettings() { #ifdef HAVE_TIDAL tidal_view_->ReloadSettings(); #endif +#ifdef HAVE_QOBUZ + qobuz_view_->ReloadSettings(); +#endif } diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index 770b6d30..7398a412 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -93,6 +93,7 @@ class TranscodeDialog; class Ui_MainWindow; class InternetSongsView; class InternetTabsView; +class SmartPlaylistsViewContainer; #ifdef Q_OS_WIN class Windows7ThumbBar; #endif @@ -325,8 +326,11 @@ class MainWindow : public QMainWindow, public PlatformInterface { std::unique_ptr track_selection_dialog_; PlaylistItemList autocomplete_tag_items_; + SmartPlaylistsViewContainer *smartplaylists_view_; + InternetSongsView *subsonic_view_; InternetTabsView *tidal_view_; + InternetTabsView *qobuz_view_; LastFMImportDialog *lastfm_import_dialog_; diff --git a/src/core/metatypes.cpp b/src/core/metatypes.cpp index aaa54986..46a00ff2 100644 --- a/src/core/metatypes.cpp +++ b/src/core/metatypes.cpp @@ -69,6 +69,8 @@ #include "internet/internetsearchview.h" +#include "smartplaylists/playlistgenerator_fwd.h" + void RegisterMetaTypes() { qRegisterMetaType("const char*"); @@ -136,4 +138,6 @@ void RegisterMetaTypes() { qRegisterMetaType("InternetSearchView::ResultList"); qRegisterMetaType("InternetSearchView::Result"); + qRegisterMetaType("PlaylistGeneratorPtr"); + } diff --git a/src/core/song.cpp b/src/core/song.cpp index 9b8c9a65..130c75d9 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -125,6 +125,8 @@ const QStringList Song::kColumns = QStringList() << "title" << "cue_path" + << "rating" + ; const QString Song::kColumnSpec = Song::kColumns.join(", "); @@ -217,6 +219,8 @@ struct Song::Private : public QSharedData { QString cue_path_; // If the song has a CUE, this contains it's path. + float rating_; // Database rating, not read from tags. + QUrl stream_url_; // Temporary stream url set by url handler. QImage image_; // Album Cover image set by album cover loader. bool init_from_file_; // Whether this song was loaded from a file using taglib. @@ -259,6 +263,8 @@ Song::Private::Private(Song::Source source) compilation_on_(false), compilation_off_(false), + rating_(-1), + init_from_file_(false), suspicious_tags_(false) @@ -347,6 +353,8 @@ const QImage &Song::image() const { return d->image_; } const QString &Song::cue_path() const { return d->cue_path_; } bool Song::has_cue() const { return !d->cue_path_.isEmpty(); } +float Song::rating() const { return d->rating_; } + bool Song::is_collection_song() const { return d->source_ == Source_Collection; } bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); } bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal || d->source_ == Source_Subsonic || d->source_ == Source_Qobuz; } @@ -444,6 +452,8 @@ void Song::set_art_automatic(const QUrl &v) { d->art_automatic_ = v; } void Song::set_art_manual(const QUrl &v) { d->art_manual_ = v; } void Song::set_cue_path(const QString &v) { d->cue_path_ = v; } +void Song::set_rating(float v) { d->rating_ = v; } + void Song::set_stream_url(const QUrl &v) { d->stream_url_ = v; } void Song::set_image(const QImage &i) { d->image_ = i; } @@ -1000,6 +1010,10 @@ void Song::InitFromQuery(const SqlRow &q, bool reliable_metadata, int col) { d->cue_path_ = tostr(x); } + else if (Song::kColumns.value(i) == "rating") { + d->rating_ = tofloat(x); + } + else { qLog(Error) << "Forgot to handle" << Song::kColumns.value(i); } @@ -1363,6 +1377,8 @@ void Song::BindToQuery(QSqlQuery *query) const { query->bindValue(":cue_path", d->cue_path_); + query->bindValue(":rating", intval(d->rating_)); + #undef intval #undef notnullintval #undef strval @@ -1441,6 +1457,16 @@ QString Song::SampleRateBitDepthToText() const { } +QString Song::PrettyRating() const { + + float rating = d->rating_; + + if (rating == -1.0f) return "0"; + + return QString::number(static_cast(rating * 100)); + +} + bool Song::IsMetadataEqual(const Song &other) const { return d->title_ == other.d->title_ && @@ -1547,5 +1573,6 @@ void Song::MergeUserSetData(const Song &other) { set_art_manual(other.art_manual()); set_compilation_on(other.compilation_on()); set_compilation_off(other.compilation_off()); + set_rating(other.rating()); } diff --git a/src/core/song.h b/src/core/song.h index 9ee81917..a34adbf7 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -245,6 +245,8 @@ class Song { const QString &cue_path() const; bool has_cue() const; + float rating() const; + const QString &effective_album() const; int effective_originalyear() const; const QString &effective_albumartist() const; @@ -288,6 +290,8 @@ class Song { QString SampleRateBitDepthToText() const; + QString PrettyRating() const; + // Setters bool IsEditable() const; @@ -346,6 +350,8 @@ class Song { void set_cue_path(const QString &v); + void set_rating(const float v); + void set_stream_url(const QUrl &v); void set_image(const QImage &i); diff --git a/src/covermanager/qobuzcoverprovider.cpp b/src/covermanager/qobuzcoverprovider.cpp index e9f7b65d..dc15d4a7 100644 --- a/src/covermanager/qobuzcoverprovider.cpp +++ b/src/covermanager/qobuzcoverprovider.cpp @@ -37,40 +37,24 @@ #include #include #include -#include #include #include "core/application.h" #include "core/network.h" #include "core/logging.h" #include "core/song.h" -#include "core/utilities.h" -#include "dialogs/userpassdialog.h" +#include "internet/internetservices.h" +#include "qobuz/qobuzservice.h" +#include "qobuz/qobuzbaserequest.h" #include "albumcoverfetcher.h" #include "jsoncoverprovider.h" #include "qobuzcoverprovider.h" -const char *QobuzCoverProvider::kSettingsGroup = "Qobuz"; -const char *QobuzCoverProvider::kAuthUrl = "https://www.qobuz.com/api.json/0.2/user/login"; -const char *QobuzCoverProvider::kApiUrl = "https://www.qobuz.com/api.json/0.2"; -const char *QobuzCoverProvider::kAppID = "OTQyODUyNTY3"; const int QobuzCoverProvider::kLimit = 10; -QobuzCoverProvider::QobuzCoverProvider(Application *app, QObject *parent) : JsonCoverProvider("Qobuz", true, true, 2.0, true, true, app, parent), network_(new NetworkAccessManager(this)) { - - QSettings s; - s.beginGroup(kSettingsGroup); - username_ = s.value("username").toString(); - QByteArray password = s.value("password").toByteArray(); - if (password.isEmpty()) password_.clear(); - else password_ = QString::fromUtf8(QByteArray::fromBase64(password)); - user_auth_token_ = s.value("user_auth_token").toString(); - user_id_ = s.value("user_id").toLongLong(); - credential_id_ = s.value("credential_id").toLongLong(); - device_id_ = s.value("device_id").toString(); - s.endGroup(); - -} +QobuzCoverProvider::QobuzCoverProvider(Application *app, QObject *parent) : JsonCoverProvider("Qobuz", true, true, 2.0, true, true, app, parent), + service_(app->internet_services()->Service()), + network_(new NetworkAccessManager(this)) {} QobuzCoverProvider::~QobuzCoverProvider() { @@ -83,221 +67,6 @@ QobuzCoverProvider::~QobuzCoverProvider() { } -void QobuzCoverProvider::Authenticate() { - - login_errors_.clear(); - - if (username_.isEmpty() || password_.isEmpty()) { - UserPassDialog dialog; - if (dialog.exec() == QDialog::Rejected || dialog.username().isEmpty() || dialog.password().isEmpty()) { - AuthError(tr("Missing username and password.")); - return; - } - username_ = dialog.username(); - password_ = dialog.password(); - } - - const ParamList params = ParamList() << Param("app_id", QString::fromUtf8(QByteArray::fromBase64(kAppID))) - << Param("username", username_) - << Param("password", password_) - << Param("device_manufacturer_id", Utilities::MacAddress()); - - QUrlQuery url_query; - for (const Param ¶m : params) { - url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); - } - - QUrl url(kAuthUrl); - QNetworkRequest req(url); -#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) - req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); -#else - req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); -#endif - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - - QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); - QNetworkReply *reply = network_->post(req, query); - replies_ << reply; - connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(HandleLoginSSLErrors(QList))); - connect(reply, &QNetworkReply::finished, [=] { HandleAuthReply(reply); }); - -} - -void QobuzCoverProvider::HandleAuthReply(QNetworkReply *reply) { - - if (!replies_.contains(reply)) return; - replies_.removeAll(reply); - disconnect(reply, nullptr, this, nullptr); - reply->deleteLater(); - - if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { - if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { - // This is a network error, there is nothing more to do. - AuthError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); - return; - } - else { - // See if there is Json data containing "status", "code" and "message" - then use that instead. - QByteArray data(reply->readAll()); - QJsonParseError json_error; - QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); - if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) { - QJsonObject json_obj = json_doc.object(); - if (!json_obj.isEmpty() && json_obj.contains("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(); - login_errors_ << QString("%1 (%2)").arg(message).arg(code); - if (code == 401) { - username_.clear(); - password_.clear(); - } - } - } - if (login_errors_.isEmpty()) { - if (reply->error() != QNetworkReply::NoError) { - login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); - if(reply->error() == QNetworkReply::AuthenticationRequiredError) { - username_.clear(); - password_.clear(); - } - } - else { - login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); - } - } - AuthError(); - return; - } - } - - login_errors_.clear(); - - QByteArray data = reply->readAll(); - QJsonParseError json_error; - QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); - - if (json_error.error != QJsonParseError::NoError) { - AuthError("Authentication reply from server missing Json data."); - return; - } - - if (json_doc.isEmpty()) { - AuthError("Authentication reply from server has empty Json document."); - return; - } - - if (!json_doc.isObject()) { - AuthError("Authentication reply from server has Json document that is not an object.", json_doc); - return; - } - - QJsonObject json_obj = json_doc.object(); - if (json_obj.isEmpty()) { - AuthError("Authentication reply from server has empty Json object.", json_doc); - return; - } - - if (!json_obj.contains("user_auth_token")) { - AuthError("Authentication reply from server is missing user_auth_token", json_obj); - return; - } - user_auth_token_ = json_obj["user_auth_token"].toString(); - - if (!json_obj.contains("user")) { - AuthError("Authentication reply from server is missing user", json_obj); - return; - } - QJsonValue json_user = json_obj["user"]; - if (!json_user.isObject()) { - AuthError("Authentication reply user is not a object", json_obj); - return; - } - QJsonObject json_obj_user = json_user.toObject(); - - if (!json_obj_user.contains("id")) { - AuthError("Authentication reply from server is missing user id", json_obj_user); - return; - } - user_id_ = json_obj_user["id"].toVariant().toLongLong(); - - if (!json_obj_user.contains("device")) { - AuthError("Authentication reply from server is missing user device", json_obj_user); - return; - } - QJsonValue json_device = json_obj_user["device"]; - if (!json_device.isObject()) { - AuthError("Authentication reply from server user device is not a object", json_device); - return; - } - QJsonObject json_obj_device = json_device.toObject(); - - if (!json_obj_device.contains("device_manufacturer_id")) { - AuthError("Authentication reply from server device is missing device_manufacturer_id", json_obj_device); - return; - } - device_id_ = json_obj_device["device_manufacturer_id"].toString(); - - if (!json_obj_user.contains("credential")) { - AuthError("Authentication reply from server is missing user credential", json_obj_user); - return; - } - QJsonValue json_credential = json_obj_user["credential"]; - if (!json_credential.isObject()) { - AuthError("Authentication reply from serve userr credential is not a object", json_device); - return; - } - QJsonObject json_obj_credential = json_credential.toObject(); - - if (!json_obj_credential.contains("id")) { - AuthError("Authentication reply user credential from server is missing user credential id", json_obj_device); - return; - } - //credential_id_ = json_obj_credential["id"].toInt(); - - QSettings s; - s.beginGroup(kSettingsGroup); - s.setValue("username", username_); - s.setValue("password", password_.toUtf8().toBase64()); - s.setValue("user_auth_token", user_auth_token_); - s.setValue("user_id", user_id_); - s.setValue("credential_id", credential_id_); - s.setValue("device_id", device_id_); - s.endGroup(); - - qLog(Debug) << "Qobuz: Login successful" << "user id" << user_id_ << "user auth token" << user_auth_token_ << "device id" << device_id_; - - emit AuthenticationComplete(true); - emit AuthenticationSuccess(); - -} - -void QobuzCoverProvider::Deauthenticate() { - - user_auth_token_.clear(); - user_id_ = 0; - credential_id_ = 0; - device_id_.clear(); - - QSettings s; - s.beginGroup(kSettingsGroup); - s.remove("user_auth_token"); - s.remove("user_id"); - s.remove("credential_id"); - s.remove("device_id"); - s.endGroup(); - -} - -void QobuzCoverProvider::HandleLoginSSLErrors(QList ssl_errors) { - - for (const QSslError &ssl_error : ssl_errors) { - login_errors_ += ssl_error.errorString(); - } - -} - bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) { if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false; @@ -319,7 +88,7 @@ bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album ParamList params = ParamList() << Param("query", query) << Param("limit", QString::number(kLimit)) - << Param("app_id", QString::fromUtf8(QByteArray::fromBase64(kAppID))); + << Param("app_id", service_->app_id().toUtf8()); std::sort(params.begin(), params.end()); @@ -328,7 +97,7 @@ bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); } - QUrl url(kApiUrl + QString("/") + resource); + QUrl url(QobuzBaseRequest::kApiUrl + QString("/") + resource); url.setQuery(url_query); QNetworkRequest req(url); @@ -338,7 +107,7 @@ bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); #endif req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); - req.setRawHeader("X-App-Id", kAppID); + req.setRawHeader("X-App-Id", service_->app_id().toUtf8()); req.setRawHeader("X-User-Auth-Token", user_auth_token_.toUtf8()); QNetworkReply *reply = network_->get(req); replies_ << reply; @@ -517,20 +286,6 @@ void QobuzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) { } -void QobuzCoverProvider::AuthError(const QString &error, const QVariant &debug) { - - if (!error.isEmpty()) login_errors_ << error; - - for (const QString &e : login_errors_) Error(e); - if (debug.isValid()) qLog(Debug) << debug; - - emit AuthenticationFailure(login_errors_); - emit AuthenticationComplete(false, login_errors_); - - login_errors_.clear(); - -} - void QobuzCoverProvider::Error(const QString &error, const QVariant &debug) { qLog(Error) << "Qobuz:" << error; diff --git a/src/covermanager/qobuzcoverprovider.h b/src/covermanager/qobuzcoverprovider.h index a2e92e22..8b161539 100644 --- a/src/covermanager/qobuzcoverprovider.h +++ b/src/covermanager/qobuzcoverprovider.h @@ -31,10 +31,12 @@ #include #include "jsoncoverprovider.h" +#include "qobuz/qobuzservice.h" class QNetworkAccessManager; class QNetworkReply; class Application; +class QobuzService; class QobuzCoverProvider : public JsonCoverProvider { Q_OBJECT @@ -46,32 +48,23 @@ class QobuzCoverProvider : public JsonCoverProvider { bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override; void CancelSearch(const int id) override; - void Authenticate() override; - void Deauthenticate() override; - bool IsAuthenticated() const override { return !user_auth_token_.isEmpty(); } - - private slots: - void HandleLoginSSLErrors(QList ssl_errors); - void HandleAuthReply(QNetworkReply *reply); + bool IsAuthenticated() const override { return service_ && service_->authenticated(); } + void Deauthenticate() override { if (service_) service_->Logout(); } private slots: void HandleSearchReply(QNetworkReply *reply, const int id); private: QByteArray GetReplyData(QNetworkReply *reply); - void AuthError(const QString &error = QString(), const QVariant &debug = QVariant()); void Error(const QString &error, const QVariant &debug = QVariant()) override; private: typedef QPair Param; typedef QList ParamList; - static const char *kSettingsGroup; - static const char *kAuthUrl; - static const char *kApiUrl; - static const char *kAppID; static const int kLimit; + QobuzService *service_; QNetworkAccessManager *network_; QList replies_; diff --git a/src/device/devicedatabasebackend.cpp b/src/device/devicedatabasebackend.cpp index abcb5663..0c689e43 100644 --- a/src/device/devicedatabasebackend.cpp +++ b/src/device/devicedatabasebackend.cpp @@ -37,7 +37,7 @@ #include "core/scopedtransaction.h" #include "devicedatabasebackend.h" -const int DeviceDatabaseBackend::kDeviceSchemaVersion = 1; +const int DeviceDatabaseBackend::kDeviceSchemaVersion = 2; DeviceDatabaseBackend::DeviceDatabaseBackend(QObject *parent) : QObject(parent), diff --git a/src/playlist/dynamicplaylistcontrols.cpp b/src/playlist/dynamicplaylistcontrols.cpp new file mode 100644 index 00000000..ef0b9de6 --- /dev/null +++ b/src/playlist/dynamicplaylistcontrols.cpp @@ -0,0 +1,31 @@ +/* This file is part of Clementine. + Copyright 2010, David Sansome + + Clementine 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. + + Clementine 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 Clementine. If not, see . +*/ + +#include "dynamicplaylistcontrols.h" +#include "ui_dynamicplaylistcontrols.h" + +DynamicPlaylistControls::DynamicPlaylistControls(QWidget *parent) + : QWidget(parent), ui_(new Ui_DynamicPlaylistControls) { + + ui_->setupUi(this); + + connect(ui_->expand, SIGNAL(clicked()), SIGNAL(Expand())); + connect(ui_->repopulate, SIGNAL(clicked()), SIGNAL(Repopulate())); + connect(ui_->off, SIGNAL(clicked()), SIGNAL(TurnOff())); +} + +DynamicPlaylistControls::~DynamicPlaylistControls() { delete ui_; } diff --git a/src/playlist/dynamicplaylistcontrols.h b/src/playlist/dynamicplaylistcontrols.h new file mode 100644 index 00000000..45fa1d8e --- /dev/null +++ b/src/playlist/dynamicplaylistcontrols.h @@ -0,0 +1,41 @@ +/* This file is part of Clementine. + Copyright 2010, David Sansome + + Clementine 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. + + Clementine 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 Clementine. If not, see . +*/ + +#ifndef DYNAMICPLAYLISTCONTROLS_H +#define DYNAMICPLAYLISTCONTROLS_H + +#include + +class Ui_DynamicPlaylistControls; + +class DynamicPlaylistControls : public QWidget { + Q_OBJECT + + public: + DynamicPlaylistControls(QWidget *parent = nullptr); + ~DynamicPlaylistControls(); + + signals: + void Expand(); + void Repopulate(); + void TurnOff(); + + private: + Ui_DynamicPlaylistControls* ui_; +}; + +#endif // DYNAMICPLAYLISTCONTROLS_H diff --git a/src/playlist/dynamicplaylistcontrols.ui b/src/playlist/dynamicplaylistcontrols.ui new file mode 100644 index 00000000..3ad3e8cb --- /dev/null +++ b/src/playlist/dynamicplaylistcontrols.ui @@ -0,0 +1,99 @@ + + + DynamicPlaylistControls + + + + 0 + 0 + 483 + 54 + + + + #container { + background: rgba(200, 200, 200, 50%); + border-radius: 10px; + border: 1px solid rgba(200, 200, 200, 75%); +} + +#label1 { + font-weight: bold; +} + +#label2 { + font-size: 7.5pt; +} + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + 0 + + + + + Dynamic mode is on + + + + + + + New tracks will be added automatically. + + + + + + + + + Expand + + + + + + + Repopulate + + + + + + + Turn off + + + + + + + + + + + diff --git a/src/playlist/playlist.cpp b/src/playlist/playlist.cpp index cd4afed9..140728c8 100644 --- a/src/playlist/playlist.cpp +++ b/src/playlist/playlist.cpp @@ -84,6 +84,10 @@ #include "songplaylistitem.h" #include "tagreadermessages.pb.h" +#include "smartplaylists/playlistgenerator.h" +#include "smartplaylists/playlistgeneratorinserter.h" +#include "smartplaylists/playlistgeneratormimedata.h" + #include "internet/internetplaylistitem.h" #include "internet/internetsongmimedata.h" @@ -271,6 +275,9 @@ QVariant Playlist::data(const QModelIndex &idx, int role) const { case Role_QueuePosition: return queue_->PositionOf(idx); + case Role_CanSetRating: + return idx.column() == Column_Rating && items_[idx.row()]->IsLocalCollectionItem() && items_[idx.row()]->Metadata().id() != -1; + case Qt::EditRole: case Qt::ToolTipRole: case Qt::DisplayRole: { @@ -314,6 +321,8 @@ QVariant Playlist::data(const QModelIndex &idx, int role) const { case Column_Source: return song.source(); + case Column_Rating: return song.rating(); + } return QVariant(); @@ -331,6 +340,9 @@ QVariant Playlist::data(const QModelIndex &idx, int role) const { if (items_[idx.row()]->HasCurrentForegroundColor()) { return QBrush(items_[idx.row()]->GetCurrentForegroundColor()); } + if (idx.row() < dynamic_history_length()) { + return QBrush(kDynamicHistoryColor); + } return QVariant(); @@ -634,6 +646,35 @@ void Playlist::set_current_row(const int i, const AutoScroll autoscroll, const b InformOfCurrentSongChange(autoscroll); } + // The structure of a dynamic playlist is as follows: + // history - active song - future + // We have to ensure that this invariant is maintained. + if (dynamic_playlist_ && current_item_index_.isValid()) { + + // When advancing to the next track + if (i > old_current_item_index.row()) { + // Move the new item one position ahead of the last item in the history. + MoveItemWithoutUndo(current_item_index_.row(), dynamic_history_length()); + + // Compute the number of new items that have to be inserted. This is not + // necessarily 1 because the user might have added or removed items + // manually. Note that the future excludes the current item. + const int count = dynamic_history_length() + 1 + dynamic_playlist_->GetDynamicFuture() - items_.count(); + if (count > 0) { + InsertDynamicItems(count); + } + + // Shrink the history, again this is not necessarily by 1, because the + // user might have moved items by hand. + const int remove_count = dynamic_history_length() - dynamic_playlist_->GetDynamicHistory(); + if (0 < remove_count) RemoveItemsWithoutUndo(0, remove_count); + } + + // the above actions make all commands on the undo stack invalid, so we + // better clear it. + undo_stack_->clear(); + } + if (current_item_index_.isValid()) { last_played_item_index_ = current_item_index_; Save(); @@ -643,6 +684,16 @@ void Playlist::set_current_row(const int i, const AutoScroll autoscroll, const b } +void Playlist::InsertDynamicItems(const int count) { + + PlaylistGeneratorInserter* inserter = new PlaylistGeneratorInserter(task_manager_, collection_, this); + connect(inserter, SIGNAL(Error(QString)), SIGNAL(Error(QString))); + connect(inserter, SIGNAL(PlayRequested(QModelIndex)), SIGNAL(PlayRequested(QModelIndex))); + + inserter->Load(this, -1, false, false, false, dynamic_playlist_, count); + +} + Qt::ItemFlags Playlist::flags(const QModelIndex &idx) const { if (idx.isValid()) { @@ -697,6 +748,9 @@ bool Playlist::dropMimeData(const QMimeData *data, Qt::DropAction action, int ro else if (const InternetSongMimeData* internet_song_data = qobject_cast(data)) { InsertInternetItems(internet_song_data->service, internet_song_data->songs, row, play_now, enqueue_now, enqueue_next_now); } + else if (const PlaylistGeneratorMimeData *generator_data = qobject_cast(data)) { + InsertSmartPlaylist(generator_data->generator_, row, play_now, enqueue_now, enqueue_next_now); + } else if (data->hasFormat(kRowsMimetype)) { // Dragged from the playlist // Rearranging it is tricky... @@ -768,6 +822,34 @@ void Playlist::InsertUrls(const QList &urls, const int pos, const bool pla } +void Playlist::InsertSmartPlaylist(PlaylistGeneratorPtr generator, const int pos, const bool play_now, const bool enqueue, const bool enqueue_next) { + + // Hack: If the generator hasn't got a collection set then use the main one + if (!generator->collection()) { + generator->set_collection(collection_); + } + + PlaylistGeneratorInserter *inserter = new PlaylistGeneratorInserter(task_manager_, collection_, this); + connect(inserter, SIGNAL(Error(QString)), SIGNAL(Error(QString))); + + inserter->Load(this, pos, play_now, enqueue, enqueue_next, generator); + + if (generator->is_dynamic()) { + TurnOnDynamicPlaylist(generator); + } + +} + +void Playlist::TurnOnDynamicPlaylist(PlaylistGeneratorPtr gen) { + + dynamic_playlist_ = gen; + playlist_sequence_->SetUsingDynamicPlaylist(true); + ShuffleModeChanged(PlaylistSequence::Shuffle_Off); + emit DynamicModeChanged(true); + Save(); + +} + void Playlist::MoveItemWithoutUndo(const int source, const int dest) { MoveItemsWithoutUndo(QList() << source, dest); } @@ -1139,6 +1221,9 @@ bool Playlist::CompareItems(const int column, const Qt::SortOrder order, std::sh case Column_Comment: strcmp(comment); case Column_Source: cmp(source); + + case Column_Rating: cmp(rating); + default: qLog(Error) << "No such column" << column; } @@ -1196,6 +1281,7 @@ QString Playlist::column_name(Column column) { case Column_Comment: return tr("Comment"); case Column_Source: return tr("Source"); case Column_Mood: return tr("Mood"); + case Column_Rating: return tr("Rating"); default: qLog(Error) << "No such column" << column;; } return ""; @@ -1226,6 +1312,9 @@ void Playlist::sort(int column, Qt::SortOrder order) { PlaylistItemList new_items(items_); PlaylistItemList::iterator begin = new_items.begin(); + if (dynamic_playlist_ && current_item_index_.isValid()) + begin += current_item_index_.row() + 1; + if (column == Column_Album) { // When sorting by album, also take into account discs and tracks. std::stable_sort(begin, new_items.end(), std::bind(&Playlist::CompareItems, Column_Track, order, _1, _2)); @@ -1291,7 +1380,7 @@ void Playlist::Save() const { if (!backend_ || is_loading_) return; - backend_->SavePlaylistAsync(id_, items_, last_played_row()); + backend_->SavePlaylistAsync(id_, items_, last_played_row(), dynamic_playlist_); } @@ -1334,6 +1423,22 @@ void Playlist::ItemsLoaded(QFuture future) { // The newly loaded list of items might be shorter than it was before so look out for a bad last_played index last_played_item_index_ = p.last_played == -1 || p.last_played >= rowCount() ? QModelIndex() : index(p.last_played); + if (p.dynamic_type == PlaylistGenerator::Type_Query) { + PlaylistGeneratorPtr gen = PlaylistGenerator::Create(p.dynamic_type); + if (gen) { + + CollectionBackend *backend = nullptr; + if (p.dynamic_backend == collection_->songs_table()) backend = collection_; + + if (backend) { + gen->set_collection(backend); + gen->Load(p.dynamic_data); + TurnOnDynamicPlaylist(gen); + } + + } + } + emit RestoreFinished(); QSettings s; @@ -1572,10 +1677,29 @@ void Playlist::Clear() { undo_stack_->push(new PlaylistUndoCommands::RemoveItems(this, 0, count)); } + TurnOffDynamicPlaylist(); + Save(); } +void Playlist::RepopulateDynamicPlaylist() { + + if (!dynamic_playlist_) return; + + RemoveItemsNotInQueue(); + InsertSmartPlaylist(dynamic_playlist_); + +} + +void Playlist::ExpandDynamicPlaylist() { + + if (!dynamic_playlist_) return; + + InsertDynamicItems(5); + +} + void Playlist::RemoveItemsNotInQueue() { if (queue_->is_empty() && !current_item_index_.isValid()) { @@ -1650,6 +1774,9 @@ void Playlist::Shuffle() { begin = 1; } + if (dynamic_playlist_ && current_item_index_.isValid()) + begin += current_item_index_.row() + 1; + const int count = items_.count(); for (int i = begin; i < count; ++i) { int new_pos = i + (rand() % (count - i)); @@ -2036,3 +2163,48 @@ void Playlist::AlbumCoverLoaded(const Song &song, const AlbumCoverLoaderResult & } } + +int Playlist::dynamic_history_length() const { + return dynamic_playlist_ && last_played_item_index_.isValid() ? last_played_item_index_.row() + 1 : 0; +} + +void Playlist::TurnOffDynamicPlaylist() { + + dynamic_playlist_.reset(); + + if (playlist_sequence_) { + playlist_sequence_->SetUsingDynamicPlaylist(false); + ShuffleModeChanged(playlist_sequence_->shuffle_mode()); + } + + emit DynamicModeChanged(false); + Save(); + +} + +void Playlist::RateSong(const QModelIndex &idx, const double rating) { + + if (has_item_at(idx.row())) { + PlaylistItemPtr item = item_at(idx.row()); + if (item && item->IsLocalCollectionItem() && item->Metadata().id() != -1) { + collection_->UpdateSongRatingAsync(item->Metadata().id(), rating); + } + } + +} + +void Playlist::RateSongs(const QModelIndexList &index_list, const double rating) { + + QList id_list; + for (const QModelIndex &idx : index_list) { + const int row = idx.row(); + if (has_item_at(row)) { + PlaylistItemPtr item = item_at(row); + if (item && item->IsLocalCollectionItem() && item->Metadata().id() != -1) { + id_list << item->Metadata().id(); + } + } + } + collection_->UpdateSongsRatingAsync(id_list, rating); + +} diff --git a/src/playlist/playlist.h b/src/playlist/playlist.h index 4c5286ef..6037b913 100644 --- a/src/playlist/playlist.h +++ b/src/playlist/playlist.h @@ -46,6 +46,7 @@ #include "covermanager/albumcoverloaderresult.h" #include "playlistitem.h" #include "playlistsequence.h" +#include "smartplaylists/playlistgenerator_fwd.h" class QMimeData; class QSortFilterProxyModel; @@ -128,6 +129,7 @@ class Playlist : public QAbstractListModel { Column_Grouping, Column_Source, Column_Mood, + Column_Rating, ColumnCount }; @@ -135,7 +137,8 @@ class Playlist : public QAbstractListModel { Role_IsCurrent = Qt::UserRole + 1, Role_IsPaused, Role_StopAfter, - Role_QueuePosition + Role_QueuePosition, + Role_CanSetRating, }; enum Path { @@ -203,6 +206,8 @@ class Playlist : public QAbstractListModel { const QModelIndex current_index() const; bool stop_after_current() const; + bool is_dynamic() const { return static_cast(dynamic_playlist_); } + int dynamic_history_length() const; QString special_type() const { return special_type_; } void set_special_type(const QString &v) { special_type_ = v; } @@ -240,6 +245,7 @@ class Playlist : public QAbstractListModel { void InsertSongs(const SongList &songs, const int pos = -1, const bool play_now = false, const bool enqueue = false, const bool enqueue_next = false); void InsertSongsOrCollectionItems(const SongList &songs, const int pos = -1, const bool play_now = false, const bool enqueue = false, const bool enqueue_next = false); void InsertInternetItems(InternetService* service, const SongList& songs, const int pos = -1, const bool play_now = false, const bool enqueue = false, const bool enqueue_next = false); + void InsertSmartPlaylist(PlaylistGeneratorPtr gen, const int pos = -1, const bool play_now = false, const bool enqueue = false, const bool enqueue_next = false); void ReshuffleIndices(); @@ -284,6 +290,10 @@ class Playlist : public QAbstractListModel { static bool ComparePathDepths(Qt::SortOrder, PlaylistItemPtr, PlaylistItemPtr); + // Changes rating of a song to the given value asynchronously + void RateSong(const QModelIndex &idx, const double rating); + void RateSongs(const QModelIndexList &index_list, const double rating); + public slots: void set_current_row(const int i, const AutoScroll autoscroll = AutoScroll_Maybe, const bool is_stopping = false); void Paused(); @@ -309,6 +319,10 @@ class Playlist : public QAbstractListModel { // Removes items with given indices from the playlist. This operation is not undoable. void RemoveItemsWithoutUndo(const QList &indicesIn); + void ExpandDynamicPlaylist(); + void RepopulateDynamicPlaylist(); + void TurnOffDynamicPlaylist(); + signals: void RestoreFinished(); void PlaylistLoaded(); @@ -349,6 +363,9 @@ class Playlist : public QAbstractListModel { // Removes rows with given indices from this playlist. bool removeRows(QList &rows); + void TurnOnDynamicPlaylist(PlaylistGeneratorPtr gen); + void InsertDynamicItems(const int count); + private slots: void TracksAboutToBeDequeued(const QModelIndex&, const int begin, const int end); void TracksDequeued(); @@ -412,6 +429,8 @@ class Playlist : public QAbstractListModel { int editing_; + PlaylistGeneratorPtr dynamic_playlist_; + }; #endif // PLAYLIST_H diff --git a/src/playlist/playlistbackend.cpp b/src/playlist/playlistbackend.cpp index bec60579..563d7810 100644 --- a/src/playlist/playlistbackend.cpp +++ b/src/playlist/playlistbackend.cpp @@ -55,6 +55,7 @@ #include "songplaylistitem.h" #include "playlistbackend.h" #include "playlistparsers/cueparser.h" +#include "smartplaylists/playlistgenerator.h" using std::placeholders::_1; @@ -121,7 +122,7 @@ PlaylistBackend::PlaylistList PlaylistBackend::GetPlaylists(GetPlaylistsFlags fl } QSqlQuery q(db); - q.prepare("SELECT ROWID, name, last_played, special_type, ui_path, is_favorite FROM playlists " + condition + " ORDER BY ui_order"); + q.prepare("SELECT ROWID, name, last_played, special_type, ui_path, is_favorite, dynamic_playlist_type, dynamic_playlist_data, dynamic_playlist_backend FROM playlists " + condition + " ORDER BY ui_order"); q.exec(); if (db_->CheckErrors(q)) return ret; @@ -133,6 +134,9 @@ PlaylistBackend::PlaylistList PlaylistBackend::GetPlaylists(GetPlaylistsFlags fl p.special_type = q.value(3).toString(); p.ui_path = q.value(4).toString(); p.favorite = q.value(5).toBool(); + p.dynamic_type = PlaylistGenerator::Type(q.value(6).toInt()); + p.dynamic_data = q.value(7).toByteArray(); + p.dynamic_backend = q.value(8).toString(); ret << p; } @@ -146,7 +150,7 @@ PlaylistBackend::Playlist PlaylistBackend::GetPlaylist(int id) { QSqlDatabase db(db_->Connect()); QSqlQuery q(db); - q.prepare("SELECT ROWID, name, last_played, special_type, ui_path, is_favorite FROM playlists WHERE ROWID=:id"); + q.prepare("SELECT ROWID, name, last_played, special_type, ui_path, is_favorite, dynamic_playlist_type, dynamic_playlist_data, dynamic_playlist_backend FROM playlists WHERE ROWID=:id"); q.bindValue(":id", id); q.exec(); @@ -161,6 +165,9 @@ PlaylistBackend::Playlist PlaylistBackend::GetPlaylist(int id) { p.special_type = q.value(3).toString(); p.ui_path = q.value(4).toString(); p.favorite = q.value(5).toBool(); + p.dynamic_type = PlaylistGenerator::Type(q.value(6).toInt()); + p.dynamic_data = q.value(7).toByteArray(); + p.dynamic_backend = q.value(8).toString(); return p; @@ -315,13 +322,13 @@ PlaylistItemPtr PlaylistBackend::RestoreCueData(PlaylistItemPtr item, std::share } -void PlaylistBackend::SavePlaylistAsync(int playlist, const PlaylistItemList &items, int last_played) { +void PlaylistBackend::SavePlaylistAsync(int playlist, const PlaylistItemList &items, int last_played, PlaylistGeneratorPtr dynamic) { - metaObject()->invokeMethod(this, "SavePlaylist", Qt::QueuedConnection, Q_ARG(int, playlist), Q_ARG(PlaylistItemList, items), Q_ARG(int, last_played)); + metaObject()->invokeMethod(this, "SavePlaylist", Qt::QueuedConnection, Q_ARG(int, playlist), Q_ARG(PlaylistItemList, items), Q_ARG(int, last_played), Q_ARG(PlaylistGeneratorPtr, dynamic)); } -void PlaylistBackend::SavePlaylist(int playlist, const PlaylistItemList &items, int last_played) { +void PlaylistBackend::SavePlaylist(int playlist, const PlaylistItemList &items, int last_played, PlaylistGeneratorPtr dynamic) { QMutexLocker l(db_->Mutex()); QSqlDatabase db(db_->Connect()); @@ -333,7 +340,7 @@ void PlaylistBackend::SavePlaylist(int playlist, const PlaylistItemList &items, QSqlQuery insert(db); insert.prepare("INSERT INTO playlist_items (playlist, type, collection_id, " + Song::kColumnSpec + ") VALUES (:playlist, :type, :collection_id, " + Song::kBindSpec + ")"); QSqlQuery update(db); - update.prepare("UPDATE playlists SET last_played=:last_played WHERE ROWID=:playlist"); + update.prepare("UPDATE playlists SET last_played=:last_played, dynamic_playlist_type=:dynamic_type, dynamic_playlist_data=:dynamic_data, dynamic_playlist_backend=:dynamic_backend WHERE ROWID=:playlist"); ScopedTransaction transaction(&db); @@ -353,6 +360,16 @@ void PlaylistBackend::SavePlaylist(int playlist, const PlaylistItemList &items, // Update the last played track number update.bindValue(":last_played", last_played); + if (dynamic) { + update.bindValue(":dynamic_type", dynamic->type()); + update.bindValue(":dynamic_data", dynamic->Save()); + update.bindValue(":dynamic_backend", dynamic->collection()->songs_table()); + } + else { + update.bindValue(":dynamic_type", 0); + update.bindValue(":dynamic_data", QByteArray()); + update.bindValue(":dynamic_backend", QString()); + } update.bindValue(":playlist", playlist); update.exec(); if (db_->CheckErrors(update)) return; diff --git a/src/playlist/playlistbackend.h b/src/playlist/playlistbackend.h index 4959b996..e1ef4d10 100644 --- a/src/playlist/playlistbackend.h +++ b/src/playlist/playlistbackend.h @@ -37,6 +37,7 @@ #include "core/song.h" #include "collection/sqlrow.h" #include "playlistitem.h" +#include "smartplaylists/playlistgenerator.h" class QThread; class Application; @@ -57,6 +58,9 @@ class PlaylistBackend : public QObject { bool favorite; int last_played; QString special_type; + PlaylistGenerator::Type dynamic_type; + QString dynamic_backend; + QByteArray dynamic_data; }; typedef QList PlaylistList; @@ -77,7 +81,7 @@ class PlaylistBackend : public QObject { void SetPlaylistUiPath(int id, const QString &path); int CreatePlaylist(const QString &name, const QString &special_type); - void SavePlaylistAsync(int playlist, const PlaylistItemList &items, int last_played); + void SavePlaylistAsync(int playlist, const PlaylistItemList &items, int last_played, PlaylistGeneratorPtr dynamic); void RenamePlaylist(int id, const QString &new_name); void FavoritePlaylist(int id, bool is_favorite); void RemovePlaylist(int id); @@ -86,7 +90,7 @@ class PlaylistBackend : public QObject { public slots: void Exit(); - void SavePlaylist(int playlist, const PlaylistItemList &items, int last_played); + void SavePlaylist(int playlist, const PlaylistItemList &items, int last_played, PlaylistGeneratorPtr dynamic); signals: void ExitFinished(); diff --git a/src/playlist/playlistdelegates.cpp b/src/playlist/playlistdelegates.cpp index c25dbc8d..0c980209 100644 --- a/src/playlist/playlistdelegates.cpp +++ b/src/playlist/playlistdelegates.cpp @@ -493,3 +493,40 @@ void SongSourceDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op painter->drawPixmap(draw_rect, pixmap); } + +RatingItemDelegate::RatingItemDelegate(QObject *parent) : PlaylistDelegateBase(parent) {} + +void RatingItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &idx) const { + + // Draw the background + option.widget->style()->drawPrimitive(QStyle::PE_PanelItemViewItem, &option, painter, option.widget); + + // Don't draw anything else if the user can't set the rating of this item + if (!idx.data(Playlist::Role_CanSetRating).toBool()) return; + + const bool hover = mouse_over_index_.isValid() && (mouse_over_index_ == idx || (selected_indexes_.contains(mouse_over_index_) && selected_indexes_.contains(idx))); + + const double rating = (hover ? RatingPainter::RatingForPos(mouse_over_pos_, option.rect) : idx.data().toDouble()); + + painter_.Paint(painter, option.rect, rating); + +} + +QSize RatingItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &idx) const { + + QSize size = PlaylistDelegateBase::sizeHint(option, idx); + size.setWidth(size.height() * RatingPainter::kStarCount); + return size; + +} + +QString RatingItemDelegate::displayText(const QVariant &value, const QLocale&) const { + + if (value.isNull() || value.toDouble() <= 0) return QString(); + + // Round to the nearest 0.5 + const double rating = float(int(value.toDouble() * RatingPainter::kStarCount * 2 + 0.5)) / 2; + + return QString::number(rating, 'f', 1); + +} diff --git a/src/playlist/playlistdelegates.h b/src/playlist/playlistdelegates.h index 123ca3a0..1d29ec83 100644 --- a/src/playlist/playlistdelegates.h +++ b/src/playlist/playlistdelegates.h @@ -51,6 +51,7 @@ #include "playlist.h" #include "core/song.h" +#include "widgets/ratingwidget.h" class CollectionBackend; class Player; @@ -185,4 +186,29 @@ class SongSourceDelegate : public PlaylistDelegateBase { mutable QPixmapCache pixmap_cache_; }; +class RatingItemDelegate : public PlaylistDelegateBase { + public: + RatingItemDelegate(QObject *parent); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &idx) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &idx) const override; + QString displayText(const QVariant &value, const QLocale &locale) const override; + + void set_mouse_over(const QModelIndex &idx, const QModelIndexList &selected_indexes, const QPoint &pos) { + mouse_over_index_ = idx; + selected_indexes_ = selected_indexes; + mouse_over_pos_ = pos; + } + + void set_mouse_out() { mouse_over_index_ = QModelIndex(); } + bool is_mouse_over() const { return mouse_over_index_.isValid(); } + QModelIndex mouse_over_index() const { return mouse_over_index_; } + + private: + RatingPainter painter_; + + QModelIndex mouse_over_index_; + QPoint mouse_over_pos_; + QModelIndexList selected_indexes_; +}; + #endif // PLAYLISTDELEGATES_H diff --git a/src/playlist/playlistheader.cpp b/src/playlist/playlistheader.cpp index 4c4cdaef..afe70332 100644 --- a/src/playlist/playlistheader.cpp +++ b/src/playlist/playlistheader.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -36,25 +37,37 @@ #include "playlistheader.h" #include "playlistview.h" +#include "settings/playlistsettingspage.h" + PlaylistHeader::PlaylistHeader(Qt::Orientation orientation, PlaylistView *view, QWidget *parent) : StretchHeaderView(orientation, parent), view_(view), - menu_(new QMenu(this)) { + menu_(new QMenu(this)), + action_hide_(nullptr), + action_reset_(nullptr), + action_stretch_(nullptr), + action_rating_lock_(nullptr), + action_align_left_(nullptr), + action_align_center_(nullptr), + action_align_right_(nullptr) + { - hide_action_ = menu_->addAction(tr("&Hide..."), this, SLOT(HideCurrent())); - stretch_action_ = menu_->addAction(tr("&Stretch columns to fit window"), this, SLOT(ToggleStretchEnabled())); - reset_action_ = menu_->addAction(tr("&Reset columns to default"), this, SLOT(ResetColumns())); + action_hide_ = menu_->addAction(tr("&Hide..."), this, SLOT(HideCurrent())); + action_stretch_ = menu_->addAction(tr("&Stretch columns to fit window"), this, SLOT(ToggleStretchEnabled())); + action_reset_ = menu_->addAction(tr("&Reset columns to default"), this, SLOT(ResetColumns())); + action_rating_lock_ = menu_->addAction(tr("&Lock rating"), this, SLOT(ToggleRatingEditStatus())); + action_rating_lock_->setCheckable(true); menu_->addSeparator(); QMenu *align_menu = new QMenu(tr("&Align text"), this); QActionGroup *align_group = new QActionGroup(this); - align_left_action_ = new QAction(tr("&Left"), align_group); - align_center_action_ = new QAction(tr("&Center"), align_group); - align_right_action_ = new QAction(tr("&Right"), align_group); + action_align_left_ = new QAction(tr("&Left"), align_group); + action_align_center_ = new QAction(tr("&Center"), align_group); + action_align_right_ = new QAction(tr("&Right"), align_group); - align_left_action_->setCheckable(true); - align_center_action_->setCheckable(true); - align_right_action_->setCheckable(true); + action_align_left_->setCheckable(true); + action_align_center_->setCheckable(true); + action_align_right_->setCheckable(true); align_menu->addActions(align_group->actions()); connect(align_group, SIGNAL(triggered(QAction*)), SLOT(SetColumnAlignment(QAction*))); @@ -62,10 +75,15 @@ PlaylistHeader::PlaylistHeader(Qt::Orientation orientation, PlaylistView *view, menu_->addMenu(align_menu); menu_->addSeparator(); - stretch_action_->setCheckable(true); - stretch_action_->setChecked(is_stretch_enabled()); + action_stretch_->setCheckable(true); + action_stretch_->setChecked(is_stretch_enabled()); - connect(this, SIGNAL(StretchEnabledChanged(bool)), stretch_action_, SLOT(setChecked(bool))); + connect(this, SIGNAL(StretchEnabledChanged(bool)), action_stretch_, SLOT(setChecked(bool))); + + QSettings s; + s.beginGroup(PlaylistSettingsPage::kSettingsGroup); + action_rating_lock_->setChecked(s.value("rating_locked", false).toBool()); + s.endGroup(); } @@ -74,17 +92,21 @@ void PlaylistHeader::contextMenuEvent(QContextMenuEvent *e) { menu_section_ = logicalIndexAt(e->pos()); if (menu_section_ == -1 || (menu_section_ == logicalIndex(0) && logicalIndex(1) == -1)) - hide_action_->setVisible(false); + action_hide_->setVisible(false); else { - hide_action_->setVisible(true); + action_hide_->setVisible(true); QString title(model()->headerData(menu_section_, Qt::Horizontal).toString()); - hide_action_->setText(tr("&Hide %1").arg(title)); + action_hide_->setText(tr("&Hide %1").arg(title)); Qt::Alignment alignment = view_->column_alignment(menu_section_); - if (alignment & Qt::AlignLeft) align_left_action_->setChecked(true); - else if (alignment & Qt::AlignHCenter) align_center_action_->setChecked(true); - else if (alignment & Qt::AlignRight) align_right_action_->setChecked(true); + if (alignment & Qt::AlignLeft) action_align_left_->setChecked(true); + else if (alignment & Qt::AlignHCenter) action_align_center_->setChecked(true); + else if (alignment & Qt::AlignRight) action_align_right_->setChecked(true); + + // Show rating lock action only for ratings section + action_rating_lock_->setVisible(menu_section_ == Playlist::Column_Rating); + } qDeleteAll(show_actions_); @@ -126,9 +148,9 @@ void PlaylistHeader::SetColumnAlignment(QAction *action) { Qt::Alignment alignment = Qt::AlignVCenter; - if (action == align_left_action_) alignment |= Qt::AlignLeft; - if (action == align_center_action_) alignment |= Qt::AlignHCenter; - if (action == align_right_action_) alignment |= Qt::AlignRight; + if (action == action_align_left_) alignment |= Qt::AlignLeft; + if (action == action_align_center_) alignment |= Qt::AlignHCenter; + if (action == action_align_right_) alignment |= Qt::AlignRight; view_->SetColumnAlignment(menu_section_, alignment); @@ -150,3 +172,8 @@ void PlaylistHeader::enterEvent(QEvent*) { void PlaylistHeader::ResetColumns() { view_->ResetHeaderState(); } + +void PlaylistHeader::ToggleRatingEditStatus() { + emit SectionRatingLockStatusChanged(action_rating_lock_->isChecked()); +} + diff --git a/src/playlist/playlistheader.h b/src/playlist/playlistheader.h index e30bc6b6..6ba8fb37 100644 --- a/src/playlist/playlistheader.h +++ b/src/playlist/playlistheader.h @@ -54,12 +54,14 @@ class PlaylistHeader : public StretchHeaderView { signals: void SectionVisibilityChanged(int logical, bool visible); void MouseEntered(); + void SectionRatingLockStatusChanged(bool); private slots: void HideCurrent(); void ToggleVisible(int section); void ResetColumns(); void SetColumnAlignment(QAction *action); + void ToggleRatingEditStatus(); private: void AddColumnAction(int index); @@ -69,12 +71,13 @@ class PlaylistHeader : public StretchHeaderView { int menu_section_; QMenu *menu_; - QAction *hide_action_; - QAction *stretch_action_; - QAction *reset_action_; - QAction *align_left_action_; - QAction *align_center_action_; - QAction *align_right_action_; + QAction *action_hide_; + QAction *action_reset_; + QAction *action_stretch_; + QAction *action_rating_lock_; + QAction *action_align_left_; + QAction *action_align_center_; + QAction *action_align_right_; QList show_actions_; }; diff --git a/src/playlist/playlistmanager.cpp b/src/playlist/playlistmanager.cpp index b4aa2147..c67f1ece 100644 --- a/src/playlist/playlistmanager.cpp +++ b/src/playlist/playlistmanager.cpp @@ -96,6 +96,7 @@ void PlaylistManager::Init(CollectionBackend *collection_backend, PlaylistBacken connect(collection_backend_, SIGNAL(SongsDiscovered(SongList)), SLOT(SongsDiscovered(SongList))); connect(collection_backend_, SIGNAL(SongsStatisticsChanged(SongList)), SLOT(SongsDiscovered(SongList))); + connect(collection_backend_, SIGNAL(SongsRatingChanged(SongList)), SLOT(SongsDiscovered(SongList))); for (const PlaylistBackend::Playlist &p : playlist_backend->GetAllOpenPlaylists()) { ++playlists_loading_; @@ -602,3 +603,26 @@ void PlaylistManager::SetCurrentOrOpen(const int id) { bool PlaylistManager::IsPlaylistOpen(const int id) { return playlists_.contains(id); } + +void PlaylistManager::PlaySmartPlaylist(PlaylistGeneratorPtr generator, bool as_new, bool clear) { + + if (as_new) { + New(generator->name()); + } + + if (clear) { + current()->Clear(); + } + + current()->InsertSmartPlaylist(generator); + +} + +void PlaylistManager::RateCurrentSong(const double rating) { + active()->RateSong(active()->current_index(), rating); +} + +void PlaylistManager::RateCurrentSong(const int rating) { + RateCurrentSong(rating / 5.0); +} + diff --git a/src/playlist/playlistmanager.h b/src/playlist/playlistmanager.h index e8a862e2..ad47ec51 100644 --- a/src/playlist/playlistmanager.h +++ b/src/playlist/playlistmanager.h @@ -34,6 +34,7 @@ #include "core/song.h" #include "playlist.h" +#include "smartplaylists/playlistgenerator.h" class QModelIndex; @@ -76,6 +77,8 @@ class PlaylistManagerInterface : public QObject { virtual PlaylistParser *parser() const = 0; virtual PlaylistContainer *playlist_container() const = 0; + virtual void PlaySmartPlaylist(PlaylistGeneratorPtr generator, const bool as_new, const bool clear) = 0; + public slots: virtual void New(const QString &name, const SongList& songs = SongList(), const QString &special_type = QString()) = 0; virtual void Load(const QString &filename) = 0; @@ -103,6 +106,11 @@ class PlaylistManagerInterface : public QObject { virtual void SetActivePaused() = 0; virtual void SetActiveStopped() = 0; + // Rate current song using 0.0 - 1.0 scale. + virtual void RateCurrentSong(const double rating) = 0; + // Rate current song using 0 - 5 scale. + virtual void RateCurrentSong(const int rating) = 0; + signals: void PlaylistManagerInitialized(); void AllPlaylistsLoaded(); @@ -196,7 +204,6 @@ class PlaylistManager : public PlaylistManagerInterface { void ShuffleCurrent() override; void RemoveDuplicatesCurrent() override; void RemoveUnavailableCurrent() override; - //void SetActiveStreamMetadata(const QUrl& url, const Song& song); void SongChangeRequestProcessed(const QUrl& url, const bool valid) override; @@ -207,6 +214,13 @@ class PlaylistManager : public PlaylistManagerInterface { // Remove the current playing song void RemoveCurrentSong(); + void PlaySmartPlaylist(PlaylistGeneratorPtr generator, const bool as_new, const bool clear) override; + + // Rate current song using 0.0 - 1.0 scale. + void RateCurrentSong(const double rating) override; + // Rate current song using 0 - 5 scale. + void RateCurrentSong(const int rating) override; + private slots: void SetActivePlaying() override; void SetActivePaused() override; diff --git a/src/playlist/playlistsequence.cpp b/src/playlist/playlistsequence.cpp index 01e099bb..3f86e8c3 100644 --- a/src/playlist/playlistsequence.cpp +++ b/src/playlist/playlistsequence.cpp @@ -47,7 +47,8 @@ PlaylistSequence::PlaylistSequence(QWidget *parent, SettingsProvider *settings) shuffle_menu_(new QMenu(this)), loading_(false), repeat_mode_(Repeat_Off), - shuffle_mode_(Shuffle_Off) + shuffle_mode_(Shuffle_Off), + dynamic_(false) { ui_->setupUi(this); @@ -161,7 +162,7 @@ void PlaylistSequence::ShuffleActionTriggered(QAction *action) { } -void PlaylistSequence::SetRepeatMode(RepeatMode mode) { +void PlaylistSequence::SetRepeatMode(const RepeatMode mode) { ui_->repeat->setChecked(mode != Repeat_Off); @@ -184,7 +185,7 @@ void PlaylistSequence::SetRepeatMode(RepeatMode mode) { } -void PlaylistSequence::SetShuffleMode(ShuffleMode mode) { +void PlaylistSequence::SetShuffleMode(const ShuffleMode mode) { ui_->shuffle->setChecked(mode != Shuffle_Off); @@ -204,12 +205,23 @@ void PlaylistSequence::SetShuffleMode(ShuffleMode mode) { } +void PlaylistSequence::SetUsingDynamicPlaylist(const bool dynamic) { + + dynamic_ = dynamic; + const QString not_available(tr("Not available while using a dynamic playlist")); + + setEnabled(!dynamic); + ui_->shuffle->setToolTip(dynamic ? not_available : tr("Shuffle")); + ui_->repeat->setToolTip(dynamic ? not_available : tr("Repeat")); + +} + PlaylistSequence::ShuffleMode PlaylistSequence::shuffle_mode() const { - return shuffle_mode_; + return dynamic_ ? Shuffle_Off : shuffle_mode_; } PlaylistSequence::RepeatMode PlaylistSequence::repeat_mode() const { - return repeat_mode_; + return dynamic_ ? Repeat_Off : repeat_mode_; } //called from global shortcut diff --git a/src/playlist/playlistsequence.h b/src/playlist/playlistsequence.h index e0e355a9..3e701c7e 100644 --- a/src/playlist/playlistsequence.h +++ b/src/playlist/playlistsequence.h @@ -68,14 +68,15 @@ class PlaylistSequence : public QWidget { QMenu *shuffle_menu() const { return shuffle_menu_; } public slots: - void SetRepeatMode(PlaylistSequence::RepeatMode mode); - void SetShuffleMode(PlaylistSequence::ShuffleMode mode); + void SetRepeatMode(const PlaylistSequence::RepeatMode mode); + void SetShuffleMode(const PlaylistSequence::ShuffleMode mode); void CycleShuffleMode(); void CycleRepeatMode(); + void SetUsingDynamicPlaylist(const bool dynamic); signals: - void RepeatModeChanged(PlaylistSequence::RepeatMode mode); - void ShuffleModeChanged(PlaylistSequence::ShuffleMode mode); + void RepeatModeChanged(const PlaylistSequence::RepeatMode mode); + void ShuffleModeChanged(const PlaylistSequence::ShuffleMode mode); private slots: void RepeatActionTriggered(QAction *); @@ -97,7 +98,7 @@ class PlaylistSequence : public QWidget { bool loading_; RepeatMode repeat_mode_; ShuffleMode shuffle_mode_; - + bool dynamic_; }; #endif // PLAYLISTSEQUENCE_H diff --git a/src/playlist/playlistview.cpp b/src/playlist/playlistview.cpp index 9766a7d9..750224b4 100644 --- a/src/playlist/playlistview.cpp +++ b/src/playlist/playlistview.cpp @@ -78,6 +78,7 @@ #include "covermanager/albumcoverloaderresult.h" #include "settings/appearancesettingspage.h" #include "settings/playlistsettingspage.h" +#include "dynamicplaylistcontrols.h" #ifdef HAVE_MOODBAR # include "moodbar/moodbaritemdelegate.h" @@ -168,7 +169,11 @@ PlaylistView::PlaylistView(QWidget *parent) cached_current_row_row_(-1), drop_indicator_row_(-1), drag_over_(false), - column_alignment_(DefaultColumnAlignment()) { + header_state_version_(1), + column_alignment_(DefaultColumnAlignment()), + rating_locked_(false), + dynamic_controls_(new DynamicPlaylistControls(this)), + rating_delegate_(nullptr) { setHeader(header_); header_->setSectionsMovable(true); @@ -195,6 +200,9 @@ PlaylistView::PlaylistView(QWidget *parent) connect(header_, SIGNAL(SectionVisibilityChanged(int, bool)), SLOT(InvalidateCachedCurrentPixmap())); connect(header_, SIGNAL(StretchEnabledChanged(bool)), SLOT(StretchChanged(bool))); + connect(header_, SIGNAL(SectionRatingLockStatusChanged(bool)), SLOT(SetRatingLockStatus(bool))); + connect(header_, SIGNAL(MouseEntered()), SLOT(RatingHoverOut())); + inhibit_autoscroll_timer_->setInterval(kAutoscrollGraceTimeout * 1000); inhibit_autoscroll_timer_->setSingleShot(true); connect(inhibit_autoscroll_timer_, SIGNAL(timeout()), SLOT(InhibitAutoscrollTimeout())); @@ -202,6 +210,8 @@ PlaylistView::PlaylistView(QWidget *parent) horizontalScrollBar()->installEventFilter(this); verticalScrollBar()->installEventFilter(this); + dynamic_controls_->hide(); + // For fading connect(fade_animation_, SIGNAL(valueChanged(qreal)), SLOT(FadePreviousBackgroundImage(qreal))); fade_animation_->setDirection(QTimeLine::Backward); // 1.0 -> 0.0 @@ -258,12 +268,16 @@ void PlaylistView::SetItemDelegates() { setItemDelegateForColumn(Playlist::Column_Mood, new MoodbarItemDelegate(app_, this, this)); #endif + rating_delegate_ = new RatingItemDelegate(this); + setItemDelegateForColumn(Playlist::Column_Rating, rating_delegate_); + } void PlaylistView::setModel(QAbstractItemModel *m) { if (model()) { disconnect(model(), SIGNAL(dataChanged(QModelIndex, QModelIndex)), this, SLOT(InvalidateCachedCurrentPixmap())); + disconnect(model(), SIGNAL(layoutAboutToBeChanged()), this, SLOT(RatingHoverOut())); // When changing the model, always invalidate the current pixmap. // If a remote client uses "stop after", without invaliding the stop mark would not appear. @@ -273,6 +287,7 @@ void PlaylistView::setModel(QAbstractItemModel *m) { QTreeView::setModel(m); connect(model(), SIGNAL(dataChanged(QModelIndex, QModelIndex)), this, SLOT(InvalidateCachedCurrentPixmap())); + connect(model(), SIGNAL(layoutAboutToBeChanged()), this, SLOT(RatingHoverOut())); } @@ -282,10 +297,16 @@ void PlaylistView::SetPlaylist(Playlist *playlist) { disconnect(playlist_, SIGNAL(MaybeAutoscroll(Playlist::AutoScroll)), this, SLOT(MaybeAutoscroll(Playlist::AutoScroll))); disconnect(playlist_, SIGNAL(destroyed()), this, SLOT(PlaylistDestroyed())); disconnect(playlist_, SIGNAL(QueueChanged()), this, SLOT(update())); + + disconnect(playlist_, SIGNAL(DynamicModeChanged(bool)), this, SLOT(DynamicModeChanged(bool))); + disconnect(dynamic_controls_, SIGNAL(Expand()), playlist_, SLOT(ExpandDynamicPlaylist())); + disconnect(dynamic_controls_, SIGNAL(Repopulate()), playlist_, SLOT(RepopulateDynamicPlaylist())); + disconnect(dynamic_controls_, SIGNAL(TurnOff()), playlist_, SLOT(TurnOffDynamicPlaylist())); } playlist_ = playlist; RestoreHeaderState(); + DynamicModeChanged(playlist->is_dynamic()); setFocus(); JumpToLastPlayedTrack(); @@ -294,13 +315,21 @@ void PlaylistView::SetPlaylist(Playlist *playlist) { connect(playlist_, SIGNAL(destroyed()), SLOT(PlaylistDestroyed())); connect(playlist_, SIGNAL(QueueChanged()), SLOT(update())); + connect(playlist_, SIGNAL(DynamicModeChanged(bool)), SLOT(DynamicModeChanged(bool))); + connect(dynamic_controls_, SIGNAL(Expand()), playlist_, SLOT(ExpandDynamicPlaylist())); + connect(dynamic_controls_, SIGNAL(Repopulate()), playlist_, SLOT(RepopulateDynamicPlaylist())); + connect(dynamic_controls_, SIGNAL(TurnOff()), playlist_, SLOT(TurnOffDynamicPlaylist())); + } void PlaylistView::LoadHeaderState() { QSettings s; s.beginGroup(Playlist::kSettingsGroup); - if (s.contains("state")) header_state_ = s.value("state").toByteArray(); + if (s.contains("state")) { + header_state_version_ = s.value("state_version", 0).toInt(); + header_state_ = s.value("state").toByteArray(); + } if (s.contains("column_alignments")) column_alignment_ = s.value("column_alignments").value(); s.endGroup(); @@ -322,6 +351,7 @@ void PlaylistView::SetHeaderState() { void PlaylistView::ResetHeaderState() { set_initial_header_layout_ = true; + header_state_version_ = 1; header_state_ = header_->ResetState(); RestoreHeaderState(); @@ -355,6 +385,7 @@ void PlaylistView::RestoreHeaderState() { header_->HideSection(Playlist::Column_Comment); header_->HideSection(Playlist::Column_Grouping); header_->HideSection(Playlist::Column_Mood); + header_->HideSection(Playlist::Column_Rating); header_->moveSection(header_->visualIndex(Playlist::Column_Track), 0); @@ -378,6 +409,11 @@ void PlaylistView::RestoreHeaderState() { } + if (header_state_version_ < 1) { + header_->HideSection(Playlist::Column_Rating); + header_state_version_ = 1; + } + // Make sure at least one column is visible bool all_hidden = true; for (int i = 0; i < header_->count(); ++i) { @@ -744,6 +780,17 @@ void PlaylistView::closeEditor(QWidget *editor, QAbstractItemDelegate::EndEditHi void PlaylistView::mouseMoveEvent(QMouseEvent *event) { + // Check whether rating section is locked by user or not + if (!rating_locked_) { + QModelIndex idx = indexAt(event->pos()); + if (idx.isValid() && idx.data(Playlist::Role_CanSetRating).toBool()) { + RatingHoverIn(idx, event->pos()); + } + else if (rating_delegate_->is_mouse_over()) { + RatingHoverOut(); + } + } + if (!drag_over_) { QTreeView::mouseMoveEvent(event); } @@ -752,6 +799,10 @@ void PlaylistView::mouseMoveEvent(QMouseEvent *event) { void PlaylistView::leaveEvent(QEvent *e) { + if (rating_delegate_->is_mouse_over() && !rating_locked_) { + RatingHoverOut(); + } + QTreeView::leaveEvent(e); } @@ -764,19 +815,47 @@ void PlaylistView::mousePressEvent(QMouseEvent *event) { } QModelIndex idx = indexAt(event->pos()); - if (event->button() == Qt::XButton1 && idx.isValid()) { - app_->player()->Previous(); - } - else if (event->button() == Qt::XButton2 && idx.isValid()) { - app_->player()->Next(); - } - else { - QTreeView::mousePressEvent(event); + if (idx.isValid()) { + switch (event->button()) { + case Qt::XButton1: + app_->player()->Previous(); + break; + case Qt::XButton2: + app_->player()->Next(); + break; + case Qt::LeftButton:{ + if (idx.data(Playlist::Role_CanSetRating).toBool() && !rating_locked_) { + // Calculate which star was clicked + double new_rating = RatingPainter::RatingForPos(event->pos(), visualRect(idx)); + if (selectedIndexes().contains(idx)) { + // Update all the selected item ratings + QModelIndexList src_index_list; + for (const QModelIndex &i : selectedIndexes()) { + if (i.data(Playlist::Role_CanSetRating).toBool()) { + src_index_list << playlist_->proxy()->mapToSource(i); + } + } + if (!src_index_list.isEmpty()) { + playlist_->RateSongs(src_index_list, new_rating); + } + } + else { + // Update only this item rating + playlist_->RateSong(playlist_->proxy()->mapToSource(idx), new_rating); + } + } + break; + } + default: + break; + } } inhibit_autoscroll_ = true; inhibit_autoscroll_timer_->start(); + QTreeView::mousePressEvent(event); + } void PlaylistView::scrollContentsBy(int dx, int dy) { @@ -1150,8 +1229,10 @@ void PlaylistView::SaveSettings() { QSettings s; s.beginGroup(Playlist::kSettingsGroup); + s.setValue("state_version", header_state_version_); s.setValue("state", header_->SaveState()); s.setValue("column_alignments", QVariant::fromValue(column_alignment_)); + s.setValue("rating_locked", rating_locked_); s.endGroup(); } @@ -1165,6 +1246,15 @@ void PlaylistView::StretchChanged(const bool stretch) { } +void PlaylistView::resizeEvent(QResizeEvent *e) { + + QTreeView::resizeEvent(e); + if (dynamic_controls_->isVisible()) { + RepositionDynamicControls(); + } + +} + bool PlaylistView::eventFilter(QObject *object, QEvent *event) { if (event->type() == QEvent::Enter && (object == horizontalScrollBar() || object == verticalScrollBar())) { @@ -1357,3 +1447,75 @@ void PlaylistView::focusInEvent(QFocusEvent *event) { } } + +void PlaylistView::DynamicModeChanged(bool dynamic) { + + if (!dynamic) { + dynamic_controls_->hide(); + } + else { + RepositionDynamicControls(); + dynamic_controls_->show(); + } + +} + +void PlaylistView::RepositionDynamicControls() { + + dynamic_controls_->resize(dynamic_controls_->sizeHint()); + dynamic_controls_->move((width() - dynamic_controls_->width()) / 2, height() - dynamic_controls_->height() - 20); + +} + +void PlaylistView::SetRatingLockStatus(const bool state) { + + if (!header_state_loaded_) return; + + rating_locked_ = state; + +} + +void PlaylistView::RatingHoverIn(const QModelIndex &idx, const QPoint &pos) { + + if (editTriggers() & QAbstractItemView::NoEditTriggers) { + return; + } + + const QModelIndex old_index = rating_delegate_->mouse_over_index(); + rating_delegate_->set_mouse_over(idx, selectedIndexes(), pos); + setCursor(Qt::PointingHandCursor); + + update(idx); + update(old_index); + for (const QModelIndex &i : selectedIndexes()) { + if (i.column() == Playlist::Column_Rating) update(i); + } + + if (idx.data(Playlist::Role_IsCurrent).toBool() || old_index.data(Playlist::Role_IsCurrent).toBool()) { + InvalidateCachedCurrentPixmap(); + } + +} + +void PlaylistView::RatingHoverOut() { + + if (editTriggers() & QAbstractItemView::NoEditTriggers) { + return; + } + + const QModelIndex old_index = rating_delegate_->mouse_over_index(); + rating_delegate_->set_mouse_out(); + setCursor(QCursor()); + + update(old_index); + for (const QModelIndex &i : selectedIndexes()) { + if (i.column() == Playlist::Column_Rating) { + update(i); + } + } + + if (old_index.data(Playlist::Role_IsCurrent).toBool()) { + InvalidateCachedCurrentPixmap(); + } + +} diff --git a/src/playlist/playlistview.h b/src/playlist/playlistview.h index c42a6ba4..737b166a 100644 --- a/src/playlist/playlistview.h +++ b/src/playlist/playlistview.h @@ -72,6 +72,8 @@ class QTimerEvent; class Application; class CollectionBackend; class PlaylistHeader; +class DynamicPlaylistControls; +class RatingItemDelegate; // This proxy style works around a bug/feature introduced in Qt 4.7's QGtkStyle // that uses Gtk to paint row backgrounds, ignoring any custom brush or palette the caller set in the QStyleOption. @@ -148,6 +150,7 @@ class PlaylistView : public QTreeView { void dropEvent(QDropEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override; void focusInEvent(QFocusEvent *event) override; + void resizeEvent(QResizeEvent *event) override; // QTreeView void drawTree(QPainter *painter, const QRegion ®ion) const; @@ -177,6 +180,10 @@ class PlaylistView : public QTreeView { void Stopped(); void SongChanged(const Song &song); void AlbumCoverLoaded(const Song &song, AlbumCoverLoaderResult result = AlbumCoverLoaderResult()); + void DynamicModeChanged(const bool dynamic); + void SetRatingLockStatus(const bool state); + void RatingHoverIn(const QModelIndex &idx, const QPoint &pos); + void RatingHoverOut(); private: void LoadHeaderState(); @@ -206,6 +213,8 @@ class PlaylistView : public QTreeView { QModelIndex NextEditableIndex(const QModelIndex ¤t); QModelIndex PrevEditableIndex(const QModelIndex ¤t); + void RepositionDynamicControls(); + Application *app_; PlaylistProxyStyle *style_; Playlist *playlist_; @@ -275,11 +284,16 @@ class PlaylistView : public QTreeView { int drop_indicator_row_; bool drag_over_; + int header_state_version_; QByteArray header_state_; ColumnAlignmentMap column_alignment_; + bool rating_locked_; Song song_playing_; + DynamicPlaylistControls *dynamic_controls_; + RatingItemDelegate *rating_delegate_; + }; #endif // PLAYLISTVIEW_H diff --git a/src/qobuz/qobuzbaserequest.cpp b/src/qobuz/qobuzbaserequest.cpp new file mode 100644 index 00000000..727484b7 --- /dev/null +++ b/src/qobuz/qobuzbaserequest.cpp @@ -0,0 +1,196 @@ +/* + * 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 "core/logging.h" +#include "core/network.h" +#include "qobuzservice.h" +#include "qobuzbaserequest.h" + +const char *QobuzBaseRequest::kApiUrl = "https://www.qobuz.com/api.json/0.2"; + +QobuzBaseRequest::QobuzBaseRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent) : + QObject(parent), + service_(service), + network_(network) + {} + +QobuzBaseRequest::~QobuzBaseRequest() {} + +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 ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QUrl url(kApiUrl + QString("/") + ressource_name); + url.setQuery(url_query); + QNetworkRequest req(url); +#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); +#else + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); +#endif + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + req.setRawHeader("X-App-Id", app_id().toUtf8()); + if (authenticated()) req.setRawHeader("X-User-Auth-Token", user_auth_token().toUtf8()); + + QNetworkReply *reply = network_->get(req); + connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(HandleSSLErrors(QList))); + + qLog(Debug) << "Qobuz: Sending request" << url; + + return reply; + +} + +void QobuzBaseRequest::HandleSSLErrors(QList ssl_errors) { + + for (QSslError &ssl_error : ssl_errors) { + Error(ssl_error.errorString()); + } + +} + +QByteArray QobuzBaseRequest::GetReplyData(QNetworkReply *reply) { + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) { + data = reply->readAll(); + } + else { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + // See if there is Json data containing "status", "code" and "message" - then use that instead. + data = reply->readAll(); + QString error; + QJsonParseError parse_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error); + if (parse_error.error == QJsonParseError::NoError && !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(); + error = QString("%1 (%2)").arg(message).arg(code); + } + } + if (error.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + error = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + error = QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + Error(error); + } + return QByteArray(); + } + + return data; + +} + +QJsonObject QobuzBaseRequest::ExtractJsonObj(QByteArray &data) { + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isEmpty()) { + Error("Received empty Json document.", data); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + return json_obj; + +} + +QJsonValue QobuzBaseRequest::ExtractItems(QByteArray &data) { + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) return QJsonValue(); + return ExtractItems(json_obj); + +} + +QJsonValue QobuzBaseRequest::ExtractItems(QJsonObject &json_obj) { + + if (!json_obj.contains("items")) { + Error("Json reply is missing items.", json_obj); + return QJsonArray(); + } + QJsonValue json_items = json_obj["items"]; + return json_items; + +} + +QString QobuzBaseRequest::ErrorsToHTML(const QStringList &errors) { + + QString error_html; + for (const QString &error : errors) { + error_html += error + "
"; + } + return error_html; + +} diff --git a/src/qobuz/qobuzbaserequest.h b/src/qobuz/qobuzbaserequest.h new file mode 100644 index 00000000..c35cbff4 --- /dev/null +++ b/src/qobuz/qobuzbaserequest.h @@ -0,0 +1,105 @@ +/* + * 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 + +#include "core/song.h" +#include "qobuzservice.h" + +class QNetworkReply; +class NetworkAccessManager; + +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, + }; + + explicit QobuzBaseRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent); + ~QobuzBaseRequest(); + + typedef QPair Param; + typedef QList ParamList; + + static const char *kApiUrl; + + QNetworkReply *CreateRequest(const QString &ressource_name, const QList ¶ms_provided); + QByteArray GetReplyData(QNetworkReply *reply); + QJsonObject ExtractJsonObj(QByteArray &data); + QJsonValue ExtractItems(QByteArray &data); + QJsonValue ExtractItems(QJsonObject &json_obj); + + virtual void Error(const QString &error, const QVariant &debug = QVariant()) = 0; + QString ErrorsToHTML(const QStringList &errors); + + 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(); } + + qint64 user_id() { return service_->user_id(); } + QString user_auth_token() { return service_->user_auth_token(); } + QString device_id() { return service_->device_id(); } + qint64 credential_id() { return service_->credential_id(); } + + 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 slots: + void HandleSSLErrors(QList ssl_errors); + + private: + QobuzService *service_; + NetworkAccessManager *network_; + +}; + +#endif // QOBUZBASEREQUEST_H diff --git a/src/qobuz/qobuzfavoriterequest.cpp b/src/qobuz/qobuzfavoriterequest.cpp new file mode 100644 index 00000000..78f7aa0c --- /dev/null +++ b/src/qobuz/qobuzfavoriterequest.cpp @@ -0,0 +1,277 @@ +/* + * 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 "core/logging.h" +#include "core/network.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, nullptr, this, nullptr); + 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().isEmpty()) continue; + id = song.artist_id(); + break; + case FavoriteType_Albums: + if (song.album_id().isEmpty()) continue; + id = song.album_id(); + break; + case FavoriteType_Songs: + if (song.song_id().isEmpty()) continue; + id = 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", user_auth_token()) + << Param(text, ids); + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QNetworkReply *reply = CreateRequest("favorite/create", params); + connect(reply, &QNetworkReply::finished, [=] { AddFavoritesReply(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; + } + + QByteArray data = GetReplyData(reply); + + 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().isEmpty()) continue; + id = song.artist_id(); + break; + case FavoriteType_Albums: + if (song.album_id().isEmpty()) continue; + id = song.album_id(); + break; + case FavoriteType_Songs: + if (song.song_id().isEmpty()) continue; + id = 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", user_auth_token()) + << Param(text, ids); + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QNetworkReply *reply = CreateRequest("favorite/delete", params); + connect(reply, &QNetworkReply::finished, [=] { RemoveFavoritesReply(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; + } + + QByteArray data = GetReplyData(reply); + 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; + } + +} + +void QobuzFavoriteRequest::Error(const QString &error, const QVariant &debug) { + + qLog(Error) << "Qobuz:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} diff --git a/src/qobuz/qobuzfavoriterequest.h b/src/qobuz/qobuzfavoriterequest.h new file mode 100644 index 00000000..00306796 --- /dev/null +++ b/src/qobuz/qobuzfavoriterequest.h @@ -0,0 +1,82 @@ +/* + * 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 +#include +#include + +#include "qobuzbaserequest.h" +#include "core/song.h" + +class QNetworkReply; +class QobuzService; +class NetworkAccessManager; + +class QobuzFavoriteRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + explicit QobuzFavoriteRequest(QobuzService *service, NetworkAccessManager *network, QObject *parent); + ~QobuzFavoriteRequest(); + + enum FavoriteType { + FavoriteType_Artists, + FavoriteType_Albums, + FavoriteType_Songs + }; + + signals: + void ArtistsAdded(SongList); + void AlbumsAdded(SongList); + void SongsAdded(SongList); + void ArtistsRemoved(SongList); + void AlbumsRemoved(SongList); + void SongsRemoved(SongList); + + private slots: + void 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: + void Error(const QString &error, const QVariant &debug = QVariant()); + 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 00000000..76edc08b --- /dev/null +++ b/src/qobuz/qobuzrequest.cpp @@ -0,0 +1,1343 @@ +/* + * 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/song.h" +#include "core/timeconstants.h" +#include "core/application.h" +#include "covermanager/albumcoverloader.h" +#include "qobuzservice.h" +#include "qobuzurlhandler.h" +#include "qobuzbaserequest.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, Application *app, NetworkAccessManager *network, QueryType type, QObject *parent) + : QobuzBaseRequest(service, network, parent), + service_(service), + url_handler_(url_handler), + app_(app), + network_(network), + type_(type), + query_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 (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, nullptr, this, nullptr); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + + while (!album_cover_replies_.isEmpty()) { + QNetworkReply *reply = album_cover_replies_.takeFirst(); + disconnect(reply, nullptr, this, nullptr); + 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 query_id, const QString &search_text) { + query_id_ = query_id; + search_text_ = search_text; +} + +void QobuzRequest::GetArtists() { + + emit UpdateStatus(query_id_, tr("Retrieving artists...")); + emit UpdateProgress(query_id_, 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", user_auth_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; + replies_ << reply; + connect(reply, &QNetworkReply::finished, [=] { ArtistsReplyReceived(reply, request.limit, request.offset); }); + + } + +} + +void QobuzRequest::GetAlbums() { + + emit UpdateStatus(query_id_, tr("Retrieving albums...")); + emit UpdateProgress(query_id_, 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", user_auth_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; + replies_ << reply; + connect(reply, &QNetworkReply::finished, [=] { AlbumsReplyReceived(reply, request.limit, request.offset); }); + + } + +} + +void QobuzRequest::GetSongs() { + + emit UpdateStatus(query_id_, tr("Retrieving songs...")); + emit UpdateProgress(query_id_, 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", user_auth_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; + replies_ << reply; + connect(reply, &QNetworkReply::finished, [=] { SongsReplyReceived(reply, request.limit, request.offset); }); + + } + +} + +void QobuzRequest::ArtistsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddArtistsSearchRequest(); + +} + +void QobuzRequest::AddArtistsSearchRequest(const int offset) { + + AddArtistsRequest(offset, service_->artistssearchlimit()); + +} + +void QobuzRequest::AlbumsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 0); + AddAlbumsSearchRequest(); + +} + +void QobuzRequest::AddAlbumsSearchRequest(const int offset) { + + AddAlbumsRequest(offset, service_->albumssearchlimit()); + +} + +void QobuzRequest::SongsSearch() { + + emit UpdateStatus(query_id_, tr("Searching...")); + emit UpdateProgress(query_id_, 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) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + + --artists_requests_active_; + + if (finished_) return; + + if (data.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + ArtistsFinishCheck(); + return; + } + + if (!json_obj.contains("artists")) { + ArtistsFinishCheck(); + Error("Json object is missing artists.", json_obj); + return; + } + QJsonValue value_artists = json_obj["artists"]; + if (!value_artists.isObject()) { + Error("Json artists is not an object.", json_obj); + ArtistsFinishCheck(); + return; + } + QJsonObject obj_artists = value_artists.toObject(); + + if (!obj_artists.contains("limit") || + !obj_artists.contains("offset") || + !obj_artists.contains("total") || + !obj_artists.contains("items")) { + ArtistsFinishCheck(); + Error("Json artists object is missing values.", json_obj); + return; + } + //int limit = obj_artists["limit"].toInt(); + int offset = obj_artists["offset"].toInt(); + int artists_total = obj_artists["total"].toInt(); + + if (offset_requested == 0) { + artists_total_ = artists_total; + } + else if (artists_total != artists_total_) { + Error(QString("total returned does not match previous total! %1 != %2").arg(artists_total).arg(artists_total_)); + ArtistsFinishCheck(); + return; + } + + if (offset != offset_requested) { + Error(QString("Offset returned does not match offset requested! %1 != %2").arg(offset).arg(offset_requested)); + ArtistsFinishCheck(); + return; + } + + if (offset_requested == 0) { + emit ProgressSetMaximum(query_id_, artists_total_); + emit UpdateProgress(query_id_, artists_received_); + } + + QJsonValue value_items = ExtractItems(obj_artists); + if (!value_items.isArray()) { + ArtistsFinishCheck(); + return; + } + + QJsonArray array_items = value_items.toArray(); + if (array_items.isEmpty()) { // Empty array means no results + if (offset_requested == 0) no_results_ = true; + ArtistsFinishCheck(); + return; + } + + int artists_received = 0; + for (const QJsonValue &value_item : array_items) { + + ++artists_received; + + if (!value_item.isObject()) { + Error("Invalid Json reply, item not a object.", value_item); + continue; + } + QJsonObject obj_item = value_item.toObject(); + + if (obj_item.contains("item")) { + QJsonValue json_item = obj_item["item"]; + if (!json_item.isObject()) { + Error("Invalid Json reply, item not a object.", json_item); + continue; + } + obj_item = json_item.toObject(); + } + + if (!obj_item.contains("id") || !obj_item.contains("name")) { + Error("Invalid Json reply, item missing id or album.", obj_item); + continue; + } + + QString artist_id; + if (obj_item["id"].isString()) { + artist_id = obj_item["id"].toString(); + } + else { + artist_id = QString::number(obj_item["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(query_id_, 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 (const QString &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(query_id_, tr("Retrieving albums for %1 artist...").arg(artist_albums_requested_)); + else emit UpdateStatus(query_id_, tr("Retrieving albums for %1 artists...").arg(artist_albums_requested_)); + emit ProgressSetMaximum(query_id_, artist_albums_requested_); + emit UpdateProgress(query_id_, 0); + } + + } + + FinishCheck(); + +} + +void QobuzRequest::AlbumsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + --albums_requests_active_; + AlbumsReceived(reply, QString(), limit_requested, offset_requested); + if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); +} + +void QobuzRequest::AddArtistAlbumsRequest(const QString &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", request.artist_id) + << Param("extra", "albums"); + + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + QNetworkReply *reply = CreateRequest(QString("artist/get"), params); + connect(reply, &QNetworkReply::finished, [=] { ArtistAlbumsReplyReceived(reply, request.artist_id, request.offset); }); + replies_ << reply; + + } + +} + +void QobuzRequest::ArtistAlbumsReplyReceived(QNetworkReply *reply, const QString artist_id, const int offset_requested) { + + --artist_albums_requests_active_; + ++artist_albums_received_; + emit UpdateProgress(query_id_, 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 QString &artist_id_requested, const int limit_requested, const int offset_requested) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + disconnect(reply, nullptr, this, nullptr); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + + if (finished_) return; + + if (data.isEmpty()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + + QString album_artist_id = artist_id_requested; + if (json_obj.contains("id")) { + if (json_obj["id"].isString()) { + album_artist_id = json_obj["id"].toString(); + } + else { + album_artist_id = QString::number(json_obj["id"].toInt()); + } + } + QString album_artist; + if (json_obj.contains("name")) { + album_artist = json_obj["name"].toString(); + } + + if (album_artist_id != artist_id_requested) { + AlbumsFinishCheck(artist_id_requested); + Error("Artist id returned does not match artist id requested.", json_obj); + return; + } + + if (!json_obj.contains("albums")) { + AlbumsFinishCheck(artist_id_requested); + Error("Json object is missing albums.", json_obj); + return; + } + QJsonValue value_albums = json_obj["albums"]; + if (!value_albums.isObject()) { + Error("Json albums is not an object.", json_obj); + AlbumsFinishCheck(artist_id_requested); + return; + } + QJsonObject obj_albums = value_albums.toObject(); + + if (!obj_albums.contains("limit") || + !obj_albums.contains("offset") || + !obj_albums.contains("total") || + !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 value_items = ExtractItems(obj_albums); + if (!value_items.isArray()) { + AlbumsFinishCheck(artist_id_requested); + return; + } + QJsonArray array_items = value_items.toArray(); + if (array_items.isEmpty()) { + if ((type_ == QueryType_Albums || type_ == QueryType_SearchAlbums) && offset_requested == 0) { + no_results_ = true; + } + AlbumsFinishCheck(artist_id_requested); + return; + } + + int albums_received = 0; + for (const QJsonValue &value : array_items) { + + ++albums_received; + + if (!value.isObject()) { + Error("Invalid Json reply, item not a object.", value); + continue; + } + QJsonObject obj_item = value.toObject(); + + if (!obj_item.contains("artist") || !obj_item.contains("title") || !obj_item.contains("id")) { + Error("Invalid Json reply, item missing artist, title or id.", obj_item); + continue; + } + + QString album_id; + if (obj_item["id"].isString()) { + album_id = obj_item["id"].toString(); + } + else { + album_id = QString::number(obj_item["id"].toInt()); + } + + if (album_songs_requests_pending_.contains(album_id)) continue; + + QString album = obj_item["title"].toString(); + + QJsonValue value_artist = obj_item["artist"]; + if (!value_artist.isObject()) { + Error("Invalid Json reply, item artist is not a object.", value_artist); + continue; + } + QJsonObject obj_artist = value_artist.toObject(); + if (!obj_artist.contains("id") || !obj_artist.contains("name")) { + Error("Invalid Json reply, item artist missing id or name.", obj_artist); + continue; + } + + QString artist_id; + if (obj_artist["id"].isString()) { + artist_id = obj_artist["id"].toString(); + } + else { + artist_id = QString::number(obj_artist["id"].toInt()); + } + + QString artist = obj_artist["name"].toString(); + if (artist_id_requested != 0 && artist_id != artist_id_requested) { + qLog(Debug) << "Skipping artist" << "artist" << artist << artist_id << "does not match album artist" << album_artist_id << album_artist; + continue; + } + + Request request; + request.artist_id = artist_id; + 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 QString &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 it; + for (it = album_songs_requests_pending_.begin() ; it != album_songs_requests_pending_.end() ; ++it) { + Request request = it.value(); + AddAlbumSongsRequest(request.artist_id, request.album_id, request.album_artist, request.album); + } + album_songs_requests_pending_.clear(); + + if (album_songs_requested_ > 0) { + if (album_songs_requested_ == 1) emit UpdateStatus(query_id_, tr("Retrieving songs for %1 album...").arg(album_songs_requested_)); + else emit UpdateStatus(query_id_, tr("Retrieving songs for %1 albums...").arg(album_songs_requested_)); + emit ProgressSetMaximum(query_id_, album_songs_requested_); + emit UpdateProgress(query_id_, 0); + } + } + + FinishCheck(); + +} + +void QobuzRequest::SongsReplyReceived(QNetworkReply *reply, const int limit_requested, const int offset_requested) { + + --songs_requests_active_; + SongsReceived(reply, QString(), QString(), limit_requested, offset_requested); + +} + +void QobuzRequest::AddAlbumSongsRequest(const QString &artist_id, const QString &album_id, const QString &album_artist, const QString &album, const int offset) { + + Request request; + request.artist_id = artist_id; + request.album_id = album_id; + request.album_artist = album_artist; + request.album = album; + 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); + replies_ << reply; + connect(reply, &QNetworkReply::finished, [=] { AlbumSongsReplyReceived(reply, request.artist_id, request.album_id, request.offset, request.album_artist, request.album); }); + + } + +} + +void QobuzRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const QString &artist_id, const QString &album_id, const int offset_requested, const QString &album_artist, const QString &album) { + + --album_songs_requests_active_; + ++album_songs_received_; + if (offset_requested == 0) { + emit UpdateProgress(query_id_, album_songs_received_); + } + SongsReceived(reply, artist_id, album_id, 0, offset_requested, album_artist, album); + +} + +void QobuzRequest::SongsReceived(QNetworkReply *reply, const QString &artist_id_requested, const QString &album_id_requested, const int limit_requested, const int offset_requested, const QString &album_artist_requested, const QString &album_requested) { + + if (!replies_.contains(reply)) return; + replies_.removeAll(reply); + reply->deleteLater(); + + QByteArray data = GetReplyData(reply); + + if (finished_) return; + + if (data.isEmpty()) { + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested, album_requested); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist_requested, album_requested); + return; + } + + 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, album_requested); + return; + } + + QString artist_id = artist_id_requested; + QString album_artist = album_artist_requested; + QString album_id = album_id_requested; + QString album = album_requested; + QUrl cover_url; + + if (json_obj.contains("id")) { + if (json_obj["id"].isString()) { + album_id = json_obj["id"].toString(); + } + else { + album_id = QString::number(json_obj["id"].toInt()); + } + } + + if (json_obj.contains("title")) { + album = json_obj["title"].toString(); + } + + if (json_obj.contains("artist")) { + QJsonValue value_artist = json_obj["artist"]; + if (!value_artist.isObject()) { + Error("Invalid Json reply, album artist is not a object.", value_artist); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist, album); + return; + } + QJsonObject obj_artist = value_artist.toObject(); + if (!obj_artist.contains("id") || !obj_artist.contains("name")) { + Error("Invalid Json reply, album artist is missing id or name.", obj_artist); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist, album); + return; + } + if (obj_artist["id"].isString()) { + artist_id = obj_artist["id"].toString(); + } + else { + artist_id = QString::number(obj_artist["id"].toInt()); + } + album_artist = obj_artist["name"].toString(); + } + + if (json_obj.contains("image")) { + QJsonValue value_image = json_obj["image"]; + if (!value_image.isObject()) { + Error("Invalid Json reply, album image is not a object.", value_image); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist, album); + return; + } + QJsonObject obj_image = value_image.toObject(); + if (!obj_image.contains("large")) { + Error("Invalid Json reply, album image is missing large.", obj_image); + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist, album); + return; + } + QString album_image = obj_image["large"].toString(); + if (!album_image.isEmpty()) { + cover_url = QUrl(album_image); + } + } + + QJsonValue value_tracks = json_obj["tracks"]; + if (!value_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, album); + return; + } + QJsonObject obj_tracks = value_tracks.toObject(); + + if (!obj_tracks.contains("limit") || + !obj_tracks.contains("offset") || + !obj_tracks.contains("total") || + !obj_tracks.contains("items")) { + SongsFinishCheck(artist_id_requested, album_id_requested, limit_requested, offset_requested, 0, 0, album_artist, album); + Error("Json songs object is missing values.", json_obj); + return; + } + + //int limit = obj_tracks["limit"].toInt(); + int offset = obj_tracks["offset"].toInt(); + int songs_total = 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, album); + return; + } + + QJsonValue value_items = ExtractItems(obj_tracks); + if (!value_items.isArray()) { + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist, album); + return; + } + + QJsonArray array_items = value_items.toArray(); + if (array_items.isEmpty()) { + if ((type_ == QueryType_Songs || type_ == QueryType_SearchSongs) && offset_requested == 0) { + no_results_ = true; + } + SongsFinishCheck(artist_id, album_id, limit_requested, offset_requested, songs_total, 0, album_artist, album); + return; + } + + bool compilation = false; + //bool multidisc = false; + SongList songs; + int songs_received = 0; + for (const QJsonValue &value_item : array_items) { + + if (!value_item.isObject()) { + Error("Invalid Json reply, track is not a object.", value_item); + continue; + } + QJsonObject obj_item = value_item.toObject(); + + ++songs_received; + Song song(Song::Source_Qobuz); + ParseSong(song, obj_item, 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, album); + +} + +void QobuzRequest::SongsFinishCheck(const QString &artist_id, const QString &album_id, const int limit, const int offset, const int songs_total, const int songs_received, const QString &album_artist, const QString &album) { + + 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: + AddSongsSearchRequest(offset_next); + break; + case QueryType_Artists: + case QueryType_SearchArtists: + case QueryType_Albums: + case QueryType_SearchAlbums: + AddAlbumSongsRequest(artist_id, album_id, album_artist, album, 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_->download_album_covers() && + IsQuery() && + 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(); + +} + +QString QobuzRequest::ParseSong(Song &song, const QJsonObject &json_obj, QString 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 QString(); + } + + QString song_id; + if (json_obj["id"].isString()) { + song_id = json_obj["id"].toString(); + } + else { + song_id = QString::number(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 value_album = json_obj["album"]; + if (!value_album.isObject()) { + Error("Invalid Json reply, album is not an object.", value_album); + return QString(); + } + QJsonObject obj_album = value_album.toObject(); + + if (obj_album.contains("id")) { + if (obj_album["id"].isString()) { + album_id = obj_album["id"].toString(); + } + else { + album_id = QString::number(obj_album["id"].toInt()); + } + } + + if (obj_album.contains("title")) { + album = obj_album["title"].toString(); + } + + if (obj_album.contains("artist")) { + QJsonValue value_artist = obj_album["artist"]; + if (!value_artist.isObject()) { + Error("Invalid Json reply, album artist is not a object.", value_artist); + return QString(); + } + QJsonObject obj_artist = value_artist.toObject(); + if (!obj_artist.contains("id") || !obj_artist.contains("name")) { + Error("Invalid Json reply, album artist is missing id or name.", obj_artist); + return QString(); + } + if (obj_artist["id"].isString()) { + artist_id = obj_artist["id"].toString(); + } + else { + artist_id = QString::number(obj_artist["id"].toInt()); + } + album_artist = obj_artist["name"].toString(); + } + + if (obj_album.contains("image")) { + QJsonValue value_image = obj_album["image"]; + if (!value_image.isObject()) { + Error("Invalid Json reply, album image is not a object.", value_image); + return QString(); + } + QJsonObject obj_image = value_image.toObject(); + if (!obj_image.contains("large")) { + Error("Invalid Json reply, album image is missing large.", obj_image); + return QString(); + } + QString album_image = obj_image["large"].toString(); + if (!album_image.isEmpty()) { + cover_url = QUrl(album_image); + } + } + } + + if (json_obj.contains("composer")) { + QJsonValue value_composer = json_obj["composer"]; + if (!value_composer.isObject()) { + Error("Invalid Json reply, track composer is not a object.", value_composer); + return QString(); + } + QJsonObject obj_composer = value_composer.toObject(); + if (!obj_composer.contains("id") || !obj_composer.contains("name")) { + Error("Invalid Json reply, track composer is missing id or name.", obj_composer); + return QString(); + } + composer = obj_composer["name"].toString(); + } + + if (json_obj.contains("performer")) { + QJsonValue value_performer = json_obj["performer"]; + if (!value_performer.isObject()) { + Error("Invalid Json reply, track performer is not a object.", value_performer); + return QString(); + } + QJsonObject obj_performer = value_performer.toObject(); + if (!obj_performer.contains("id") || !obj_performer.contains("name")) { + Error("Invalid Json reply, track performer is missing id or name.", obj_performer); + return QString(); + } + performer = 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(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_album_id(album_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); + 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(query_id_, tr("Retrieving album cover for %1 album...").arg(album_covers_requested_)); + else emit UpdateStatus(query_id_, tr("Retrieving album covers for %1 albums...").arg(album_covers_requested_)); + emit ProgressSetMaximum(query_id_, album_covers_requested_); + emit UpdateProgress(query_id_, 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_.insert(cover_url, &song); + return; + } + + AlbumCoverRequest request; + request.url = cover_url; + request.filename = app_->album_cover_loader()->CoverFilePath(song.source(), song.effective_albumartist(), song.effective_album(), song.album_id(), QString(), cover_url); + if (request.filename.isEmpty()) return; + + album_covers_requests_sent_.insert(cover_url, &song); + ++album_covers_requested_; + + 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); +#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); +#else + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); +#endif + QNetworkReply *reply = network_->get(req); + album_cover_replies_ << reply; + connect(reply, &QNetworkReply::finished, [=] { AlbumCoverReceived(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(query_id_, album_covers_received_); + + if (!album_covers_requests_sent_.contains(cover_url)) { + AlbumCoverFinishCheck(); + return; + } + + if (reply->error() != QNetworkReply::NoError) { + Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + album_covers_requests_sent_.remove(cover_url); + AlbumCoverFinishCheck(); + return; + } + + if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + Error(QString("Received HTTP code %1 for %2.").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()).arg(cover_url.toString())); + if (album_covers_requests_sent_.contains(cover_url)) album_covers_requests_sent_.remove(cover_url); + AlbumCoverFinishCheck(); + return; + } + + QString mimetype = reply->header(QNetworkRequest::ContentTypeHeader).toString(); + if (!QImageReader::supportedMimeTypes().contains(mimetype.toUtf8())) { + Error(QString("Unsupported mimetype for image reader %1 for %2").arg(mimetype).arg(cover_url.toString())); + if (album_covers_requests_sent_.contains(cover_url)) album_covers_requests_sent_.remove(cover_url); + AlbumCoverFinishCheck(); + return; + } + +#if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0)) + QList format_list = QImageReader::imageFormatsForMimeType(mimetype.toUtf8()); +#else + QList format_list = Utilities::ImageFormatsForMimeType(mimetype.toUtf8()); +#endif + + QByteArray data = reply->readAll(); + if (format_list.isEmpty() || data.isEmpty()) { + Error(QString("Received empty image data for %1").arg(cover_url.toString())); + album_covers_requests_sent_.remove(cover_url); + AlbumCoverFinishCheck(); + return; + } + QByteArray format = format_list.first(); + + QImage image; + if (image.loadFromData(data, format)) { + if (image.save(filename, format)) { + while (album_covers_requests_sent_.contains(cover_url)) { + Song *song = album_covers_requests_sent_.take(cover_url); + song->set_art_automatic(QUrl::fromLocalFile(filename)); + } + } + + } + else { + album_covers_requests_sent_.remove(cover_url); + Error(QString("Error decoding image data from %1").arg(cover_url.toString())); + } + + AlbumCoverFinishCheck(); + +} + +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_requested_ <= album_covers_received_ && + album_covers_requests_active_ <= 0 && + album_covers_received_ >= album_covers_requested_ + ) { + finished_ = true; + if (no_results_ && songs_.isEmpty()) { + if (IsSearch()) + emit Results(query_id_, SongList(), tr("No match.")); + else + emit Results(query_id_, SongList(), QString()); + } + else { + if (songs_.isEmpty() && errors_.isEmpty()) + emit Results(query_id_, songs_, tr("Unknown error")); + else + emit Results(query_id_, songs_, ErrorsToHTML(errors_)); + } + } + +} + +void QobuzRequest::Error(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) { + errors_ << error; + qLog(Error) << "Qobuz:" << error; + } + if (debug.isValid()) qLog(Debug) << debug; + FinishCheck(); + +} + +void QobuzRequest::Warn(const QString &error, const 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 00000000..1aeff0b1 --- /dev/null +++ b/src/qobuz/qobuzrequest.h @@ -0,0 +1,205 @@ +/* + * 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 QNetworkReply; +class Application; +class NetworkAccessManager; +class QobuzService; +class QobuzUrlHandler; + +class QobuzRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + + explicit QobuzRequest(QobuzService *service, QobuzUrlHandler *url_handler, Application *app, 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(const int id, const SongList &songs, const QString &error); + void UpdateStatus(const int id, const QString &text); + void ProgressSetMaximum(const int id, const int max); + void UpdateProgress(const int id, const int max); + void StreamURLFinished(const QUrl original_url, const QUrl url, const Song::FileType, QString error = QString()); + + private slots: + + 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 QString &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 QString &artist_id_requested, const QString &album_id_requested, const int limit_requested, const int offset_requested, const QString &album_artist_requested = QString(), const QString &album_requested = QString()); + + void ArtistAlbumsReplyReceived(QNetworkReply *reply, const QString artist_id, const int offset_requested); + void AlbumSongsReplyReceived(QNetworkReply *reply, const QString &artist_id, const QString &album_id, const int offset_requested, const QString &album_artist, const QString &album); + void AlbumCoverReceived(QNetworkReply *reply, const QUrl &cover_url, const QString &filename); + + private: + + struct Request { + Request() : offset(0), limit(0) {} + QString artist_id; + QString album_id; + QString song_id; + int offset; + int limit; + QString album_artist; + QString album; + }; + struct AlbumCoverRequest { + QUrl url; + QString filename; + }; + + bool IsQuery() { return (type_ == QueryType_Artists || type_ == QueryType_Albums || type_ == QueryType_Songs); } + bool IsSearch() { return (type_ == QueryType_SearchArtists || type_ == QueryType_SearchAlbums || type_ == QueryType_SearchSongs); } + + void GetArtists(); + void GetAlbums(); + void GetSongs(); + + void ArtistsSearch(); + void AlbumsSearch(); + void SongsSearch(); + + void AddArtistsRequest(const int offset = 0, const int limit = 0); + void AddArtistsSearchRequest(const int offset = 0); + void FlushArtistsRequests(); + void AddAlbumsRequest(const int offset = 0, const int limit = 0); + void AddAlbumsSearchRequest(const int offset = 0); + void FlushAlbumsRequests(); + void AddSongsRequest(const int offset = 0, const int limit = 0); + void AddSongsSearchRequest(const int offset = 0); + void FlushSongsRequests(); + + void ArtistsFinishCheck(const int limit = 0, const int offset = 0, const int artists_received = 0); + void AlbumsFinishCheck(const QString &artist_id, const int limit = 0, const int offset = 0, const int albums_total = 0, const int albums_received = 0); + void SongsFinishCheck(const QString &artist_id, const QString &album_id, const int limit, const int offset, const int songs_total, const int songs_received, const QString &album_artist, const QString &album); + + void AddArtistAlbumsRequest(const QString &artist_id, const int offset = 0); + void FlushArtistAlbumsRequests(); + + void AddAlbumSongsRequest(const QString &artist_id, const QString &album_id, const QString &album_artist, const QString &album, const int offset = 0); + void FlushAlbumSongsRequests(); + + QString ParseSong(Song &song, const QJsonObject &json_obj, QString 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(const QString &error, const QVariant &debug = QVariant()); + void Error(const QString &error, const QVariant &debug = QVariant()) override; + + static const int kMaxConcurrentArtistsRequests; + static const int kMaxConcurrentAlbumsRequests; + static const int kMaxConcurrentSongsRequests; + static const int kMaxConcurrentArtistAlbumsRequests; + static const int kMaxConcurrentAlbumSongsRequests; + static const int kMaxConcurrentAlbumCoverRequests; + + QobuzService *service_; + QobuzUrlHandler *url_handler_; + Application *app_; + NetworkAccessManager *network_; + + QueryType type_; + int query_id_; + QString search_text_; + + bool finished_; + + QQueue artists_requests_queue_; + QQueue albums_requests_queue_; + QQueue songs_requests_queue_; + + QQueue artist_albums_requests_queue_; + QQueue album_songs_requests_queue_; + QQueue album_cover_requests_queue_; + + 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_; + QStringList errors_; + bool no_results_; + QList replies_; + QList album_cover_replies_; + +}; + +#endif // QOBUZREQUEST_H diff --git a/src/qobuz/qobuzservice.cpp b/src/qobuz/qobuzservice.cpp new file mode 100644 index 00000000..81af2b98 --- /dev/null +++ b/src/qobuz/qobuzservice.cpp @@ -0,0 +1,761 @@ +/* + * 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 "core/application.h" +#include "core/player.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/database.h" +#include "core/song.h" +#include "core/utilities.h" +#include "internet/internetsearchview.h" +#include "collection/collectionbackend.h" +#include "collection/collectionmodel.h" +#include "qobuzservice.h" +#include "qobuzurlhandler.h" +#include "qobuzbaserequest.h" +#include "qobuzrequest.h" +#include "qobuzfavoriterequest.h" +#include "qobuzstreamurlrequest.h" +#include "settings/settingsdialog.h" +#include "settings/qobuzsettingspage.h" + +using std::shared_ptr; + +const Song::Source QobuzService::kSource = Song::Source_Qobuz; +const char *QobuzService::kAuthUrl = "https://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", QobuzSettingsPage::kSettingsGroup, SettingsDialog::Page_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), + download_album_covers_(true), + user_id_(-1), + credential_id_(-1), + 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(), Song::Source_Qobuz, kArtistsSongsTable, QString(), QString(), kArtistsSongsFtsTable); + + albums_collection_backend_ = new CollectionBackend(); + albums_collection_backend_->moveToThread(app_->database()->thread()); + albums_collection_backend_->Init(app_->database(), Song::Source_Qobuz, kAlbumsSongsTable, QString(), QString(), kAlbumsSongsFtsTable); + + songs_collection_backend_ = new CollectionBackend(); + songs_collection_backend_->moveToThread(app_->database()->thread()); + songs_collection_backend_->Init(app_->database(), Song::Source_Qobuz, 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(SongList)), favorite_request_, SLOT(AddArtists(SongList))); + connect(this, SIGNAL(AddAlbums(SongList)), favorite_request_, SLOT(AddAlbums(SongList))); + connect(this, SIGNAL(AddSongs(SongList)), favorite_request_, SLOT(AddSongs(SongList))); + + connect(this, SIGNAL(RemoveArtists(SongList)), favorite_request_, SLOT(RemoveArtists(SongList))); + connect(this, SIGNAL(RemoveAlbums(SongList)), favorite_request_, SLOT(RemoveAlbums(SongList))); + connect(this, SIGNAL(RemoveSongs(SongList)), favorite_request_, SLOT(RemoveSongs(SongList))); + + connect(favorite_request_, SIGNAL(ArtistsAdded(SongList)), artists_collection_backend_, SLOT(AddOrUpdateSongs(SongList))); + connect(favorite_request_, SIGNAL(AlbumsAdded(SongList)), albums_collection_backend_, SLOT(AddOrUpdateSongs(SongList))); + connect(favorite_request_, SIGNAL(SongsAdded(SongList)), songs_collection_backend_, SLOT(AddOrUpdateSongs(SongList))); + + connect(favorite_request_, SIGNAL(ArtistsRemoved(SongList)), artists_collection_backend_, SLOT(DeleteSongs(SongList))); + connect(favorite_request_, SIGNAL(AlbumsRemoved(SongList)), albums_collection_backend_, SLOT(DeleteSongs(SongList))); + connect(favorite_request_, SIGNAL(SongsRemoved(SongList)), songs_collection_backend_, SLOT(DeleteSongs(SongList))); + + ReloadSettings(); + +} + +QobuzService::~QobuzService() { + + while (!stream_url_requests_.isEmpty()) { + QobuzStreamURLRequest *stream_url_req = stream_url_requests_.takeFirst(); + disconnect(stream_url_req, 0, this, 0); + stream_url_req->deleteLater(); + } + + artists_collection_backend_->deleteLater(); + albums_collection_backend_->deleteLater(); + songs_collection_backend_->deleteLater(); + +} + +void QobuzService::Exit() { + + wait_for_exit_ << artists_collection_backend_ << albums_collection_backend_ << songs_collection_backend_; + + connect(artists_collection_backend_, SIGNAL(ExitFinished()), this, SLOT(ExitReceived())); + connect(albums_collection_backend_, SIGNAL(ExitFinished()), this, SLOT(ExitReceived())); + connect(songs_collection_backend_, SIGNAL(ExitFinished()), this, SLOT(ExitReceived())); + + artists_collection_backend_->ExitAsync(); + albums_collection_backend_->ExitAsync(); + songs_collection_backend_->ExitAsync(); + +} + +void QobuzService::ExitReceived() { + + QObject *obj = qobject_cast(sender()); + disconnect(obj, nullptr, this, nullptr); + qLog(Debug) << obj << "successfully exited."; + wait_for_exit_.removeAll(obj); + if (wait_for_exit_.isEmpty()) emit ExitFinished(); + +} + +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", 4).toInt(); + albumssearchlimit_ = s.value("albumssearchlimit", 10).toInt(); + songssearchlimit_ = s.value("songssearchlimit", 10).toInt(); + download_album_covers_ = s.value("downloadalbumcovers", true).toBool(); + + user_id_ = s.value("user_id").toInt(); + device_id_ = s.value("device_id").toString(); + user_auth_token_ = s.value("user_auth_token").toString(); + + s.endGroup(); + +} + +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_errors_.clear(); + + 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) + << Param("device_manufacturer_id", Utilities::MacAddress()); + + QUrlQuery url_query; + for (const Param ¶m : params) { + url_query.addQueryItem(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + } + + QUrl url(kAuthUrl); + QNetworkRequest req(url); +#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); +#else + req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); +#endif + + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QByteArray query = url_query.toString(QUrl::FullyEncoded).toUtf8(); + QNetworkReply *reply = network_->post(req, query); + replies_ << reply; + connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(HandleLoginSSLErrors(QList))); + connect(reply, &QNetworkReply::finished, [=] { HandleAuthReply(reply); }); + + qLog(Debug) << "Qobuz: Sending request" << url << query; + +} + +void QobuzService::HandleLoginSSLErrors(QList ssl_errors) { + + for (QSslError &ssl_error : ssl_errors) { + login_errors_ += ssl_error.errorString(); + } + +} + +void QobuzService::HandleAuthReply(QNetworkReply *reply) { + + reply->deleteLater(); + + login_sent_ = false; + + if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + if (reply->error() != QNetworkReply::NoError && reply->error() < 200) { + // This is a network error, there is nothing more to do. + LoginError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + return; + } + else { + // See if there is Json data containing "status", "code" and "message" - then use that instead. + QByteArray data(reply->readAll()); + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("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(); + login_errors_ << QString("%1 (%2)").arg(message).arg(code); + } + } + if (login_errors_.isEmpty()) { + if (reply->error() != QNetworkReply::NoError) { + login_errors_ << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + else { + login_errors_ << QString("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()); + } + } + LoginError(); + return; + } + } + + login_errors_.clear(); + + 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.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; + } + user_auth_token_ = json_obj["user_auth_token"].toString(); + + if (!json_obj.contains("user")) { + LoginError("Authentication reply from server is missing user", json_obj); + return; + } + QJsonValue value_user = json_obj["user"]; + if (!value_user.isObject()) { + LoginError("Authentication reply user is not a object", json_obj); + return; + } + QJsonObject obj_user = value_user.toObject(); + + if (!obj_user.contains("id")) { + LoginError("Authentication reply from server is missing user id", obj_user); + return; + } + user_id_ = obj_user["id"].toInt(); + + if (!obj_user.contains("device")) { + LoginError("Authentication reply from server is missing user device", obj_user); + return; + } + QJsonValue value_device = obj_user["device"]; + if (!value_device.isObject()) { + LoginError("Authentication reply from server user device is not a object", value_device); + return; + } + QJsonObject obj_device = value_device.toObject(); + + if (!obj_device.contains("device_manufacturer_id")) { + LoginError("Authentication reply from server device is missing device_manufacturer_id", obj_device); + return; + } + device_id_ = obj_device["device_manufacturer_id"].toString(); + + if (!obj_user.contains("credential")) { + LoginError("Authentication reply from server is missing user credential", obj_user); + return; + } + QJsonValue value_credential = obj_user["credential"]; + if (!value_credential.isObject()) { + LoginError("Authentication reply from serve userr credential is not a object", value_device); + return; + } + QJsonObject obj_credential = value_credential.toObject(); + + if (!obj_credential.contains("id")) { + LoginError("Authentication reply user credential from server is missing user credential id", obj_credential); + return; + } + credential_id_ = obj_credential["id"].toInt(); + + QSettings s; + s.beginGroup(QobuzSettingsPage::kSettingsGroup); + s.setValue("user_auth_token", user_auth_token_); + s.setValue("user_id", user_id_); + s.setValue("credential_id", credential_id_); + s.setValue("device_id", device_id_); + s.endGroup(); + + qLog(Debug) << "Qobuz: Login successful" << "user id" << user_id_ << "device id" << device_id_; + + login_attempts_ = 0; + if (timer_login_attempt_->isActive()) timer_login_attempt_->stop(); + + emit LoginComplete(true); + emit LoginSuccess(); + +} + +void QobuzService::Logout() { + + user_auth_token_.clear(); + device_id_.clear(); + user_id_ = -1; + credential_id_ = -1; + + QSettings s; + s.beginGroup(QobuzSettingsPage::kSettingsGroup); + s.remove("user_id"); + s.remove("credential_id"); + s.remove("device_id"); + 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, tr("Maximum number of login attempts reached.")); + return; + } + if (app_id_.isEmpty()) { + emit LoginComplete(false, tr("Missing Qobuz app ID.")); + return; + } + if (username_.isEmpty()) { + emit LoginComplete(false, tr("Missing Qobuz username.")); + return; + } + if (password_.isEmpty()) { + emit LoginComplete(false, tr("Missing Qobuz password.")); + return; + } + + emit Login(); + +} + +void QobuzService::ResetArtistsRequest() { + + if (artists_request_.get()) { + disconnect(artists_request_.get(), 0, this, 0); + disconnect(this, 0, artists_request_.get(), 0); + artists_request_.reset(); + } + +} + +void QobuzService::GetArtists() { + + if (app_id().isEmpty()) { + emit ArtistsResults(SongList(), tr("Missing Qobuz app ID.")); + return; + } + + if (!authenticated()) { + emit ArtistsResults(SongList(), tr("Not authenticated with Qobuz.")); + return; + } + + ResetArtistsRequest(); + + artists_request_.reset(new QobuzRequest(this, url_handler_, app_, network_, QobuzBaseRequest::QueryType_Artists, this)); + + connect(artists_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(ArtistsResultsReceived(int, SongList, QString))); + connect(artists_request_.get(), SIGNAL(UpdateStatus(int, QString)), SLOT(ArtistsUpdateStatusReceived(int, QString))); + connect(artists_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SLOT(ArtistsProgressSetMaximumReceived(int, int))); + connect(artists_request_.get(), SIGNAL(UpdateProgress(int, int)), SLOT(ArtistsUpdateProgressReceived(int, int))); + + artists_request_->Process(); + +} + +void QobuzService::ArtistsResultsReceived(const int id, const SongList &songs, const QString &error) { + Q_UNUSED(id); + emit ArtistsResults(songs, error); +} + +void QobuzService::ArtistsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit ArtistsUpdateStatus(text); +} + +void QobuzService::ArtistsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit ArtistsProgressSetMaximum(max); +} + +void QobuzService::ArtistsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit ArtistsUpdateProgress(progress); +} + +void QobuzService::ResetAlbumsRequest() { + + if (albums_request_.get()) { + disconnect(albums_request_.get(), 0, this, 0); + disconnect(this, 0, albums_request_.get(), 0); + albums_request_.reset(); + } + +} + +void QobuzService::GetAlbums() { + + if (app_id().isEmpty()) { + emit AlbumsResults(SongList(), tr("Missing Qobuz app ID.")); + return; + } + + if (!authenticated()) { + emit AlbumsResults(SongList(), tr("Not authenticated with Qobuz.")); + return; + } + + ResetAlbumsRequest(); + albums_request_.reset(new QobuzRequest(this, url_handler_, app_, network_, QobuzBaseRequest::QueryType_Albums, this)); + connect(albums_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(AlbumsResultsReceived(int, SongList, QString))); + connect(albums_request_.get(), SIGNAL(UpdateStatus(int, QString)), SLOT(AlbumsUpdateStatusReceived(int, QString))); + connect(albums_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SLOT(AlbumsProgressSetMaximumReceived(int, int))); + connect(albums_request_.get(), SIGNAL(UpdateProgress(int, int)), SLOT(AlbumsUpdateProgressReceived(int, int))); + + albums_request_->Process(); + +} + +void QobuzService::AlbumsResultsReceived(const int id, const SongList &songs, const QString &error) { + Q_UNUSED(id); + emit AlbumsResults(songs, error); +} + +void QobuzService::AlbumsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit AlbumsUpdateStatus(text); +} + +void QobuzService::AlbumsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit AlbumsProgressSetMaximum(max); +} + +void QobuzService::AlbumsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit AlbumsUpdateProgress(progress); +} + +void QobuzService::ResetSongsRequest() { + + if (songs_request_.get()) { + disconnect(songs_request_.get(), 0, this, 0); + disconnect(this, 0, songs_request_.get(), 0); + songs_request_.reset(); + } + +} + +void QobuzService::GetSongs() { + + if (app_id().isEmpty()) { + emit SongsResults(SongList(), tr("Missing Qobuz app ID.")); + return; + } + + if (!authenticated()) { + emit SongsResults(SongList(), tr("Not authenticated with Qobuz.")); + return; + } + + ResetSongsRequest(); + songs_request_.reset(new QobuzRequest(this, url_handler_, app_, network_, QobuzBaseRequest::QueryType_Songs, this)); + connect(songs_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(SongsResultsReceived(int, SongList, QString))); + connect(songs_request_.get(), SIGNAL(UpdateStatus(int, QString)), SLOT(SongsUpdateStatusReceived(int, QString))); + connect(songs_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SLOT(SongsProgressSetMaximumReceived(int, int))); + connect(songs_request_.get(), SIGNAL(UpdateProgress(int, int)), SLOT(SongsUpdateProgressReceived(int, int))); + + songs_request_->Process(); + +} + +void QobuzService::SongsResultsReceived(const int id, const SongList &songs, const QString &error) { + Q_UNUSED(id); + emit SongsResults(songs, error); +} + +void QobuzService::SongsUpdateStatusReceived(const int id, const QString &text) { + Q_UNUSED(id); + emit SongsUpdateStatus(text); +} + +void QobuzService::SongsProgressSetMaximumReceived(const int id, const int max) { + Q_UNUSED(id); + emit SongsProgressSetMaximum(max); +} + +void QobuzService::SongsUpdateProgressReceived(const int id, const int progress) { + Q_UNUSED(id); + emit SongsUpdateProgress(progress); +} + +int QobuzService::Search(const QString &text, InternetSearchView::SearchType type) { + + pending_search_id_ = next_pending_search_id_; + pending_search_text_ = text; + pending_search_type_ = type; + + next_pending_search_id_++; + + if (text.isEmpty()) { + timer_search_delay_->stop(); + return pending_search_id_; + } + timer_search_delay_->setInterval(search_delay_); + timer_search_delay_->start(); + + return pending_search_id_; + +} + +void QobuzService::StartSearch() { + + search_id_ = pending_search_id_; + search_text_ = pending_search_text_; + + if (app_id_.isEmpty()) { // App ID is the only thing needed to search. + emit SearchResults(search_id_, SongList(), tr("Missing Qobuz app ID.")); + return; + } + + SendSearch(); + +} + +void QobuzService::CancelSearch() { +} + +void QobuzService::SendSearch() { + + QobuzBaseRequest::QueryType type; + + switch (pending_search_type_) { + case InternetSearchView::SearchType_Artists: + type = QobuzBaseRequest::QueryType_SearchArtists; + break; + case InternetSearchView::SearchType_Albums: + type = QobuzBaseRequest::QueryType_SearchAlbums; + break; + case InternetSearchView::SearchType_Songs: + type = QobuzBaseRequest::QueryType_SearchSongs; + break; + } + + search_request_.reset(new QobuzRequest(this, url_handler_, app_, network_, type, this)); + + connect(search_request_.get(), SIGNAL(Results(int, SongList, QString)), SLOT(SearchResultsReceived(int, SongList, QString))); + connect(search_request_.get(), SIGNAL(UpdateStatus(int, QString)), SIGNAL(SearchUpdateStatus(int, QString))); + connect(search_request_.get(), SIGNAL(ProgressSetMaximum(int, int)), SIGNAL(SearchProgressSetMaximum(int, int))); + connect(search_request_.get(), SIGNAL(UpdateProgress(int, int)), SIGNAL(SearchUpdateProgress(int, int))); + + search_request_->Search(search_id_, search_text_); + search_request_->Process(); + +} + +void QobuzService::SearchResultsReceived(const int id, const SongList &songs, const QString &error) { + emit SearchResults(id, songs, error); +} + +void QobuzService::GetStreamURL(const QUrl &url) { + + if (app_id().isEmpty() || app_secret().isEmpty()) { // Don't check for login here, because we allow automatic login. + emit StreamURLFinished(url, url, Song::FileType_Stream, -1, -1, -1, tr("Missing Qobuz app ID or secret.")); + return; + } + + 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, int, int, qint64, QString)), this, SLOT(HandleStreamURLFinished(QUrl, QUrl, Song::FileType, int, int, qint64, 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, const int samplerate, const int bit_depth, const qint64 duration, 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, samplerate, bit_depth, duration, error); + +} + +void QobuzService::LoginError(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) login_errors_ << error; + + QString error_html; + for (const QString &e : login_errors_) { + qLog(Error) << "Qobuz:" << e; + error_html += e + "
"; + } + if (debug.isValid()) qLog(Debug) << debug; + + emit LoginFailure(error_html); + emit LoginComplete(false, error_html); + + login_errors_.clear(); + +} diff --git a/src/qobuz/qobuzservice.h b/src/qobuz/qobuzservice.h new file mode 100644 index 00000000..b2ac1b86 --- /dev/null +++ b/src/qobuz/qobuzservice.h @@ -0,0 +1,231 @@ +/* + * 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 + +#include "core/song.h" +#include "internet/internetservice.h" +#include "internet/internetsearchview.h" + +class QTimer; +class QNetworkReply; +class QSortFilterProxyModel; +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: + explicit QobuzService(Application *app, QObject *parent); + ~QobuzService(); + + static const Song::Source kSource; + + void Exit() override; + void ReloadSettings() override; + + void Logout(); + int Search(const QString &text, InternetSearchView::SearchType type) override; + void CancelSearch() override; + + int max_login_attempts() { return kLoginAttempts; } + + Application *app() { return app_; } + 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 download_album_covers() { return download_album_covers_; } + + QString user_auth_token() { return user_auth_token_; } + qint64 user_id() { return user_id_; } + QString device_id() { return device_id_; } + qint64 credential_id() { return credential_id_; } + + bool authenticated() override { return (!app_id_.isEmpty() && !app_secret_.isEmpty() && !user_auth_token_.isEmpty()); } + bool login_sent() { return login_sent_; } + bool login_attempts() { return login_attempts_; } + + void GetStreamURL(const QUrl &url); + + CollectionBackend *artists_collection_backend() override { return artists_collection_backend_; } + CollectionBackend *albums_collection_backend() override { return albums_collection_backend_; } + CollectionBackend *songs_collection_backend() override { return songs_collection_backend_; } + + CollectionModel *artists_collection_model() override { return artists_collection_model_; } + CollectionModel *albums_collection_model() override { return albums_collection_model_; } + CollectionModel *songs_collection_model() override { return songs_collection_model_; } + + QSortFilterProxyModel *artists_collection_sort_model() override { return artists_collection_sort_model_; } + QSortFilterProxyModel *albums_collection_sort_model() override { return albums_collection_sort_model_; } + QSortFilterProxyModel *songs_collection_sort_model() override { return songs_collection_sort_model_; } + + enum QueryType { + QueryType_Artists, + QueryType_Albums, + QueryType_Songs, + QueryType_SearchArtists, + QueryType_SearchAlbums, + QueryType_SearchSongs, + }; + + public slots: + void ShowConfig() override; + void TryLogin(); + void SendLogin(const QString &app_id, const QString &username, const QString &password); + void GetArtists() override; + void GetAlbums() override; + void GetSongs() override; + void ResetArtistsRequest() override; + void ResetAlbumsRequest() override; + void ResetSongsRequest() override; + + private slots: + void ExitReceived(); + void SendLogin(); + void HandleLoginSSLErrors(QList ssl_errors); + void HandleAuthReply(QNetworkReply *reply); + void ResetLoginAttempts(); + void StartSearch(); + void ArtistsResultsReceived(const int id, const SongList &songs, const QString &error); + void AlbumsResultsReceived(const int id, const SongList &songs, const QString &error); + void SongsResultsReceived(const int id, const SongList &songs, const QString &error); + void SearchResultsReceived(const int id, const SongList &songs, const QString &error); + void ArtistsUpdateStatusReceived(const int id, const QString &text); + void AlbumsUpdateStatusReceived(const int id, const QString &text); + void SongsUpdateStatusReceived(const int id, const QString &text); + void ArtistsProgressSetMaximumReceived(const int id, const int max); + void AlbumsProgressSetMaximumReceived(const int id, const int max); + void SongsProgressSetMaximumReceived(const int id, const int max); + void ArtistsUpdateProgressReceived(const int id, const int progress); + void AlbumsUpdateProgressReceived(const int id, const int progress); + void SongsUpdateProgressReceived(const int id, const int progress); + void HandleStreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error); + + private: + typedef QPair Param; + typedef QList ParamList; + + void SendSearch(); + void LoginError(const QString &error = QString(), const 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 download_album_covers_; + + qint64 user_id_; + QString user_auth_token_; + QString device_id_; + qint64 credential_id_; + + int pending_search_id_; + int next_pending_search_id_; + QString pending_search_text_; + InternetSearchView::SearchType pending_search_type_; + + int search_id_; + QString search_text_; + bool login_sent_; + int login_attempts_; + + QList stream_url_requests_; + + QStringList login_errors_; + + QList wait_for_exit_; + QList replies_; + +}; + +#endif // QOBUZSERVICE_H diff --git a/src/qobuz/qobuzstreamurlrequest.cpp b/src/qobuz/qobuzstreamurlrequest.cpp new file mode 100644 index 00000000..931070b5 --- /dev/null +++ b/src/qobuz/qobuzstreamurlrequest.cpp @@ -0,0 +1,249 @@ +/* + * 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 "core/logging.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.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, this, 0); + if (reply_->isRunning()) reply_->abort(); + reply_->deleteLater(); + } + +} + +void QobuzStreamURLRequest::LoginComplete(const bool success, const QString &error) { + + if (!need_login_) return; + need_login_ = false; + + if (!success) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, error); + return; + } + + Process(); + +} + +void QobuzStreamURLRequest::Process() { + + if (app_id().isEmpty() || app_secret().isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, tr("Missing Qobuz 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, -1, -1, -1, tr("Cancelled.")); + } + +} + +void QobuzStreamURLRequest::GetStreamURL() { + + ++tries_; + + if (reply_) { + disconnect(reply_, 0, this, 0); + if (reply_->isRunning()) reply_->abort(); + reply_->deleteLater(); + } + +#if 0 + QByteArray appid = app_id().toUtf8(); + QByteArray secret_decoded = QByteArray::fromBase64(app_secret().toUtf8()); + QString secret; + for (int x = 0, y = 0; x < secret_decoded.length(); ++x , ++y) { + if (y == appid.length()) y = 0; + secret.append(QChar(secret_decoded[x] ^ appid[y])); + } +#endif + + QString secret = app_secret(); + quint64 timestamp = QDateTime::currentDateTime().toSecsSinceEpoch(); + + 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) { + data_to_sign += param.first + param.second; + } + data_to_sign += QString::number(timestamp); + data_to_sign += secret.toUtf8(); + + 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", user_auth_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; + + QByteArray data = GetReplyData(reply_); + + disconnect(reply_, 0, this, 0); + reply_->deleteLater(); + reply_ = nullptr; + + if (data.isEmpty()) { + if (!authenticated() && login_sent() && tries_ <= 1) { + need_login_ = true; + return; + } + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data); + if (json_obj.isEmpty()) { + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + if (!json_obj.contains("track_id")) { + Error("Invalid Json reply, stream url is missing track_id.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + int track_id = json_obj["track_id"].toInt(); + if (track_id != song_id_) { + Error("Incorrect track ID returned.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + return; + } + + if (!json_obj.contains("mime_type") || !json_obj.contains("url")) { + Error("Invalid Json reply, stream url is missing url or mime_type.", json_obj); + emit StreamURLFinished(original_url_, original_url_, Song::FileType_Stream, -1, -1, -1, errors_.first()); + 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("Returned stream url is invalid.", json_obj); + emit StreamURLFinished(original_url_, original_url_, filetype, -1, -1, -1, errors_.first()); + return; + } + + qint64 duration = -1; + if (json_obj.contains("duration")) { + duration = json_obj["duration"].toDouble() * kNsecPerSec; + } + int samplerate = -1; + if (json_obj.contains("sampling_rate")) { + samplerate = json_obj["sampling_rate"].toDouble() * 1000; + } + int bit_depth = -1; + if (json_obj.contains("bit_depth")) { + bit_depth = json_obj["bit_depth"].toDouble(); + } + + emit StreamURLFinished(original_url_, url, filetype, samplerate, bit_depth, duration); + +} + +void QobuzStreamURLRequest::Error(const QString &error, const QVariant &debug) { + + if (!error.isEmpty()) { + qLog(Error) << "Qobuz:" << error; + errors_ << error; + } + if (debug.isValid()) qLog(Debug) << debug; + +} + diff --git a/src/qobuz/qobuzstreamurlrequest.h b/src/qobuz/qobuzstreamurlrequest.h new file mode 100644 index 00000000..14166cb2 --- /dev/null +++ b/src/qobuz/qobuzstreamurlrequest.h @@ -0,0 +1,76 @@ +/* + * 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 +#include +#include +#include + +#include "core/song.h" +#include "qobuzbaserequest.h" + +class QNetworkReply; +class NetworkAccessManager; +class QobuzService; + +class QobuzStreamURLRequest : public QobuzBaseRequest { + Q_OBJECT + + public: + explicit 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, const int samplerate, const int bit_depth, const qint64 duration, QString error = QString()); + + private slots: + void LoginComplete(const bool success, const QString &error = QString()); + void StreamURLReceived(); + + private: + void Error(const QString &error, const QVariant &debug = QVariant()); + + QobuzService *service_; + QNetworkReply *reply_; + QUrl original_url_; + int song_id_; + int tries_; + bool need_login_; + QStringList errors_; + +}; + +#endif // QOBUZSTREAMURLREQUEST_H diff --git a/src/qobuz/qobuzurlhandler.cpp b/src/qobuz/qobuzurlhandler.cpp new file mode 100644 index 00000000..c9086aea --- /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/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, int, int, qint64, QString)), this, SLOT(GetStreamURLFinished(QUrl, QUrl, Song::FileType, int, int, qint64, 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(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error) { + + if (task_id_ == -1) return; + CancelTask(); + if (error.isEmpty()) { + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::TrackAvailable, stream_url, filetype, samplerate, bit_depth, duration)); + } + else { + emit AsyncLoadComplete(LoadResult(original_url, LoadResult::Error, 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 00000000..1e0b34ff --- /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 + +#include "core/urlhandler.h" +#include "core/song.h" +#include "qobuz/qobuzservice.h" + +class Application; + +class QobuzUrlHandler : public UrlHandler { + Q_OBJECT + + public: + explicit QobuzUrlHandler(Application *app, QobuzService *service); + + QString scheme() const { return service_->url_scheme(); } + LoadResult StartLoading(const QUrl &url); + + void CancelTask(); + + private slots: + void GetStreamURLFinished(const QUrl &original_url, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 duration, QString error = QString()); + + private: + Application *app_; + QobuzService *service_; + int task_id_; + +}; + +#endif diff --git a/src/settings/coverssettingspage.cpp b/src/settings/coverssettingspage.cpp index 0cb5f08f..1f712463 100644 --- a/src/settings/coverssettingspage.cpp +++ b/src/settings/coverssettingspage.cpp @@ -121,6 +121,10 @@ void CoversSettingsPage::CurrentItemChanged(QListWidgetItem *item_current, QList DisableAuthentication(); ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate.")); } + else if (provider->name() == "Qobuz" && !provider->IsAuthenticated()) { + DisableAuthentication(); + ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate.")); + } else { ui_->login_state->SetLoggedIn(provider->IsAuthenticated() ? LoginStateWidget::LoggedIn : LoginStateWidget::LoggedOut); ui_->button_authenticate->setEnabled(true); @@ -229,6 +233,10 @@ void CoversSettingsPage::LogoutClicked() { DisableAuthentication(); ui_->label_auth_info->setText(tr("Use Tidal settings to authenticate.")); } + else if (provider->name() == "Qobuz") { + DisableAuthentication(); + ui_->label_auth_info->setText(tr("Use Qobuz settings to authenticate.")); + } else { ui_->button_authenticate->setEnabled(true); ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); diff --git a/src/settings/qobuzsettingspage.cpp b/src/settings/qobuzsettingspage.cpp new file mode 100644 index 00000000..db2e422c --- /dev/null +++ b/src/settings/qobuzsettingspage.cpp @@ -0,0 +1,171 @@ +/* + * 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 "settingsdialog.h" +#include "qobuzsettingspage.h" +#include "ui_qobuzsettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "widgets/loginstatewidget.h" +#include "internet/internetservices.h" +#include "qobuz/qobuzservice.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", 4).toInt()); + ui_->albumssearchlimit->setValue(s.value("albumssearchlimit", 10).toInt()); + ui_->songssearchlimit->setValue(s.value("songssearchlimit", 10).toInt()); + ui_->checkbox_download_album_covers->setChecked(s.value("downloadalbumcovers", true).toBool()); + + s.endGroup(); + + if (service_->authenticated()) ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + + Init(ui_->layout_qobuzsettingspage->parentWidget()); + +} + +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("downloadalbumcovers", ui_->checkbox_download_album_covers->isChecked()); + s.endGroup(); + + service_->ReloadSettings(); + +} + +void QobuzSettingsPage::LoginClicked() { + + if (ui_->app_id->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing app id.")); + return; + } + if (ui_->username->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing username.")); + return; + } + if (ui_->password->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing 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_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); + ui_->button_login->setEnabled(true); +} + +void QobuzSettingsPage::LoginSuccess() { + if (!this->isVisible()) return; + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn); + ui_->button_login->setEnabled(true); +} + +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 00000000..21db0a4f --- /dev/null +++ b/src/settings/qobuzsettingspage.h @@ -0,0 +1,62 @@ +/* + * 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 "settings/settingspage.h" + +class QEvent; +class SettingsDialog; +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 00000000..7c7b5571 --- /dev/null +++ b/src/settings/qobuzsettingspage.ui @@ -0,0 +1,306 @@ + + + QobuzSettingsPage + + + + 0 + 0 + 472 + 697 + + + + Qobuz + + + + + + Enable + + + + + + + Qobuz support is not official and requires an API app ID and secret from a registered application to work. We can't help you getting these. + + + true + + + 10 + + + + + + + + 0 + 0 + + + + Authentication + + + + + + + 150 + 0 + + + + App ID + + + + + + + + + + Username + + + + + + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + App Secret + + + + + + + + + + + + + Login + + + + + + + + + + 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 + + + + + + + Download album covers + + + + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + :/icons/64x64/qobuz.png + + + + + + + + + + LoginStateWidget + QWidget +

widgets/loginstatewidget.h
+ 1 + + + + enable + app_id + app_secret + username + password + button_login + format + searchdelay + artistssearchlimit + albumssearchlimit + songssearchlimit + checkbox_download_album_covers + + + + + + + diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index 935c051e..3deb582d 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -78,6 +78,9 @@ #ifdef HAVE_TIDAL # include "tidalsettingspage.h" #endif +#ifdef HAVE_QOBUZ +# include "qobuzsettingspage.h" +#endif #include "ui_settingsdialog.h" @@ -153,7 +156,7 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main AddPage(Page_Moodbar, new MoodbarSettingsPage(this), iface); #endif -#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) +#if defined(HAVE_SUBSONIC) || defined(HAVE_TIDAL) || defined(HAVE_QOBUZ) QTreeWidgetItem *streaming = AddCategory(tr("Streaming")); #endif @@ -163,6 +166,9 @@ SettingsDialog::SettingsDialog(Application *app, OSDBase *osd, QMainWindow *main #ifdef HAVE_TIDAL AddPage(Page_Tidal, new TidalSettingsPage(this), streaming); #endif +#ifdef HAVE_QOBUZ + AddPage(Page_Qobuz, new QobuzSettingsPage(this), streaming); +#endif // List box connect(ui_->list, SIGNAL(currentItemChanged(QTreeWidgetItem*,QTreeWidgetItem*)), SLOT(CurrentItemChanged(QTreeWidgetItem*))); diff --git a/src/settings/settingsdialog.h b/src/settings/settingsdialog.h index 8b937d02..4892c6f7 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -90,6 +90,7 @@ class SettingsDialog : public QDialog { Page_Moodbar, Page_Subsonic, Page_Tidal, + Page_Qobuz, }; enum Role { diff --git a/src/smartplaylists/playlistgenerator.cpp b/src/smartplaylists/playlistgenerator.cpp new file mode 100644 index 00000000..e3e9f38a --- /dev/null +++ b/src/smartplaylists/playlistgenerator.cpp @@ -0,0 +1,43 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "core/logging.h" + +#include "playlistgenerator.h" +#include "playlistquerygenerator.h" + +const int PlaylistGenerator::kDefaultLimit = 20; +const int PlaylistGenerator::kDefaultDynamicHistory = 5; +const int PlaylistGenerator::kDefaultDynamicFuture = 15; + +PlaylistGenerator::PlaylistGenerator() : QObject(nullptr) {} + +PlaylistGeneratorPtr PlaylistGenerator::Create(const Type type) { + + Q_UNUSED(type) + + return PlaylistGeneratorPtr(new PlaylistQueryGenerator); + +} diff --git a/src/smartplaylists/playlistgenerator.h b/src/smartplaylists/playlistgenerator.h new file mode 100644 index 00000000..a637a6bc --- /dev/null +++ b/src/smartplaylists/playlistgenerator.h @@ -0,0 +1,100 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 PLAYLISTGENERATOR_H +#define PLAYLISTGENERATOR_H + +#include "config.h" + +#include + +#include +#include +#include + +#include "playlist/playlistitem.h" + +class CollectionBackend; + +class PlaylistGenerator : public QObject, public std::enable_shared_from_this { + Q_OBJECT + + public: + explicit PlaylistGenerator(); + + static const int kDefaultLimit; + static const int kDefaultDynamicHistory; + static const int kDefaultDynamicFuture; + + enum Type { + Type_None = 0, + Type_Query = 1 + }; + + // Creates a new PlaylistGenerator of the given type + static std::shared_ptr Create(const Type type = Type_Query); + + // Should be called before Load on a new PlaylistGenerator + void set_collection(CollectionBackend *backend) { backend_ = backend; } + void set_name(const QString &name) { name_ = name; } + CollectionBackend *collection() const { return backend_; } + QString name() const { return name_; } + + // Name of the subclass + virtual Type type() const = 0; + + // Serializes the PlaylistGenerator's settings + // Called on UI-thread. + virtual void Load(const QByteArray &data) = 0; + // Called on UI-thread. + virtual QByteArray Save() const = 0; + + // Creates and returns a playlist + // Called from non-UI thread. + virtual PlaylistItemList Generate() = 0; + + // If the generator can be used as a dynamic playlist then GenerateMore should return the next tracks in the sequence. + // The subclass should remember the last GetDynamicHistory() + GetDynamicFuture() tracks, + // and ensure that the tracks returned from this method are not in that set. + virtual bool is_dynamic() const { return false; } + virtual void set_dynamic(const bool dynamic) { Q_UNUSED(dynamic); } + // Called from non-UI thread. + virtual PlaylistItemList GenerateMore(int count) { + Q_UNUSED(count); + return PlaylistItemList(); + } + + virtual int GetDynamicHistory() { return kDefaultDynamicHistory; } + virtual int GetDynamicFuture() { return kDefaultDynamicFuture; } + + signals: + void Error(const QString& message); + + protected: + CollectionBackend *backend_; + + private: + QString name_; + +}; + +#include "playlistgenerator_fwd.h" + +#endif // PLAYLISTGENERATOR_H diff --git a/src/smartplaylists/playlistgenerator_fwd.h b/src/smartplaylists/playlistgenerator_fwd.h new file mode 100644 index 00000000..bb3d58b1 --- /dev/null +++ b/src/smartplaylists/playlistgenerator_fwd.h @@ -0,0 +1,32 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 PLAYLISTGENERATOR_FWD_H +#define PLAYLISTGENERATOR_FWD_H + +#include "config.h" + +#include + +class PlaylistGenerator; + +typedef std::shared_ptr PlaylistGeneratorPtr; + +#endif // PLAYLISTGENERATOR_FWD_H diff --git a/src/smartplaylists/playlistgeneratorinserter.cpp b/src/smartplaylists/playlistgeneratorinserter.cpp new file mode 100644 index 00000000..12edac2a --- /dev/null +++ b/src/smartplaylists/playlistgeneratorinserter.cpp @@ -0,0 +1,90 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "core/closure.h" +#include "core/taskmanager.h" + +#include "playlist/playlist.h" +#include "playlistgenerator.h" +#include "playlistgeneratorinserter.h" + +class CollectionBackend; + +PlaylistGeneratorInserter::PlaylistGeneratorInserter(TaskManager *task_manager, CollectionBackend *collection, QObject *parent) + : QObject(parent), + task_manager_(task_manager), + collection_(collection), + task_id_(-1), + is_dynamic_(false) + {} + +PlaylistItemList PlaylistGeneratorInserter::Generate(PlaylistGeneratorPtr generator, int dynamic_count) { + + if (dynamic_count) { + return generator->GenerateMore(dynamic_count); + } + else { + return generator->Generate(); + } + +} + +void PlaylistGeneratorInserter::Load(Playlist *destination, const int row, const bool play_now, const bool enqueue, const bool enqueue_next, PlaylistGeneratorPtr generator, const int dynamic_count) { + + task_id_ = task_manager_->StartTask(tr("Loading smart playlist")); + + destination_ = destination; + row_ = row; + play_now_ = play_now; + enqueue_ = enqueue; + enqueue_next_ = enqueue_next; + is_dynamic_ = generator->is_dynamic(); + + connect(generator.get(), SIGNAL(Error(QString)), SIGNAL(Error(QString))); + + QFuture future = QtConcurrent::run(PlaylistGeneratorInserter::Generate, generator, dynamic_count); + NewClosure(future, this, SLOT(Finished(QFuture)), future); + +} + +void PlaylistGeneratorInserter::Finished(QFuture future) { + + PlaylistItemList items = future.result(); + + if (items.isEmpty()) { + if (is_dynamic_) { + destination_->TurnOffDynamicPlaylist(); + } + } + else { + destination_->InsertItems(items, row_, play_now_, enqueue_); + } + + task_manager_->SetTaskFinished(task_id_); + + deleteLater(); + +} diff --git a/src/smartplaylists/playlistgeneratorinserter.h b/src/smartplaylists/playlistgeneratorinserter.h new file mode 100644 index 00000000..fdcc402b --- /dev/null +++ b/src/smartplaylists/playlistgeneratorinserter.h @@ -0,0 +1,71 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 PLAYLISTGENERATORINSERTER_H +#define PLAYLISTGENERATORINSERTER_H + +#include "config.h" + +#include +#include +#include + +#include "playlist/playlist.h" +#include "playlist/playlistitem.h" + +#include "playlistgenerator_fwd.h" + +class TaskManager; +class CollectionBackend; +class Playlist; + +class PlaylistGeneratorInserter : public QObject { + Q_OBJECT + + public: + explicit PlaylistGeneratorInserter(TaskManager *task_manager, CollectionBackend *collection, QObject *parent); + + void Load(Playlist *destination, const int row, const bool play_now, const bool enqueue, const bool enqueue_next, PlaylistGeneratorPtr generator, const int dynamic_count = 0); + + private: + static PlaylistItemList Generate(PlaylistGeneratorPtr generator, const int dynamic_count); + + signals: + void Error(const QString &message); + void PlayRequested(const QModelIndex &idx); + + private slots: + void Finished(QFuture future); + + private: + TaskManager *task_manager_; + CollectionBackend *collection_; + int task_id_; + + Playlist *destination_; + int row_; + bool play_now_; + bool enqueue_; + bool enqueue_next_; + bool is_dynamic_; + +}; + +#endif // PLAYLISTGENERATORINSERTER_H diff --git a/src/smartplaylists/playlistgeneratormimedata.h b/src/smartplaylists/playlistgeneratormimedata.h new file mode 100644 index 00000000..5139610d --- /dev/null +++ b/src/smartplaylists/playlistgeneratormimedata.h @@ -0,0 +1,41 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 PLAYLISTGENERATORMIMEDATA_H +#define PLAYLISTGENERATORMIMEDATA_H + +#include "config.h" + +#include +#include + +#include "core/mimedata.h" +#include "playlistgenerator_fwd.h" + +class PlaylistGeneratorMimeData : public MimeData { + Q_OBJECT + + public: + PlaylistGeneratorMimeData(PlaylistGeneratorPtr generator) : generator_(generator) {} + + PlaylistGeneratorPtr generator_; +}; + +#endif // PLAYLISTGENERATORMIMEDATA_H diff --git a/src/smartplaylists/playlistquerygenerator.cpp b/src/smartplaylists/playlistquerygenerator.cpp new file mode 100644 index 00000000..93a8b25c --- /dev/null +++ b/src/smartplaylists/playlistquerygenerator.cpp @@ -0,0 +1,101 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "core/logging.h" +#include "playlistquerygenerator.h" +#include "collection/collectionbackend.h" + +PlaylistQueryGenerator::PlaylistQueryGenerator() : dynamic_(false), current_pos_(0) {} + +PlaylistQueryGenerator::PlaylistQueryGenerator(const QString &name, const SmartPlaylistSearch &search, const bool dynamic) + : search_(search), dynamic_(dynamic), current_pos_(0) { + + set_name(name); + +} + +void PlaylistQueryGenerator::Load(const SmartPlaylistSearch &search) { + + search_ = search; + dynamic_ = false; + current_pos_ = 0; + +} + +void PlaylistQueryGenerator::Load(const QByteArray &data) { + + QDataStream s(data); + s >> search_; + s >> dynamic_; + +} + +QByteArray PlaylistQueryGenerator::Save() const { + + QByteArray ret; + QDataStream s(&ret, QIODevice::WriteOnly); + s << search_; + s << dynamic_; + + return ret; + +} + +PlaylistItemList PlaylistQueryGenerator::Generate() { + + previous_ids_.clear(); + current_pos_ = 0; + return GenerateMore(0); + +} + +PlaylistItemList PlaylistQueryGenerator::GenerateMore(const int count) { + + SmartPlaylistSearch search_copy = search_; + search_copy.id_not_in_ = previous_ids_; + if (count) { + search_copy.limit_ = count; + } + + if (search_copy.sort_type_ != SmartPlaylistSearch::Sort_Random) { + search_copy.first_item_ = current_pos_; + current_pos_ += search_copy.limit_; + } + + SongList songs = backend_->FindSongs(search_copy); + PlaylistItemList items; + for (const Song &song : songs) { + items << PlaylistItemPtr(PlaylistItem::NewFromSong(song)); + previous_ids_ << song.id(); + + if (previous_ids_.count() > GetDynamicFuture() + GetDynamicHistory()) + previous_ids_.removeFirst(); + } + + return items; + +} diff --git a/src/smartplaylists/playlistquerygenerator.h b/src/smartplaylists/playlistquerygenerator.h new file mode 100644 index 00000000..6709d431 --- /dev/null +++ b/src/smartplaylists/playlistquerygenerator.h @@ -0,0 +1,61 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 PLAYLISTQUERYGENERATOR_H +#define PLAYLISTQUERYGENERATOR_H + +#include "config.h" + +#include +#include +#include + +#include "playlistgenerator.h" +#include "smartplaylistsearch.h" + +class PlaylistQueryGenerator : public PlaylistGenerator { + public: + explicit PlaylistQueryGenerator(); + explicit PlaylistQueryGenerator(const QString &name, const SmartPlaylistSearch &search, const bool dynamic = false); + + Type type() const { return Type_Query; } + + void Load(const SmartPlaylistSearch &search); + void Load(const QByteArray &data); + QByteArray Save() const; + + PlaylistItemList Generate(); + PlaylistItemList GenerateMore(const int count); + bool is_dynamic() const { return dynamic_; } + void set_dynamic(bool dynamic) { dynamic_ = dynamic; } + + SmartPlaylistSearch search() const { return search_; } + int GetDynamicFuture() { return search_.limit_; } + + private: + SmartPlaylistSearch search_; + bool dynamic_; + + QList previous_ids_; + int current_pos_; + +}; + +#endif // PLAYLISTQUERYGENERATOR_H diff --git a/src/smartplaylists/smartplaylistquerysearchpage.ui b/src/smartplaylists/smartplaylistquerysearchpage.ui new file mode 100644 index 00000000..2fb9b0a8 --- /dev/null +++ b/src/smartplaylists/smartplaylistquerysearchpage.ui @@ -0,0 +1,134 @@ + + + SmartPlaylistQuerySearchPage + + + + 0 + 0 + 448 + 450 + + + + + 0 + 0 + + + + Form + + + #terms_scroll_area, #terms_scroll_area_content { + background: transparent; +} + + + + 0 + + + + + Search mode + + + + + + + Match every search term (AND) + + + + + Match one or more search terms (OR) + + + + + Include all songs + + + + + + + + + + + + 0 + 0 + + + + + 0 + 300 + + + + Search terms + + + + + + + 0 + 0 + + + + QFrame::NoFrame + + + true + + + + + 0 + 0 + 418 + 251 + + + + + 0 + 0 + + + + + 0 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + + + diff --git a/src/smartplaylists/smartplaylistquerysortpage.ui b/src/smartplaylists/smartplaylistquerysortpage.ui new file mode 100644 index 00000000..974da600 --- /dev/null +++ b/src/smartplaylists/smartplaylistquerysortpage.ui @@ -0,0 +1,130 @@ + + + SmartPlaylistQuerySortPage + + + + 0 + 0 + 723 + 335 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Sorting + + + + + + Put songs in a random order + + + true + + + + + + + Sort songs by + + + + + + + + + + + + QComboBox::AdjustToContents + + + + + + + + + + + + Limits + + + + + + Show all the songs + + + true + + + + + + + Only show the first + + + + + + + songs + + + 1000 + + + 15 + + + + + + + + + + Qt::Horizontal + + + + + + + + + + + SmartPlaylistSearchPreview + QWidget +
smartplaylists/smartplaylistsearchpreview.h
+ 1 +
+
+ + +
diff --git a/src/smartplaylists/smartplaylistquerywizardplugin.cpp b/src/smartplaylists/smartplaylistquerywizardplugin.cpp new file mode 100644 index 00000000..d8f4fe95 --- /dev/null +++ b/src/smartplaylists/smartplaylistquerywizardplugin.cpp @@ -0,0 +1,327 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "core/logging.h" +#include "playlistquerygenerator.h" +#include "smartplaylistquerywizardplugin.h" +#include "smartplaylistsearchtermwidget.h" +#include "ui_smartplaylistquerysearchpage.h" +#include "ui_smartplaylistquerysortpage.h" + +class SmartPlaylistQueryWizardPlugin::SearchPage : public QWizardPage { + + friend class SmartPlaylistQueryWizardPlugin; + + public: + SearchPage(QWidget *parent = 0) + : QWizardPage(parent), ui_(new Ui_SmartPlaylistQuerySearchPage) { + ui_->setupUi(this); + } + + bool isComplete() const { + if (ui_->type->currentIndex() == 2) // All songs + return true; + + for (SmartPlaylistSearchTermWidget *widget : terms_) { + if (!widget->Term().is_valid()) return false; + } + return true; + } + + QVBoxLayout *layout_; + QList terms_; + SmartPlaylistSearchTermWidget *new_term_; + + SmartPlaylistSearchPreview *preview_; + + std::unique_ptr ui_; +}; + +class SmartPlaylistQueryWizardPlugin::SortPage : public QWizardPage { + public: + SortPage(SmartPlaylistQueryWizardPlugin *plugin, QWidget *parent, int next_id) + : QWizardPage(parent), next_id_(next_id), plugin_(plugin) {} + + void showEvent(QShowEvent*) { plugin_->UpdateSortPreview(); } + + int nextId() const { return next_id_; } + int next_id_; + + SmartPlaylistQueryWizardPlugin *plugin_; +}; + +SmartPlaylistQueryWizardPlugin::SmartPlaylistQueryWizardPlugin(Application *app, CollectionBackend *collection, QObject *parent) + : SmartPlaylistWizardPlugin(app, collection, parent), + search_page_(nullptr), + previous_scrollarea_max_(0) {} + +SmartPlaylistQueryWizardPlugin::~SmartPlaylistQueryWizardPlugin() {} + +QString SmartPlaylistQueryWizardPlugin::name() const { return tr("Collection search"); } + +QString SmartPlaylistQueryWizardPlugin::description() const { + return tr("Find songs in your collection that match the criteria you specify."); +} + +int SmartPlaylistQueryWizardPlugin::CreatePages(QWizard *wizard, int finish_page_id) { + + // Create the UI + search_page_ = new SearchPage(wizard); + + QWizardPage *sort_page = new SortPage(this, wizard, finish_page_id); + sort_ui_.reset(new Ui_SmartPlaylistQuerySortPage); + sort_ui_->setupUi(sort_page); + + sort_ui_->limit_value->setValue(PlaylistGenerator::kDefaultLimit); + + connect(search_page_->ui_->type, SIGNAL(currentIndexChanged(int)), SLOT(SearchTypeChanged())); + + // Create the new search term widget + search_page_->new_term_ = new SmartPlaylistSearchTermWidget(collection_, search_page_); + search_page_->new_term_->SetActive(false); + connect(search_page_->new_term_, SIGNAL(Clicked()), SLOT(AddSearchTerm())); + + // Add an empty initial term + search_page_->layout_ = static_cast(search_page_->ui_->terms_scroll_area_content->layout()); + search_page_->layout_->addWidget(search_page_->new_term_); + AddSearchTerm(); + + // Ensure that the terms are scrolled to the bottom when a new one is added + connect(search_page_->ui_->terms_scroll_area->verticalScrollBar(), SIGNAL(rangeChanged(int, int)), this, SLOT(MoveTermListToBottom(int, int))); + + // Add the preview widget at the bottom of the search terms page + QVBoxLayout *terms_page_layout = static_cast(search_page_->layout()); + terms_page_layout->addStretch(); + search_page_->preview_ = new SmartPlaylistSearchPreview(search_page_); + search_page_->preview_->set_application(app_); + search_page_->preview_->set_collection(collection_); + terms_page_layout->addWidget(search_page_->preview_); + + // Add sort field texts + for (int i = 0; i < SmartPlaylistSearchTerm::FieldCount; ++i) { + const SmartPlaylistSearchTerm::Field field = SmartPlaylistSearchTerm::Field(i); + const QString field_name = SmartPlaylistSearchTerm::FieldName(field); + sort_ui_->field_value->addItem(field_name); + } + connect(sort_ui_->field_value, SIGNAL(currentIndexChanged(int)), SLOT(UpdateSortOrder())); + UpdateSortOrder(); + + // Set the sort and limit radio buttons back to their defaults - they would + // have been changed by setupUi + sort_ui_->random->setChecked(true); + sort_ui_->limit_none->setChecked(true); + + // Set up the preview widget that's already at the bottom of the sort page + sort_ui_->preview->set_application(app_); + sort_ui_->preview->set_collection(collection_); + connect(sort_ui_->field, SIGNAL(toggled(bool)), SLOT(UpdateSortPreview())); + connect(sort_ui_->field_value, SIGNAL(currentIndexChanged(int)), SLOT(UpdateSortPreview())); + connect(sort_ui_->limit_limit, SIGNAL(toggled(bool)), SLOT(UpdateSortPreview())); + connect(sort_ui_->limit_none, SIGNAL(toggled(bool)), SLOT(UpdateSortPreview())); + connect(sort_ui_->limit_value, SIGNAL(valueChanged(int)), SLOT(UpdateSortPreview())); + connect(sort_ui_->order, SIGNAL(currentIndexChanged(int)), SLOT(UpdateSortPreview())); + connect(sort_ui_->random, SIGNAL(toggled(bool)), SLOT(UpdateSortPreview())); + + // Configure the page text + search_page_->setTitle(tr("Search terms")); + search_page_->setSubTitle(tr("A song will be included in the playlist if it matches these conditions.")); + sort_page->setTitle(tr("Search options")); + sort_page->setSubTitle(tr("Choose how the playlist is sorted and how many songs it will contain.")); + + // Add the pages + const int first_page = wizard->addPage(search_page_); + wizard->addPage(sort_page); + return first_page; + +} + +void SmartPlaylistQueryWizardPlugin::SetGenerator(PlaylistGeneratorPtr g) { + + std::shared_ptr gen = std::dynamic_pointer_cast(g); + if (!gen) return; + SmartPlaylistSearch search = gen->search(); + + // Search type + search_page_->ui_->type->setCurrentIndex(search.search_type_); + + // Search terms + qDeleteAll(search_page_->terms_); + search_page_->terms_.clear(); + + for (const SmartPlaylistSearchTerm& term : search.terms_) { + AddSearchTerm(); + search_page_->terms_.last()->SetTerm(term); + } + + // Sort order + if (search.sort_type_ == SmartPlaylistSearch::Sort_Random) { + sort_ui_->random->setChecked(true); + } + else { + sort_ui_->field->setChecked(true); + sort_ui_->order->setCurrentIndex( + search.sort_type_ == SmartPlaylistSearch::Sort_FieldAsc ? 0 : 1); + sort_ui_->field_value->setCurrentIndex(search.sort_field_); + } + + // Limit + if (search.limit_ == -1) { + sort_ui_->limit_none->setChecked(true); + } + else { + sort_ui_->limit_limit->setChecked(true); + sort_ui_->limit_value->setValue(search.limit_); + } + +} + +PlaylistGeneratorPtr SmartPlaylistQueryWizardPlugin::CreateGenerator() const { + + std::shared_ptr gen(new PlaylistQueryGenerator); + gen->Load(MakeSearch()); + + return std::static_pointer_cast(gen); + +} + +void SmartPlaylistQueryWizardPlugin::UpdateSortOrder() { + + const SmartPlaylistSearchTerm::Field field = SmartPlaylistSearchTerm::Field(sort_ui_->field_value->currentIndex()); + const SmartPlaylistSearchTerm::Type type = SmartPlaylistSearchTerm::TypeOf(field); + const QString asc = SmartPlaylistSearchTerm::FieldSortOrderText(type, true); + const QString desc = SmartPlaylistSearchTerm::FieldSortOrderText(type, false); + + const int old_current_index = sort_ui_->order->currentIndex(); + sort_ui_->order->clear(); + sort_ui_->order->addItem(asc); + sort_ui_->order->addItem(desc); + sort_ui_->order->setCurrentIndex(old_current_index); + +} + +void SmartPlaylistQueryWizardPlugin::AddSearchTerm() { + + SmartPlaylistSearchTermWidget *widget = new SmartPlaylistSearchTermWidget(collection_, search_page_); + connect(widget, SIGNAL(RemoveClicked()), SLOT(RemoveSearchTerm())); + connect(widget, SIGNAL(Changed()), SLOT(UpdateTermPreview())); + + search_page_->layout_->insertWidget(search_page_->terms_.count(), widget); + search_page_->terms_ << widget; + + UpdateTermPreview(); + +} + +void SmartPlaylistQueryWizardPlugin::RemoveSearchTerm() { + + SmartPlaylistSearchTermWidget *widget = qobject_cast(sender()); + if (!widget) return; + + const int index = search_page_->terms_.indexOf(widget); + if (index == -1) return; + + search_page_->terms_.takeAt(index)->deleteLater(); + UpdateTermPreview(); + +} + +void SmartPlaylistQueryWizardPlugin::UpdateTermPreview() { + + SmartPlaylistSearch search = MakeSearch(); + emit search_page_->completeChanged(); + // When removing last term, update anyway the search + if (!search.is_valid() && !search_page_->terms_.isEmpty()) return; + + // Don't apply limits in the term page + search.limit_ = -1; + + search_page_->preview_->Update(search); + +} + +void SmartPlaylistQueryWizardPlugin::UpdateSortPreview() { + + SmartPlaylistSearch search = MakeSearch(); + if (!search.is_valid()) return; + + sort_ui_->preview->Update(search); + +} + +SmartPlaylistSearch SmartPlaylistQueryWizardPlugin::MakeSearch() const { + + SmartPlaylistSearch ret; + + // Search type + ret.search_type_ = SmartPlaylistSearch::SearchType(search_page_->ui_->type->currentIndex()); + + // Search terms + for (SmartPlaylistSearchTermWidget *widget : search_page_->terms_) { + SmartPlaylistSearchTerm term = widget->Term(); + if (term.is_valid()) ret.terms_ << term; + } + + // Sort order + if (sort_ui_->random->isChecked()) { + ret.sort_type_ = SmartPlaylistSearch::Sort_Random; + } + else { + const bool ascending = sort_ui_->order->currentIndex() == 0; + ret.sort_type_ = ascending ? SmartPlaylistSearch::Sort_FieldAsc : SmartPlaylistSearch::Sort_FieldDesc; + ret.sort_field_ = SmartPlaylistSearchTerm::Field(sort_ui_->field_value->currentIndex()); + } + + // Limit + if (sort_ui_->limit_none->isChecked()) + ret.limit_ = -1; + else + ret.limit_ = sort_ui_->limit_value->value(); + + return ret; + +} + +void SmartPlaylistQueryWizardPlugin::SearchTypeChanged() { + + const bool all = search_page_->ui_->type->currentIndex() == 2; + search_page_->ui_->terms_scroll_area_content->setEnabled(!all); + + UpdateTermPreview(); + +} + +void SmartPlaylistQueryWizardPlugin::MoveTermListToBottom(int min, int max) { + + Q_UNUSED(min); + // Only scroll to the bottom if a new term is added + if (previous_scrollarea_max_ < max) + search_page_->ui_->terms_scroll_area->verticalScrollBar()->setValue(max); + + previous_scrollarea_max_ = max; + +} diff --git a/src/smartplaylists/smartplaylistquerywizardplugin.h b/src/smartplaylists/smartplaylistquerywizardplugin.h new file mode 100644 index 00000000..fb676003 --- /dev/null +++ b/src/smartplaylists/smartplaylistquerywizardplugin.h @@ -0,0 +1,80 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTQUERYWIZARDPLUGIN_H +#define SMARTPLAYLISTQUERYWIZARDPLUGIN_H + +#include "config.h" + +#include + +#include +#include + +#include "smartplaylistwizardplugin.h" +#include "smartplaylistsearch.h" + +class QWizard; + +class CollectionBackend; +class SmartPlaylistSearch; +class Ui_SmartPlaylistQuerySortPage; + +class SmartPlaylistQueryWizardPlugin : public SmartPlaylistWizardPlugin { + Q_OBJECT + + public: + explicit SmartPlaylistQueryWizardPlugin(Application *app, CollectionBackend *collection, QObject *parent); + ~SmartPlaylistQueryWizardPlugin(); + + QString type() const { return "Query"; } + QString name() const; + QString description() const; + bool is_dynamic() const { return true; } + + int CreatePages(QWizard *wizard, const int finish_page_id); + void SetGenerator(PlaylistGeneratorPtr); + PlaylistGeneratorPtr CreateGenerator() const; + + private slots: + void AddSearchTerm(); + void RemoveSearchTerm(); + + void SearchTypeChanged(); + + void UpdateTermPreview(); + void UpdateSortPreview(); + void UpdateSortOrder(); + + void MoveTermListToBottom(const int min, const int max); + + private: + class SearchPage; + class SortPage; + + SmartPlaylistSearch MakeSearch() const; + + SearchPage *search_page_; + std::unique_ptr sort_ui_; + + int previous_scrollarea_max_; +}; + +#endif // SMARTPLAYLISTQUERYWIZARDPLUGIN_H diff --git a/src/smartplaylists/smartplaylistsearch.cpp b/src/smartplaylists/smartplaylistsearch.cpp new file mode 100644 index 00000000..f686c98e --- /dev/null +++ b/src/smartplaylists/smartplaylistsearch.cpp @@ -0,0 +1,148 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "search.h" +#include "core/logging.h" +#include "core/song.h" + +#include "smartplaylistsearch.h" + +SmartPlaylistSearch::SmartPlaylistSearch() { Reset(); } + +SmartPlaylistSearch::SmartPlaylistSearch(const SearchType type, const TermList terms, const SortType sort_type, const SmartPlaylistSearchTerm::Field sort_field, const int limit) + : search_type_(type), + terms_(terms), + sort_type_(sort_type), + sort_field_(sort_field), + limit_(limit), + first_item_(0) {} + +void SmartPlaylistSearch::Reset() { + + search_type_ = Type_And; + terms_.clear(); + sort_type_ = Sort_Random; + sort_field_ = SmartPlaylistSearchTerm::Field_Title; + limit_ = -1; + first_item_ = 0; + +} + +QString SmartPlaylistSearch::ToSql(const QString &songs_table) const { + + QString sql = "SELECT ROWID," + Song::kColumnSpec + " FROM " + songs_table; + + // Add search terms + QStringList where_clauses; + QStringList term_where_clauses; + for (const SmartPlaylistSearchTerm &term : terms_) { + term_where_clauses << term.ToSql(); + } + + if (!terms_.isEmpty() && search_type_ != Type_All) { + QString boolean_op = search_type_ == Type_And ? " AND " : " OR "; + where_clauses << "(" + term_where_clauses.join(boolean_op) + ")"; + } + + // Restrict the IDs of songs if we're making a dynamic playlist + if (!id_not_in_.isEmpty()) { + QString numbers; + for (int id : id_not_in_) { + numbers += (numbers.isEmpty() ? "" : ",") + QString::number(id); + } + where_clauses << "(ROWID NOT IN (" + numbers + "))"; + } + + // We never want to include songs that have been deleted, + // but are still kept in the database in case the directory containing them has just been unmounted. + where_clauses << "unavailable = 0"; + + if (!where_clauses.isEmpty()) { + sql += " WHERE " + where_clauses.join(" AND "); + } + + // Add sort by + if (sort_type_ == Sort_Random) { + sql += " ORDER BY random()"; + } + else { + sql += " ORDER BY " + SmartPlaylistSearchTerm::FieldColumnName(sort_field_) + (sort_type_ == Sort_FieldAsc ? " ASC" : " DESC"); + } + + // Add limit + if (first_item_) { + sql += QString(" LIMIT %1 OFFSET %2").arg(limit_).arg(first_item_); + } + else if (limit_ != -1) { + sql += " LIMIT " + QString::number(limit_); + } + //qLog(Debug) << sql; + + return sql; + +} + +bool SmartPlaylistSearch::is_valid() const { + + if (search_type_ == Type_All) return true; + return !terms_.isEmpty(); + +} + +bool SmartPlaylistSearch::operator==(const SmartPlaylistSearch &other) const { + + return search_type_ == other.search_type_ && + terms_ == other.terms_ && + sort_type_ == other.sort_type_ && + sort_field_ == other.sort_field_ && + limit_ == other.limit_; +} + +QDataStream &operator<<(QDataStream &s, const SmartPlaylistSearch &search) { + + s << search.terms_; + s << quint8(search.sort_type_); + s << quint8(search.sort_field_); + s << qint32(search.limit_); + s << quint8(search.search_type_); + return s; + +} + +QDataStream &operator>>(QDataStream &s, SmartPlaylistSearch &search) { + + quint8 sort_type, sort_field, search_type; + qint32 limit; + + s >> search.terms_ >> sort_type >> sort_field >> limit >> search_type; + search.sort_type_ = SmartPlaylistSearch::SortType(sort_type); + search.sort_field_ = SmartPlaylistSearchTerm::Field(sort_field); + search.limit_ = limit; + search.search_type_ = SmartPlaylistSearch::SearchType(search_type); + + return s; + +} diff --git a/src/smartplaylists/smartplaylistsearch.h b/src/smartplaylists/smartplaylistsearch.h new file mode 100644 index 00000000..cec1fbe5 --- /dev/null +++ b/src/smartplaylists/smartplaylistsearch.h @@ -0,0 +1,69 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTSEARCH_H +#define SMARTPLAYLISTSEARCH_H + +#include "config.h" + +#include +#include +#include + +#include "playlistgenerator.h" +#include "smartplaylistsearchterm.h" + +class SmartPlaylistSearch { + + public: + typedef QList TermList; + + // These values are persisted, so add to the end of the enum only + enum SearchType { Type_And = 0, Type_Or, Type_All, }; + + // These values are persisted, so add to the end of the enum only + enum SortType { Sort_Random = 0, Sort_FieldAsc, Sort_FieldDesc, }; + + explicit SmartPlaylistSearch(); + explicit SmartPlaylistSearch(const SearchType type, const TermList terms, const SortType sort_type, const SmartPlaylistSearchTerm::Field sort_field, const int limit = PlaylistGenerator::kDefaultLimit); + + bool is_valid() const; + bool operator==(const SmartPlaylistSearch &other) const; + bool operator!=(const SmartPlaylistSearch &other) const { return !(*this == other); } + + SearchType search_type_; + TermList terms_; + SortType sort_type_; + SmartPlaylistSearchTerm::Field sort_field_; + int limit_; + + // Not persisted, used to alter the behaviour of the query + QList id_not_in_; + int first_item_; + + void Reset(); + QString ToSql(const QString &songs_table) const; + +}; + +QDataStream &operator<<(QDataStream &s, const SmartPlaylistSearch &search); +QDataStream &operator>>(QDataStream &s, SmartPlaylistSearch &search); + +#endif // SMARTPLAYLISTSEARCH_H diff --git a/src/smartplaylists/smartplaylistsearchpreview.cpp b/src/smartplaylists/smartplaylistsearchpreview.cpp new file mode 100644 index 00000000..35b7247e --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchpreview.cpp @@ -0,0 +1,151 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "smartplaylistsearchpreview.h" +#include "ui_smartplaylistsearchpreview.h" + +#include "core/closure.h" +#include "playlist/playlist.h" +#include "playlistquerygenerator.h" + +SmartPlaylistSearchPreview::SmartPlaylistSearchPreview(QWidget* parent) + : QWidget(parent), + ui_(new Ui_SmartPlaylistSearchPreview), + model_(nullptr) { + + ui_->setupUi(this); + + // Prevent editing songs and saving settings (like header columns and geometry) + ui_->tree->setEditTriggers(QAbstractItemView::NoEditTriggers); + ui_->tree->SetReadOnlySettings(true); + + QFont bold_font; + bold_font.setBold(true); + ui_->preview_label->setFont(bold_font); + ui_->busy_container->hide(); + +} + +SmartPlaylistSearchPreview::~SmartPlaylistSearchPreview() { + delete ui_; +} + +void SmartPlaylistSearchPreview::set_application(Application *app) { + + ui_->tree->Init(app); + +} + +void SmartPlaylistSearchPreview::set_collection(CollectionBackend *backend) { + + backend_ = backend; + + model_ = new Playlist(nullptr, nullptr, backend_, -1, QString(), false, this); + ui_->tree->setModel(model_); + ui_->tree->SetPlaylist(model_); + ui_->tree->SetItemDelegates(); + +} + +void SmartPlaylistSearchPreview::Update(const SmartPlaylistSearch &search) { + + if (search == last_search_) { + // This search was the same as the last one we did + return; + } + + if (generator_ || isHidden()) { + // It's busy generating something already, or the widget isn't visible + pending_search_ = search; + return; + } + + RunSearch(search); + +} + +void SmartPlaylistSearchPreview::showEvent(QShowEvent *e) { + + if (pending_search_.is_valid() && !generator_) { + // There was a search waiting while we were hidden, so run it now + RunSearch(pending_search_); + pending_search_ = SmartPlaylistSearch(); + } + + QWidget::showEvent(e); + +} + +namespace { +PlaylistItemList DoRunSearch(PlaylistGeneratorPtr gen) { return gen->Generate(); } +} // namespace + +void SmartPlaylistSearchPreview::RunSearch(const SmartPlaylistSearch &search) { + + generator_.reset(new PlaylistQueryGenerator); + generator_->set_collection(backend_); + std::dynamic_pointer_cast(generator_)->Load(search); + + ui_->busy_container->show(); + ui_->count_label->hide(); + QFuture future = QtConcurrent::run(DoRunSearch, generator_); + NewClosure(future, this, SLOT(SearchFinished(QFuture)), future); + +} + +void SmartPlaylistSearchPreview::SearchFinished(QFuture future) { + + last_search_ = std::dynamic_pointer_cast(generator_)->search(); + generator_.reset(); + + if (pending_search_.is_valid() && pending_search_ != last_search_) { + // There was another search done while we were running + // throw away these results and do that one now instead + RunSearch(pending_search_); + pending_search_ = SmartPlaylistSearch(); + return; + } + + PlaylistItemList all_items = future.result(); + PlaylistItemList displayed_items = all_items.mid(0, PlaylistGenerator::kDefaultLimit); + + model_->Clear(); + model_->InsertItems(displayed_items); + + if (displayed_items.count() < all_items.count()) { + ui_->count_label->setText(tr("%1 songs found (showing %2)").arg(all_items.count()).arg(displayed_items.count())); + } + else { + ui_->count_label->setText(tr("%1 songs found").arg(all_items.count())); + } + + ui_->busy_container->hide(); + ui_->count_label->show(); + +} diff --git a/src/smartplaylists/smartplaylistsearchpreview.h b/src/smartplaylists/smartplaylistsearchpreview.h new file mode 100644 index 00000000..1f7af709 --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchpreview.h @@ -0,0 +1,74 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTSEARCHPREVIEW_H +#define SMARTPLAYLISTSEARCHPREVIEW_H + +#include "config.h" + +#include +#include +#include + +#include "smartplaylistsearch.h" +#include "playlistgenerator_fwd.h" + +class QShowEvent; + +class Application; +class CollectionBackend; +class Playlist; +class Ui_SmartPlaylistSearchPreview; + +class SmartPlaylistSearchPreview : public QWidget { + Q_OBJECT + + public: + explicit SmartPlaylistSearchPreview(QWidget *parent = nullptr); + ~SmartPlaylistSearchPreview(); + + void set_application(Application *app); + void set_collection(CollectionBackend *backend); + + void Update(const SmartPlaylistSearch &search); + + protected: + void showEvent(QShowEvent*); + + private: + void RunSearch(const SmartPlaylistSearch &search); + + private slots: + void SearchFinished(QFuture future); + + private: + Ui_SmartPlaylistSearchPreview *ui_; + QList fields_; + + CollectionBackend *backend_; + Playlist *model_; + + SmartPlaylistSearch pending_search_; + SmartPlaylistSearch last_search_; + PlaylistGeneratorPtr generator_; + +}; + +#endif // SMARTPLAYLISTSEARCHPREVIEW_H diff --git a/src/smartplaylists/smartplaylistsearchpreview.ui b/src/smartplaylists/smartplaylistsearchpreview.ui new file mode 100644 index 00000000..4f8147c6 --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchpreview.ui @@ -0,0 +1,99 @@ + + + SmartPlaylistSearchPreview + + + + 0 + 0 + 651 + 377 + + + + Form + + + + 0 + + + + + + + Preview + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + + + + + + + + Loading... + + + + + + + + + + + + Qt::ScrollBarAlwaysOn + + + false + + + false + + + true + + + + + + + + BusyIndicator + QWidget +
widgets/busyindicator.h
+ 1 +
+ + PlaylistView + QTreeView +
playlist/playlistview.h
+
+
+ + +
diff --git a/src/smartplaylists/smartplaylistsearchterm.cpp b/src/smartplaylists/smartplaylistsearchterm.cpp new file mode 100644 index 00000000..cc0f0589 --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchterm.cpp @@ -0,0 +1,466 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "smartplaylistsearchterm.h" +#include "playlist/playlist.h" + +SmartPlaylistSearchTerm::SmartPlaylistSearchTerm() : field_(Field_Title), operator_(Op_Equals) {} + +SmartPlaylistSearchTerm::SmartPlaylistSearchTerm(Field field, Operator op, const QVariant &value) + : field_(field), operator_(op), value_(value) {} + +QString SmartPlaylistSearchTerm::ToSql() const { + + QString col = FieldColumnName(field_); + QString date = DateName(date_, true); + QString value = value_.toString(); + value.replace('\'', "''"); + + QString second_value; + + bool special_date_query = (operator_ == SmartPlaylistSearchTerm::Op_NumericDate || + operator_ == SmartPlaylistSearchTerm::Op_NumericDateNot || + operator_ == SmartPlaylistSearchTerm::Op_RelativeDate); + + // Floating point problems... + // Theoretically 0.0 == 0 stars, 0.1 == 0.5 star, 0.2 == 1 star etc. + // but in reality we need to consider anything from [0.05, 0.15) range to be 0.5 star etc. + // To make this simple, I transform the ranges to integeres and then operate on ints: [0.0, 0.05) -> 0, [0.05, 0.15) -> 1 etc. + if (TypeOf(field_) == Type_Date) { + if (!special_date_query) { + // We have the exact date + // The calendar widget specifies no time so ditch the possible time part + // from integers representing the dates. + col = "DATE(" + col + ", 'unixepoch', 'localtime')"; + value = "DATE(" + value + ", 'unixepoch', 'localtime')"; + } + else { + // We have a numeric date, consider also the time for more precision + col = "DATETIME(" + col + ", 'unixepoch', 'localtime')"; + second_value = second_value_.toString(); + second_value.replace('\'', "''"); + if (date == "weeks") { + // Sqlite doesn't know weeks, transform them to days + date = "days"; + value = QString::number(value_.toInt() * 7); + second_value = QString::number(second_value_.toInt() * 7); + } + } + } + else if (TypeOf(field_) == Type_Time) { + // Convert seconds to nanoseconds + value = "CAST (" + value + " *1000000000 AS INTEGER)"; + } + + // File paths need some extra processing since they are stored as encoded urls in the database. + if (field_ == Field_Filepath) { + if (operator_ == Op_StartsWith || operator_ == Op_Equals) { + value = QUrl::fromLocalFile(value).toEncoded(); + } + else { + value = QUrl(value).toEncoded(); + } + } + else if (TypeOf(field_) == Type_Rating) { + col = "CAST ((" + col + " + 0.05) * 10 AS INTEGER)"; + value = "CAST ((" + value + " + 0.05) * 10 AS INTEGER)"; + } + + switch (operator_) { + case Op_Contains: + return col + " LIKE '%" + value + "%'"; + case Op_NotContains: + return col + " NOT LIKE '%" + value + "%'"; + case Op_StartsWith: + return col + " LIKE '" + value + "%'"; + case Op_EndsWith: + return col + " LIKE '%" + value + "'"; + case Op_Equals: + if (TypeOf(field_) == Type_Text) + return col + " LIKE '" + value + "'"; + else if (TypeOf(field_) == Type_Date || TypeOf(field_) == Type_Time || TypeOf(field_) == Type_Rating) + return col + " = " + value; + else + return col + " = '" + value + "'"; + case Op_GreaterThan: + if (TypeOf(field_) == Type_Date || TypeOf(field_) == Type_Time || TypeOf(field_) == Type_Rating) + return col + " > " + value; + else + return col + " > '" + value + "'"; + case Op_LessThan: + if (TypeOf(field_) == Type_Date || TypeOf(field_) == Type_Time || TypeOf(field_) == Type_Rating) + return col + " < " + value; + else + return col + " < '" + value + "'"; + case Op_NumericDate: + return col + " > " + "DATETIME('now', '-" + value + " " + date + "', 'localtime')"; + case Op_NumericDateNot: + return col + " < " + "DATETIME('now', '-" + value + " " + date + "', 'localtime')"; + case Op_RelativeDate: + // Consider the time range before the first date but after the second one + return "(" + col + " < " + "DATETIME('now', '-" + value + " " + date + "', 'localtime') AND " + col + " > " + "DATETIME('now', '-" + second_value + " " + date + "', 'localtime'))"; + case Op_NotEquals: + if (TypeOf(field_) == Type_Text) { + return col + " <> '" + value + "'"; + } + else { + return col + " <> " + value; + } + case Op_Empty: + return col + " = ''"; + case Op_NotEmpty: + return col + " <> ''"; + } + + return QString(); +} + +bool SmartPlaylistSearchTerm::is_valid() const { + + // We can accept also a zero value in these cases + if (operator_ == SmartPlaylistSearchTerm::Op_NumericDate) { + return value_.toInt() >= 0; + } + else if (operator_ == SmartPlaylistSearchTerm::Op_RelativeDate) { + return (value_.toInt() >= 0 && value_.toInt() < second_value_.toInt()); + } + + switch (TypeOf(field_)) { + case Type_Text: + if (operator_ == SmartPlaylistSearchTerm::Op_Empty || operator_ == SmartPlaylistSearchTerm::Op_NotEmpty) { + return true; + } + // Empty fields should be possible. + // All values for Type_Text should be valid. + return !value_.toString().isEmpty(); + case Type_Date: + return value_.toInt() != 0; + case Type_Number: + return value_.toInt() >= 0; + case Type_Time: + return true; + case Type_Rating: + return value_.toFloat() >= 0.0; + case Type_Invalid: + return false; + } + return false; + +} + +bool SmartPlaylistSearchTerm::operator==(const SmartPlaylistSearchTerm &other) const { + return field_ == other.field_ && operator_ == other.operator_ && + value_ == other.value_ && date_ == other.date_ && + second_value_ == other.second_value_; +} + +SmartPlaylistSearchTerm::Type SmartPlaylistSearchTerm::TypeOf(const Field field) { + + switch (field) { + case Field_Length: + return Type_Time; + + case Field_Track: + case Field_Disc: + case Field_Year: + case Field_OriginalYear: + case Field_Filesize: + case Field_PlayCount: + case Field_SkipCount: + case Field_Samplerate: + case Field_Bitdepth: + case Field_Bitrate: + return Type_Number; + + case Field_LastPlayed: + case Field_DateCreated: + case Field_DateModified: + return Type_Date; + + case Field_Rating: + return Type_Rating; + + default: + return Type_Text; + } + +} + +OperatorList SmartPlaylistSearchTerm::OperatorsForType(const Type type) { + + switch (type) { + case Type_Text: + return OperatorList() << Op_Contains << Op_NotContains << Op_Equals + << Op_NotEquals << Op_Empty << Op_NotEmpty + << Op_StartsWith << Op_EndsWith; + case Type_Date: + return OperatorList() << Op_Equals << Op_NotEquals << Op_GreaterThan + << Op_LessThan << Op_NumericDate + << Op_NumericDateNot << Op_RelativeDate; + default: + return OperatorList() << Op_Equals << Op_NotEquals << Op_GreaterThan + << Op_LessThan; + } + +} + +QString SmartPlaylistSearchTerm::OperatorText(const Type type, const Operator op) { + + if (type == Type_Date) { + switch (op) { + case Op_GreaterThan: + return QObject::tr("after"); + case Op_LessThan: + return QObject::tr("before"); + case Op_Equals: + return QObject::tr("on"); + case Op_NotEquals: + return QObject::tr("not on"); + case Op_NumericDate: + return QObject::tr("in the last"); + case Op_NumericDateNot: + return QObject::tr("not in the last"); + case Op_RelativeDate: + return QObject::tr("between"); + default: + return QString(); + } + } + + switch (op) { + case Op_Contains: + return QObject::tr("contains"); + case Op_NotContains: + return QObject::tr("does not contain"); + case Op_StartsWith: + return QObject::tr("starts with"); + case Op_EndsWith: + return QObject::tr("ends with"); + case Op_GreaterThan: + return QObject::tr("greater than"); + case Op_LessThan: + return QObject::tr("less than"); + case Op_Equals: + return QObject::tr("equals"); + case Op_NotEquals: + return QObject::tr("not equals"); + case Op_Empty: + return QObject::tr("empty"); + case Op_NotEmpty: + return QObject::tr("not empty"); + default: + return QString(); + } + + return QString(); + +} + +QString SmartPlaylistSearchTerm::FieldColumnName(const Field field) { + + switch (field) { + case Field_AlbumArtist: + return "albumartist"; + case Field_Artist: + return "artist"; + case Field_Album: + return "album"; + case Field_Title: + return "title"; + case Field_Track: + return "track"; + case Field_Disc: + return "disc"; + case Field_Year: + return "year"; + case Field_OriginalYear: + return "originalyear"; + case Field_Genre: + return "genre"; + case Field_Composer: + return "composer"; + case Field_Performer: + return "performer"; + case Field_Grouping: + return "grouping"; + case Field_Comment: + return "comment"; + case Field_Length: + return "length"; + case Field_Filepath: + return "filename"; + case Field_Filetype: + return "filetype"; + case Field_Filesize: + return "filesize"; + case Field_DateCreated: + return "ctime"; + case Field_DateModified: + return "mtime"; + case Field_PlayCount: + return "playcount"; + case Field_SkipCount: + return "skipcount"; + case Field_LastPlayed: + return "lastplayed"; + case Field_Rating: + return "rating"; + case Field_Samplerate: + return "samplerate"; + case Field_Bitdepth: + return "bitdepth"; + case Field_Bitrate: + return "bitrate"; + case FieldCount: + Q_ASSERT(0); + } + return QString(); + +} + +QString SmartPlaylistSearchTerm::FieldName(const Field field) { + + switch (field) { + case Field_AlbumArtist: + return Playlist::column_name(Playlist::Column_AlbumArtist); + case Field_Artist: + return Playlist::column_name(Playlist::Column_Artist); + case Field_Album: + return Playlist::column_name(Playlist::Column_Album); + case Field_Title: + return Playlist::column_name(Playlist::Column_Title); + case Field_Track: + return Playlist::column_name(Playlist::Column_Track); + case Field_Disc: + return Playlist::column_name(Playlist::Column_Disc); + case Field_Year: + return Playlist::column_name(Playlist::Column_Year); + case Field_OriginalYear: + return Playlist::column_name(Playlist::Column_OriginalYear); + case Field_Genre: + return Playlist::column_name(Playlist::Column_Genre); + case Field_Composer: + return Playlist::column_name(Playlist::Column_Composer); + case Field_Performer: + return Playlist::column_name(Playlist::Column_Performer); + case Field_Grouping: + return Playlist::column_name(Playlist::Column_Grouping); + case Field_Comment: + return QObject::tr("Comment"); + case Field_Length: + return Playlist::column_name(Playlist::Column_Length); + case Field_Filepath: + return Playlist::column_name(Playlist::Column_Filename); + case Field_Filetype: + return Playlist::column_name(Playlist::Column_Filetype); + case Field_Filesize: + return Playlist::column_name(Playlist::Column_Filesize); + case Field_DateCreated: + return Playlist::column_name(Playlist::Column_DateCreated); + case Field_DateModified: + return Playlist::column_name(Playlist::Column_DateModified); + case Field_PlayCount: + return Playlist::column_name(Playlist::Column_PlayCount); + case Field_SkipCount: + return Playlist::column_name(Playlist::Column_SkipCount); + case Field_LastPlayed: + return Playlist::column_name(Playlist::Column_LastPlayed); + case Field_Rating: + return Playlist::column_name(Playlist::Column_Rating); + case Field_Samplerate: + return Playlist::column_name(Playlist::Column_Samplerate); + case Field_Bitdepth: + return Playlist::column_name(Playlist::Column_Bitdepth); + case Field_Bitrate: + return Playlist::column_name(Playlist::Column_Bitrate); + case FieldCount: + Q_ASSERT(0); + } + return QString(); + +} + +QString SmartPlaylistSearchTerm::FieldSortOrderText(const Type type, const bool ascending) { + + switch (type) { + case Type_Text: + return ascending ? QObject::tr("A-Z") : QObject::tr("Z-A"); + case Type_Date: + return ascending ? QObject::tr("oldest first") : QObject::tr("newest first"); + case Type_Time: + return ascending ? QObject::tr("shortest first") : QObject::tr("longest first"); + case Type_Number: + case Type_Rating: + return ascending ? QObject::tr("smallest first") : QObject::tr("biggest first"); + case Type_Invalid: + return QString(); + } + return QString(); + +} + +QString SmartPlaylistSearchTerm::DateName(const DateType date, const bool forQuery) { + + // If forQuery is true, untranslated keywords are returned + switch (date) { + case Date_Hour: + return (forQuery ? "hours" : QObject::tr("Hours")); + case Date_Day: + return (forQuery ? "days" : QObject::tr("Days")); + case Date_Week: + return (forQuery ? "weeks" : QObject::tr("Weeks")); + case Date_Month: + return (forQuery ? "months" : QObject::tr("Months")); + case Date_Year: + return (forQuery ? "years" : QObject::tr("Years")); + } + return QString(); + +} + +QDataStream &operator<<(QDataStream &s, const SmartPlaylistSearchTerm &term) { + + s << quint8(term.field_); + s << quint8(term.operator_); + s << term.value_; + s << term.second_value_; + s << quint8(term.date_); + return s; + +} + +QDataStream &operator>>(QDataStream &s, SmartPlaylistSearchTerm &term) { + + quint8 field, op, date; + s >> field >> op >> term.value_ >> term.second_value_ >> date; + term.field_ = SmartPlaylistSearchTerm::Field(field); + term.operator_ = SmartPlaylistSearchTerm::Operator(op); + term.date_ = SmartPlaylistSearchTerm::DateType(date); + return s; + +} diff --git a/src/smartplaylists/smartplaylistsearchterm.h b/src/smartplaylists/smartplaylistsearchterm.h new file mode 100644 index 00000000..4b65355c --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchterm.h @@ -0,0 +1,141 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTSEARCHTERM_H +#define SMARTPLAYLISTSEARCHTERM_H + +#include "config.h" + +#include +#include +#include +#include + +class SmartPlaylistSearchTerm { + public: + // These values are persisted, so add to the end of the enum only + enum Field { + Field_AlbumArtist = 0, + Field_Artist, + Field_Album, + Field_Title, + Field_Track, + Field_Disc, + Field_Year, + Field_OriginalYear, + Field_Genre, + Field_Composer, + Field_Performer, + Field_Grouping, + Field_Comment, + Field_Length, + Field_Filepath, + Field_Filetype, + Field_Filesize, + Field_DateCreated, + Field_DateModified, + Field_PlayCount, + Field_SkipCount, + Field_LastPlayed, + Field_Rating, + Field_Samplerate, + Field_Bitdepth, + Field_Bitrate, + FieldCount + }; + + // These values are persisted, so add to the end of the enum only + enum Operator { + // For text + Op_Contains = 0, + Op_NotContains = 1, + Op_StartsWith = 2, + Op_EndsWith = 3, + + // For numbers + Op_GreaterThan = 4, + Op_LessThan = 5, + + // For everything + Op_Equals = 6, + Op_NotEquals = 9, + + // For numeric dates (e.g. in the last X days) + Op_NumericDate = 7, + // For relative dates + Op_RelativeDate = 8, + + // For numeric dates (e.g. not in the last X days) + Op_NumericDateNot = 10, + + Op_Empty = 11, + Op_NotEmpty = 12, + + // Next value = 13 + }; + + enum Type { + Type_Text, + Type_Date, + Type_Time, + Type_Number, + Type_Rating, + Type_Invalid + }; + + // These values are persisted, so add to the end of the enum only + enum DateType { + Date_Hour = 0, + Date_Day, + Date_Week, + Date_Month, Date_Year + }; + + explicit SmartPlaylistSearchTerm(); + explicit SmartPlaylistSearchTerm(const Field field, const Operator op, const QVariant &value); + + Field field_; + Operator operator_; + QVariant value_; + DateType date_; + // For relative dates, we need a second parameter, might be useful somewhere else + QVariant second_value_; + + QString ToSql() const; + bool is_valid() const; + bool operator==(const SmartPlaylistSearchTerm &other) const; + bool operator!=(const SmartPlaylistSearchTerm &other) const { return !(*this == other); } + + static Type TypeOf(const Field field); + static QList OperatorsForType(const Type type); + static QString OperatorText(const Type type, const Operator op); + static QString FieldName(const Field field); + static QString FieldColumnName(const Field field); + static QString FieldSortOrderText(const Type type, const bool ascending); + static QString DateName(const DateType date, const bool forQuery); + +}; + +typedef QList OperatorList; + +QDataStream &operator<<(QDataStream &s, const SmartPlaylistSearchTerm &term); +QDataStream &operator>>(QDataStream &s, SmartPlaylistSearchTerm &term); + +#endif // SMARTPLAYLISTSEARCHTERM_H diff --git a/src/smartplaylists/smartplaylistsearchtermwidget.cpp b/src/smartplaylists/smartplaylistsearchtermwidget.cpp new file mode 100644 index 00000000..27d106fa --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchtermwidget.cpp @@ -0,0 +1,511 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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/utilities.h" +#include "core/iconloader.h" +#include "playlist/playlist.h" +#include "playlist/playlistdelegates.h" +#include "smartplaylistsearchterm.h" +#include "smartplaylistsearchtermwidget.h" +#include "ui_smartplaylistsearchtermwidget.h" + +// Exported by QtGui +void qt_blurImage(QPainter *p, QImage &blurImage, qreal radius, bool quality, bool alphaOnly, int transposed = 0); + +class SmartPlaylistSearchTermWidget::Overlay : public QWidget { + public: + explicit Overlay(SmartPlaylistSearchTermWidget *parent); + void Grab(); + void SetOpacity(const float opacity); + float opacity() const { return opacity_; } + + static const int kSpacing; + static const int kIconSize; + + protected: + void paintEvent(QPaintEvent*) override; + void mouseReleaseEvent(QMouseEvent*) override; + void keyReleaseEvent(QKeyEvent *e) override; + + private: + SmartPlaylistSearchTermWidget *parent_; + + float opacity_; + QString text_; + QPixmap pixmap_; + QPixmap icon_; + +}; + +const int SmartPlaylistSearchTermWidget::Overlay::kSpacing = 6; +const int SmartPlaylistSearchTermWidget::Overlay::kIconSize = 22; + +SmartPlaylistSearchTermWidget::SmartPlaylistSearchTermWidget(CollectionBackend* collection, QWidget* parent) + : QWidget(parent), + ui_(new Ui_SmartPlaylistSearchTermWidget), + collection_(collection), + overlay_(nullptr), + animation_(new QPropertyAnimation(this, "overlay_opacity", this)), + active_(true), + initialized_(false), + current_field_type_(SmartPlaylistSearchTerm::Type_Invalid) { + + ui_->setupUi(this); + connect(ui_->field, SIGNAL(currentIndexChanged(int)), SLOT(FieldChanged(int))); + connect(ui_->op, SIGNAL(currentIndexChanged(int)), SLOT(OpChanged(int))); + connect(ui_->remove, SIGNAL(clicked()), SIGNAL(RemoveClicked())); + + connect(ui_->value_date, SIGNAL(dateChanged(QDate)), SIGNAL(Changed())); + connect(ui_->value_number, SIGNAL(valueChanged(int)), SIGNAL(Changed())); + connect(ui_->value_text, SIGNAL(textChanged(QString)), SIGNAL(Changed())); + connect(ui_->value_time, SIGNAL(timeChanged(QTime)), SIGNAL(Changed())); + connect(ui_->value_date_numeric, SIGNAL(valueChanged(int)), SIGNAL(Changed())); + connect(ui_->value_date_numeric1, SIGNAL(valueChanged(int)), SLOT(RelativeValueChanged())); + connect(ui_->value_date_numeric2, SIGNAL(valueChanged(int)), SLOT(RelativeValueChanged())); + connect(ui_->date_type, SIGNAL(currentIndexChanged(int)), SIGNAL(Changed())); + connect(ui_->date_type_relative, SIGNAL(currentIndexChanged(int)), SIGNAL(Changed())); + connect(ui_->value_rating, SIGNAL(RatingChanged(float)), SIGNAL(Changed())); + + ui_->value_date->setDate(QDate::currentDate()); + + // Populate the combo boxes + for (int i = 0; i < SmartPlaylistSearchTerm::FieldCount; ++i) { + ui_->field->addItem(SmartPlaylistSearchTerm::FieldName(SmartPlaylistSearchTerm::Field(i))); + ui_->field->setItemData(i, i); + } + ui_->field->model()->sort(0); + + // Populate the date type combo box + for (int i = 0; i < 5; ++i) { + ui_->date_type->addItem(SmartPlaylistSearchTerm::DateName(SmartPlaylistSearchTerm::DateType(i), false)); + ui_->date_type->setItemData(i, i); + + ui_->date_type_relative->addItem(SmartPlaylistSearchTerm::DateName(SmartPlaylistSearchTerm::DateType(i), false)); + ui_->date_type_relative->setItemData(i, i); + } + + // Icons on the buttons + ui_->remove->setIcon(IconLoader::Load("list-remove")); + + // Set stylesheet + QFile stylesheet_file(":/style/smartplaylistsearchterm.css"); + stylesheet_file.open(QIODevice::ReadOnly); + QString stylesheet = QString::fromLatin1(stylesheet_file.readAll()); + const QColor base(222, 97, 97, 128); + stylesheet.replace("%light2", Utilities::ColorToRgba(base.lighter(140))); + stylesheet.replace("%light", Utilities::ColorToRgba(base.lighter(120))); + stylesheet.replace("%dark", Utilities::ColorToRgba(base.darker(120))); + stylesheet.replace("%base", Utilities::ColorToRgba(base)); + setStyleSheet(stylesheet); + +} + +SmartPlaylistSearchTermWidget::~SmartPlaylistSearchTermWidget() { delete ui_; } + +void SmartPlaylistSearchTermWidget::FieldChanged(int index) { + + SmartPlaylistSearchTerm::Field field = SmartPlaylistSearchTerm::Field(ui_->field->itemData(index).toInt()); + SmartPlaylistSearchTerm::Type type = SmartPlaylistSearchTerm::TypeOf(field); + + // Populate the operator combo box + if (type != current_field_type_) { + ui_->op->clear(); + for (SmartPlaylistSearchTerm::Operator op : SmartPlaylistSearchTerm::OperatorsForType(type)) { + const int i = ui_->op->count(); + ui_->op->addItem(SmartPlaylistSearchTerm::OperatorText(type, op)); + ui_->op->setItemData(i, op); + } + current_field_type_ = type; + } + + // Show the correct value editor + QWidget* page = nullptr; + SmartPlaylistSearchTerm::Operator op = static_cast( + ui_->op->itemData(ui_->op->currentIndex()).toInt() + ); + switch (type) { + case SmartPlaylistSearchTerm::Type_Time: + page = ui_->page_time; + break; + case SmartPlaylistSearchTerm::Type_Number: + page = ui_->page_number; + break; + case SmartPlaylistSearchTerm::Type_Date: + page = ui_->page_date; + break; + case SmartPlaylistSearchTerm::Type_Text: + if (op == SmartPlaylistSearchTerm::Op_Empty || op == SmartPlaylistSearchTerm::Op_NotEmpty) { + page = ui_->page_empty; + } + else { + page = ui_->page_text; + } + break; + case SmartPlaylistSearchTerm::Type_Rating: + page = ui_->page_rating; + break; + case SmartPlaylistSearchTerm::Type_Invalid: + page = nullptr; + break; + } + ui_->value_stack->setCurrentWidget(page); + + // Maybe set a tag completer + switch (field) { + case SmartPlaylistSearchTerm::Field_Artist: + new TagCompleter(collection_, Playlist::Column_Artist, ui_->value_text); + break; + + case SmartPlaylistSearchTerm::Field_Album: + new TagCompleter(collection_, Playlist::Column_Album, ui_->value_text); + break; + + default: + ui_->value_text->setCompleter(nullptr); + } + + emit Changed(); + +} + +void SmartPlaylistSearchTermWidget::OpChanged(int idx) { + + Q_UNUSED(idx); + + // Determine the currently selected operator + SmartPlaylistSearchTerm::Operator op = static_cast( + // This uses the operators’s index in the combobox to get its enum value + ui_->op->itemData(ui_->op->currentIndex()).toInt() + ); + + // We need to change the page only in the following case + if ((ui_->value_stack->currentWidget() == ui_->page_text) || (ui_->value_stack->currentWidget() == ui_->page_empty)) { + QWidget* page = nullptr; + if (op == SmartPlaylistSearchTerm::Op_Empty || op == SmartPlaylistSearchTerm::Op_NotEmpty) { + page = ui_->page_empty; + } + else { + page = ui_->page_text; + } + ui_->value_stack->setCurrentWidget(page); + } + else if ( + (ui_->value_stack->currentWidget() == ui_->page_date) || + (ui_->value_stack->currentWidget() == ui_->page_date_numeric) || + (ui_->value_stack->currentWidget() == ui_->page_date_relative) + ) { + QWidget* page = nullptr; + if (op == SmartPlaylistSearchTerm::Op_NumericDate || op == SmartPlaylistSearchTerm::Op_NumericDateNot) { + page = ui_->page_date_numeric; + } + else if (op == SmartPlaylistSearchTerm::Op_RelativeDate) { + page = ui_->page_date_relative; + } + else { + page = ui_->page_date; + } + ui_->value_stack->setCurrentWidget(page); + } + + emit Changed(); + +} + +void SmartPlaylistSearchTermWidget::SetActive(bool active) { + + active_ = active; + + if (overlay_) { + delete overlay_; + overlay_ = nullptr; + } + + ui_->container->setEnabled(active); + + if (!active) { + overlay_ = new Overlay(this); + } + +} + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +void SmartPlaylistSearchTermWidget::enterEvent(QEnterEvent*) { +#else +void SmartPlaylistSearchTermWidget::enterEvent(QEvent*) { +#endif + + if (!overlay_ || !isEnabled()) return; + + animation_->stop(); + animation_->setEndValue(1.0); + animation_->setDuration(80); + animation_->start(); + +} + +void SmartPlaylistSearchTermWidget::leaveEvent(QEvent*) { + + if (!overlay_) return; + + animation_->stop(); + animation_->setEndValue(0.0); + animation_->setDuration(160); + animation_->start(); + +} + +void SmartPlaylistSearchTermWidget::resizeEvent(QResizeEvent* e) { + + QWidget::resizeEvent(e); + if (overlay_ && overlay_->isVisible()) { + QTimer::singleShot(0, this, SLOT(Grab())); + } + +} + +void SmartPlaylistSearchTermWidget::showEvent(QShowEvent* e) { + + QWidget::showEvent(e); + if (overlay_) { + QTimer::singleShot(0, this, SLOT(Grab())); + } + +} + +void SmartPlaylistSearchTermWidget::Grab() { overlay_->Grab(); } + +void SmartPlaylistSearchTermWidget::set_overlay_opacity(float opacity) { + if (overlay_) overlay_->SetOpacity(opacity); +} + +float SmartPlaylistSearchTermWidget::overlay_opacity() const { + return overlay_ ? overlay_->opacity() : 0.0; +} + +void SmartPlaylistSearchTermWidget::SetTerm(const SmartPlaylistSearchTerm &term) { + + ui_->field->setCurrentIndex(ui_->field->findData(term.field_)); + ui_->op->setCurrentIndex(ui_->op->findData(term.operator_)); + + // The value depends on the data type + switch (SmartPlaylistSearchTerm::TypeOf(term.field_)) { + case SmartPlaylistSearchTerm::Type_Text: + if (ui_->value_stack->currentWidget() == ui_->page_empty) { + ui_->value_text->setText(""); + } else { + ui_->value_text->setText(term.value_.toString()); + } + break; + + case SmartPlaylistSearchTerm::Type_Number: + ui_->value_number->setValue(term.value_.toInt()); + break; + + case SmartPlaylistSearchTerm::Type_Date: + if (ui_->value_stack->currentWidget() == ui_->page_date_numeric) { + ui_->value_date_numeric->setValue(term.value_.toInt()); + ui_->date_type->setCurrentIndex(term.date_); + } + else if (ui_->value_stack->currentWidget() == ui_->page_date_relative) { + ui_->value_date_numeric1->setValue(term.value_.toInt()); + ui_->value_date_numeric2->setValue(term.second_value_.toInt()); + ui_->date_type_relative->setCurrentIndex(term.date_); + } + else if (ui_->value_stack->currentWidget() == ui_->page_date) { + ui_->value_date->setDateTime(QDateTime::fromSecsSinceEpoch(term.value_.toInt())); + } + break; + + case SmartPlaylistSearchTerm::Type_Time: + ui_->value_time->setTime(QTime(0, 0).addSecs(term.value_.toInt())); + break; + + case SmartPlaylistSearchTerm::Type_Rating: + ui_->value_rating->set_rating(term.value_.toFloat()); + break; + + case SmartPlaylistSearchTerm::Type_Invalid: + break; + } + +} + +SmartPlaylistSearchTerm SmartPlaylistSearchTermWidget::Term() const { + + const int field = ui_->field->itemData(ui_->field->currentIndex()).toInt(); + const int op = ui_->op->itemData(ui_->op->currentIndex()).toInt(); + + SmartPlaylistSearchTerm ret; + ret.field_ = SmartPlaylistSearchTerm::Field(field); + ret.operator_ = SmartPlaylistSearchTerm::Operator(op); + + // The value depends on the data type + const QWidget *value_page = ui_->value_stack->currentWidget(); + if (value_page == ui_->page_text) { + ret.value_ = ui_->value_text->text(); + } + else if (value_page == ui_->page_empty) { + ret.value_ = ""; + } + else if (value_page == ui_->page_number) { + ret.value_ = ui_->value_number->value(); + } + else if (value_page == ui_->page_date) { + ret.value_ = ui_->value_date->dateTime().toSecsSinceEpoch(); + } + else if (value_page == ui_->page_time) { + ret.value_ = QTime(0, 0).secsTo(ui_->value_time->time()); + } + else if (value_page == ui_->page_date_numeric) { + ret.date_ = SmartPlaylistSearchTerm::DateType(ui_->date_type->currentIndex()); + ret.value_ = ui_->value_date_numeric->value(); + } + else if (value_page == ui_->page_date_relative) { + ret.date_ = SmartPlaylistSearchTerm::DateType(ui_->date_type_relative->currentIndex()); + ret.value_ = ui_->value_date_numeric1->value(); + ret.second_value_ = ui_->value_date_numeric2->value(); + } + else if (value_page == ui_->page_rating) { + ret.value_ = ui_->value_rating->rating(); + } + + return ret; + +} + +void SmartPlaylistSearchTermWidget::RelativeValueChanged() { + + // Don't check for validity when creating the widget + if (!initialized_) { + initialized_ = true; + return; + } + // Explain the user why he can't proceed + if (ui_->value_date_numeric1->value() >= ui_->value_date_numeric2->value()) { + QMessageBox::warning(this, tr("Strawberry"), tr("The second value must be greater than the first one!")); + } + // Emit the signal in any case, so the Next button will be disabled + emit Changed(); + +} + +SmartPlaylistSearchTermWidget::Overlay::Overlay(SmartPlaylistSearchTermWidget *parent) + : QWidget(parent), + parent_(parent), + opacity_(0.0), + text_(tr("Add search term")), + icon_(IconLoader::Load("list-add").pixmap(kIconSize)) { + + raise(); + setFocusPolicy(Qt::TabFocus); + +} + +void SmartPlaylistSearchTermWidget::Overlay::SetOpacity(const float opacity) { + + opacity_ = opacity; + update(); + +} + +void SmartPlaylistSearchTermWidget::Overlay::Grab() { + + hide(); + + // Take a "screenshot" of the window + QPixmap pixmap = parent_->grab(); + QImage image = pixmap.toImage(); + + // Blur it + QImage blurred(image.size(), QImage::Format_ARGB32_Premultiplied); + blurred.fill(Qt::transparent); + + QPainter blur_painter(&blurred); + qt_blurImage(&blur_painter, image, 10.0, true, false); + blur_painter.end(); + + pixmap_ = QPixmap::fromImage(blurred); + + resize(parent_->size()); + show(); + update(); + +} + +void SmartPlaylistSearchTermWidget::Overlay::paintEvent(QPaintEvent*) { + + QPainter p(this); + + // Background + p.fillRect(rect(), palette().window()); + + // Blurred parent widget + p.setOpacity(0.25 + opacity_ * 0.25); + p.drawPixmap(0, 0, pixmap_); + + // Draw a frame + p.setOpacity(1.0); + p.setPen(palette().color(QPalette::Mid)); + p.setRenderHint(QPainter::Antialiasing); + p.drawRoundedRect(rect(), 5, 5); + + // Geometry + +#if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0)) + const QSize contents_size(kIconSize + kSpacing + fontMetrics().horizontalAdvance(text_), qMax(kIconSize, fontMetrics().height())); +#else + const QSize contents_size(kIconSize + kSpacing + fontMetrics().width(text_), qMax(kIconSize, fontMetrics().height())); +#endif + + const QRect contents(QPoint((width() - contents_size.width()) / 2, (height() - contents_size.height()) / 2), contents_size); + const QRect icon(contents.topLeft(), QSize(kIconSize, kIconSize)); + const QRect text(icon.right() + kSpacing, icon.top(), contents.width() - kSpacing - kIconSize, contents.height()); + + // Icon and text + p.setPen(palette().color(QPalette::Text)); + p.drawPixmap(icon, icon_); + p.drawText(text, Qt::TextDontClip | Qt::AlignVCenter, text_); + +} + +void SmartPlaylistSearchTermWidget::Overlay::mouseReleaseEvent(QMouseEvent*) { + emit parent_->Clicked(); +} + +void SmartPlaylistSearchTermWidget::Overlay::keyReleaseEvent(QKeyEvent *e) { + if (e->key() == Qt::Key_Space) emit parent_->Clicked(); +} diff --git a/src/smartplaylists/smartplaylistsearchtermwidget.h b/src/smartplaylists/smartplaylistsearchtermwidget.h new file mode 100644 index 00000000..3bd192fb --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchtermwidget.h @@ -0,0 +1,94 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTSEARCHTERMWIDGET_H +#define SMARTPLAYLISTSEARCHTERMWIDGET_H + +#include "config.h" + +#include +#include + +#include "smartplaylistsearchterm.h" + +class QPropertyAnimation; +class QEvent; +class QShowEvent; +class QEnterEvent; +class QResizeEvent; + +class CollectionBackend; +class Ui_SmartPlaylistSearchTermWidget; + +class SmartPlaylistSearchTermWidget : public QWidget { + Q_OBJECT + + Q_PROPERTY(float overlay_opacity READ overlay_opacity WRITE set_overlay_opacity) + + public: + explicit SmartPlaylistSearchTermWidget(CollectionBackend *collection, QWidget *parent); + ~SmartPlaylistSearchTermWidget(); + + void SetActive(const bool active); + + float overlay_opacity() const; + void set_overlay_opacity(const float opacity); + + void SetTerm(const SmartPlaylistSearchTerm& term); + SmartPlaylistSearchTerm Term() const; + + signals: + void Clicked(); + void RemoveClicked(); + + void Changed(); + + protected: + void showEvent(QShowEvent*) override; +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + void enterEvent(QEnterEvent*) override; +#else + void enterEvent(QEvent*) override; +#endif + void leaveEvent(QEvent*) override; + void resizeEvent(QResizeEvent*) override; + + private slots: + void FieldChanged(const int index); + void OpChanged(const int index); + void RelativeValueChanged(); + void Grab(); + + private: + class Overlay; + friend class Overlay; + + Ui_SmartPlaylistSearchTermWidget *ui_; + CollectionBackend *collection_; + + Overlay *overlay_; + QPropertyAnimation *animation_; + bool active_; + bool initialized_; + + SmartPlaylistSearchTerm::Type current_field_type_; +}; + +#endif // SMARTPLAYLISTSEARCHTERMWIDGET_H diff --git a/src/smartplaylists/smartplaylistsearchtermwidget.ui b/src/smartplaylists/smartplaylistsearchtermwidget.ui new file mode 100644 index 00000000..6c6f2422 --- /dev/null +++ b/src/smartplaylists/smartplaylistsearchtermwidget.ui @@ -0,0 +1,350 @@ + + + SmartPlaylistSearchTermWidget + + + + 0 + 0 + 640 + 38 + + + + + 0 + 0 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + mm:ss + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 1000000 + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + true + + + + + + + + + 0 + + + 0 + + + + + 1 + + + 999 + + + + + + + + + + + + 0 + + + 0 + + + + + 0 + + + 999 + + + 1 + + + + + + + + 0 + 0 + + + + and + + + Qt::AlignCenter + + + + + + + 1 + + + 999 + + + 2 + + + + + + + + + + + 0 + 0 + + + + ago + + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + RatingWidget + QWidget +
widgets/ratingwidget.h
+ 1 +
+
+ + +
diff --git a/src/smartplaylists/smartplaylistsitem.h b/src/smartplaylists/smartplaylistsitem.h new file mode 100644 index 00000000..b20daee0 --- /dev/null +++ b/src/smartplaylists/smartplaylistsitem.h @@ -0,0 +1,45 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTSITEM_H +#define SMARTPLAYLISTSITEM_H + +#include "config.h" + +#include + +#include "core/simpletreeitem.h" +#include "playlistgenerator.h" + +class SmartPlaylistsItem : public SimpleTreeItem { + public: + enum Type { + Type_Root, + Type_SmartPlaylist, + }; + + SmartPlaylistsItem(SimpleTreeModel *_model) : SimpleTreeItem(Type_Root, _model) {} + SmartPlaylistsItem(const Type _type, SmartPlaylistsItem *_parent = nullptr) : SimpleTreeItem(_type, _parent) {} + + PlaylistGenerator::Type smart_playlist_type; + QByteArray smart_playlist_data; +}; + +#endif // SMARTPLAYLISTSITEM_H diff --git a/src/smartplaylists/smartplaylistsmodel.cpp b/src/smartplaylists/smartplaylistsmodel.cpp new file mode 100644 index 00000000..9ed1a367 --- /dev/null +++ b/src/smartplaylists/smartplaylistsmodel.cpp @@ -0,0 +1,312 @@ +/* + * Strawberry Music Player + * This code was part of Clementine. + * Copyright 2010, David Sansome + * 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 "core/application.h" +#include "core/database.h" +#include "core/logging.h" +#include "core/iconloader.h" +#include "core/simpletreemodel.h" +#include "collection/collectionbackend.h" +#include "playlist/songmimedata.h" + +#include "smartplaylistsitem.h" +#include "smartplaylistsmodel.h" +#include "smartplaylistsview.h" +#include "smartplaylistsearch.h" +#include "playlistgenerator.h" +#include "playlistgeneratormimedata.h" +#include "playlistquerygenerator.h" + +const char *SmartPlaylistsModel::kSettingsGroup = "SerializedSmartPlaylists"; +const char *SmartPlaylistsModel::kSmartPlaylistsMimeType = "application/x-strawberry-smart-playlist-generator"; +const int SmartPlaylistsModel::kSmartPlaylistsVersion = 1; + +SmartPlaylistsModel::SmartPlaylistsModel(CollectionBackend *backend, QObject *parent) + : SimpleTreeModel(new SmartPlaylistsItem(this), parent), + backend_(backend), + icon_(IconLoader::Load("view-media-playlist")) { + + root_->lazy_loaded = true; + +} + +SmartPlaylistsModel::~SmartPlaylistsModel() { delete root_; } + +void SmartPlaylistsModel::Init() { + + default_smart_playlists_ = + SmartPlaylistsModel::DefaultGenerators() + << (SmartPlaylistsModel::GeneratorList() + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "Newest tracks"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), + SmartPlaylistSearch::Sort_FieldDesc, + SmartPlaylistSearchTerm::Field_DateCreated) + ) + ) + << PlaylistGeneratorPtr(new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "50 random tracks"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_Random, SmartPlaylistSearchTerm::Field_Title, 50) + ) + ) + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "Ever played"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_And, SmartPlaylistSearch::TermList() << SmartPlaylistSearchTerm( SmartPlaylistSearchTerm::Field_PlayCount, SmartPlaylistSearchTerm::Op_GreaterThan, 0), SmartPlaylistSearch::Sort_Random, SmartPlaylistSearchTerm::Field_Title) + ) + ) + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "Never played"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_And, SmartPlaylistSearch::TermList() << SmartPlaylistSearchTerm(SmartPlaylistSearchTerm::Field_PlayCount, SmartPlaylistSearchTerm::Op_Equals, 0), SmartPlaylistSearch::Sort_Random, SmartPlaylistSearchTerm::Field_Title) + ) + ) + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "Last played"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_FieldDesc, SmartPlaylistSearchTerm::Field_LastPlayed) + ) + ) + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "Most played"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_FieldDesc, SmartPlaylistSearchTerm::Field_PlayCount) + ) + ) + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("SmartPlaylists", "Favourite tracks"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_FieldDesc, SmartPlaylistSearchTerm::Field_Rating) + ) + ) + << PlaylistGeneratorPtr( + new PlaylistQueryGenerator( + QT_TRANSLATE_NOOP("Library", "Least favourite tracks"), + SmartPlaylistSearch(SmartPlaylistSearch::Type_Or, SmartPlaylistSearch::TermList() + << SmartPlaylistSearchTerm(SmartPlaylistSearchTerm::Field_Rating, SmartPlaylistSearchTerm::Op_LessThan, 0.5) + << SmartPlaylistSearchTerm(SmartPlaylistSearchTerm::Field_SkipCount, SmartPlaylistSearchTerm::Op_GreaterThan, 4), SmartPlaylistSearch::Sort_FieldDesc, SmartPlaylistSearchTerm::Field_SkipCount) + ) + ) + ) + << (SmartPlaylistsModel::GeneratorList() << PlaylistGeneratorPtr(new PlaylistQueryGenerator(QT_TRANSLATE_NOOP("SmartPlaylists", "All tracks"), SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_FieldAsc, SmartPlaylistSearchTerm::Field_Artist, -1)))) + << (SmartPlaylistsModel::GeneratorList() << PlaylistGeneratorPtr(new PlaylistQueryGenerator( QT_TRANSLATE_NOOP("SmartPlaylists", "Dynamic random mix"), SmartPlaylistSearch(SmartPlaylistSearch::Type_All, SmartPlaylistSearch::TermList(), SmartPlaylistSearch::Sort_Random, SmartPlaylistSearchTerm::Field_Title), true))); + + QSettings s; + s.beginGroup(kSettingsGroup); + int version = s.value(backend_->songs_table() + "_version", 0).toInt(); + + // How many defaults do we have to write? + int unwritten_defaults = 0; + for (int i = version; i < default_smart_playlists_.count(); ++i) { + unwritten_defaults += default_smart_playlists_[i].count(); + } + + // Save the defaults if there are any unwritten ones + if (unwritten_defaults) { + // How many items are stored already? + int playlist_index = s.beginReadArray(backend_->songs_table()); + s.endArray(); + + // Append the new ones + s.beginWriteArray(backend_->songs_table(), playlist_index + unwritten_defaults); + for (; version < default_smart_playlists_.count(); ++version) { + for (PlaylistGeneratorPtr gen : default_smart_playlists_[version]) { + SaveGenerator(&s, playlist_index++, gen); + } + } + s.endArray(); + } + + s.setValue(backend_->songs_table() + "_version", version); + + const int count = s.beginReadArray(backend_->songs_table()); + for (int i = 0; i < count; ++i) { + s.setArrayIndex(i); + ItemFromSmartPlaylist(s, false); + } + s.endArray(); + s.endGroup(); + +} + +void SmartPlaylistsModel::ItemFromSmartPlaylist(const QSettings &s, const bool notify) { + + SmartPlaylistsItem *item = new SmartPlaylistsItem(SmartPlaylistsItem::Type_SmartPlaylist, notify ? nullptr : root_); + item->display_text = tr(qPrintable(s.value("name").toString())); + item->sort_text = item->display_text; + item->smart_playlist_type = PlaylistGenerator::Type(s.value("type").toInt()); + item->smart_playlist_data = s.value("data").toByteArray(); + item->lazy_loaded = true; + + if (notify) item->InsertNotify(root_); + +} + +void SmartPlaylistsModel::AddGenerator(PlaylistGeneratorPtr gen) { + + QSettings s; + s.beginGroup(kSettingsGroup); + + // Count the existing items + const int count = s.beginReadArray(backend_->songs_table()); + s.endArray(); + + // Add this one to the end + s.beginWriteArray(backend_->songs_table(), count + 1); + SaveGenerator(&s, count, gen); + + // Add it to the model + ItemFromSmartPlaylist(s, true); + + s.endArray(); + s.endGroup(); + +} + +void SmartPlaylistsModel::UpdateGenerator(const QModelIndex &idx, PlaylistGeneratorPtr gen) { + + if (idx.parent() != ItemToIndex(root_)) return; + SmartPlaylistsItem *item = IndexToItem(idx); + if (!item) return; + + // Update the config + QSettings s; + s.beginGroup(kSettingsGroup); + + // Count the existing items + const int count = s.beginReadArray(backend_->songs_table()); + s.endArray(); + + s.beginWriteArray(backend_->songs_table(), count); + SaveGenerator(&s, idx.row(), gen); + + s.endGroup(); + + // Update the text of the item + item->display_text = gen->name(); + item->sort_text = item->display_text; + item->smart_playlist_type = gen->type(); + item->smart_playlist_data = gen->Save(); + item->ChangedNotify(); + +} + +void SmartPlaylistsModel::DeleteGenerator(const QModelIndex &idx) { + + if (idx.parent() != ItemToIndex(root_)) return; + + // Remove the item from the tree + root_->DeleteNotify(idx.row()); + + QSettings s; + s.beginGroup(kSettingsGroup); + + // Rewrite all the items to the settings + s.beginWriteArray(backend_->songs_table(), root_->children.count()); + int i = 0; + for (SmartPlaylistsItem *item : root_->children) { + s.setArrayIndex(i++); + s.setValue("name", item->display_text); + s.setValue("type", item->smart_playlist_type); + s.setValue("data", item->smart_playlist_data); + } + s.endArray(); + s.endGroup(); + +} + +void SmartPlaylistsModel::SaveGenerator(QSettings *s, const int i, PlaylistGeneratorPtr generator) const { + + s->setArrayIndex(i); + s->setValue("name", generator->name()); + s->setValue("type", generator->type()); + s->setValue("data", generator->Save()); + +} + +PlaylistGeneratorPtr SmartPlaylistsModel::CreateGenerator(const QModelIndex &idx) const { + + PlaylistGeneratorPtr ret; + + const SmartPlaylistsItem *item = IndexToItem(idx); + if (!item || item->type != SmartPlaylistsItem::Type_SmartPlaylist) return ret; + + ret = PlaylistGenerator::Create(item->smart_playlist_type); + if (!ret) return ret; + + ret->set_name(item->display_text); + ret->set_collection(backend_); + ret->Load(item->smart_playlist_data); + + return ret; + +} + +QVariant SmartPlaylistsModel::data(const QModelIndex &idx, const int role) const { + + if (!idx.isValid()) return QVariant(); + const SmartPlaylistsItem *item = IndexToItem(idx); + if (!item) return QVariant(); + + switch (role) { + case Qt::DecorationRole: + return icon_; + case Qt::DisplayRole: + case Qt::ToolTipRole: + return item->DisplayText(); + } + + return QVariant(); + +} + +void SmartPlaylistsModel::LazyPopulate(SmartPlaylistsItem *parent, const bool signal) { + Q_UNUSED(parent); + Q_UNUSED(signal); +} + +QStringList SmartPlaylistsModel::mimeTypes() const { + return QStringList() << "text/uri-list"; +} + +QMimeData *SmartPlaylistsModel::mimeData(const QModelIndexList &indexes) const { + + if (indexes.isEmpty()) return nullptr; + + PlaylistGeneratorPtr generator = CreateGenerator(indexes.first()); + if (!generator) return nullptr; + + PlaylistGeneratorMimeData *data = new PlaylistGeneratorMimeData(generator); + data->setData(kSmartPlaylistsMimeType, QByteArray()); + data->name_for_new_playlist_ = this->data(indexes.first()).toString(); + return data; + +} diff --git a/src/smartplaylists/smartplaylistsmodel.h b/src/smartplaylists/smartplaylistsmodel.h new file mode 100644 index 00000000..118fb2ed --- /dev/null +++ b/src/smartplaylists/smartplaylistsmodel.h @@ -0,0 +1,94 @@ +/* + * Strawberry Music Player + * This code was part of Clementine. + * Copyright 2010, David Sansome + * 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 SMARTPLAYLISTSMODEL_H +#define SMARTPLAYLISTSMODEL_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "core/simpletreemodel.h" +#include "smartplaylistsitem.h" +#include "playlistgenerator_fwd.h" + +class Application; +class CollectionBackend; + +class QModelIndex; +class QMimeData; + +class SmartPlaylistsModel : public SimpleTreeModel { + Q_OBJECT + + public: + explicit SmartPlaylistsModel(CollectionBackend *backend, QObject *parent = nullptr); + ~SmartPlaylistsModel(); + + void Init(); + + enum Role { + Role_Type = Qt::UserRole + 1, + Role_SortText, + Role_DisplayText, + Role_Smartplaylist, + LastRole + }; + + typedef QList GeneratorList; + typedef QList DefaultGenerators; + + PlaylistGeneratorPtr CreateGenerator(const QModelIndex &idx) const; + void AddGenerator(PlaylistGeneratorPtr gen); + void UpdateGenerator(const QModelIndex &idx, PlaylistGeneratorPtr gen); + void DeleteGenerator(const QModelIndex &idx); + + private: + QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; + QStringList mimeTypes() const override; + QMimeData *mimeData(const QModelIndexList &indexes) const override; + + protected: + void LazyPopulate(SmartPlaylistsItem *item) override { LazyPopulate(item, true); } + void LazyPopulate(SmartPlaylistsItem *item, bool signal); + + private: + static const char *kSettingsGroup; + static const char *kSmartPlaylistsMimeType; + static const int kSmartPlaylistsVersion; + + void SaveGenerator(QSettings *s, const int i, PlaylistGeneratorPtr generator) const; + void ItemFromSmartPlaylist(const QSettings &s, const bool notify); + + private: + CollectionBackend *backend_; + QIcon icon_; + DefaultGenerators default_smart_playlists_; + QList items_; + +}; + +#endif // SMARTPLAYLISTSMODEL_H diff --git a/src/smartplaylists/smartplaylistsview.cpp b/src/smartplaylists/smartplaylistsview.cpp new file mode 100644 index 00000000..4e40f46c --- /dev/null +++ b/src/smartplaylists/smartplaylistsview.cpp @@ -0,0 +1,53 @@ +/* + * 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 "core/application.h" +#include "core/logging.h" +#include "core/mimedata.h" +#include "core/iconloader.h" +#include "smartplaylistsmodel.h" +#include "smartplaylistsview.h" +#include "smartplaylistwizard.h" + +SmartPlaylistsView::SmartPlaylistsView(QWidget *_parent) : QListView(_parent) { + + setAttribute(Qt::WA_MacShowFocusRect, false); + setDragEnabled(true); + setDragDropMode(QAbstractItemView::DragOnly); + setSelectionMode(QAbstractItemView::SingleSelection); + +} + +SmartPlaylistsView::~SmartPlaylistsView() {} + +void SmartPlaylistsView::selectionChanged(const QItemSelection&, const QItemSelection&) { + emit ItemsSelectedChanged(); +} + +void SmartPlaylistsView::contextMenuEvent(QContextMenuEvent *e) { + + emit RightClicked(e->globalPos(), indexAt(e->pos())); + e->accept(); + +} diff --git a/src/smartplaylists/smartplaylistsview.h b/src/smartplaylists/smartplaylistsview.h new file mode 100644 index 00000000..a8a1efec --- /dev/null +++ b/src/smartplaylists/smartplaylistsview.h @@ -0,0 +1,47 @@ +/* + * 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 SMARTPLAYLISTSVIEW_H +#define SMARTPLAYLISTSVIEW_H + +#include "config.h" + +#include +#include +#include +#include + +class SmartPlaylistsView : public QListView { + Q_OBJECT + + public: + explicit SmartPlaylistsView(QWidget *parent = nullptr); + ~SmartPlaylistsView(); + + protected: + void selectionChanged(const QItemSelection&, const QItemSelection&) override; + void contextMenuEvent(QContextMenuEvent *e) override; + + signals: + void ItemsSelectedChanged(); + void RightClicked(QPoint global_pos, QModelIndex idx); + +}; + +#endif // SMARTPLAYLISTSVIEW_H diff --git a/src/smartplaylists/smartplaylistsviewcontainer.cpp b/src/smartplaylists/smartplaylistsviewcontainer.cpp new file mode 100644 index 00000000..47db7708 --- /dev/null +++ b/src/smartplaylists/smartplaylistsviewcontainer.cpp @@ -0,0 +1,283 @@ +/* + * 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 "core/application.h" +#include "core/logging.h" +#include "core/iconloader.h" +#include "core/mimedata.h" +#include "collection/collectionbackend.h" +#include "settings/appearancesettingspage.h" + +#include "smartplaylistsviewcontainer.h" +#include "smartplaylistsmodel.h" +#include "smartplaylistsview.h" +#include "smartplaylistsearchterm.h" +#include "smartplaylistwizard.h" +#include "playlistquerygenerator.h" +#include "playlistgenerator_fwd.h" + +#include "ui_smartplaylistsviewcontainer.h" + +SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(Application *app, QWidget *parent) + : QWidget(parent), + ui_(new Ui_SmartPlaylistsViewContainer), + app_(app), + context_menu_(new QMenu(this)), + context_menu_selected_(new QMenu(this)), + action_new_smart_playlist_(nullptr), + action_edit_smart_playlist_(nullptr), + action_delete_smart_playlist_(nullptr), + action_append_to_playlist_(nullptr), + action_replace_current_playlist_(nullptr), + action_open_in_new_playlist_(nullptr), + action_add_to_playlist_enqueue_(nullptr), + action_add_to_playlist_enqueue_next_(nullptr) + { + + ui_->setupUi(this); + + model_ = new SmartPlaylistsModel(app_->collection_backend(), this); + ui_->view->setModel(model_); + + model_->Init(); + + action_new_smart_playlist_ = context_menu_->addAction(IconLoader::Load("document-new"), tr("New smart playlist..."), this, SLOT(NewSmartPlaylist())); + + action_append_to_playlist_ = context_menu_selected_->addAction(IconLoader::Load("media-playback-start"), tr("Append to current playlist"), this, SLOT(AppendToPlaylist())); + action_replace_current_playlist_ = context_menu_selected_->addAction(IconLoader::Load("media-playback-start"), tr("Replace current playlist"), this, SLOT(ReplaceCurrentPlaylist())); + action_open_in_new_playlist_ = context_menu_selected_->addAction(IconLoader::Load("document-new"), tr("Open in new playlist"), this, SLOT(OpenInNewPlaylist())); + + context_menu_selected_->addSeparator(); + action_add_to_playlist_enqueue_ = context_menu_selected_->addAction(IconLoader::Load("go-next"), tr("Queue track"), this, SLOT(AddToPlaylistEnqueue())); + action_add_to_playlist_enqueue_next_ = context_menu_selected_->addAction(IconLoader::Load("go-next"), tr("Play next"), this, SLOT(AddToPlaylistEnqueueNext())); + context_menu_selected_->addSeparator(); + + context_menu_selected_->addSeparator(); + context_menu_selected_->addActions(QList() << action_new_smart_playlist_); + action_edit_smart_playlist_ = context_menu_selected_->addAction(IconLoader::Load("edit-rename"), tr("Edit smart playlist..."), this, SLOT(EditSmartPlaylistFromContext())); + action_delete_smart_playlist_ = context_menu_selected_->addAction(IconLoader::Load("edit-delete"), tr("Delete smart playlist"), this, SLOT(DeleteSmartPlaylistFromContext())); + + context_menu_selected_->addSeparator(); + + ui_->new_->setDefaultAction(action_new_smart_playlist_); + ui_->edit_->setIcon(IconLoader::Load("edit-rename")); + ui_->delete_->setIcon(IconLoader::Load("edit-delete")); + connect(ui_->edit_, SIGNAL(clicked()), SLOT(EditSmartPlaylistFromButton())); + connect(ui_->delete_, SIGNAL(clicked()), SLOT(DeleteSmartPlaylistFromButton())); + + connect(ui_->view, SIGNAL(ItemsSelectedChanged()), SLOT(ItemsSelectedChanged())); + connect(ui_->view, SIGNAL(doubleClicked(QModelIndex)), SLOT(ItemDoubleClicked(QModelIndex))); + connect(ui_->view, SIGNAL(RightClicked(QPoint, QModelIndex)), SLOT(RightClicked(QPoint, QModelIndex))); + + ReloadSettings(); + + ItemsSelectedChanged(); + +} + +SmartPlaylistsViewContainer::~SmartPlaylistsViewContainer() { delete ui_; } + +SmartPlaylistsView *SmartPlaylistsViewContainer::view() const { return ui_->view; } + +void SmartPlaylistsViewContainer::showEvent(QShowEvent *e) { + + ItemsSelectedChanged(); + + QWidget::showEvent(e); + +} + +void SmartPlaylistsViewContainer::ReloadSettings() { + + QSettings s; + s.beginGroup(AppearanceSettingsPage::kSettingsGroup); + int iconsize = s.value(AppearanceSettingsPage::kIconSizeLeftPanelButtons, 22).toInt(); + s.endGroup(); + + ui_->new_->setIconSize(QSize(iconsize, iconsize)); + ui_->delete_->setIconSize(QSize(iconsize, iconsize)); + ui_->edit_->setIconSize(QSize(iconsize, iconsize)); + +} + +void SmartPlaylistsViewContainer::ItemsSelectedChanged() { + + ui_->edit_->setEnabled(ui_->view->selectionModel()->selectedRows().count() > 0); + ui_->delete_->setEnabled(ui_->view->selectionModel()->selectedRows().count() > 0); + +} + +void SmartPlaylistsViewContainer::RightClicked(const QPoint &global_pos, const QModelIndex &idx) { + + context_menu_index_ = idx; + if (context_menu_index_.isValid()) { + context_menu_selected_->popup(global_pos); + } + else { + context_menu_->popup(global_pos); + } + +} + +void SmartPlaylistsViewContainer::ReplaceCurrentPlaylist() { + + QMimeData *q_mimedata = ui_->view->model()->mimeData(ui_->view->selectionModel()->selectedIndexes()); + if (MimeData *mimedata = qobject_cast(q_mimedata)) { + mimedata->clear_first_ = true; + } + emit AddToPlaylist(q_mimedata); + +} + +void SmartPlaylistsViewContainer::AppendToPlaylist() { + + emit AddToPlaylist(ui_->view->model()->mimeData(ui_->view->selectionModel()->selectedIndexes())); + +} + +void SmartPlaylistsViewContainer::OpenInNewPlaylist() { + + QMimeData *q_mimedata = ui_->view->model()->mimeData(ui_->view->selectionModel()->selectedIndexes()); + if (MimeData *mimedata = qobject_cast(q_mimedata)) { + mimedata->open_in_new_playlist_ = true; + } + emit AddToPlaylist(q_mimedata); + +} + +void SmartPlaylistsViewContainer::AddToPlaylistEnqueue() { + + QMimeData *q_mimedata = ui_->view->model()->mimeData(ui_->view->selectionModel()->selectedIndexes()); + if (MimeData *mimedata = qobject_cast(q_mimedata)) { + mimedata->enqueue_now_ = true; + } + emit AddToPlaylist(q_mimedata); + +} + +void SmartPlaylistsViewContainer::AddToPlaylistEnqueueNext() { + + QMimeData *q_mimedata = ui_->view->model()->mimeData(ui_->view->selectionModel()->selectedIndexes()); + if (MimeData *mimedata = qobject_cast(q_mimedata)) { + mimedata->enqueue_next_now_ = true; + } + emit AddToPlaylist(q_mimedata); + +} + +void SmartPlaylistsViewContainer::NewSmartPlaylist() { + + SmartPlaylistWizard *wizard = new SmartPlaylistWizard(app_, app_->collection_backend(), this); + wizard->setAttribute(Qt::WA_DeleteOnClose); + connect(wizard, SIGNAL(accepted()), SLOT(NewSmartPlaylistFinished())); + + wizard->show(); + +} + +void SmartPlaylistsViewContainer::EditSmartPlaylist(const QModelIndex &idx) { + + if (!idx.isValid()) return; + + SmartPlaylistWizard *wizard = new SmartPlaylistWizard(app_, app_->collection_backend(), this); + wizard->setAttribute(Qt::WA_DeleteOnClose); + connect(wizard, SIGNAL(accepted()), SLOT(EditSmartPlaylistFinished())); + + wizard->show(); + wizard->SetGenerator(model_->CreateGenerator(idx)); + +} + +void SmartPlaylistsViewContainer::EditSmartPlaylistFromContext() { + + if (!context_menu_index_.isValid()) return; + + EditSmartPlaylist(context_menu_index_); + +} + +void SmartPlaylistsViewContainer::EditSmartPlaylistFromButton() { + + if (ui_->view->selectionModel()->selectedIndexes().count() == 0) return; + + EditSmartPlaylist(ui_->view->selectionModel()->selectedIndexes().first()); + +} + +void SmartPlaylistsViewContainer::DeleteSmartPlaylist(const QModelIndex &idx) { + + if (!idx.isValid()) return; + model_->DeleteGenerator(idx); + +} + +void SmartPlaylistsViewContainer::DeleteSmartPlaylistFromContext() { + + if (!context_menu_index_.isValid()) return; + DeleteSmartPlaylist(context_menu_index_); + +} + +void SmartPlaylistsViewContainer::DeleteSmartPlaylistFromButton() { + + if (ui_->view->selectionModel()->selectedIndexes().count() == 0) return; + + DeleteSmartPlaylist(ui_->view->selectionModel()->selectedIndexes().first()); + +} + +void SmartPlaylistsViewContainer::NewSmartPlaylistFinished() { + + SmartPlaylistWizard *wizard = qobject_cast(sender()); + if (!wizard) return; + disconnect(wizard, SIGNAL(accepted()), this, SLOT(NewSmartPlaylistFinished())); + model_->AddGenerator(wizard->CreateGenerator()); + +} + +void SmartPlaylistsViewContainer::EditSmartPlaylistFinished() { + + if (!context_menu_index_.isValid()) return; + + const SmartPlaylistWizard *wizard = qobject_cast(sender()); + if (!wizard) return; + + disconnect(wizard, SIGNAL(accepted()), this, SLOT(EditSmartPlaylistFinished())); + + model_->UpdateGenerator(context_menu_index_, wizard->CreateGenerator()); + +} + +void SmartPlaylistsViewContainer::ItemDoubleClicked(const QModelIndex &idx) { + + QMimeData *q_mimedata = ui_->view->model()->mimeData(QModelIndexList() << idx); + if (MimeData *mimedata = qobject_cast(q_mimedata)) { + mimedata->from_doubleclick_ = true; + } + emit AddToPlaylist(q_mimedata); + +} diff --git a/src/smartplaylists/smartplaylistsviewcontainer.h b/src/smartplaylists/smartplaylistsviewcontainer.h new file mode 100644 index 00000000..03e47576 --- /dev/null +++ b/src/smartplaylists/smartplaylistsviewcontainer.h @@ -0,0 +1,101 @@ +/* + * 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 SMARTPLAYLISTSVIEWCONTAINER_H +#define SMARTPLAYLISTSVIEWCONTAINER_H + +#include "config.h" + +#include +#include + +class QMimeData; +class QMenu; +class QAction; +class QShowEvent; + +class Application; +class SmartPlaylistsModel; +class SmartPlaylistsView; +class Ui_SmartPlaylistsViewContainer; + +class SmartPlaylistsViewContainer : public QWidget { + Q_OBJECT + + public: + explicit SmartPlaylistsViewContainer(Application *app, QWidget *parent = nullptr); + ~SmartPlaylistsViewContainer(); + + SmartPlaylistsView *view() const; + + void ReloadSettings(); + + protected: + void showEvent(QShowEvent *e) override; + + private slots: + void ItemsSelectedChanged(); + void ItemDoubleClicked(const QModelIndex &idx); + + void RightClicked(const QPoint &global_pos, const QModelIndex &idx); + + void AppendToPlaylist(); + void ReplaceCurrentPlaylist(); + void OpenInNewPlaylist(); + + void AddToPlaylistEnqueue(); + void AddToPlaylistEnqueueNext(); + + void NewSmartPlaylist(); + + void EditSmartPlaylist(const QModelIndex &idx); + void DeleteSmartPlaylist(const QModelIndex &idx); + + void EditSmartPlaylistFromButton(); + void DeleteSmartPlaylistFromButton(); + void EditSmartPlaylistFromContext(); + void DeleteSmartPlaylistFromContext(); + + void NewSmartPlaylistFinished(); + void EditSmartPlaylistFinished(); + + signals: + void AddToPlaylist(QMimeData *data); + void ItemsSelectedChanged(bool); + + private: + Ui_SmartPlaylistsViewContainer *ui_; + Application *app_; + SmartPlaylistsModel *model_; + + QMenu *context_menu_; + QMenu *context_menu_selected_; + QAction *action_new_smart_playlist_; + QAction *action_edit_smart_playlist_; + QAction *action_delete_smart_playlist_; + QAction *action_append_to_playlist_; + QAction *action_replace_current_playlist_; + QAction *action_open_in_new_playlist_; + QAction *action_add_to_playlist_enqueue_; + QAction *action_add_to_playlist_enqueue_next_; + QModelIndex context_menu_index_; + +}; + +#endif // SMARTPLAYLISTSVIEWCONTAINER_H diff --git a/src/smartplaylists/smartplaylistsviewcontainer.ui b/src/smartplaylists/smartplaylistsviewcontainer.ui new file mode 100644 index 00000000..93b0d7a1 --- /dev/null +++ b/src/smartplaylists/smartplaylistsviewcontainer.ui @@ -0,0 +1,136 @@ + + + SmartPlaylistsViewContainer + + + + 0 + 0 + 415 + 495 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + New smart playlist + + + + 22 + 22 + + + + + + + + Edit smart playlist + + + + 22 + 22 + + + + + + + + Delete smart playlist + + + + 22 + 22 + + + + + + + + Qt::Horizontal + + + + 70 + 20 + + + + + + + + + + + + 0 + 0 + + + + + + + + + SmartPlaylistsView + QWidget +
smartplaylists/smartplaylistsview.h
+ 1 +
+
+ + +
diff --git a/src/smartplaylists/smartplaylistwizard.cpp b/src/smartplaylists/smartplaylistwizard.cpp new file mode 100644 index 00000000..a373c9f0 --- /dev/null +++ b/src/smartplaylists/smartplaylistwizard.cpp @@ -0,0 +1,179 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "core/logging.h" +#include "core/iconloader.h" + +#include "smartplaylistquerywizardplugin.h" +#include "smartplaylistwizard.h" +#include "smartplaylistwizardplugin.h" +#include "ui_smartplaylistwizardfinishpage.h" + +class SmartPlaylistWizard::TypePage : public QWizardPage { + public: + TypePage(QWidget *parent) : QWizardPage(parent), next_id_(-1) {} + + int nextId() const { return next_id_; } + int next_id_; +}; + +class SmartPlaylistWizard::FinishPage : public QWizardPage { + public: + FinishPage(QWidget *parent) : QWizardPage(parent), ui_(new Ui_SmartPlaylistWizardFinishPage) { + ui_->setupUi(this); + connect(ui_->name, SIGNAL(textChanged(QString)), SIGNAL(completeChanged())); + } + + ~FinishPage() { delete ui_; } + + int nextId() const { return -1; } + bool isComplete() const { return !ui_->name->text().isEmpty(); } + + Ui_SmartPlaylistWizardFinishPage *ui_; + +}; + +SmartPlaylistWizard::SmartPlaylistWizard(Application *app, CollectionBackend *collection, QWidget *parent) + : QWizard(parent), + app_(app), + collection_(collection), + type_page_(new TypePage(this)), + finish_page_(new FinishPage(this)), + type_index_(-1) { + + setWindowIcon(IconLoader::Load("strawberry")); + setWindowTitle(tr("Smart playlist")); + + resize(788, 628); + +#ifdef Q_OS_MACOS + // MacStyle leaves an ugly empty space on the left side of the dialog. + setWizardStyle(QWizard::ClassicStyle); +#endif + + // Type page + type_page_->setTitle(tr("Playlist type")); + type_page_->setSubTitle(tr("A smart playlist is a dynamic list of songs that come from your collection. There are different types of smart playlist that offer different ways of selecting songs.")); + type_page_->setStyleSheet("QRadioButton { font-weight: bold; } QLabel { margin-bottom: 1em; margin-left: 24px; }"); + addPage(type_page_); + + // Finish page + finish_page_->setTitle(tr("Finish")); + finish_page_->setSubTitle(tr("Choose a name for your smart playlist")); + finish_id_ = addPage(finish_page_); + + new QVBoxLayout(type_page_); + AddPlugin(new SmartPlaylistQueryWizardPlugin(app_, collection_, this)); + + // Skip the type page - remove this when we have more than one type + setStartId(2); + +} + +SmartPlaylistWizard::~SmartPlaylistWizard() { + qDeleteAll(plugins_); +} + +void SmartPlaylistWizard::SetGenerator(PlaylistGeneratorPtr gen) { + + // Find the right type and jump to the start page + for (int i = 0; i < plugins_.count(); ++i) { + if (plugins_[i]->type() == gen->type()) { + TypeChanged(i); + // TODO: Put this back in when the setStartId is removed from the ctor next(); + break; + } + } + + if (type_index_ == -1) { + qLog(Error) << "Plugin was not found for generator type" << gen->type(); + return; + } + + // Set the name + if (!gen->name().isEmpty()) { + setWindowTitle(windowTitle() + " - " + gen->name()); + } + finish_page_->ui_->name->setText(gen->name()); + finish_page_->ui_->dynamic->setChecked(gen->is_dynamic()); + + // Tell the plugin to load + plugins_[type_index_]->SetGenerator(gen); + +} + +void SmartPlaylistWizard::AddPlugin(SmartPlaylistWizardPlugin *plugin) { + + const int index = plugins_.count(); + plugins_ << plugin; + plugin->Init(this, finish_id_); + + // Create the radio button + QRadioButton *radio_button = new QRadioButton(plugin->name(), type_page_); + QLabel *description = new QLabel(plugin->description(), type_page_); + type_page_->layout()->addWidget(radio_button); + type_page_->layout()->addWidget(description); + + connect(radio_button, &QRadioButton::clicked, [this, index]() { TypeChanged(index); } ); + + if (index == 0) { + radio_button->setChecked(true); + TypeChanged(0); + } + +} + +void SmartPlaylistWizard::TypeChanged(const int index) { + + type_index_ = index; + type_page_->next_id_ = plugins_[type_index_]->start_page(); + +} + +PlaylistGeneratorPtr SmartPlaylistWizard::CreateGenerator() const { + + PlaylistGeneratorPtr ret; + if (type_index_ == -1) return ret; + + ret = plugins_[type_index_]->CreateGenerator(); + if (!ret) return ret; + + ret->set_name(finish_page_->ui_->name->text()); + ret->set_dynamic(finish_page_->ui_->dynamic->isChecked()); + return ret; + +} + +void SmartPlaylistWizard::initializePage(const int id) { + + if (id == finish_id_) { + finish_page_->ui_->dynamic_container->setEnabled(plugins_[type_index_]->is_dynamic()); + } + QWizard::initializePage(id); + +} diff --git a/src/smartplaylists/smartplaylistwizard.h b/src/smartplaylists/smartplaylistwizard.h new file mode 100644 index 00000000..99bc6b60 --- /dev/null +++ b/src/smartplaylists/smartplaylistwizard.h @@ -0,0 +1,68 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTWIZARD_H +#define SMARTPLAYLISTWIZARD_H + +#include "config.h" + +#include + +#include "playlistgenerator_fwd.h" + +class Application; +class CollectionBackend; +class SmartPlaylistWizardPlugin; + +class SmartPlaylistWizard : public QWizard { + Q_OBJECT + + public: + explicit SmartPlaylistWizard(Application *app, CollectionBackend *collection, QWidget *parent); + ~SmartPlaylistWizard(); + + void SetGenerator(PlaylistGeneratorPtr gen); + PlaylistGeneratorPtr CreateGenerator() const; + + protected: + void initializePage(const int id); + + private: + class TypePage; + class FinishPage; + + void AddPlugin(SmartPlaylistWizardPlugin *plugin); + + private slots: + void TypeChanged(const int index); + + private: + Application *app_; + CollectionBackend *collection_; + TypePage *type_page_; + FinishPage *finish_page_; + int finish_id_; + + int type_index_; + QList plugins_; + +}; + +#endif // SMARTPLAYLISTWIZARD_H diff --git a/src/smartplaylists/smartplaylistwizardfinishpage.ui b/src/smartplaylists/smartplaylistwizardfinishpage.ui new file mode 100644 index 00000000..cafdaba3 --- /dev/null +++ b/src/smartplaylists/smartplaylistwizardfinishpage.ui @@ -0,0 +1,69 @@ + + + SmartPlaylistWizardFinishPage + + + + 0 + 0 + 583 + 370 + + + + Form + + + + + + Name + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Use dynamic mode + + + + + + + In dynamic mode new tracks will be chosen and added to the playlist every time a song finishes. + + + true + + + 24 + + + + + + + + + + + diff --git a/src/smartplaylists/smartplaylistwizardplugin.cpp b/src/smartplaylists/smartplaylistwizardplugin.cpp new file mode 100644 index 00000000..f2c95cac --- /dev/null +++ b/src/smartplaylists/smartplaylistwizardplugin.cpp @@ -0,0 +1,32 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "smartplaylistwizardplugin.h" + +SmartPlaylistWizardPlugin::SmartPlaylistWizardPlugin(Application *app, CollectionBackend *collection, QObject *parent) : QObject(parent), app_(app), collection_(collection), start_page_(-1) {} + +void SmartPlaylistWizardPlugin::Init(QWizard *wizard, const int finish_page_id) { + start_page_ = CreatePages(wizard, finish_page_id); +} diff --git a/src/smartplaylists/smartplaylistwizardplugin.h b/src/smartplaylists/smartplaylistwizardplugin.h new file mode 100644 index 00000000..e5b233c2 --- /dev/null +++ b/src/smartplaylists/smartplaylistwizardplugin.h @@ -0,0 +1,60 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 SMARTPLAYLISTWIZARDPLUGIN_H +#define SMARTPLAYLISTWIZARDPLUGIN_H + +#include + +#include "playlistgenerator_fwd.h" + +class QWizard; + +class Application; +class CollectionBackend; + +class SmartPlaylistWizardPlugin : public QObject { + Q_OBJECT + + public: + explicit SmartPlaylistWizardPlugin(Application *app, CollectionBackend *collection, QObject *parent); + + virtual QString type() const = 0; + virtual QString name() const = 0; + virtual QString description() const = 0; + virtual bool is_dynamic() const { return false; } + int start_page() const { return start_page_; } + + virtual void SetGenerator(PlaylistGeneratorPtr gen) = 0; + virtual PlaylistGeneratorPtr CreateGenerator() const = 0; + + void Init(QWizard *wizard, const int finish_page_id); + + protected: + virtual int CreatePages(QWizard *wizard, const int finish_page_id) = 0; + + Application *app_; + CollectionBackend *collection_; + + private: + int start_page_; +}; + +#endif // SMARTPLAYLISTWIZARDPLUGIN_H diff --git a/src/widgets/ratingwidget.cpp b/src/widgets/ratingwidget.cpp new file mode 100644 index 00000000..8d8816b9 --- /dev/null +++ b/src/widgets/ratingwidget.cpp @@ -0,0 +1,164 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 "ratingwidget.h" + +#include +#include +#include +#include +#include +#include + +const int RatingPainter::kStarCount; +const int RatingPainter::kStarSize; + +RatingPainter::RatingPainter() { + + // Load the base pixmaps + QIcon star_on(":/pictures/star-on.png"); + QPixmap on(star_on.pixmap(star_on.availableSizes().last())); + QIcon star_off(":/pictures/star-off.png"); + QPixmap off(star_off.pixmap(star_off.availableSizes().last())); + + // Generate the 10 states, better to do it now than on the fly + for (int i = 0 ; i < kStarCount * 2 + 1 ; ++i) { + const float rating = float(i) / 2.0; + + // Clear the pixmap + stars_[i] = QPixmap(kStarSize * kStarCount, kStarSize); + stars_[i].fill(Qt::transparent); + QPainter p(&stars_[i]); + + // Draw the stars + int x = 0; + for (int y = 0 ; y < kStarCount ; ++y, x += kStarSize) { + const QRect rect(x, 0, kStarSize, kStarSize); + + if (rating - 0.25 <= y) { // Totally empty + p.drawPixmap(rect, off); + } + else if (rating - 0.75 <= y) { // Half full + const QRect target_left(rect.x(), rect.y(), kStarSize / 2, kStarSize); + const QRect target_right(rect.x() + kStarSize / 2, rect.y(), kStarSize / 2, kStarSize); + const QRect source_left(0, 0, kStarSize / 2, kStarSize); + const QRect source_right(kStarSize / 2, 0, kStarSize / 2, kStarSize); + p.drawPixmap(target_left, on, source_left); + p.drawPixmap(target_right, off, source_right); + } + else { // Totally full + p.drawPixmap(rect, on); + } + } + } +} + +QRect RatingPainter::Contents(const QRect &rect) { + + const int width = kStarSize * kStarCount; + const int x = rect.x() + (rect.width() - width) / 2; + + return QRect(x, rect.y(), width, rect.height()); + +} + +double RatingPainter::RatingForPos(const QPoint &pos, const QRect &rect) { + + const QRect contents = Contents(rect); + const double raw = double(pos.x() - contents.left()) / contents.width(); + + // Round to the nearest 0.1 + return double(int(raw * kStarCount * 2 + 0.5)) / (kStarCount * 2); + +} + +void RatingPainter::Paint(QPainter* painter, const QRect &rect, float rating) const { + + QSize size(qMin(kStarSize * kStarCount, rect.width()), qMin(kStarSize, rect.height())); + QPoint pos(rect.center() - QPoint(size.width() / 2, size.height() / 2)); + + rating *= kStarCount; + + // Draw the stars + const int star = qBound(0, int(rating * 2.0 + 0.5), kStarCount * 2); + painter->drawPixmap(QRect(pos, size), stars_[star], QRect(QPoint(0, 0), size)); + +} + +RatingWidget::RatingWidget(QWidget *parent) : QWidget(parent), rating_(0.0), hover_rating_(-1.0) { + + setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + setMouseTracking(true); + +} + +QSize RatingWidget::sizeHint() const { + + const int frame_width = 1 + style()->pixelMetric(QStyle::PM_DefaultFrameWidth); + return QSize(RatingPainter::kStarSize * (RatingPainter::kStarCount + 2) + frame_width * 2, RatingPainter::kStarSize + frame_width * 2); + +} + +void RatingWidget::set_rating(const float rating) { + + rating_ = rating; + update(); + +} + +void RatingWidget::paintEvent(QPaintEvent*) { + + QStylePainter p(this); + + // Draw the background + QStyleOptionFrame opt; + opt.initFrom(this); + opt.state |= QStyle::State_Sunken; + opt.frameShape = QFrame::StyledPanel; + opt.lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, &opt, this); + opt.midLineWidth = 0; + + p.drawPrimitive(QStyle::PE_PanelLineEdit, opt); + + // Draw the stars + painter_.Paint(&p, rect(), hover_rating_ == -1.0 ? rating_ : hover_rating_); + +} + +void RatingWidget::mousePressEvent(QMouseEvent *e) { + + rating_ = RatingPainter::RatingForPos(e->pos(), rect()); + emit RatingChanged(rating_); + +} + +void RatingWidget::mouseMoveEvent(QMouseEvent *e) { + + hover_rating_ = RatingPainter::RatingForPos(e->pos(), rect()); + update(); + +} + +void RatingWidget::leaveEvent(QEvent*) { + + hover_rating_ = -1.0; + update(); + +} diff --git a/src/widgets/ratingwidget.h b/src/widgets/ratingwidget.h new file mode 100644 index 00000000..66870638 --- /dev/null +++ b/src/widgets/ratingwidget.h @@ -0,0 +1,71 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2010, David Sansome + * + * 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 RATINGWIDGET_H +#define RATINGWIDGET_H + +#include +#include +#include +#include + +class RatingPainter { + public: + RatingPainter(); + + static const int kStarCount = 5; + static const int kStarSize = 16; + static QRect Contents(const QRect &rect); + static double RatingForPos(const QPoint &pos, const QRect &rect); + + void Paint(QPainter *painter, const QRect &rect, float rating) const; + + private: + QPixmap stars_[kStarCount * 2 + 1]; +}; + +class RatingWidget : public QWidget { + Q_OBJECT + Q_PROPERTY(float rating READ rating WRITE set_rating) + + public: + RatingWidget(QWidget *parent = nullptr); + + QSize sizeHint() const override; + + float rating() const { return rating_; } + void set_rating(const float rating); + + signals: + void RatingChanged(float); + + protected: + void paintEvent(QPaintEvent*) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + void leaveEvent(QEvent*) override; + + private: + RatingPainter painter_; + float rating_; + float hover_rating_; +}; + +#endif // RATINGWIDGET_H