From 7b54cef23b9163638069ee045c1fb512c52ed14b Mon Sep 17 00:00:00 2001 From: Jonas Kvinge Date: Mon, 17 Jun 2019 23:54:24 +0200 Subject: [PATCH] Add Subsonic support (#180) --- CMakeLists.txt | 1 + README.md | 2 +- data/data.qrc | 1 + data/icons.qrc | 5 + data/icons/128x128/subsonic.png | Bin 0 -> 3576 bytes data/icons/22x22/subsonic.png | Bin 0 -> 1495 bytes data/icons/32x32/subsonic.png | Bin 0 -> 804 bytes data/icons/48x48/subsonic.png | Bin 0 -> 1707 bytes data/icons/64x64/subsonic.png | Bin 0 -> 1596 bytes data/schema/schema-6.sql | 73 ++ data/schema/schema.sql | 59 +- debian/control | 2 +- dist/rpm/strawberry.spec.in | 2 +- dist/unix/org.strawbs.strawberry.appdata.xml | 2 +- src/CMakeLists.txt | 19 + src/config.h.in | 1 + src/core/application.cpp | 7 + src/core/database.cpp | 2 +- src/core/mainwindow.cpp | 28 + src/core/mainwindow.h | 2 + src/core/song.cpp | 11 +- src/core/song.h | 1 + src/internet/internetcollectionview.h | 2 +- .../internetcollectionviewcontainer.cpp | 3 +- .../internetcollectionviewcontainer.ui | 14 +- src/internet/internetservice.h | 46 +- src/internet/internetsongsview.cpp | 124 +++ src/internet/internetsongsview.h | 67 ++ src/internet/internettabsview.cpp | 2 +- src/internet/internettabsview.h | 2 +- src/settings/settingsdialog.cpp | 8 +- src/settings/settingsdialog.h | 1 + src/settings/subsonicsettingspage.cpp | 132 +++ src/settings/subsonicsettingspage.h | 60 ++ src/settings/subsonicsettingspage.ui | 223 +++++ src/subsonic/subsonicbaserequest.cpp | 207 +++++ src/subsonic/subsonicbaserequest.h | 86 ++ src/subsonic/subsonicrequest.cpp | 759 ++++++++++++++++++ src/subsonic/subsonicrequest.h | 149 ++++ src/subsonic/subsonicservice.cpp | 350 ++++++++ src/subsonic/subsonicservice.h | 130 +++ src/subsonic/subsonicurlhandler.cpp | 57 ++ src/subsonic/subsonicurlhandler.h | 57 ++ src/tidal/tidalservice.h | 2 +- 44 files changed, 2656 insertions(+), 43 deletions(-) create mode 100644 data/icons/128x128/subsonic.png create mode 100644 data/icons/22x22/subsonic.png create mode 100644 data/icons/32x32/subsonic.png create mode 100644 data/icons/48x48/subsonic.png create mode 100644 data/icons/64x64/subsonic.png create mode 100644 data/schema/schema-6.sql create mode 100644 src/internet/internetsongsview.cpp create mode 100644 src/internet/internetsongsview.h create mode 100644 src/settings/subsonicsettingspage.cpp create mode 100644 src/settings/subsonicsettingspage.h create mode 100644 src/settings/subsonicsettingspage.ui create mode 100644 src/subsonic/subsonicbaserequest.cpp create mode 100644 src/subsonic/subsonicbaserequest.h create mode 100644 src/subsonic/subsonicrequest.cpp create mode 100644 src/subsonic/subsonicrequest.h create mode 100644 src/subsonic/subsonicservice.cpp create mode 100644 src/subsonic/subsonicservice.h create mode 100644 src/subsonic/subsonicurlhandler.cpp create mode 100644 src/subsonic/subsonicurlhandler.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ea41d98d..28c42078d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -345,6 +345,7 @@ optional_component(TRANSLATIONS ON "Translations" ) optional_component(TIDAL ON "Tidal support") +optional_component(SUBSONIC ON "Subsonic support") optional_component(MOODBAR ON "Moodbar" DEPENDS "fftw3" FFTW3_FOUND diff --git a/README.md b/README.md index 6385eae31..a2dc00cca 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Strawberry is a music player and music collection organizer. It is a fork of Cle * Audio analyzer * Audio equalizer * Transfer music to iPod, iPhone, MTP or mass-storage USB player - * Streaming support for Tidal + * Streaming support for Tidal and Subsonic * Scrobbler with support for Last.fm, Libre.fm and ListenBrainz It has so far been tested to work on Linux, OpenBSD, macOS and Windows. diff --git a/data/data.qrc b/data/data.qrc index 94180a664..a075d0c17 100644 --- a/data/data.qrc +++ b/data/data.qrc @@ -6,6 +6,7 @@ schema/schema-3.sql schema/schema-4.sql schema/schema-5.sql + schema/schema-6.sql schema/device-schema.sql style/strawberry.css html/playing-tooltip-plain.html diff --git a/data/icons.qrc b/data/icons.qrc index 99068a035..b6d184914 100644 --- a/data/icons.qrc +++ b/data/icons.qrc @@ -88,6 +88,7 @@ icons/128x128/scrobble-disabled.png icons/128x128/moodbar.png icons/128x128/love.png + icons/128x128/subsonic.png icons/64x64/albums.png icons/64x64/alsa.png icons/64x64/application-exit.png @@ -176,6 +177,7 @@ icons/64x64/scrobble-disabled.png icons/64x64/moodbar.png icons/64x64/love.png + icons/64x64/subsonic.png icons/48x48/albums.png icons/48x48/alsa.png icons/48x48/application-exit.png @@ -267,6 +269,7 @@ icons/48x48/scrobble-disabled.png icons/48x48/moodbar.png icons/48x48/love.png + icons/48x48/subsonic.png icons/32x32/albums.png icons/32x32/alsa.png icons/32x32/application-exit.png @@ -359,6 +362,7 @@ icons/32x32/scrobble-disabled.png icons/32x32/moodbar.png icons/32x32/love.png + icons/32x32/subsonic.png icons/22x22/albums.png icons/22x22/alsa.png icons/22x22/application-exit.png @@ -451,5 +455,6 @@ icons/22x22/scrobble-disabled.png icons/22x22/moodbar.png icons/22x22/love.png + icons/22x22/subsonic.png diff --git a/data/icons/128x128/subsonic.png b/data/icons/128x128/subsonic.png new file mode 100644 index 0000000000000000000000000000000000000000..1b734d859c01a55373564c4ccbf544a7e2c27e7c GIT binary patch literal 3576 zcmb7{_dgWyAICrE?znQCJ+h9>%%YHNg+d}HgcG8yGtYK5Nir+zj1bu)L^%~lM%y@> zLbhaI?(6#>e7`@u-p|M5Jzl@Oe|jaFn&>l~JarNPz+`BkXLd}&e}$kwuJ1J*`i^k| zXJF+E0P~0c3X&E34066^3nxCf8y45>#pBaKBIvsTcVyw z>i-Nwv0LZ(KkUBjAd-0_5+f&00NBAB^k%drcRM9B!M5V%-USi{xN8vj6)prUTw?)w zutoL9d)F~$qatt|WNp+trKsHcJcGQ@K`Xk>rg=No&C|-UYxR&a=F^$!k8Kx`YB%D5 zJ)3e6v$-IV1HOA(q8;(61%J}Nq$6w`fL2^~WGFsd6>7tb^$+c||4DBt4e5{OgjITy z1OYbXeXlAIW5>Zlv5)YsG|OwFxvK+aKo>L_T8uecN|BVo0I7Wb+AEYUfkx+`E?tf^ zgdo*6k{U>R#o!Ra^G%Bj>j%Zy{}G~+gtZ3fzq`|~$v^{nwKR*NoHgJt#SaK=eB%-z+n1f*(yJDs{)JOu-;C0RtR|hB}-|C&uKOs*D z3+cchOQ|RxBBL~q?ynWEA)b7c%AwX>uD@JbdJ?xX ztAVw)j5H7?1rr7)+k96@(8_9Aa_ljw(}{Q7}-d2xadH#t3sxz?2( zP47gA=Tv9H*+LbZmG%B&Ft24Vn$YpoL(r0hv=e6aceZ9bNxU(-O_Q8-sNlW}Li{2? zEjp4`N=m%+QawOXkIh~jXAfnua}mqj6^{oAS}??^O6MQ^3i^Txs-3D<*>^OD-AAOo zp4vOh9ALlv7FtlD;#)-Oe6F91IvXi5B!HNc!&o<6D2FNd37I#L8Vvzn$3I~SGqyLM z!@4s@hFZf@`141aj$YhkFb~GYdtrSw?h6Gp)LpvbuoKy@p)i0dGGsg{@aQ~h!COL^ z;=IA$wM3uskRP}E4P;3;(2NbXv!QM^+KvvZICQg zwcJ_0(EXmN!YZs_iD}XOb`q(-ZSMZ?XbV@xByLjb_*T!={d`JU9-xl$$M0w$pR8Lu zaUpBmPc{ut?@h*g+p=C)4M{YmwFVt~OnZC(ynD*E(mJlFo<@l1-j&PtOhn`*dlmSv z>-O|1&3KnfxyZAc_$Q^vhtPi4LlY-1`|X59ybIK1WQqDba=%bh2e;54713?G!6_4y z#rm={`3M6W!xuDo%ZjLP_uv%!CCxfjuNj7%ehGT)URNx4H!JHeeKi3tx#JXv;s(px zulgiETFNbtJ2i95hwwejE*plg2fjS_HQf$R$1syy*rIhAZBx)-iB>^lk#DaRsRCmB zd*Z;1_hw@)x4b+R@2czYHZ9zO6nij!4<7n%gpXO`;!0^1aWTTxvthN7oA5JKFrsR` z>-XjR`o3eoBHZ@t>85{G+54GXF&2&vbyO_h-*V~BpK4dGy`lU9uQGN0pJ8DdEhX7- zMt;ioe)+pP$0-NjTXc_YwX(Hf2TvA4h!=-E!-mE*+h5wBL?;K%rOfz$>X@K~@5@v< zo_*h^I_5_Dcq1&&8Z}S)N?XIwUS0bv+dMHL@0bx|&n5+ZZ$@cOM-R zSHeX#^}W}F!oE@}k@*t3yqan|zsJ_7WrG%@&)VPA{aAa{{O>7>E*9;!-MukJJ6fJ* z%!aOJF@^Uf5ALrYjnj(ev4Xr`XSO=4d%D-DHp7m3i#!Qt-wfOz7J{DYgIl!4-r;EZ zbuXn9SLPV16&0~yO1apzjhFF@2%4B{{SobfAc%o%~g@ToAv2yDeZ`t=DV-@ zk%M=hv6$~Wt=J`4FyfwWVA62cYoE}{UxD#Fo1v@HA_UWswF5`v#)CI`sa2T~Uw4s< z&!K1w?UagXr|+a7^GvFhOmRa>wQRqo&s4C;`WLuwZgUTgIM8{w!E;~N7PYG8B)Ao1 z6{27y!QP~LhAv_0dvlweX02~Wo!238TfOc6_WM_Rck1lSXBO`u%VhbU@E6W6;4EEg zo>t>p!hLGiy(}jdLP&+@cJ)y0&gOqdw+k%=gH&6@i_)JhJIYRIpUM<6eq;j*JARukF_`tf(>-#Y`%;Jnt}tPv0~gkq##{i2shO`RL+%#{Co^apPv5&{r4IZd&rziUuvE~0dj z0!SQ;1PBV?-&K{)<+xHl$ye@sm*h&Rb9lwK|K^;Z?b7>X^xos!!!*p4|0os=4M^)wz*r#g2!HlSS$q7{NCtk2yDCv85=;6U0IlP=A5jN zw_R!VSbM_noLl!itcMes^a!u{z&)hwOns1}|4;L84>-+pi?7ctD#$+k{l{BMf>4Ny zLxOPa9ta77z5h1No2)Y&^&rRgS5lv?{pRZ+?4^_&h;vIh{Rgsb+Y>@74~E<)_|#n~ z_LY7msH`5_C(@}UKu#9MjE+aN!kn1ZYwDMq-gV@@9FQfh=BndM=dKNNR1BZ7bWJrB zVe3?A3LD4rAGCD7m@@bJZZW*HT^2HEz1%&GeDko7%(W=wHB}M|233V)feB#xoUn+V5e2nUk=3Z>au7(MnDbcYZ}QX z&`<(5fq`&ng!dLp1HjQBh+mLuBf1G!6!sR`;VYp`Pd(~1h8G}D#t`R(&bEw3=cu;u oM%(z|($nvX{U3>^mvf#5Sp6-M@^uERkE0wgylkRZt%HsF4;@pN>Hq)$ literal 0 HcmV?d00001 diff --git a/data/icons/22x22/subsonic.png b/data/icons/22x22/subsonic.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3544e9552d38f63c56d2b4b10dc08fb46c343f GIT binary patch literal 1495 zcmbtSc~H|w6#hjaKse2%ryjw2|Q8XcowTPB)pcc6&HgoiSEHJzW$aboFe6&3b z@aBNB(@`a1tA$(jJ#?+u6-YK}yUCwgP)#?v<(i#RfS$>+c-KH2t0S=KSODB^7g={! z;@LDTi-vtxZddlD70w7lbu#!i+fvRa0pJ1v6#(a{C&au2a|hdX7zBYGLJN420*2zi zz)sNb1^QV)whQ(g)e>p)bRUqq11SykP}dA@_&7)*u!$4i439j3*b#`GfQ+-Ni4(@- zyzgNFsoPpoLIF~`PRi8ELUet8a-sK3n->_1590-?C7S^>DP7+mpz0(m1RIw-wl4DB z+dEjG$4x2m*N9vd9q2Nj0>mf~Q}jLVS_$O7Nh^fhNpMvNPJE_Hi7Oh3BNvT8Fw)+l$AnGGk4jU$vWWCaJp6nMNYqCq>}=w;64xV&SX7 z@%<6Y-n;fM?-SlXB`P~ny$FR@L5T)`C5WoOt{V6p^AsY-$4 z9FHV_K>{KInBW4S2Rem*JV(DW3}7%hP67!VPmp75<9WgsT1WUO2|5qle~+u(&eEOIV!uUOPx%OaI;oK zS?=1t+D+Gs9if;&MACGl(a=t$(bizjj+l7M z`^jgfbNltGt{i)8#5UuiWz|Dhi&qQxA3w4~r!;zbV%e3dvV6@#jMdvYp{)U5h) zD5YppH(FqW!5Lr!O8L_(>g6}QVwNG985GL#B&_#%)nEo2#iT}r1`BM>NRu!y$qoA~ z4?!#hYZG&oW7iUkmCGfQlJ&p*_pn{ z4Uc!+;P9KZ!JV|!X@QHgL(PBjJLV+z(TT_TW2*eFcZc`{d}8wNfWK+-0_cN0WGI=2hGezZMH#Z9hh>7_{p7}%Gizj+-C2NJkto;7bI-L;ZA!b zy5@)7^;IKusI?0>Ep{}__Dld42Ca*k#Xwx ieg6U+J(ixGS@0hK=0AYJV%#kVK*B?!gX@Bl&-?{Syssqy literal 0 HcmV?d00001 diff --git a/data/icons/32x32/subsonic.png b/data/icons/32x32/subsonic.png new file mode 100644 index 0000000000000000000000000000000000000000..4b4f2de38444ecefe25662fb2878690bb066a842 GIT binary patch literal 804 zcmV+<1Ka$GP)rRsw^n%Mg)}@nk~$~{Hq9qIg@R<&7ZdR^L^(#U8u1&+rG^~ zH+ycr!^1i6`<(aZdk>I60tx(QFp)Ntca^sRKvQ8;*%C*GOKyS8*8o)ck7E)zO(T=n zSsBSbA8CWlW7{7>`b<|r1uo1n7J~B}fRzi7Ah982c;|}{Sve`-qsf+?Ixd{98==bz@V?8Y1j&$? z6bE?Si}ya<9-*SSI%$FhNF17u15{b}m~CBGmKV^SNksyPghC_$;q_Ln#;;RTmpXop zuasP5!(Q#~E@Q+Zq~7TabxJ_Nr@YXiU8@)r-H|Z zzzB#U#kw^C0=5qT3jM-bumc}=$mfEiU}tuHOAm?w6@EnSY(l{+0@hJ*L6Mb&w3z+_ zv!k`p<<U! z2QNT<(%g2h;`xHE*l;c&RR5JAWdhS2{`JlbW+f1ZjyRwGSUr?=0V>ch#qdr`euPVsP zQRHN2@auINMhk#(d8o^5CDxH~VZ3|D*W1$VX)#;KXS#5y*j_|opah^^{Bn!72mn6- i4**}{!I?k;a_}35UR{OY->Yc=0000+J;V{nwXR@l?~KT zlG2ZDKdP!#t5zFg{a0zKx-qR`otU)k$E1BoXd0Tfbz<5kRa#ddm=v{Z3!%|Q2rOwP zP#OpXk`UWTNPIuu_t_7}iIdpG355NaAIb93z3;jAocBEEFNFi)CjmS%%lOXi4?y2&qc;)-b;j77`}t)7%$0BG&PO*J4#Q{m^(!V)P~(-wmdK*Z=tAq9D|a)vem{Vxi%en>NVevSP~CtY z>#bYu_c2?ArgsMhC9+*$)qs;dlw_-9`+Y!L;(be7SQg`iGTq{wo|Mc!DJ7Z zY^6ZwkA`#YNp^1AEDyimR?q=40-!38m{geGX$|21Ujxy`>A)cw2hM*BfC5(30T2Ej zFv>D|vDbkAyabGYGNT*WG4ssC(J5V>=lyAc8h;r;eh1W0fd77cT6Y#zB0IoH1Cfa5r;{4rR+vJQ zy+S=hn_LQIjL$?fmqN5^rmy9Ooz?~*5htt~k-3%1+Aysvd8!z!4I;^7`4gJXy|<;2 zWsy#Picl=y^=^a!q*I^5iq_7)m+FQ!rE68^*R=|~PpjEOq&as~nmfx5R~m}Qyu-UL!z%?SB3y2(pN5Uy^TerBq_1^z@U8Dw4x z^$)53n>diAO7Rpb4*}P@cC+_Fg9O7HV5hoSZXcIOrGNx0rCoaCTM=BflK{jXJdAbG zYq`4Uz3?c5;fKUdb)(!q&Pb&QkiS8myWoCMu#u^@d#E&_HL0|S(u7hOS!+URV0EQ2 zzl@Uf6vTFjU_j_M=i~#4Nt*;9R#0Z;3;?(uX*-l)Kw$b%u&I&I-`qE{^>^=1e4IA| zzRj1!IZ?eAz}}*(Q1(GS(6pprBgz_{uUVNsz_et+hWFcdZv5%P)Ap&GCh^Sj`4mcw zG7$<;A{9pDR>p^=j1q08Zs&J?mHgg)F>}?yeO3DdCIWZr z=;WggyZwdc>o4|QRRar7qn;vsM#lfkgX+rBw)Ba=zBh5G?X1%Z!9Y)+Ywmn?DE-zC ztA4ho%2>Uevk(dWi$9P3@y*|)niFHZSk(8xo|h_`&i8v8+q<39eFN$|i6ns0+}>{b zU{-~V+I_Et_q_a6`PY30%-cyJ0BG-WI$ryE@~5A4IY(>t|pil%zaAZ`Coal5qT27`<{_TUwBUgsic>veRg*WiF zIWQ-u!U9yRx!V_e=w7pC{pZZss!Agg@QWpYcnbDVQY9~5RablZ+@8Jx)u)NEDVo|# z*t}&wdo`zi*@GVqzyshGI$tWGgc3?9p@jb%{1@E{hns3{WRw5^002ovPDHLkV1j=B BKq~+M literal 0 HcmV?d00001 diff --git a/data/icons/64x64/subsonic.png b/data/icons/64x64/subsonic.png new file mode 100644 index 0000000000000000000000000000000000000000..624d4b5f0e131775625f3f92c943caefbb61e702 GIT binary patch literal 1596 zcmV-C2E+M@P)3mt-#XBE%lSwBM;z=fH)UBUO~+8-B@!KaCcP*Fq|L0-WB)k`3+MV z+Mlh#87cB<0`52z_doM3fron0M$uZ1(4qM zAc{4>{iOS(6jA3~+b7yT0Cpj=Lq$6VW-JUY?^yno`M7_@el-#HR-x3BIHQ+hkAP%L z9KZwMi-0fUpnNYPvPDHZdS+aJAJ-Ydbq&^2xQR97&nUz$|2#Guz-rPM{#}UtMnyZ% z%!mNB3jWY+9iHXJmf?)fz=Do|)iQ=szfwy&cUDw@pn~7c@$nsf3Uqn~-I@V`1z^^1 zN8}eO+G$^u6SRBUhXi^=I117&!olEm9f_$_+%T&eIuD>BHvqob_hB`+O7CqyQqjG+ zY0rS%-xrFgAX?R+RSn9l&4W6q6uS`lQ_c0AnX@zC-a3=}f@i>uS67TP>%qU^C6K#R z^bI{d0lHg73urxY2#8UOm=R2?VMrNl+LLvkdMz*6?Xwep%Z^oNpf|(dpSMv(-`G^7 z0CG+UEhbpaxS@vMCw%s@#qKBHD(z0M#8}NXX8jGSZGTT`HbUZ}vx6uny?Sv(Q`MaZ zus^jhYaRzylx7BK&|1&}Esf^X4-Q@qt?MB?ks-&s;q+sW?Juw0{_Fv~`RZM{7 zr(J{>teE23VZvpyD>x{wZC6Zy_=lURX}SrYzu~JsD?d*B!!3lv<*_Tozhmy{iV4sd z_=Nt>m9$**5cMs~07%3R6YJT`!pOhQ1)x&f72=}-`^b6s0s8Gr#Qm=@SIj;jlM@Dq z91^K7HcIuiiUmzRLIG7)yQ$c?ef+LX&&GFGtRxl+C>FO`6}Ov=-u`@F*RfpZd6$53 zbkqC=hYhmZH${YU!pece5|o~~oLcgKuXRV0cUqpcvAcE-GAi${QJjF=1H>4{Gd4-XFy4-XFy4-XHIV)z_zXBT7P-qv^k0000Audio analyzer
  • Audio equalizer
  • Transfer music to iPod, iPhone, MTP or mass-storage USB player
  • -
  • Streaming support for Tidal
  • +
  • Streaming support for Tidal and Subsonic
  • Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
  • diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 834e95c35..641ebcfe7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -264,6 +264,7 @@ set(SOURCES internet/internetsearchsortmodel.cpp internet/internetsearchitemdelegate.cpp internet/localredirectserver.cpp + internet/internetsongsview.cpp internet/internettabsview.cpp internet/internetcollectionview.cpp internet/internetcollectionviewcontainer.cpp @@ -443,6 +444,7 @@ set(HEADERS internet/internetsearchview.h internet/internetsearchmodel.h internet/localredirectserver.h + internet/internetsongsview.h internet/internettabsview.h internet/internetcollectionview.h internet/internetcollectionviewcontainer.h @@ -909,6 +911,23 @@ optional_source(HAVE_TIDAL settings/tidalsettingspage.ui ) +optional_source(HAVE_SUBSONIC + SOURCES + subsonic/subsonicservice.cpp + subsonic/subsonicurlhandler.cpp + subsonic/subsonicbaserequest.cpp + subsonic/subsonicrequest.cpp + settings/subsonicsettingspage.cpp + HEADERS + subsonic/subsonicservice.h + subsonic/subsonicurlhandler.h + subsonic/subsonicbaserequest.h + subsonic/subsonicrequest.h + settings/subsonicsettingspage.h + UI + settings/subsonicsettingspage.ui +) + # Moodbar optional_source(HAVE_MOODBAR SOURCES diff --git a/src/config.h.in b/src/config.h.in index 1b2d09dad..c017d1554 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -50,6 +50,7 @@ #cmakedefine XINE_ANALYZER #cmakedefine HAVE_TIDAL +#cmakedefine HAVE_SUBSONIC #cmakedefine HAVE_MOODBAR diff --git a/src/core/application.cpp b/src/core/application.cpp index a95852167..83fc8399e 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -71,6 +71,10 @@ # include "covermanager/tidalcoverprovider.h" #endif +#ifdef HAVE_SUBSONIC +# include "subsonic/subsonicservice.h" +#endif + #ifdef HAVE_MOODBAR # include "moodbar/moodbarcontroller.h" # include "moodbar/moodbarloader.h" @@ -135,6 +139,9 @@ class ApplicationImpl { InternetServices *internet_services = new InternetServices(app); #ifdef HAVE_TIDAL internet_services->AddService(new TidalService(app, internet_services)); +#endif +#ifdef HAVE_SUBSONIC + internet_services->AddService(new SubsonicService(app, internet_services)); #endif return internet_services; }), diff --git a/src/core/database.cpp b/src/core/database.cpp index 3c2c8dd23..249596150 100644 --- a/src/core/database.cpp +++ b/src/core/database.cpp @@ -52,7 +52,7 @@ #include "scopedtransaction.h" const char *Database::kDatabaseFilename = "strawberry.db"; -const int Database::kSchemaVersion = 5; +const int Database::kSchemaVersion = 6; const char *Database::kMagicAllSongsTables = "%allsongstables"; int Database::sNextConnectionId = 1; diff --git a/src/core/mainwindow.cpp b/src/core/mainwindow.cpp index 80b3a0e0b..64740a90d 100644 --- a/src/core/mainwindow.cpp +++ b/src/core/mainwindow.cpp @@ -138,9 +138,14 @@ # include "tidal/tidalservice.h" # include "settings/tidalsettingspage.h" #endif +#ifdef HAVE_SUBSONIC +# include "subsonic/subsonicservice.h" +# include "settings/subsonicsettingspage.h" +#endif #include "internet/internetservices.h" #include "internet/internetservice.h" +#include "internet/internetsongsview.h" #include "internet/internettabsview.h" #include "scrobbler/audioscrobbler.h" @@ -210,6 +215,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co }), #ifdef HAVE_TIDAL tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Tidal), app_->tidal_search(), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, this)), +#endif +#ifdef HAVE_SUBSONIC + subsonic_view_(new InternetSongsView(app_, app->internet_services()->ServiceBySource(Song::Source_Subsonic), SubsonicSettingsPage::kSettingsGroup, SettingsDialog::Page_Subsonic, this)), #endif playlist_menu_(new QMenu(this)), playlist_add_to_another_(nullptr), @@ -265,6 +273,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co #ifdef HAVE_TIDAL ui_->tabs->AddTab(tidal_view_, "tidal", IconLoader::Load("tidal"), tr("Tidal")); #endif +#ifdef HAVE_SUBSONIC + ui_->tabs->AddTab(subsonic_view_, "subsonic", IconLoader::Load("subsonic"), tr("Subsonic")); +#endif // Add the playing widget to the fancy tab widget ui_->tabs->addBottomWidget(ui_->widget_playing); @@ -558,6 +569,10 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co #endif +#ifdef HAVE_SUBSONIC + connect(subsonic_view_->view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*))); +#endif + // Playlist menu playlist_play_pause_ = playlist_menu_->addAction(tr("Play"), this, SLOT(PlaylistPlay())); playlist_menu_->addAction(ui_->action_stop); @@ -874,6 +889,16 @@ void MainWindow::ReloadSettings() { ui_->tabs->DisableTab(tidal_view_); #endif +#ifdef HAVE_SUBSONIC + settings.beginGroup(SubsonicSettingsPage::kSettingsGroup); + bool enable_subsonic = settings.value("enabled", false).toBool(); + settings.endGroup(); + if (enable_subsonic) + ui_->tabs->EnableTab(subsonic_view_); + else + ui_->tabs->DisableTab(subsonic_view_); +#endif + } void MainWindow::ReloadAllSettings() { @@ -892,6 +917,9 @@ void MainWindow::ReloadAllSettings() { #ifdef HAVE_TIDAL tidal_view_->ReloadSettings(); #endif +#ifdef HAVE_SUBSONIC + subsonic_view_->ReloadSettings(); +#endif } diff --git a/src/core/mainwindow.h b/src/core/mainwindow.h index f44b31600..416852f41 100644 --- a/src/core/mainwindow.h +++ b/src/core/mainwindow.h @@ -91,6 +91,7 @@ class TranscodeDialog; #endif class Ui_MainWindow; class Windows7ThumbBar; +class InternetSongsView; class InternetTabsView; class MainWindow : public QMainWindow, public PlatformInterface { @@ -313,6 +314,7 @@ signals: #endif InternetTabsView *tidal_view_; + InternetSongsView *subsonic_view_; QAction *collection_show_all_; QAction *collection_show_duplicates_; diff --git a/src/core/song.cpp b/src/core/song.cpp index eacdd026e..a2d0eb150 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -333,7 +333,7 @@ bool Song::has_cue() const { return !d->cue_path_.isEmpty(); } bool Song::is_collection_song() const { return !is_cdda() && !is_stream() && id() != -1; } bool Song::is_metadata_good() const { return !d->title_.isEmpty() && !d->album_.isEmpty() && !d->artist_.isEmpty() && !d->url_.isEmpty() && d->end_ > 0; } -bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal; } +bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal || d->source_ == Source_Subsonic; } bool Song::is_cdda() const { return d->source_ == Source_CDDA; } const QString &Song::error() const { return d->error_; } @@ -410,6 +410,7 @@ Song::Source Song::SourceFromURL(const QUrl &url) { if (url.scheme() == "file") return Source_LocalFile; else if (url.scheme() == "cdda") return Source_CDDA; else if (url.scheme() == "tidal") return Source_Tidal; + else if (url.scheme() == "subsonic") return Source_Subsonic; else if (url.scheme() == "http" || url.scheme() == "https" || url.scheme() == "rtsp") return Source_Stream; else return Source_Unknown; @@ -424,8 +425,10 @@ QString Song::TextForSource(Source source) { case Song::Source_Device: return QObject::tr("Device"); case Song::Source_Stream: return QObject::tr("Stream"); case Song::Source_Tidal: return QObject::tr("Tidal"); - default: return QObject::tr("Unknown"); + case Song::Source_Subsonic: return QObject::tr("subsonic"); + case Song::Source_Unknown: return QObject::tr("Unknown"); } + return QObject::tr("Unknown"); } @@ -438,8 +441,10 @@ QIcon Song::IconForSource(Source source) { case Song::Source_Device: return IconLoader::Load("device"); case Song::Source_Stream: return IconLoader::Load("applications-internet"); case Song::Source_Tidal: return IconLoader::Load("tidal"); - default: return IconLoader::Load("edit-delete"); + case Song::Source_Subsonic: return IconLoader::Load("subsonic"); + case Song::Source_Unknown: return IconLoader::Load("edit-delete"); } + return IconLoader::Load("edit-delete"); } diff --git a/src/core/song.h b/src/core/song.h index 656cef12d..0bd09e158 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -74,6 +74,7 @@ class Song { Source_Device = 4, Source_Stream = 5, Source_Tidal = 6, + Source_Subsonic = 7, }; // Don't change these values - they're stored in the database, and defined in the tag reader protobuf. diff --git a/src/internet/internetcollectionview.h b/src/internet/internetcollectionview.h index 586859be2..ec12a758c 100644 --- a/src/internet/internetcollectionview.h +++ b/src/internet/internetcollectionview.h @@ -54,7 +54,7 @@ class InternetCollectionView : public AutoExpandingTreeView { public: InternetCollectionView(QWidget *parent = nullptr); ~InternetCollectionView(); - + void Init(Application *app, CollectionBackend *backend, CollectionModel *model); // Returns Songs currently selected in the collection view. diff --git a/src/internet/internetcollectionviewcontainer.cpp b/src/internet/internetcollectionviewcontainer.cpp index 5774e86e7..38b6a57e0 100644 --- a/src/internet/internetcollectionviewcontainer.cpp +++ b/src/internet/internetcollectionviewcontainer.cpp @@ -54,5 +54,4 @@ InternetCollectionViewContainer::InternetCollectionViewContainer(QWidget *parent InternetCollectionViewContainer::~InternetCollectionViewContainer() { delete ui_; } -void InternetCollectionViewContainer::contextMenuEvent(QContextMenuEvent *e) { -} +void InternetCollectionViewContainer::contextMenuEvent(QContextMenuEvent *e) {} diff --git a/src/internet/internetcollectionviewcontainer.ui b/src/internet/internetcollectionviewcontainer.ui index e6e9cf606..1945e9ca2 100644 --- a/src/internet/internetcollectionviewcontainer.ui +++ b/src/internet/internetcollectionviewcontainer.ui @@ -29,13 +29,6 @@ 0 - - - - Refresh catalogue - - - @@ -135,6 +128,13 @@ + + + + Refresh catalogue + + + diff --git a/src/internet/internetservice.h b/src/internet/internetservice.h index 49b523653..3b8b51900 100644 --- a/src/internet/internetservice.h +++ b/src/internet/internetservice.h @@ -24,11 +24,11 @@ #include #include #include +#include #include "core/song.h" #include "internetsearch.h" -class QSortFilterProxyModel; class Application; class CollectionBackend; class CollectionModel; @@ -38,6 +38,7 @@ class InternetService : public QObject { public: InternetService(Song::Source source, const QString &name, const QString &url_scheme, Application *app, QObject *parent = nullptr); + virtual ~InternetService() {} virtual Song::Source source() const { return source_; } @@ -47,40 +48,45 @@ class InternetService : public QObject { virtual void InitialLoadSettings() {} virtual void ReloadSettings() {} virtual QIcon Icon() { return Song::IconForSource(source_); } - virtual const bool oauth() = 0; - virtual const bool authenticated() = 0; - virtual int Search(const QString &query, InternetSearch::SearchType type) = 0; - virtual void CancelSearch() = 0; + virtual const bool oauth() { return false; } + virtual const bool authenticated() { return false; } + virtual int Search(const QString &query, InternetSearch::SearchType type) { return 0; } + virtual void CancelSearch() {} - virtual CollectionBackend *artists_collection_backend() = 0; - virtual CollectionBackend *albums_collection_backend() = 0; - virtual CollectionBackend *songs_collection_backend() = 0; + virtual CollectionBackend *artists_collection_backend() { return nullptr; } + virtual CollectionBackend *albums_collection_backend() { return nullptr; } + virtual CollectionBackend *songs_collection_backend() { return nullptr; } - virtual CollectionModel *artists_collection_model() = 0; - virtual CollectionModel *albums_collection_model() = 0; - virtual CollectionModel *songs_collection_model() = 0; + virtual CollectionModel *artists_collection_model() { return nullptr; } + virtual CollectionModel *albums_collection_model() { return nullptr; } + virtual CollectionModel *songs_collection_model() { return nullptr; } - virtual QSortFilterProxyModel *artists_collection_sort_model() = 0; - virtual QSortFilterProxyModel *albums_collection_sort_model() = 0; - virtual QSortFilterProxyModel *songs_collection_sort_model() = 0; + virtual QSortFilterProxyModel *artists_collection_sort_model() { return nullptr; } + virtual QSortFilterProxyModel *albums_collection_sort_model() { return nullptr; } + virtual QSortFilterProxyModel *songs_collection_sort_model() { return nullptr; } public slots: virtual void ShowConfig() {} - virtual void GetArtists() = 0; - virtual void GetAlbums() = 0; - virtual void GetSongs() = 0; - virtual void ResetArtistsRequest() = 0; - virtual void ResetAlbumsRequest() = 0; - virtual void ResetSongsRequest() = 0; + virtual void GetArtists() {} + virtual void GetAlbums() {} + virtual void GetSongs() {} + virtual void ResetArtistsRequest() {} + virtual void ResetAlbumsRequest() {} + virtual void ResetSongsRequest() {} signals: void Login(); void Logout(); void Login(const QString &username, const QString &password, const QString &token); + void Login(const QString &hostname, const int, const QString &username, const QString &password); void LoginSuccess(); void LoginFailure(QString failure_reason); void LoginComplete(bool success, QString error = QString()); + void TestSuccess(); + void TestFailure(QString failure_reason); + void TestComplete(bool success, QString error = QString()); + void Error(QString message); void Results(SongList songs); void UpdateStatus(QString text); diff --git a/src/internet/internetsongsview.cpp b/src/internet/internetsongsview.cpp new file mode 100644 index 000000000..64b34a575 --- /dev/null +++ b/src/internet/internetsongsview.cpp @@ -0,0 +1,124 @@ +/* + * 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 "config.h" + +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "collection/collectionbackend.h" +#include "collection/collectionfilterwidget.h" +#include "internetservice.h" +#include "internetsongsview.h" +#include "ui_internetcollectionviewcontainer.h" + +InternetSongsView::InternetSongsView(Application *app, InternetService *service, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent) + : QWidget(parent), + app_(app), + service_(service), + settings_group_(settings_group), + settings_page_(settings_page), + ui_(new Ui_InternetCollectionViewContainer) + { + + ui_->setupUi(this); + + ui_->stacked->setCurrentWidget(ui_->internetcollection_page); + ui_->view->Init(app_, service_->songs_collection_backend(), service_->songs_collection_model()); + ui_->view->setModel(service_->songs_collection_sort_model()); + ui_->view->SetFilter(ui_->filter); + ui_->filter->SetSettingsGroup(settings_group); + ui_->filter->SetCollectionModel(service_->songs_collection_model()); + + connect(ui_->view, SIGNAL(GetSongs()), SLOT(GetSongs())); + connect(ui_->view, SIGNAL(RemoveSongs(const SongList&)), service_, SIGNAL(RemoveSongs(const SongList&))); + + connect(ui_->refresh, SIGNAL(clicked()), SLOT(GetSongs())); + connect(ui_->close, SIGNAL(clicked()), SLOT(AbortGetSongs())); + connect(ui_->abort, SIGNAL(clicked()), SLOT(AbortGetSongs())); + connect(service_, SIGNAL(SongsResults(SongList)), SLOT(SongsFinished(SongList))); + connect(service_, SIGNAL(SongsError(QString)), SLOT(SongsError(QString))); + connect(service_, SIGNAL(SongsUpdateStatus(QString)), ui_->status, SLOT(setText(QString))); + connect(service_, SIGNAL(SongsProgressSetMaximum(int)), ui_->progressbar, SLOT(setMaximum(int))); + connect(service_, SIGNAL(SongsUpdateProgress(int)), ui_->progressbar, SLOT(setValue(int))); + + connect(service_->songs_collection_model(), SIGNAL(TotalArtistCountUpdated(int)), ui_->view, SLOT(TotalArtistCountUpdated(int))); + connect(service_->songs_collection_model(), SIGNAL(TotalAlbumCountUpdated(int)), ui_->view, SLOT(TotalAlbumCountUpdated(int))); + connect(service_->songs_collection_model(), SIGNAL(TotalSongCountUpdated(int)), ui_->view, SLOT(TotalSongCountUpdated(int))); + connect(service_->songs_collection_model(), SIGNAL(modelAboutToBeReset()), ui_->view, SLOT(SaveFocus())); + connect(service_->songs_collection_model(), SIGNAL(modelReset()), ui_->view, SLOT(RestoreFocus())); + + ReloadSettings(); + +} + +InternetSongsView::~InternetSongsView() { delete ui_; } + +void InternetSongsView::ReloadSettings() {} + +void InternetSongsView::contextMenuEvent(QContextMenuEvent *e) {} + +void InternetSongsView::GetSongs() { + + if (!service_->authenticated() && service_->oauth()) { + service_->ShowConfig(); + return; + } + + ui_->status->clear(); + ui_->progressbar->show(); + ui_->abort->show(); + ui_->close->hide(); + ui_->stacked->setCurrentWidget(ui_->help_page); + service_->GetSongs(); + +} + +void InternetSongsView::AbortGetSongs() { + + service_->ResetSongsRequest(); + ui_->progressbar->setValue(0); + ui_->status->clear(); + ui_->stacked->setCurrentWidget(ui_->internetcollection_page); + +} + +void InternetSongsView::SongsError(QString error) { + + ui_->status->setText(error); + ui_->progressbar->setValue(0); + ui_->progressbar->hide(); + ui_->abort->hide(); + ui_->close->show(); + +} + +void InternetSongsView::SongsFinished(SongList songs) { + + service_->songs_collection_backend()->DeleteAll(); + ui_->stacked->setCurrentWidget(ui_->internetcollection_page); + ui_->status->clear(); + service_->songs_collection_backend()->AddOrUpdateSongs(songs); + +} diff --git a/src/internet/internetsongsview.h b/src/internet/internetsongsview.h new file mode 100644 index 000000000..c63741c66 --- /dev/null +++ b/src/internet/internetsongsview.h @@ -0,0 +1,67 @@ +/* + * 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 INTERNETSONGSVIEW_H +#define INTERNETSONGSVIEW_H + +#include "config.h" + +#include +#include + +#include "settings/settingsdialog.h" +#include "internetcollectionviewcontainer.h" +#include "ui_internetcollectionviewcontainer.h" +#include "core/song.h" + +class QContextMenuEvent; + +class Application; +class InternetService; +class Ui_InternetCollectionViewContainer; +class InternetCollectionView; + +class InternetSongsView : public QWidget { + Q_OBJECT + + public: + InternetSongsView(Application *app, InternetService *service, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent = nullptr); + ~InternetSongsView(); + + void ReloadSettings(); + + InternetCollectionView *view() const { return ui_->view; } + + private slots: + void contextMenuEvent(QContextMenuEvent *e); + void GetSongs(); + void AbortGetSongs();; + void SongsError(QString error); + void SongsFinished(SongList songs); + + private: + Application *app_; + InternetService *service_; + QString settings_group_; + SettingsDialog::Page settings_page_; + Ui_InternetCollectionViewContainer *ui_; + +}; + +#endif // INTERNETSONGSVIEW_H diff --git a/src/internet/internettabsview.cpp b/src/internet/internettabsview.cpp index b965a1b71..3b29ff6c9 100644 --- a/src/internet/internettabsview.cpp +++ b/src/internet/internettabsview.cpp @@ -33,7 +33,7 @@ #include "internettabsview.h" #include "ui_internettabsview.h" -InternetTabsView::InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, QString settings_group, SettingsDialog::Page settings_page, QWidget *parent) +InternetTabsView::InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent) : QWidget(parent), app_(app), service_(service), diff --git a/src/internet/internettabsview.h b/src/internet/internettabsview.h index 13b895ad2..9dbfd206b 100644 --- a/src/internet/internettabsview.h +++ b/src/internet/internettabsview.h @@ -44,7 +44,7 @@ class InternetTabsView : public QWidget { Q_OBJECT public: - InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, QString settings_group, SettingsDialog::Page settings_page, QWidget *parent = nullptr); + InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent = nullptr); ~InternetTabsView(); void ReloadSettings(); diff --git a/src/settings/settingsdialog.cpp b/src/settings/settingsdialog.cpp index e1f2b7b62..a7ac3aa66 100644 --- a/src/settings/settingsdialog.cpp +++ b/src/settings/settingsdialog.cpp @@ -68,6 +68,9 @@ #ifdef HAVE_MOODBAR # include "moodbarsettingspage.h" #endif +#ifdef HAVE_SUBSONIC +# include "subsonicsettingspage.h" +#endif #include "ui_settingsdialog.h" @@ -140,12 +143,15 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent) AddPage(Page_Moodbar, new MoodbarSettingsPage(this), iface); #endif -#if defined(HAVE_TIDAL) +#if defined(HAVE_TIDAL) || defined(HAVE_SUBSONIC) QTreeWidgetItem *streaming = AddCategory(tr("Streaming")); #endif #ifdef HAVE_TIDAL AddPage(Page_Tidal, new TidalSettingsPage(this), streaming); #endif +#ifdef HAVE_SUBSONIC + AddPage(Page_Subsonic, new SubsonicSettingsPage(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 ad2ceb467..6eae7329f 100644 --- a/src/settings/settingsdialog.h +++ b/src/settings/settingsdialog.h @@ -83,6 +83,7 @@ class SettingsDialog : public QDialog { Page_Proxy, Page_Scrobbler, Page_Tidal, + Page_Subsonic, Page_Moodbar, }; diff --git a/src/settings/subsonicsettingspage.cpp b/src/settings/subsonicsettingspage.cpp new file mode 100644 index 000000000..9748fbb17 --- /dev/null +++ b/src/settings/subsonicsettingspage.cpp @@ -0,0 +1,132 @@ +/* + * 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 "subsonicsettingspage.h" +#include "ui_subsonicsettingspage.h" +#include "core/application.h" +#include "core/iconloader.h" +#include "internet/internetservices.h" +#include "subsonic/subsonicservice.h" + +const char *SubsonicSettingsPage::kSettingsGroup = "Subsonic"; + +SubsonicSettingsPage::SubsonicSettingsPage(SettingsDialog *parent) + : SettingsPage(parent), + ui_(new Ui::SubsonicSettingsPage), + service_(dialog()->app()->internet_services()->Service()) { + + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("subsonic")); + + connect(ui_->button_test, SIGNAL(clicked()), SLOT(TestClicked())); + + connect(this, SIGNAL(Test(QString, int, QString, QString)), service_, SLOT(SendPing(QString, int, QString, QString))); + + connect(service_, SIGNAL(TestFailure(QString)), SLOT(TestFailure(QString))); + connect(service_, SIGNAL(TestSuccess()), SLOT(TestSuccess())); + + dialog()->installEventFilter(this); + +} + +SubsonicSettingsPage::~SubsonicSettingsPage() { delete ui_; } + +void SubsonicSettingsPage::Load() { + + QSettings s; + + s.beginGroup(kSettingsGroup); + ui_->enable->setChecked(s.value("enabled", false).toBool()); + ui_->hostname->setText(s.value("hostname").toString()); + ui_->port->setText(QString::number(s.value("port", 4040).toInt())); + ui_->username->setText(s.value("username").toString()); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) ui_->password->clear(); + else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password))); + ui_->checkbox_verify_certificate->setChecked(s.value("verifycertificate", false).toBool()); + ui_->checkbox_cache_album_covers->setChecked(s.value("cachealbumcovers", true).toBool()); + s.endGroup(); + +} + +void SubsonicSettingsPage::Save() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("enabled", ui_->enable->isChecked()); + s.setValue("hostname", ui_->hostname->text()); + s.setValue("port", ui_->port->text().toInt()); + s.setValue("username", ui_->username->text()); + s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64())); + s.setValue("verifycertificate", ui_->checkbox_verify_certificate->isChecked()); + s.setValue("cachealbumcovers", ui_->checkbox_cache_album_covers->isChecked()); + s.endGroup(); + + service_->ReloadSettings(); + +} + +void SubsonicSettingsPage::TestClicked() { + + if (ui_->hostname->text().isEmpty() || ui_->username->text().isEmpty() || ui_->password->text().isEmpty()) { + QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing hostname, username or password.")); + return; + } + + emit Test(ui_->hostname->text(), ui_->port->text().toInt(), ui_->username->text(), ui_->password->text()); + ui_->button_test->setEnabled(false); + +} + +bool SubsonicSettingsPage::eventFilter(QObject *object, QEvent *event) { + + if (object == dialog() && event->type() == QEvent::Enter) { + ui_->button_test->setEnabled(true); + return false; + } + + return SettingsPage::eventFilter(object, event); + +} + +void SubsonicSettingsPage::TestSuccess() { + + if (!this->isVisible()) return; + ui_->button_test->setEnabled(true); + + QMessageBox::information(this, tr("Test successful!"), tr("Test successful!")); + +} + +void SubsonicSettingsPage::TestFailure(QString failure_reason) { + + if (!this->isVisible()) return; + ui_->button_test->setEnabled(true); + + QMessageBox::warning(this, tr("Test failed!"), failure_reason); + +} diff --git a/src/settings/subsonicsettingspage.h b/src/settings/subsonicsettingspage.h new file mode 100644 index 000000000..6097ad09e --- /dev/null +++ b/src/settings/subsonicsettingspage.h @@ -0,0 +1,60 @@ +/* + * 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 SUBSONICSETTINGSPAGE_H +#define SUBSONICSETTINGSPAGE_H + +#include +#include +#include + +#include "settings/settingspage.h" + +class SubsonicService; +class Ui_SubsonicSettingsPage; + +class SubsonicSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit SubsonicSettingsPage(SettingsDialog* parent = nullptr); + ~SubsonicSettingsPage(); + + static const char *kSettingsGroup; + + void Load(); + void Save(); + + bool eventFilter(QObject *object, QEvent *event); + + signals: + void Test(); + void Test(const QString &hostname, const int port, const QString &username, const QString &password); + + private slots: + void TestClicked(); + void TestSuccess(); + void TestFailure(QString failure_reason); + + private: + Ui_SubsonicSettingsPage* ui_; + SubsonicService *service_; +}; + +#endif // SUBSONICSETTINGSPAGE_H diff --git a/src/settings/subsonicsettingspage.ui b/src/settings/subsonicsettingspage.ui new file mode 100644 index 000000000..178d269fa --- /dev/null +++ b/src/settings/subsonicsettingspage.ui @@ -0,0 +1,223 @@ + + + SubsonicSettingsPage + + + + 0 + 0 + 715 + 836 + + + + Subsonic + + + + + + Enable + + + + + + + Qt::Horizontal + + + + + + + Server + + + + + + + + Hostname + + + + + + + + + + Port + + + + + + + + 50 + 16777215 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + 0 + 0 + + + + Authentication + + + + + + Username + + + + + + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + + + + Test + + + + + + + Qt::Horizontal + + + + + + + Preferences + + + + + + + + Verify server certificate + + + + + + + Cache album covers + + + + + + + Qt::Vertical + + + + 20 + 30 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 64 + 64 + + + + + 64 + 64 + + + + :/icons/64x64/subsonic.png + + + + + + + + + username + password + + + + + + + diff --git a/src/subsonic/subsonicbaserequest.cpp b/src/subsonic/subsonicbaserequest.cpp new file mode 100644 index 000000000..53983a137 --- /dev/null +++ b/src/subsonic/subsonicbaserequest.cpp @@ -0,0 +1,207 @@ +/* + * 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 "subsonicservice.h" +#include "subsonicbaserequest.h" + +SubsonicBaseRequest::SubsonicBaseRequest(SubsonicService *service, NetworkAccessManager *network, QObject *parent) : + QObject(parent), + service_(service), + network_(network) + {} + +SubsonicBaseRequest::~SubsonicBaseRequest() { + + while (!replies_.isEmpty()) { + QNetworkReply *reply = replies_.takeFirst(); + disconnect(reply, 0, nullptr, 0); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +QUrl SubsonicBaseRequest::CreateUrl(const QString &ressource_name, const QList ¶ms_provided) { + + ParamList params = ParamList() << params_provided + << Param("c", client_name()) + << Param("v", api_version()) + << Param("f", "json") + << Param("u", username()) + << Param("p", QString("enc:" + password().toUtf8().toHex())); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url; + url.setScheme("https"); + url.setHost(hostname()); + if (port() > 0 && port() != 443) + url.setPort(port()); + url.setPath(QString("/rest/") + ressource_name); + url.setQuery(url_query); + + return url; + +} + +QNetworkReply *SubsonicBaseRequest::CreateGetRequest(const QString &ressource_name, const QList ¶ms_provided) { + + QUrl url = CreateUrl(ressource_name, params_provided); + QNetworkRequest req(url); + + if (!verify_certificate()) { + QSslConfiguration sslconfig = QSslConfiguration::defaultConfiguration(); + sslconfig.setPeerVerifyMode(QSslSocket::VerifyNone); + req.setSslConfiguration(sslconfig); + } + + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QNetworkReply *reply = network_->get(req); + replies_ << reply; + + //qLog(Debug) << "Subsonic: Sending request" << url; + + return reply; + +} + +QByteArray SubsonicBaseRequest::GetReplyData(QNetworkReply *reply, QString &error) { + + if (replies_.contains(reply)) { + replies_.removeAll(reply); + reply->deleteLater(); + } + + QByteArray data; + + if (reply->error() == QNetworkReply::NoError) { + data = reply->readAll(); + } + else { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + } + else { + // See if there is Json data containing "error" - then use that instead. + data = reply->readAll(); + QJsonParseError parse_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error); + QString failure_reason; + if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("error")) { + QJsonValue json_error = json_obj["error"]; + if (json_error.isObject()) { + json_obj = json_error.toObject(); + if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) { + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + failure_reason = QString("%1 (%2)").arg(message).arg(code); + } + } + } + } + if (failure_reason.isEmpty()) { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + error = Error(failure_reason); + } + return QByteArray(); + } + + return data; + +} + +QJsonObject SubsonicBaseRequest::ExtractJsonObj(QByteArray &data, QString &error) { + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + error = Error("Reply from server missing Json data.", data); + return QJsonObject(); + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + error = Error("Received empty Json document.", data); + return QJsonObject(); + } + + if (!json_doc.isObject()) { + error = Error("Json document is not an object.", json_doc); + return QJsonObject(); + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + error = Error("Received empty Json object.", json_doc); + return QJsonObject(); + } + + if (!json_obj.contains("subsonic-response")) { + error = Error("Json reply is missing subsonic-response.", json_obj); + return QJsonObject(); + } + + QJsonValue json_response = json_obj["subsonic-response"]; + if (!json_response.isObject()) { + error = Error("Json response is not an object.", json_response); + return QJsonObject(); + } + json_obj = json_response.toObject(); + + return json_obj; + +} + +QString SubsonicBaseRequest::Error(QString error, QVariant debug) { + + qLog(Error) << "Subsonic:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + return error; + +} diff --git a/src/subsonic/subsonicbaserequest.h b/src/subsonic/subsonicbaserequest.h new file mode 100644 index 000000000..a169da036 --- /dev/null +++ b/src/subsonic/subsonicbaserequest.h @@ -0,0 +1,86 @@ +/* + * 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 SUBSONICBASEREQUEST_H +#define SUBSONICBASEREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetservices.h" +#include "internet/internetservice.h" +#include "internet/internetsearch.h" +#include "subsonicservice.h" + +class Application; +class NetworkAccessManager; +class SubsonicUrlHandler; +class CollectionBackend; +class CollectionModel; + +class SubsonicBaseRequest : public QObject { + Q_OBJECT + + public: + + SubsonicBaseRequest(SubsonicService *service, NetworkAccessManager *network, QObject *parent); + ~SubsonicBaseRequest(); + + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + QUrl CreateUrl(const QString &ressource_name, const QList ¶ms_provided); + QNetworkReply *CreateGetRequest(const QString &ressource_name, const QList ¶ms_provided); + QByteArray GetReplyData(QNetworkReply *reply, QString &error); + QJsonObject ExtractJsonObj(QByteArray &data, QString &error); + + virtual QString Error(QString error, QVariant debug = QVariant()); + + QString client_name() { return service_->client_name(); } + QString api_version() { return service_->api_version(); } + QString hostname() { return service_->hostname(); } + int port() { return service_->port(); } + QString username() { return service_->username(); } + QString password() { return service_->password(); } + bool verify_certificate() { return service_->verify_certificate(); } + bool cache_album_covers() { return service_->cache_album_covers(); } + + private: + + SubsonicService *service_; + NetworkAccessManager *network_; + QList replies_; + +}; + +#endif // SUBSONICBASEREQUEST_H diff --git a/src/subsonic/subsonicrequest.cpp b/src/subsonic/subsonicrequest.cpp new file mode 100644 index 000000000..c2539ddee --- /dev/null +++ b/src/subsonic/subsonicrequest.cpp @@ -0,0 +1,759 @@ +/* + * Strawberry Music Player + * Copyright 2019, Jonas Kvinge + * + * Strawberry is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Strawberry is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Strawberry. If not, see . + * + */ + +#include "config.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/song.h" +#include "core/timeconstants.h" +#include "organise/organiseformat.h" +#include "subsonicservice.h" +#include "subsonicurlhandler.h" +#include "subsonicrequest.h" + +const int SubsonicRequest::kMaxConcurrentAlbumsRequests = 3; +const int SubsonicRequest::kMaxConcurrentAlbumSongsRequests = 3; +const int SubsonicRequest::kMaxConcurrentAlbumCoverRequests = 1; + +SubsonicRequest::SubsonicRequest(SubsonicService *service, SubsonicUrlHandler *url_handler, NetworkAccessManager *network, QObject *parent) + : SubsonicBaseRequest(service, network, parent), + service_(service), + url_handler_(url_handler), + network_(network), + finished_(false), + albums_requests_active_(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) + {} + +SubsonicRequest::~SubsonicRequest() { + + while (!album_cover_replies_.isEmpty()) { + QNetworkReply *reply = album_cover_replies_.takeFirst(); + disconnect(reply, 0, nullptr, 0); + if (reply->isRunning()) reply->abort(); + reply->deleteLater(); + } + +} + +void SubsonicRequest::Reset() { + + finished_ = false; + + albums_requests_queue_.clear(); + album_songs_requests_queue_.clear(); + album_cover_requests_queue_.clear(); + album_songs_requests_pending_.clear(); + album_covers_requests_sent_.clear(); + + albums_requests_active_ = 0; + album_songs_requests_active_ = 0; + album_songs_requested_ = 0; + album_songs_received_ = 0; + album_covers_requests_active_ = 0; + album_covers_requested_ = 0; + album_covers_received_ = 0; + + songs_.clear(); + errors_.clear(); + no_results_ = false; + album_cover_replies_.clear(); + +} + +void SubsonicRequest::GetAlbums() { + + emit UpdateStatus(tr("Retrieving albums...")); + emit UpdateProgress(0); + AddAlbumsRequest(); + +} + +void SubsonicRequest::AddAlbumsRequest(const int offset, const int size) { + + Request request; + request.size = size; + request.offset = offset; + albums_requests_queue_.enqueue(request); + if (albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); + +} + +void SubsonicRequest::FlushAlbumsRequests() { + + while (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) { + + Request request = albums_requests_queue_.dequeue(); + ++albums_requests_active_; + + ParamList params = ParamList() << Param("type", "alphabeticalByName"); + if (request.size > 0) params << Param("size", QString::number(request.size)); + if (request.offset > 0) params << Param("offset", QString::number(request.offset)); + + QNetworkReply *reply; + reply = CreateGetRequest(QString("getAlbumList2"), params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumsReplyReceived(QNetworkReply*, int)), reply, request.offset); + + } + +} + +void SubsonicRequest::AlbumsReplyReceived(QNetworkReply *reply, const int offset_requested) { + + --albums_requests_active_; + + QString error; + QByteArray data = GetReplyData(reply, error); + + if (finished_) return; + + if (data.isEmpty()) { + AlbumsFinishCheck(offset_requested); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + AlbumsFinishCheck(offset_requested); + return; + } + + if (json_obj.contains("error")) { + QJsonValue json_error = json_obj["error"]; + if (!json_error.isObject()) { + Error("Json error is not an object.", json_obj); + AlbumsFinishCheck(offset_requested); + return; + } + json_obj = json_error.toObject(); + if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) { + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + Error(QString("%1 (%2)").arg(message).arg(code)); + AlbumsFinishCheck(offset_requested); + } + else { + Error("Json error object missing code or message.", json_obj); + AlbumsFinishCheck(offset_requested); + return; + } + return; + } + + if (!json_obj.contains("albumList") && !json_obj.contains("albumList2")) { + error = Error("Json reply is missing albumList.", json_obj); + AlbumsFinishCheck(offset_requested); + return; + } + QJsonValue json_albumlist; + if (json_obj.contains("albumList")) json_albumlist = json_obj["albumList"]; + else if (json_obj.contains("albumList2")) json_albumlist = json_obj["albumList2"]; + + if (!json_albumlist.isObject()) { + error = Error("Json album list is not an object.", json_albumlist); + AlbumsFinishCheck(offset_requested); + } + json_obj = json_albumlist.toObject(); + if (json_obj.isEmpty()) { + if (offset_requested == 0) no_results_ = true; + AlbumsFinishCheck(offset_requested); + return; + } + + if (!json_obj.contains("album")) { + error = Error("Json album list does not contain album array.", json_obj); + AlbumsFinishCheck(offset_requested); + } + QJsonValue json_album = json_obj["album"]; + if (json_album.isNull()) { + if (offset_requested == 0) no_results_ = true; + AlbumsFinishCheck(offset_requested); + return; + } + if (!json_album.isArray()) { + error = Error("Json album is not an array.", json_album); + AlbumsFinishCheck(offset_requested); + } + QJsonArray json_albums = json_album.toArray(); + + if (json_albums.isEmpty()) { + if (offset_requested == 0) no_results_ = true; + AlbumsFinishCheck(offset_requested); + return; + } + + int albums_received = 0; + for (const QJsonValue &value : json_albums) { + + ++albums_received; + + if (!value.isObject()) { + Error("Invalid Json reply, album is not an object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + if (!json_obj.contains("id") || !json_obj.contains("artist")) { + Error("Invalid Json reply, album object is missing ID or artist.", json_obj); + continue; + } + + if (!json_obj.contains("album") && !json_obj.contains("name")) { + Error("Invalid Json reply, album object is missing album or name.", json_obj); + continue; + } + + int album_id = json_obj["id"].toString().toInt(); + QString artist = json_obj["artist"].toString(); + QString album; + if (json_obj.contains("album")) album = json_obj["album"].toString(); + else if (json_obj.contains("name")) album = json_obj["name"].toString(); + + if (album_songs_requests_pending_.contains(album_id)) continue; + + Request request; + request.album_id = album_id; + request.album_artist = artist; + album_songs_requests_pending_.insert(album_id, request); + + } + + AlbumsFinishCheck(offset_requested, albums_received); + +} + +void SubsonicRequest::AlbumsFinishCheck(const int offset, const int albums_received) { + + if (finished_) return; + + if (albums_received > 0) { + int offset_next = offset + albums_received; + if (offset_next > 0) { + AddAlbumsRequest(offset_next); + } + } + + if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests(); + + if (albums_requests_queue_.isEmpty() && albums_requests_active_ <= 0) { // Albums list is finished, get songs for all albums. + + QHash ::iterator i; + for (i = album_songs_requests_pending_.begin() ; i != album_songs_requests_pending_.end() ; ++i) { + Request request = i.value(); + AddAlbumSongsRequest(request.artist_id, request.album_id, request.album_artist); + } + album_songs_requests_pending_.clear(); + + if (album_songs_requested_ > 0) { + if (album_songs_requested_ == 1) emit UpdateStatus(tr("Retrieving songs for %1 album...").arg(album_songs_requested_)); + else emit UpdateStatus(tr("Retrieving songs for %1 albums...").arg(album_songs_requested_)); + emit ProgressSetMaximum(album_songs_requested_); + emit UpdateProgress(0); + } + } + + FinishCheck(); + +} + +void SubsonicRequest::AddAlbumSongsRequest(const int artist_id, const int album_id, const QString &album_artist, const int offset) { + + Request request; + request.artist_id = artist_id; + request.album_id = album_id; + request.album_artist = album_artist; + request.offset = offset; + album_songs_requests_queue_.enqueue(request); + ++album_songs_requested_; + if (album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + +} + +void SubsonicRequest::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("id", QString::number(request.album_id)); + QNetworkReply *reply = CreateGetRequest(QString("getAlbum"), params); + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumSongsReplyReceived(QNetworkReply*, int, int, QString)), reply, request.artist_id, request.album_id, request.album_artist); + + } + +} + +void SubsonicRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const int artist_id, const int album_id, const QString album_artist) { + + --album_songs_requests_active_; + ++album_songs_received_; + + emit UpdateProgress(album_songs_received_); + + QString error; + QByteArray data = GetReplyData(reply, error); + + if (finished_) return; + + if (data.isEmpty()) { + SongsFinishCheck(); + return; + } + + QJsonObject json_obj = ExtractJsonObj(data, error); + if (json_obj.isEmpty()) { + SongsFinishCheck(); + return; + } + + if (json_obj.contains("error")) { + QJsonValue json_error = json_obj["error"]; + if (!json_error.isObject()) { + Error("Json error is not an object.", json_obj); + SongsFinishCheck(); + return; + } + json_obj = json_error.toObject(); + if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) { + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + Error(QString("%1 (%2)").arg(message).arg(code)); + SongsFinishCheck(); + } + else { + Error("Json error object missing code or message.", json_obj); + SongsFinishCheck(); + } + return; + } + + if (!json_obj.contains("album")) { + error = Error("Json reply is missing albumList.", json_obj); + SongsFinishCheck(); + return; + } + QJsonValue json_album = json_obj["album"]; + + if (!json_album.isObject()) { + error = Error("Json album is not an object.", json_album); + SongsFinishCheck(); + return; + } + QJsonObject json_album_obj = json_album.toObject(); + + if (!json_album_obj.contains("song")) { + error = Error("Json album object does not contain song array.", json_obj); + SongsFinishCheck(); + return; + } + QJsonValue json_song = json_album_obj["song"]; + if (!json_song.isArray()) { + error = Error("Json song is not an array.", json_album_obj); + SongsFinishCheck(); + return; + } + QJsonArray json_array = json_song.toArray(); + + bool compilation = false; + bool multidisc = false; + SongList songs; + int songs_received = 0; + for (const QJsonValue &value : json_array) { + + if (!value.isObject()) { + Error("Invalid Json reply, track is not a object.", value); + continue; + } + QJsonObject json_obj = value.toObject(); + + ++songs_received; + Song song; + ParseSong(song, json_obj, artist_id, album_id, album_artist); + 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(); + +} + +void SubsonicRequest::SongsFinishCheck() { + + if (finished_) return; + + if (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests(); + + if ( + cache_album_covers() && + album_songs_requests_queue_.isEmpty() && + album_songs_requests_active_ <= 0 && + album_cover_requests_queue_.isEmpty() && + album_covers_received_ <= 0 && + album_covers_requests_sent_.isEmpty() && + album_songs_received_ >= album_songs_requested_ + ) { + GetAlbumCovers(); + } + + FinishCheck(); + +} + +int SubsonicRequest::ParseSong(Song &song, const QJsonObject &json_obj, const int artist_id_requested, const int album_id_requested, const QString &album_artist) { + + if ( + !json_obj.contains("id") || + !json_obj.contains("title") || + !json_obj.contains("album") || + !json_obj.contains("artist") || + !json_obj.contains("size") || + !json_obj.contains("contentType") || + !json_obj.contains("suffix") || + !json_obj.contains("duration") || + !json_obj.contains("bitRate") || + !json_obj.contains("albumId") || + !json_obj.contains("artistId") || + !json_obj.contains("type") + ) { + Error("Invalid Json reply, song is missing one or more values.", json_obj); + return -1; + } + + int song_id = json_obj["id"].toString().toInt(); + int album_id = json_obj["albumId"].toString().toInt(); + int artist_id = json_obj["artistId"].toString().toInt(); + + QString title = json_obj["title"].toString(); + title.remove(Song::kTitleRemoveMisc); + QString album = json_obj["album"].toString(); + QString artist = json_obj["artist"].toString(); + int size = json_obj["size"].toInt(); + QString mimetype = json_obj["contentType"].toString(); + quint64 duration = json_obj["duration"].toInt() * kNsecPerSec; + int bitrate = json_obj["bitRate"].toInt(); + + int year = 0; + if (json_obj.contains("year")) year = json_obj["year"].toInt(); + + int disc = 0; + if (json_obj.contains("disc")) disc = json_obj["disc"].toString().toInt(); + + int track = 0; + if (json_obj.contains("track")) track = json_obj["track"].toInt(); + + QString genre; + if (json_obj.contains("genre")) genre = json_obj["genre"].toString(); + + int cover_art_id = -1; + if (json_obj.contains("coverArt")) cover_art_id = json_obj["coverArt"].toString().toInt(); + + QUrl url; + url.setScheme(url_handler_->scheme()); + url.setPath(QString::number(song_id)); + + QUrl cover_url; + if (cover_art_id != -1) { + const ParamList params = ParamList() << Param("id", QString::number(cover_art_id)); + cover_url = CreateUrl("getCoverArt", params); + } + + Song::FileType filetype(Song::FileType_Stream); + 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) << "Subsonic: Unknown mimetype" << mimetype; + filetype = Song::FileType_Stream; + } + + song.set_source(Song::Source_Subsonic); + song.set_song_id(song_id); + song.set_album_id(album_id); + song.set_artist_id(artist_id); + if (album_artist != artist) song.set_albumartist(album_artist); + song.set_album(album); + song.set_artist(artist); + song.set_title(title); + if (track > 0) song.set_track(track); + if (disc > 0) song.set_disc(disc); + if (year > 0) song.set_year(year); + song.set_url(url); + song.set_length_nanosec(duration); + if (cover_url.isValid()) song.set_art_automatic(cover_url.toEncoded()); + song.set_genre(genre); + song.set_directory_id(0); + song.set_filetype(filetype); + song.set_filesize(size); + song.set_mtime(0); + song.set_ctime(0); + song.set_bitrate(bitrate); + song.set_valid(true); + + return song_id; + +} + +void SubsonicRequest::GetAlbumCovers() { + + for (Song &song : songs_) { + if (!song.art_automatic().isEmpty()) AddAlbumCoverRequest(song); + } + FlushAlbumCoverRequests(); + + if (album_covers_requested_ == 1) emit UpdateStatus(tr("Retrieving album cover for %1 album...").arg(album_covers_requested_)); + else emit UpdateStatus(tr("Retrieving album covers for %1 albums...").arg(album_covers_requested_)); + emit ProgressSetMaximum(album_covers_requested_); + emit UpdateProgress(0); + +} + +void SubsonicRequest::AddAlbumCoverRequest(Song &song) { + + QUrl url(song.art_automatic()); + if (!url.isValid()) return; + + if (album_covers_requests_sent_.contains(song.album_id())) { + album_covers_requests_sent_.insertMulti(song.album_id(), &song); + return; + } + + album_covers_requests_sent_.insertMulti(song.album_id(), &song); + ++album_covers_requested_; + + AlbumCoverRequest request; + request.album_id = song.album_id(); + request.url = url; + request.filename = AlbumCoverFileName(song); + + album_cover_requests_queue_.enqueue(request); + +} + +QString SubsonicRequest::AlbumCoverFileName(const Song &song) { + + QString artist = song.effective_albumartist(); + QString album = song.effective_album(); + QString title = song.title(); + + artist.remove('/'); + album.remove('/'); + title.remove('/'); + + QString filename = artist + "-" + album + ".jpg"; + filename = filename.toLower(); + filename.replace(' ', '-'); + filename.replace("--", "-"); + filename.replace(230, "ae"); + filename.replace(198, "AE"); + filename.replace(246, 'o'); + filename.replace(248, 'o'); + filename.replace(214, 'O'); + filename.replace(216, 'O'); + filename.replace(228, 'a'); + filename.replace(229, 'a'); + filename.replace(196, 'A'); + filename.replace(197, 'A'); + filename.remove(OrganiseFormat::kValidFatCharacters); + + return filename; + +} + +void SubsonicRequest::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 (!verify_certificate()) { + QSslConfiguration sslconfig = QSslConfiguration::defaultConfiguration(); + sslconfig.setPeerVerifyMode(QSslSocket::VerifyNone); + req.setSslConfiguration(sslconfig); + } + + QNetworkReply *reply = network_->get(req); + album_cover_replies_ << reply; + NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumCoverReceived(QNetworkReply*, const int, const QUrl&, const QString&)), reply, request.album_id, request.url, request.filename); + + } + +} + +void SubsonicRequest::AlbumCoverReceived(QNetworkReply *reply, const int album_id, const QUrl &url, const QString &filename) { + + if (album_cover_replies_.contains(reply)) { + album_cover_replies_.removeAll(reply); + reply->deleteLater(); + } + else { + AlbumCoverFinishCheck(); + return; + } + + --album_covers_requests_active_; + ++album_covers_received_; + + if (finished_) return; + + emit UpdateProgress(album_covers_received_); + + if (!album_covers_requests_sent_.contains(album_id)) { + AlbumCoverFinishCheck(); + return; + } + + QString error; + if (reply->error() != QNetworkReply::NoError) { + error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QByteArray data = reply->readAll(); + if (data.isEmpty()) { + error = Error(QString("Received empty image data for %1").arg(url.toString())); + album_covers_requests_sent_.remove(album_id); + AlbumCoverFinishCheck(); + return; + } + + QImage image; + if (image.loadFromData(data)) { + + QDir dir; + if (dir.mkpath(service_->CoverCacheDir())) { + QString filepath(service_->CoverCacheDir() + "/" + filename); + if (image.save(filepath, "JPG")) { + while (album_covers_requests_sent_.contains(album_id)) { + Song *song = album_covers_requests_sent_.take(album_id); + song->set_art_automatic(filepath); + } + } + } + + } + else { + album_covers_requests_sent_.remove(album_id); + error = Error(QString("Error decoding image data from %1").arg(url.toString())); + } + + AlbumCoverFinishCheck(); + +} + +void SubsonicRequest::AlbumCoverFinishCheck() { + + if (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) + FlushAlbumCoverRequests(); + + FinishCheck(); + +} + +void SubsonicRequest::FinishCheck() { + + if ( + !finished_ && + albums_requests_queue_.isEmpty() && + album_songs_requests_queue_.isEmpty() && + album_cover_requests_queue_.isEmpty() && + album_songs_requests_pending_.isEmpty() && + album_covers_requests_sent_.isEmpty() && + albums_requests_active_ <= 0 && + album_songs_requests_active_ <= 0 && + album_songs_received_ >= album_songs_requested_ && + album_covers_requests_active_ <= 0 && + album_covers_received_ >= album_covers_requested_ + ) { + finished_ = true; + if (songs_.isEmpty()) { + if (no_results_) emit Results(songs_); + else if (errors_.isEmpty()) emit ErrorSignal(tr("Unknown error")); + else emit ErrorSignal(errors_); + } + else { + emit Results(songs_); + } + + } + +} + +QString SubsonicRequest::Error(QString error, QVariant debug) { + + qLog(Error) << "Subsonic:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + if (!error.isEmpty()) { + errors_ += error; + errors_ += "
    "; + } + FinishCheck(); + + return error; + +} + +void SubsonicRequest::Warn(QString error, QVariant debug) { + + qLog(Error) << "Subsonic:" << error; + if (debug.isValid()) qLog(Debug) << debug; + +} + diff --git a/src/subsonic/subsonicrequest.h b/src/subsonic/subsonicrequest.h new file mode 100644 index 000000000..946e29e06 --- /dev/null +++ b/src/subsonic/subsonicrequest.h @@ -0,0 +1,149 @@ +/* + * 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 SUBSONICREQUEST_H +#define SUBSONICREQUEST_H + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "subsonicbaserequest.h" + +class NetworkAccessManager; +class SubsonicService; +class SubsonicUrlHandler; + +class SubsonicRequest : public SubsonicBaseRequest { + Q_OBJECT + + public: + + SubsonicRequest(SubsonicService *service, SubsonicUrlHandler *url_handler, NetworkAccessManager *network, QObject *parent); + ~SubsonicRequest(); + + void ReloadSettings(); + + void GetAlbums(); + void Reset(); + + signals: + void Results(SongList songs); + void ErrorSignal(QString message); + void ErrorSignal(int id, QString message); + void UpdateStatus(QString text); + void ProgressSetMaximum(int max); + void UpdateProgress(int max); + + private slots: + + void AlbumsReplyReceived(QNetworkReply *reply, const int offset_requested); + void AlbumSongsReplyReceived(QNetworkReply *reply, const int artist_id, const int album_id, const QString album_artist); + void AlbumCoverReceived(QNetworkReply *reply, const int album_id, const QUrl &url, const QString &filename); + + private: + typedef QPair Param; + typedef QList ParamList; + + struct Request { + int artist_id = 0; + int album_id = 0; + int song_id = 0; + int offset = 0; + int size = 0; + QString album_artist; + }; + struct AlbumCoverRequest { + int artist_id = 0; + int album_id = 0; + QUrl url; + QString filename; + }; + + void AddAlbumsRequest(const int offset = 0, const int size = 0); + void FlushAlbumsRequests(); + + void AlbumsFinishCheck(const int offset = 0, const int albums_received = 0); + void SongsFinishCheck(); + + void AddAlbumSongsRequest(const int artist_id, const int album_id, const QString &album_artist, const int offset = 0); + QString AlbumCoverFileName(const Song &song); + void FlushAlbumSongsRequests(); + + int ParseSong(Song &song, const QJsonObject &json_obj, const int artist_id_requested = 0, const int album_id_requested = 0, const QString &album_artist = QString()); + + void GetAlbumCovers(); + void AddAlbumCoverRequest(Song &song); + void FlushAlbumCoverRequests(); + void AlbumCoverFinishCheck(); + + void FinishCheck(); + void Warn(QString error, QVariant debug = QVariant()); + QString Error(QString error, QVariant debug = QVariant()); + + static const int kMaxConcurrentAlbumsRequests; + static const int kMaxConcurrentArtistAlbumsRequests; + static const int kMaxConcurrentAlbumSongsRequests; + static const int kMaxConcurrentAlbumCoverRequests; + + SubsonicService *service_; + SubsonicUrlHandler *url_handler_; + NetworkAccessManager *network_; + + bool finished_; + + QQueue albums_requests_queue_; + QQueue album_songs_requests_queue_; + QQueue album_cover_requests_queue_; + + QHash album_songs_requests_pending_; + QMultiMap album_covers_requests_sent_; + + int albums_requests_active_; + + int album_songs_requests_active_; + int album_songs_requested_; + int album_songs_received_; + + int album_covers_requests_active_; + int album_covers_requested_; + int album_covers_received_; + + SongList songs_; + QString errors_; + bool no_results_; + QList album_cover_replies_; + +}; + +#endif // SUBSONICREQUEST_H diff --git a/src/subsonic/subsonicservice.cpp b/src/subsonic/subsonicservice.cpp new file mode 100644 index 000000000..6fad3cf45 --- /dev/null +++ b/src/subsonic/subsonicservice.cpp @@ -0,0 +1,350 @@ +/* + * 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 "core/application.h" +#include "core/player.h" +#include "core/closure.h" +#include "core/logging.h" +#include "core/network.h" +#include "core/database.h" +#include "core/song.h" +#include "internet/internetsearch.h" +#include "collection/collectionbackend.h" +#include "collection/collectionmodel.h" +#include "subsonicservice.h" +#include "subsonicurlhandler.h" +#include "subsonicrequest.h" +#include "settings/subsonicsettingspage.h" + +using std::shared_ptr; + +const Song::Source SubsonicService::kSource = Song::Source_Subsonic; +const char *SubsonicService::kClientName = "Strawberry"; +const char *SubsonicService::kApiVersion = "1.16.1"; +const char *SubsonicService::kSongsTable = "subsonic_songs"; +const char *SubsonicService::kSongsFtsTable = "subsonic_songs_fts"; + +SubsonicService::SubsonicService(Application *app, QObject *parent) + : InternetService(Song::Source_Subsonic, "Subsonic", "subsonic", app, parent), + app_(app), + network_(new NetworkAccessManager(this)), + url_handler_(new SubsonicUrlHandler(app, this)), + collection_backend_(nullptr), + collection_model_(nullptr), + collection_sort_model_(new QSortFilterProxyModel(this)), + verify_certificate_(false), + cache_album_covers_(true) + { + + app->player()->RegisterUrlHandler(url_handler_); + + // Backend + + collection_backend_ = new CollectionBackend(); + collection_backend_->moveToThread(app_->database()->thread()); + collection_backend_->Init(app_->database(), kSongsTable, QString(), QString(), kSongsFtsTable); + + // Model + + collection_model_ = new CollectionModel(collection_backend_, app_, this); + collection_sort_model_->setSourceModel(collection_model_); + collection_sort_model_->setSortRole(CollectionModel::Role_SortText); + collection_sort_model_->setDynamicSortFilter(true); + collection_sort_model_->setSortLocaleAware(true); + collection_sort_model_->sort(0); + + ReloadSettings(); + +} + +SubsonicService::~SubsonicService() {} + +void SubsonicService::ShowConfig() { + app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Subsonic); +} + +void SubsonicService::ReloadSettings() { + + QSettings s; + s.beginGroup(SubsonicSettingsPage::kSettingsGroup); + + hostname_ = s.value("hostname").toString(); + port_ = s.value("port", 443).toInt(); + username_ = s.value("username").toString(); + QByteArray password = s.value("password").toByteArray(); + if (password.isEmpty()) password_.clear(); + else password_ = QString::fromUtf8(QByteArray::fromBase64(password)); + + verify_certificate_ = s.value("verifycertificate", false).toBool(); + cache_album_covers_ = s.value("cachealbumcovers", true).toBool(); + + s.endGroup(); + +} + +QString SubsonicService::CoverCacheDir() { + return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/subsonicalbumcovers"; +} + +void SubsonicService::SendPing() { + SendPing(hostname_, port_, username_, password_); +} + +void SubsonicService::SendPing(const QString &hostname, const int port, const QString &username, const QString &password) { + + const ParamList params = ParamList() << Param("c", kClientName) + << Param("v", kApiVersion) + << Param("f", "json") + << Param("u", username) + << Param("p", QString("enc:" + password.toUtf8().toHex())); + + QUrlQuery url_query; + for (const Param ¶m : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl url; + url.setScheme("https"); + url.setHost(hostname); + url.setPort(port); + url.setPath("/rest/ping.view"); + url.setQuery(url_query); + + QNetworkRequest req(url); + + if (!verify_certificate_) { + QSslConfiguration sslconfig = QSslConfiguration::defaultConfiguration(); + sslconfig.setPeerVerifyMode(QSslSocket::VerifyNone); + req.setSslConfiguration(sslconfig); + } + + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded"); + + QNetworkReply *reply = network_->get(req); + NewClosure(reply, SIGNAL(finished()), this, SLOT(HandlePingReply(QNetworkReply*)), reply); + + //qLog(Debug) << "Subsonic: Sending request" << url << query; + +} + +void SubsonicService::HandlePingReply(QNetworkReply *reply) { + + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + if (reply->error() < 200) { + // This is a network error, there is nothing more to do. + PingError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error())); + return; + } + else { + // See if there is Json data containing "error" - then use that instead. + QByteArray data = reply->readAll(); + QJsonParseError parse_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error); + QString failure_reason; + if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) { + QJsonObject json_obj = json_doc.object(); + if (!json_obj.isEmpty() && json_obj.contains("error")) { + QJsonValue json_error = json_obj["error"]; + if (json_error.isObject()) { + json_obj = json_error.toObject(); + if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) { + int code = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + failure_reason = QString("%1 (%2)").arg(message).arg(code); + } + } + } + } + if (failure_reason.isEmpty()) { + failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()); + } + PingError(failure_reason); + return; + } + } + + QByteArray data(reply->readAll()); + + QJsonParseError json_error; + QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error); + + if (json_error.error != QJsonParseError::NoError) { + PingError("Ping reply from server missing Json data."); + return; + } + + if (json_doc.isNull() || json_doc.isEmpty()) { + PingError("Ping reply from server has empty Json document."); + return; + } + + if (!json_doc.isObject()) { + PingError("Ping reply from server has Json document that is not an object.", json_doc); + return; + } + + QJsonObject json_obj = json_doc.object(); + if (json_obj.isEmpty()) { + PingError("Ping reply from server has empty Json object.", json_doc); + return; + } + + if (!json_obj.contains("subsonic-response")) { + PingError("Ping reply from server is missing subsonic-response", json_obj); + return; + } + QJsonValue json_response = json_obj["subsonic-response"]; + if (!json_response.isObject()) { + PingError("Ping reply from server subsonic-response is not an object", json_response); + return; + } + + json_obj = json_response.toObject(); + + if (json_obj.contains("error")) { + QJsonValue json_error = json_obj["error"]; + if (!json_error.isObject()) { + PingError("Authentication error reply from server is not an object", json_response); + return; + } + json_obj = json_error.toObject(); + if (!json_obj.contains("code") || !json_obj.contains("message")) { + PingError("Authentication error reply from server is missing status or message", json_obj); + return; + } + //int status = json_obj["code"].toInt(); + QString message = json_obj["message"].toString(); + emit TestComplete(false, message); + emit TestFailure(message); + return; + } + + if (!json_obj.contains("status")) { + PingError("Ping reply from server is missing status", json_obj); + return; + } + + QString status = json_obj["status"].toString().toLower(); + QString message = json_obj["message"].toString(); + + if (status == "failed") { + emit TestComplete(false, message); + emit TestFailure(message); + return; + } + else if (status == "ok") { + emit TestComplete(true); + emit TestSuccess(); + return; + } + else { + PingError("Ping reply status from server is unknown", json_obj); + return; + } + +} + +void SubsonicService::CheckConfiguration() { + + if (hostname_.isEmpty()) { + emit TestComplete(false, "Missing Subsonic hostname."); + return; + } + if (username_.isEmpty()) { + emit TestComplete(false, "Missing Subsonic username."); + return; + } + if (password_.isEmpty()) { + emit TestComplete(false, "Missing Subsonic password."); + return; + } + +} + +void SubsonicService::ResetSongsRequest() { + + if (songs_request_.get()) { + disconnect(songs_request_.get(), 0, nullptr, 0); + disconnect(this, 0, songs_request_.get(), 0); + songs_request_.reset(); + } + +} + +void SubsonicService::GetSongs() { + + ResetSongsRequest(); + songs_request_.reset(new SubsonicRequest(this, url_handler_, network_, this)); + connect(songs_request_.get(), SIGNAL(ErrorSignal(QString)), SLOT(SongsErrorReceived(QString))); + connect(songs_request_.get(), SIGNAL(Results(SongList)), SLOT(SongsResultsReceived(SongList))); + connect(songs_request_.get(), SIGNAL(UpdateStatus(QString)), SIGNAL(SongsUpdateStatus(QString))); + connect(songs_request_.get(), SIGNAL(ProgressSetMaximum(int)), SIGNAL(SongsProgressSetMaximum(int))); + connect(songs_request_.get(), SIGNAL(UpdateProgress(int)), SIGNAL(SongsUpdateProgress(int))); + + songs_request_->GetAlbums(); + +} + +void SubsonicService::SongsResultsReceived(SongList songs) { + + emit SongsResults(songs); + +} + +void SubsonicService::SongsErrorReceived(QString error) { + + emit SongsError(error); + +} + +QString SubsonicService::PingError(QString error, QVariant debug) { + + qLog(Error) << "Subsonic:" << error; + if (debug.isValid()) qLog(Debug) << debug; + + emit TestFailure(error); + emit TestComplete(false, error); + + return error; + +} diff --git a/src/subsonic/subsonicservice.h b/src/subsonic/subsonicservice.h new file mode 100644 index 000000000..a3f7ea337 --- /dev/null +++ b/src/subsonic/subsonicservice.h @@ -0,0 +1,130 @@ +/* + * 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 SUBSONICSERVICE_H +#define SUBSONICSERVICE_H + +#include "config.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" +#include "internet/internetservice.h" +#include "internet/internetsearch.h" +#include "settings/subsonicsettingspage.h" + +class QSortFilterProxyModel; +class Application; +class NetworkAccessManager; +class SubsonicUrlHandler; +class SubsonicRequest; +class CollectionBackend; +class CollectionModel; + +using std::shared_ptr; + +class SubsonicService : public InternetService { + Q_OBJECT + + public: + SubsonicService(Application *app, QObject *parent); + ~SubsonicService(); + + static const Song::Source kSource; + + void ReloadSettings(); + QString CoverCacheDir(); + + QString client_name() { return kClientName; } + QString api_version() { return kApiVersion; } + QString hostname() { return hostname_; } + int port() { return port_; } + QString username() { return username_; } + QString password() { return password_; } + bool verify_certificate() { return verify_certificate_; } + bool cache_album_covers() { return cache_album_covers_; } + + CollectionBackend *collection_backend() { return collection_backend_; } + CollectionModel *collection_model() { return collection_model_; } + QSortFilterProxyModel *collection_sort_model() { return collection_sort_model_; } + + CollectionBackend *songs_collection_backend() { return collection_backend_; } + CollectionModel *songs_collection_model() { return collection_model_; } + QSortFilterProxyModel *songs_collection_sort_model() { return collection_sort_model_; } + + void CheckConfiguration(); + + signals: + + public slots: + void ShowConfig(); + void SendPing(); + void SendPing(const QString &hostname, const int port, const QString &username, const QString &password); + void GetSongs(); + void ResetSongsRequest(); + + private slots: + void HandlePingReply(QNetworkReply *reply); + void SongsResultsReceived(SongList songs); + void SongsErrorReceived(QString error); + + private: + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + QString PingError(QString error, QVariant debug = QVariant()); + + static const char *kClientName; + static const char *kApiVersion; + static const char *kSongsTable; + static const char *kSongsFtsTable; + + Application *app_; + NetworkAccessManager *network_; + SubsonicUrlHandler *url_handler_; + + CollectionBackend *collection_backend_; + CollectionModel *collection_model_; + QSortFilterProxyModel *collection_sort_model_; + + std::shared_ptr songs_request_; + + QString hostname_; + int port_; + QString username_; + QString password_; + bool verify_certificate_; + bool cache_album_covers_; + +}; + +#endif // SUBSONICSERVICE_H diff --git a/src/subsonic/subsonicurlhandler.cpp b/src/subsonic/subsonicurlhandler.cpp new file mode 100644 index 000000000..909f6cb91 --- /dev/null +++ b/src/subsonic/subsonicurlhandler.cpp @@ -0,0 +1,57 @@ +/* + * 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 +#include +#include +#include + +#include "subsonicservice.h" +#include "subsonicurlhandler.h" + +class Application; + +SubsonicUrlHandler::SubsonicUrlHandler(Application *app, SubsonicService *service) : UrlHandler(service), service_(service) {} + +UrlHandler::LoadResult SubsonicUrlHandler::StartLoading(const QUrl &url) { + + ParamList params = ParamList() << Param("c", service_->client_name()) + << Param("v", service_->api_version()) + << Param("f", "json") + << Param("u", service_->username()) + << Param("p", QString("enc:" + service_->password().toUtf8().toHex())) + << Param("id", url.path()); + + QUrlQuery url_query; + for (const Param& param : params) { + EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second)); + url_query.addQueryItem(encoded_param.first, encoded_param.second); + } + + QUrl media_url; + media_url.setScheme("https"); + media_url.setHost(service_->hostname()); + if (service_->port() > 0 && service_->port() != 443) + media_url.setPort(service_->port()); + media_url.setPath("/rest/stream"); + media_url.setQuery(url_query); + + return LoadResult(url, LoadResult::TrackAvailable, media_url); + +} diff --git a/src/subsonic/subsonicurlhandler.h b/src/subsonic/subsonicurlhandler.h new file mode 100644 index 000000000..1a907f2e0 --- /dev/null +++ b/src/subsonic/subsonicurlhandler.h @@ -0,0 +1,57 @@ +/* + * 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 SUBSONICURLHANDLER_H +#define SUBSONICURLHANDLER_H + +#include +#include +#include +#include +#include +#include + +#include "core/urlhandler.h" +#include "core/song.h" +#include "subsonic/subsonicservice.h" + +class Application; +class SubsonicService; + +class SubsonicUrlHandler : public UrlHandler { + Q_OBJECT + + public: + SubsonicUrlHandler(Application *app, SubsonicService *service); + + QString scheme() const { return service_->url_scheme(); } + LoadResult StartLoading(const QUrl &url); + + private: + typedef QPair Param; + typedef QList ParamList; + + typedef QPair EncodedParam; + typedef QList EncodedParamList; + + SubsonicService *service_; + +}; + +#endif // SUBSONICURLHANDLER_H diff --git a/src/tidal/tidalservice.h b/src/tidal/tidalservice.h index 64a829f8c..d6ae0942c 100644 --- a/src/tidal/tidalservice.h +++ b/src/tidal/tidalservice.h @@ -33,13 +33,13 @@ #include #include #include +#include #include "core/song.h" #include "internet/internetservice.h" #include "internet/internetsearch.h" #include "settings/tidalsettingspage.h" -class QSortFilterProxyModel; class Application; class NetworkAccessManager; class TidalUrlHandler;