a|G(vCCj*qF{#Q?`
zcavl*_htCCEsTjJ!R+L&>F>@YZx1xxwdcCl&pGz9pY-p#)4W^FwBqnmHpUqrjaj7<
zq_SQ%tdGgMzC^wF`lm}`bvB@dn?YGOmOOtxx#s3lxt_@??uYb6@4nx+-I0MUK_Tk}
z)Bk$Ux?@Y_dYV<{a~ud+bfn1OcX$H>PlMwx?!Vvlwq{@4DCKl&;x0xe0|urKAO5UW
z+5fia`@8Lv|D5%*%jUft;`^SJCBfpj-UWl2ttHX_=BwC0E&Bd$`J_Ly=Ib)cpNlM*KYrvf2pMD;1f
zuOB#ntz=5Y1ziSaCIbdnU9F#U_RoE?e)pZdKeJb+%a-Zd2di2gSikZj_e~WZmIStU
z9-Ijuo{838+dt!pdF-cif72%I54iYzt?H6FD`KCUZt|RAsKsO;`mcz^EIaUG`d|CW
ze_Fin?VRuNsh;z|*dB0@cyZoTzdV80%-Yd@H
z=j-F%v%H-}fq{8Lqhq>n^Q!&g*^RruT%Y_W$aQ}CG^=T%GGeRlPFZ8C!oWD=mj$=P
zvBbce&;RyM`m^hF+?{-tpPQ%FK8s@ND)?Ts?1t20C5B+d_|E;i^B$b?HGT4*
z=2Lc`of!6Q+S%|mXqkibVkHLV4UF%@e$1>lIjO(n%h5mUb9?{l_+Iv6c&C~$lZltf
zfH}R2v&T92b;IN*+MfIFFV%nMztwczNu9EWeSPLvUM!8{KDL760MD$Kss?hmMeCmZ
zS3DUzz3%b5WiJ_iOU_z$*=hg%=f@oMmon${bTRN8Xw%X9d#7IQmtA`2
z{kOrdbF9=We}s88F!-!IC}6;6x;DP%metAqZ^d?OOFi|5&xWo0%H@UHf(_5JN*2D)
z0IkG6%(yI!f6|``(GO15X`Ec#Wnb@`{i3uX{@ky4frgbg8ifvI?1}#Q{QpFi`g^$r
znetn$Yz^FI{&9)OwO+mrgvmW6x?9
z&q?;Om#gBRxKG-%p6BY{GgCMYa5JvH#m)+{%jZ%QXF|xcmD$eo#eXg|c^&;TCpJgo
zTEwz-cPn!nz7{QKWt#ROJEx1GM_+QmhXwPFt=sTDbiT^Z^2Nq=alZWBeL>5L)0lM_
zZ`=@JoZ)GBmgU&^7DE1z5+9@DI^{xjM0->TaWI|Gt_eg2x0S|$4>sPqs>
z`NRzu8G0@UanAb?uln<^XWhC(=l>OZ-pk59uskbTE8*AYqie371hqLEKJHw|GXG<@
z=RV_;{b65b{xAAHv;OGQ|2@%DYwxwQ-YZs&i9K}t-dd}PII&l6I
z|KvZ_Zx8$xSNmgGzwmF5{e`-;d0W=#Oo{k^JD;KMcK@9P>-m`sYTCAn8Tf6V@?a<5
zq(7fC&02qbYO%XMeR56x@0&JzYWCfpT6<6E?)}JR-vu77I8c4=?+(eyN({z!^R^wd
z$W5IUEMoq&{8A3r*R`H?HB9rLyWc+lDg4&?W1DrgcvP3!$Di4C;k&`HB^(ESv@(=D
z*(S8-%Jj)K@srGNDcCI2JiLGEle|gyC#Pk7pC0RHFYIrrD
z;kB^7{`!mitnCyKJ}CGexKMU*W|j_zxzM?N&4hJ<(~iC
zCdHR(t`ogwx9UUj{&{EfWw!Vf_Jne``JWWjfcNTd(qSw`bk5y>b5+U&=qTm&^Y1;wd*x%d4BVYJL2>
z<3sZHj~g{O4wM|*_Sqn>`fA*8;Nj0^T)>prpsoia5OWi3g`hWe{ulse(AMW(e1C1X%{&)0&Lb=3tYtMT@ZAx{xNNeZ`fJ
z?!htbnd~nLdt4u0dc$-!bnR3Iw|wnXvm=d)(P2_9KK1){1i8Cr%1Cr8F+BEWe{pl}
z4cEz4XM)~G-dJWIe`nQ&y~~+%bh;RN;`t6RFdghO3h11BWjEhu7lwKMCRb+u1Ih86
zFImH4z_~rSSZrDTyeDBx=imME{9p6cL;VX3HJA(@Zx3Ud(Npc^!1}#n?d8ATzV-VQ
z8MqG|TflMPO3G3e1Ig|gTxUykwfPj5dw%2I^WNt2oNLtqFKTleQhB-<&akYR%Aj^M
zBx6~|w5onvuZ16G`;`CM{B=J_|AW))EUXf1XDzo=KN+|1``lMwo(H9?Ci9$f*t3Ww
z=CZmM!{lP)r@P!PEPboOul4T_d+VX=HYeSW9h=T|_5H8HDTxwnvI*;6dvGQcy*m<;
zaV%eK_U^ahRrgo=)z^i*sJ;K?amJfYhBGBHT8utM7B&HFZtK3SJipXzzt@|8HIknz
z7J#IC^c536Y@75(i&=T=?3ZPl`l~{I+&xudUb^Yu^&7&B5;dHTNAslj7&>)-ySFxW
z<=OSis$y;xp2}JC#QF+IKHlj-hfT-J>YnzOzk7dcZufo{VqRReBJS8cjspdP>Y4Lq
zFa}x}B&0?kGI|_J%M^Le*1G8K2u>bNJ7Eczd+u@)FBm
z8KE!s=KJi(Q?)v9JI_7Yk0qf-lJW7DoOpvPzduhis`={Z!1>wdJEQZ;$NfI_X3tz0
z(iVkzF@)|=XEAn&F1+`C=Hn^Rr*|1Y+%;9=_!*wOyy63&l
zm94~JoV&|0ApFCI+j@uIZ+IhC^IkK*ZfpFD+O_}h?wKm2
z!0_0c^F{Z{^a{scDT6sYF9T-nB!~Z`DvDs!_?+U<+t85XrxYiX7g%kkj+Djr`MjGpIl?|@Yd8@
zCOK1|2r_My(EQ^*(22IXz3N$(XxVtCy;P-noE8fb9Ro#!y
zJFRraC?$CfW7=iAKqi9#&rflua`c)%ct2KnY8E|fY20{J~anhIhmn
z$W#L37R4X)0uHmj7mkw)@SeLf*7z4mpkhLDH%m<__%!|S1@Xcot+)EGG7Cvo=^5VUFzj;cewQc{)^T(!e
z90+;4P>4ZaBE{aXC|Yw>`EK6*#Y=Zg*xh>Af%oqHzfSM7e{D8YRet27>tzXwF$Vny
zdmdPwy}agYtj5t++2t<{rp5fW{#sn`f4h3U3$N_&uy?a=S~vAxW?VL9st|*Iq1oTk
zl_FPf^l6?C->4%|H#sf*w`-BoQnR&LlDlP){=bY;ko
z@>gNY_J1?i-2U@wU-TA<%kwo4iDY=qTt3r}wIEPE(<-ohYEn?%mWR$C_U@TN+{d;3?>>2UoP9D}548fA&TDxL*Eim7(`Y{PF=ENhCt2Z|>z-OXHE+M|D{HL#Ehz8v>elG15|ch|
zWSeoxE|4jKnQ!tQ`PfE9_WQS)4j$VR{3CAt!yAi@*NbxLs8TXr~PoHi*IsL%Z
z^_A7Uce-cS%9ee(uxMJNKg*ZpqZbzT;^
z&7q#@&ke6X2t4&xNoRqiHPfC8i8g1yeEOvIdsAdsr1_gNsBdwtg=xRCeDZtm<@o0uO4Uu}-;m|4UxV%PWi
zq<57zX!V275v!BZrx?CGSR%`F-+V*WI@ej5=WeropKIlNIn!SA&+|9S=DmAgcls4;&)>)I6lRNA0RZiM|`~73rlBi$b>fD+C%_{TKzv+0#
z@6&(xa|uoP9%OmYxZPby`fEPZdK@QwkfL2Q_k9)e*J2p
zz!T7d1%B3q1ebG8PkuVy{1symw|)KNZ}VekEWa60@Ga!R+}$yYIrn$-{hJf{YWBL_
z(I57f-`O>{M{uFA)q#?;CK(B<_&&ef^78Zlefts|;`ThSzuk3r=J9u1_V&Hz`?qGV
z%66?)xhLPfw>kSPe9GUOAO5*bTT`EbWB+yjtnXU9S*qDNZ}ZK+O>MAy!Ekwc<($>*4Iipy`7im@uRD8piuF8?
z@7t~8eazjP4+@twygY4na-tD~!k@U4eoG&IHz})|*QESVoNy+lHU2C^hU7)|r;qvmxN&Qn986JK=ly&!umgfG!S=e_jXGp~r>
z=s{~q!^_K_)&&d<55GT6%e?mYnw4vLQHvdwS*+>uTHf5}=lq3ytA
z2Zm!0wk-cpz5dgO-;Sr=N$gSoQMofwMusaU-Kd-H%um%QrvH$8KZX5q3P&
z?4Q=wv?~%zqR;(XT5ffK=b+WuTXGB%ix!);aPGewz3fe!=l47J=2{BW2{t?o{_VwZ
zz~S)wMeU8dKiqyJwnyv7zuRxO9*yYln!$8-!W@-KUr7du0EXyqAD-u_{@n2C*8F8N
z=f!OL{W~z{#FeLCx15{hc!4p^o2sXQTdJ
z4TcxjGiR;bxLWRT{@b~m+%`rt`?)>~p}ODYvp&u4v;1~q246{o
zEal2
z3AFNVvI_rH#s;Ro$$OOl@SR#b#a=Bv{Kwln&JU+6USdpJ@vKlqh+#H=)aiNbPfx3S
zkLgG_y@zQ#{wk}W)vxJ(Xj?^B-0uw&0-vyydeAIf)5
z|70!Ge>RZ)V2Rx6mA`)7oUP65yp;LOg|}La4AV=t^M=>9-;bQce&~JroU#yM+45xb
zA3U-N#~!HkxiCF=+BfM0bUdf8XBAe3N(Xz)J>WwrVei
z1Kw3I?_Q9=^2Fd2h6^l3yOy;{Z$9R|V0>DTs`wTV=&;IfFv3J63*_QiDDkGHsd~^Rk{o+N&w4P@d
zS=nY>VyLn|eCx)m%*`s>FRw9vnsmM7=E3=!=Pr}>uP><=Y*?uL+cQst`GCgUZTBBn
zD@)kqZ&l2Gw<*>me0H4Wrxo?Fwl5eMB^Gh>7+fpZGXJ4pVco5_Cc!y7XDl}RGq2=B
zx^3Ue7qyrFrcQ|qWpc}!`|RXmJ%a-qdRN}APMi0vLYeV((e+Bu+PlZMzMQ)i@8-=u
z!*I5{xJ1GVIn~M;A4{sw&sqD`bnCaGhvgIV%=X^$`m}J;cfaq`FJ59yJ9F(KE1N{p
zi`p4^R*kdy!oDZDKU^JACQ^64&t?%QOlR>KB%Al^88lp|-WyP=eeGA=)w3nnpKgCw
zE>Ti1X)2p=EKfEzp+Rr%w#0JNM;EHoCN6tzT^_mqNnQKHa{nK)XH3QGK+2XS%u9Q9
zHQS3Z;7Fj|+fQ$!
zcYE7!ITy}(=GX$7T4vtT(`(dVb?C&+gp#ZOem~qy?|5cL&)W+`hbe+v&OW+i!h%EyZ^@(8t-Gq3vMwk;%yk
z5$Dc%izcz&-`INJb=T}r1Mg20Ut4Ir|8dWWp{+1N6|{9^p6v3-S&0mqKe+bm@1Ivz
zBR8%2h7BL1gj@2wXPQP1@w?*8HGlm3|MsqJdGz(EY!A%KxcpUrh)euqZQ5PZwELw?
z=}VT*BHA9^2V;}r|K{1x{c!u_IF9{1Gx-dXA4I4m
z%$HNO3~Y;@xvZpG-0;z!_aCh97r!XH6IdS4ZKmYR&^B-O@r@n_>ek%7aR1a}sjBB*
zihnL?oi>-SQNGY~(8%lv)3?VloOvQmvwYX~wcfwja^JJ|UhDmLmAZe7*sKop9Nltn
z#w`Kv8>-LK>VED0k?6Jk`i!R*hu3ee+kNs_IJnH&=vB0Yhhc5YJ^4w0OrO?#Q)XOI
zy1q63UXjWlBQdK3J%$|hi+K+8yBu2aF
z@`sM63SEoMFVFp|lDc>6htq1(@r*oo3MbX?jM2N^w{GK{NNeAldw=?#atoXH`(0eP
z?moTyE9#f)pFS)hE1PgEW0Db9uCq>p_%uz~sfzo`?L1z7IJbE5b@A7~YPN24{<->1
z{he8}_nIx+_)VNq!Ywh-eg6@NJL?!4T);q-C9A(2%&~-0QPS2iECiYgnFr>mFo2J?Bi3?V>v@
zx79uv`Q_%7ss8!aZd@0W(L
z2ZlDjO2Ne!7`~i8t>Pd3E8l<8ADQ?YUs^Aw3p6}5?{Q+jApTmaroT1bt{E&O>$Z^b
zh4+leC3^$I9&WGiv2*^qROrC}->Of&7w!yVxDdEp^L1|cA^rI;4Nm%>VrbkMn1iznOL0
zvRnU?BSV|?tYtmCcZ0bYzP&J6`nu}sr^gkc^6Y;%`AJ5`YqIWn&8^zNAGLJOMh=GG
zTzcn&toG-&-S?biekecRR=?>ktYJI1t+_k7@7G#OHEw~1hkoYF
zt0h|55`OP0*b{iz@BH7g$oYr0mp1SlGx5$Z6TQETX+!OD(+wqxf6leuzZcE1UxDH9
zHl4{bTFea58~i?LJUx9ilwIz8@x$%k`7ZzKdw7A{ZNbB5jyEoPFxPmw_di=$6oE{d6oFe
zs;c*?yEZmTed!X+*s=Gs%J->^4ckj{?^tTSuDyRG>f*v39ej3oz>>{Vwwg0c_%-u`
zPU`*+z1I7YllF7(Z?2kqa|aJg!j0XbYuMQqR75uCUv~?t_xN<(;!%c&*v5C_d1s{(
zcFf+tR7Z7&HtPWm)7LfiUgg(*E@Qj*-|v&U|HTC|YCpS;4!n(I=DEGG=W`?Du_sDT
zZSJSC%inf9CFs6u-{D7l;sqKO8mnnO_BhARCb8|bivA>-@-LfTRPsLKIc=4CX$<@riB5>*G2WxN3+u=WCm+kz(&16t=wsNl}lf#F*%hMKXAAiJi
zd2&@hV@{)B#*KZCRr;qgF8F@%yKB31{-d1xh842vehmyfwgvN^>@#BE*k_%zc=f~0
zFGV>HT#%kD)wDm5t08WOhvnhzuMd{bdU9N#aZ^>wP4)!s&rC*_JxSDKO6mm%PQ=;CO$W;
zkeVHL@LbJGmJOc;{_Ku8%zT0MwMxC;CykT-FWuYUR!*8M)g-y^@DzQk2HA~l65k$M
zl(n&CcyHeKUFEN&_T1%bzLsveSaIW{Buj!skLRh5xpNmXeoKyJejoVD)X&QE<*z6w
zV?FVYsq7pF3=Rn7q-QkTwep_1>t*ov?S&IAeVtn+<$kp9yaL1FgtI|b2ft5YO31!u
z`bADP^6Qt)50Br_Pg=A7Ze>mtE0Y1w4aZ65rx*&-mut?w_jsP&KTqC$-2P89Y*(#M
zbYN(6j8>^!%EPdoZ*Q$k;k_$sK9{b!SYhi~zkg5Ty_MQ1>W_6bP`9gOEhQll7s#LDv
zVX*YQsZpS@(7bSs;QM{Y_@^+kBuE&ivoI%@Mjp-+u4?~w@1OIU&rff;o?>Zu+~>f+
zrWnD&=9bhE8+TCrp*TyzgSI(>JUka8Pf0HKU0%L|CEo5xDvJT?1VPtBaj@O#TWj-9>_N`>PO~g5h+{ySG>Kv&7oHO+5AM-p(Keka-*j54@XG`{4b3z73~)=a=Sh
ze_S|8eB(Z_UmQ5t^4^*})wwfu!!66pQZ;X1^nBoEkmWx3zi4O7-d%o+X5R?qpS)mN4(d%`Cm?U``|{hr!`YXfmdkkLG_lrh@95pQAX_i^T;JA{q16k&
zC)U=_(PXzyIIR6*<9%($FHxTvby%FsWjOac=^xU+;A?fjfQMUA!s_wuDU16Ass!sA
z`z|uJC1%)7Xidnt-uhv8@MNi~`CNNB_BSx_IBgK%d294*Pwi$E{z)<_Gqu?d=|iGO
z#CLjm-1?Q16#g(V8GP6%P%|%O=976v47LWV7k*c8oBgt_^pv>4b1%ljNy0i3-*}e_
z@ciEDb(5vLfA+E-)AQLc=4x<4&H8e_Ja5kZ-*H!1?nU;PD*s{9HDuyFToz}4qJQ$8
zaK0Pe%@gn2_`mvnw&rn@-3|}SFz+9&jF%bPKHiz2*P3vnd-W8<7aQMj{8d=G*!py7
zyZA4Oy5}1wTXP&Zu)yg<;VC!c19@{*Dor;0U!HktddBA7>{lz(82o~*_Jh+AgRFYx
z8lDZs?VfR8OkUJ|4m&Zo+DdZ8byJYX3=;Gw*@Uy**!g%$vCHlyk&CTwa_pEb-s2j-
ze+d&y!UHie&p1uyH+Qs7Zl1F7MeMAnRmZ=p-O2m6=V!S=nSao}1xzdn2`+rEcpH)@
zCD=Pw%-nSNO?%nQx%H3ke=O^mtF3+Kgw<2-2f|Ak7$qc@NA4B;;ikzTvEj7EG9H!$
zRfb11R2bUM8oE17dcv*B@QU}KO{%#MgT$okhx9M}2;yvbb$v?Ui~C1+IWe?7G&p(r
zmn6Fa1M~ZcWost?vO5`miqV08?c_i1la!Y-m_;)4)O4KP$nZjWb}S>4L4mf}ai#_S
z=dylmy(eCwKRba@zIf^-&TW=r46*5(wI56_X=psZ)#HY&x10u7D1vzGR_
z+z;kruu7PF>Fd^e;y-lU7BtSb@Hg^}OlD=swOT#j^N{|Bn=;KTcdo40Oinw;&9*?d
zUDLaG&U@}V9fBDT^!TzQ%!GIk*saO_U>nHgz`%CPV6C}QpBGcYbiJT`6|tJE1<}_S
zn9H|%-IRH~G>Bn?vi|Gz-VfY&I{6GX__{4(yrHIizJYSce3)neViZFZ2UEOVxj
zQ0*!ahMGf}r`*E9$#8G!)J>MxCQmu*!;o-!SDl;8krT!nlHC?G*3LS{7ReB^`J(8C
zaI=F<-%hecGMLO${TWck)Wf*5*Exq4my9;4Y%dzq2#)+g^t`(4Z>
z5`6e3|Jhi_#_(n?pTPyzT?ZKsFMG{h(gfoyea&6c#%FNB6eP3YHFrrLh%@`{gZGna
zmZ~u9-Q@S_`HNpnvP+nGN^Y8*<7Imx{QMUalfeO5*((Y){+bL{nX%39JA)WvzqJZ6
z*cME>#9OyggkkRIP5a!r_d7g)$-rD|>|Z1@JB4w}TcwkiRlJun{5qr-;c$OV(qx?m
zv)sI*twDSa3~V>{rcRnasb+3bi4eo??^Y-GtN)Z=%JAz1hlv5pXNhjM8>)}b9ZLvao@>GT#!|jdh-mf)&C@#UIeBr=^Kl!JAJ*fBo#Qv0F$K>gn$1ia1JCGpJ
z$kvuMtElV)^HPQ#6E{yWt_}BMkJJL4vUef%a@FH1fyWmQJdakl?Cf0&QYV*njZ;6#
zM(W3g(^CY~Usp4-Bs4f$tZ@9XFo@x5ChtCVFGkSH%8Mp_flY?RJ%XU)_Pn~QZklkn
zys|&X1BUsD
zLCqg7t9+lzZ1r9#HTS*engc~>q}uLJpz
z;dkMNLdzh|vh=yHP4`~=3JN-o!v-4DJ*PQ=g3V#O(>*b>Y`tWrop=RELB{Qz$Fq)4^uBZUsM~{FJy1BkSW~%f_mRyvGG~3?eTQS`
z=@;*qK*u#QO#ZVi*rwa>o~S0_~O6D;c01Fed6U^i*p
z!Fj?rp>M*Udj+$59d5JSQTqV0fPvXyeSIF=le7Et|3$_hTQ|G#$+4ox&(HR~y%fyf
zz`(3}$St$qWokuc|^KN`ey06$*;-(=u~X
z6-p`#QWa7wGSe6sDsH`<9zE%e8c*B(NY`l%DlJEf45qJoqj35>|GqO{&UhC2srs+n
zcFKWU=j1PzyRygR_n-eDt*159Ip}jtQQwXr&sJ?Vx$~>vOiQ};cIB$;9dTbD&P@2y
z6nBnub-I6coW0zR+6y07@zr?87upBaG9&+E44fS
zia^q%gKhJ6GM>A%Q$jqC!FkfO3n`P8Vr9-q^(!!$+Hbt@EqdF2H@@?8H%#F?mT~wW
zH)p;1Pi>)RIohrwe@}`&dK7w{BY9%9ZWtrS+3wlzSnd}+?4SRNeJNMduFG#K4odZ3
zKCe0!o)6L8Elrx
zzXEozsGA(Wz)jrr-jZup)hisO&*TYj=@X7UpoBO~t2qF}E*Cv?*`8kn^j
zHC;KXRuXlj!GJeu?w1X}6}|`GY5L_~__$t4Tx{3>rWl4@@9)$(tX=AGmO&pZ!lKZ#`4<^tq2FM+oIHFfg`cIy(n=Iy=Ks
zJp)6ZRXRnp}P6DT|pFjvPzwD5|S$U>(TqEV#=JHK$xGEvdg>ubtB
zc(DKIs^;#^>-g3+Dg0o3^z7M^7fP!C^fkA1e0{J({{1`KclQ{b_MS0Woy~Ni-IF)-}vhN
zBps~3xVv`h{GLkBgBBP5KC+Ns?2sUyaYg3&?;C|u>J9%TcU+48
z=OsJir^?=Imz3Nc{+ksP-n}%%_CXQ5M3qrWaG9#Tui||6d*@%w-gMxA?uG5ozFxa?
z{3Y{*W$PxHA2_IJ5VqjWSHAb}a^JtbZ+!M&9fNOG(TsVV526?t7}%1$-CY>|gW!U_
z%O^81FmM)lL>4nJa0`PlBg3pY5)2Fs>?NMQuIx|QM0uszw8Hxn7#L)}db&7CHG=+
z?(4ye7#f|JUCbtwoEM#HR`_0}Oh4np-_}!^>$F?za~B;OhE}#68?P7L7o8?}cH`Ei^Y}LJbzA2J(sIg78ky
zD3|SC9(DRU*MX`TJyTO$R#coQkP6t&+;+0V<8UT>&E1AwCQ0^2N@wk|c;oFC)UNv7@w)4I=hna0jo91uW8j*0~`X)a41)Qr26f#e3VCDH)=P5t=ORi_Vdojb$Bv;9U
zr%Y7J+toC}PK!%OMtc2H^)#Nf$VUGni)+<_t6cJ3JVmc+=kF>O|B{jL=k%|DI}>~N
zoQ-*(<{2E2k#PEg=*+fd4vb>!Zk);N`eRrU*5?j_AFP!q{D8_hk!egvZq?ZidoKkSFFwcz1t)-q&PfB%!IX-Es^)=
zrKhYLiygaN|!^w)ah@H(lrbHCH;|Y(!1E7-@jSfo0nWH*fs0RwAYzUPR-IK
zqOlWWcRbuEeDrQu%6ZX_W1`&$r`iYh_3!Q$XSm+w!|N=)xx;pw?b>&D>NV8fME0@;
zq^z3pfdA)Fd5>M|ufAGrJW0oKBjY@&zyve15?e7Y?!xE|vAnCyLUyj6HjT?z(Yj=>
zSlgivyXwUoFI%p4RL4m!any%
z$SDiCxLY}OwzZx;nsd85lUv3~A>Pd#FhNM@bRxv&FsW#qW+byPjEw_25_8u`xRqD_13_npC
zr}kWB_nk+lj~hr%e0+}a%yk88OZ8hj&GsLjzuZq~=P~C?Vns&h_MFHmN;MZ)eX?U$
zdskyd(Y=Y4r($>x3P^pweqi6`Q(b46CkY2e%$ayB;pUP>51ZU_WSyr-3tzhV?#@Ea
zvb4Ds#nC%IedMh&UK=L7+;q*QoflqdE)3^Yx%+sE{;i-(?fF06G%hKc!|~p%Vm>=-
z>{h+4x3<4M5qq#NMljdrR^kGqykn|2Oc`cMga@S*)gL){MpWc}(xc|2!&)W^J7n_D
zyfLfjUp`Z8+Qvicl%#gd6ZD)ZZP}PmwA-+FkE7Dr%uD}|@HQUx;WHL8GB09UxMgN-
z;xgO&_X8?z?mam2^-@r5<-7?!H@SH^p04?zlw9di@bFo*6!Y_gmYp-Ivl|3<_3Tsm
z+|+f(XPew%YbDOlKhhOi=7=$Txx4#J*2I~TGW!x<%ZjlbIDFdVpxd#Ta|)*GN$ORL
z%nVL`|8}CZ(rnd=w+ol-zW#6B-k_8l!mIajpV@fBbb)yp+l%!VvwZB>*MIl8aIMRy
zhiUV^!}|*_?%Kb&;epxuPFZGw8zE11ljTmFYxx%R@Yo?~&)-pZD^9W4305;l9k33Z
z`ptDi$^0`H`>I2jJ-=_gl{o8=(;FkHONv?V&VHHu$KJH)%x;-=yWUsn`dktIlI4)E
zc3?yB{FdCr_nS`g*~?eyvFw#f(QRLw_Up{)CHFW=HZG3y(Uw#6y|CJm@fMHYugN|4
z&X=$k8~*+h$H9D%%QeKcVV8yOe3K6k|W@?(6COR4u|B7%nqWZ6ojN>RDeUjl8%1e4Nw$bB6OqmzfFtCL(!B
ze^}o4h@bI2zP|m`Ye60sL&eyn;AziU(roV?&vbKr*097iMdJAq@0>-!lEM=n`*fXY
z*eRj(`Gv>q7pFh@Htd_+@7tuf*|*C_J8X*J&*Pez!nG!C{?F{V8RXIeQusUrTpTY>
zuzUFW#D~r|)rTKzG%FSRBq?!mPqR8a`>N{JZn@90hW8KWH2;+7;t6~elpgb0$oGS+
z)y-I8`;UKi&gh?2x2DconCDJJ+3}TaOuQD`A0B?C#Bl$fkMiu#uCMhKzaJOS;c~bt
zby$7<`_P~>tPIXwJdPVQ8WwJ@b93u|J@Jp)?b{vi9|lLgO}v)Aqeu6uqn_i}HF1T%
zzojMd{`Te03~%BPO0SEhQ?=#!kslY11+7**F!7KctNf1ZGj?51iRgT2
z9J0!=YLOq0=*i0dZ#lg6KbLn_uYbMBSt;$Sq?YlyA1b|5uhfS3MAk&yIV76vZT^Vw
zj^V}AiSFqkkB&UxtV()u<9Cm-y-8R?(T_#t?;@QfevdCQYP26sJ=^PrNz)VETYpO4
zKT?^u#JqUkie5I+`J0)qvwabovD{HDKBDcc+5FirHH<{07lnsZzq@8rlD1L%_2ehc
z{Ih*+>)#m^-ComvdfK5&TaPBsYMR4*TkY7oGBLKc)iwQ!W%+5EVQ+2p3pY9_%%5>#
z@k;;MiTP;~77wG|p55|@G01#o_wI8iEe+1z;yY}&db`v2LsIAUxlcRgzI4fNtUaIL
zx7bFV%X!_T&v#S5nIz5ju?(&L>gM@OhofPmUQXWWS$=_aHJaVUMzp(b1UlB%UJl-w5a6{T^bIQCQEVajtSKjZxBA37V
z{<(`4RR=$boppHE$a}4fB|}Jl^LeM1iH~$s(@x|RGjDt{Qgsqg#wCG!@{=?YET
znXoz~#wEB)Uu$}MEo
z&*NaVcH`u{M}=o5H>`hed{937#u=ylXU|n?T`%pHmifPX(!Y>P?!0^Y`&!POGB#Lq
zA-zuJNw8S$xA?A)J6>PeJG0;G*D=2P=goOkM5;_}W*=*j3ZEM}JL8+@hVM-K_MPwA
zF0uM-kZt(icXJtOH17+Pdjh+Ua>tstuy|A*2n(J;%Bm+^(rs9C)r6n{A#E1
z(tFZBg-<`XUU@Z7WkR!t$;7+HB|3H|O;gNo2QBQ3O!?<>X}#)?J@to0XIoFK{-D6F
zXcRW*Yjm3Aufnq%x6k}Jr}6yX??N#b3{sBnHTjb9A!gycQ{^kg^<5-1{v3H~k`%p9
z@#SSUyUX{BjytVq6Pd-d^;qBW^cPxJD%PFP+PrY0*^O5D$zOiA+5JAReJ{&|%hy(X
zf{0X(%9V%fc!t-CNMe+*|^sJv~8LG>%;KCA9{|cvNaUBY4
z!|ooPvG}L2r+xO8_PeeXbC>=JvtSaL#k701zSkzj^;JRkvlOmP{dU@G^Pw}>)9)Bw
zTD{Uc`10(vYcD*x7RwtcQClTc&im!Xwy&or)!q1z{zv(pM5se_vy<~e!)Hn#Uq?sq
zZ0Pu4cmKqLm>&@>*CRemWo}(&w{rgvF}cY$+<$(@Pd>YI=Ic7gOW!XpW^85C+`<{Z
zyXEQh?;_vz{`l5TUowB?gF{RrQXxmuZ|Hrgxwe?&eX;)H^D4(S+I9V?)4Th6@8Tba
zkBGl34c=p>VPaEoA~NKYUA@)fRyoY%iwiCTJ-Yf7p*tn_32i=
zRL@8vYQOD7l=Wb(z`svMDKpnW2?c=~m6d%6$usc;+fDz2Nyg-zckl|Cv2&Yq@!CS-5xwLSH0KsXx8s#vbLD
z_D1>H@$1!U-d5K<)i7Cbp~c|r0@);)WZ{RV&)oWyQ`hr}NNw{nS981cv)J>0&JNyx
zu9xCnD&}50@Ll`s;*+|O0U4)`iivDH5+U%xepfC3nk#nC`wX{SIP7M3`}~p`lbu`d
z@wBpas9Y<0rSJW#y7~S8e9`CgmNw?tOtTKzwEv6Eb%%wHOK)+k|G0nBH`Tw>7k>Y{
zZi)Y{WbWLQCtS86o6Z+5S+pwg&n>RAJ$vF(7fxJ~@AYfT!tZ}yPx`lVf!R0DZwtB3
zUb;{jR#dp;fO8!Us{Ca{ngQbqmujErlx-@nHc|m@}z$T2Y>%*S`zOY
zkl{32Rc_ZO&60B7x$mDW;F@!x*+5dW&CG7$)g?7AzJFV{#9V04e#a%}&pmfa6#DQr
z_}enG&dglT>2kblB-AU;>U;m{ShBfc{l|z){#mB>Az8D3eaUEJyls9pyZudLV2080
zcMN8-DzC2oXW9}m3vLDKVXn$5$m{MU*nB09gG_*~(%ci;OB
zGnaneeQM$FsB*T1dB@5Y)i@aET%Fyybw$#vkH(7cAJ44(Zkzqn5tO#R!+_{RV0di4su=;F+U`4V3(?a%ehy&mHbxR8l}o`7{>|#)osza__eYy;6TW}?ac94=Nd@2bIonF>0zDs89-TSE
z$2T&?&!#^=Dd9$rju_JcjxvrDn{SwiNL~(qqBuQL-=_cItxeBX}xZ;Y{MHX
zw6gHrvcO$GS8cc5uOIt>yHWUj*`~6(3E5iLE;^s6I=5_Dl5xncvx?Jg+iIQeo4g7>
zqQf`4bjj^1Q@t6RS$jjp#}jJ%E(mt+nz8rdt)_FQ!wSFa-Tsz#vvcCZ-JJ)EKX;gPPg9AFd9U_7
zv8(aX`{-@CO+6oZ=B%6;C;I(-Lh_sHuzOC8hk4E)uDw|)l`{X~yp2P%)>e+0EFlP494+RAvdra$OA`6$4De*4s;
z5f7glf4L%I``zl2_L})iTxM9#`OSHL>+d|iXPpbb99
zi~8~zLf6DhVi|OzF{)lUo
z*F4w9v%XqvcJX58ko70)g#!#~+yssu+mt>rM_D%f-CoJIvj;v%INO)leBLplgP67LTNh}
zmB>_@98?eNPc7$bxx<*x8@}Jg@#6Eo|Epcqp56VnkDFo5JEj9l3^^%oO-fuHnQX4h
z&QF;6lwI_k$Lt$Rr2Q8u70v6H%q_TgT+gWXo#jTmOBW`ZZR}*_>^@PlV$I1VC%#n1
zonHCSEPjIC{Qjd1+h<>5IdLX>UBVaN8t3UJlI=D>-{h{JpmV$5MJ+rbnR(-tnJ1Qu
zZ+>}f-paPl+j94vPjfT;dRN1Hr(&s-lCqBa&fITTtvpKZ)XHu6cHu{Qfy=BT+oUE*
zCYmgZj`8FdDUY7%TW%(o`oYpJc#j|x#zdYiOW28lNDbIe#zqCIupzWEop{4eDW%&H8RgYgyuy4_
zb0%Ql~XvKXNZ^?;aoFFWV-3S@t_<@w-QbfA>WEzZ+9Hf6I>bSCuEM
z<$3-^*T~sCWm>bjrcp<$?S)s}od-V`#D9~^yz{;A;!dA`o0m&Ydf~B8WTuO_Wjh=9
z88ufoNm0pp=FKV3R32)nz1NkOIZ3>rA2B^Mi9r+C!p*3Ppc+Eq$~1O?tsq
zd;L8pzTCB9zp+zzl3}ZwL*dD%b(8ja&v@FVWXawzud}h0@u^$S(l^d)=W!R$N@RK$
zowDo9J!Ni&gkv*WVoG|-Yxcd#G+p>V;M?|t_n-Q9$F{xSti1Q&BuUPlH!2=g$<$Ww
zs-E;NUQ_G&s_h@1MWm!H*?Ko2z2R==or^;LhI(u~}Qyqwlla
z$u#{r|8})(*KFg&3oWbb7hSlKaw&e(#_AGH^A}9#W4=zB(J|>!#^LveHh$7w)sX4D
zk*inZ`A>_*r@GDr*)TE8Oc0d{JoMLjHqX?jTgoGQMB5w!?ws&^QsufobCYaZT*?kZ
z;hPWL{1#1%>)CQ^(bLXPdK>P{=+xP&qQB_FPKnIY{=j#+7bdNnuvuA1GSNaUI_4z*
zl=Jg)6vM7EE<59q(Q+_xQ(AYi)%Twg@BX<@{A|iSNjgzwW1h87Nq|SR&W!1fnQbbT
zN}rjG1L~}O?u$8J67sq-@k0j>ch8n6oexVEl)dzD*SI<5iMz|J=9{jKnyH8CZ@bGd
z+?+X)qmyA{iRYG^7b~j*BJ|r%)`z4jnB7+?+s&D>{H||~dF$DuIlGxnC#-dQBjb>>
zJ?WLl?z;>{3NIZG?%mtArCDm@W#f&Wg`69sRvk&)wA!^zG;PJL4i>LH34Vz(27&KZ
z&vEy$*LPbY*^qkv$mY)+?s}GObNp80#&kS$SdHi3J5WwSL6L?oTxU0`|E
zb=L4~z!dNBfNAf}w{}M!NtW=~^`0v#*mHM%%pHB_g$Ij$?}nwa9~b71uigDoM)g4Kg}tpD`x+4FVv4N1WzPXbb;Jlj{L|7%rZ
ze#618=Ga?VzFFP!-T_rPm$6~IbXr6
zzux_uId6aJnOzeSzRT?{i@ziyB6Y;D;AmR^G6z<o$&Q)srJj9w!#7VPIODtK9CXgsS)j`x${(+G6FkCFoe5HM-bkj}=hJ$PKKdC4REtG3Ln|F;t#kTes`^kwnuBOzVzRq>P
z*Q)Juhi>~xt9@<^HEoyGRD2hu`$(uUUkF#u^kPzt6A0coi-&==eZc{S1rG#7Bp3>!
zxcs)Vrk!}nuTaMS++$6UcCF}5L0buim21OS>Ux|xdcR
z&SE*7%;2Ekevg}B$&05wN9Sd&{H`E {
+ const exists = await RNFS.exists(path);
+ if (exists) {
+ const isDir = (await RNFS.stat(path)).isDirectory();
+ if (!isDir) {
+ throw new Error(`path exists and is not a directory: ${path}`);
+ } else {
+ return;
+ }
+ }
+
+ return await RNFS.mkdir(path);
+}
+
+const SplashPage: React.FC<{}> = ({ children }) => {
+ const [ready, setReady] = useState(false);
+
+ const minSplashTime = new Promise(resolve => setTimeout(resolve, 1));
+
+ const prepare = async () => {
+ const filesPath = RNFS.DocumentDirectoryPath;
+
+ await mkdir(`${filesPath}/image_cache`);
+ await mkdir(`${filesPath}/song_cache`);
+ await mkdir(`${filesPath}/songs`);
+
+ await musicDb.openDb();
+ await settingsDb.openDb();
+
+ if (!(await musicDb.dbExists())) {
+ await musicDb.createDb();
+ }
+ if (!(await settingsDb.dbExists())) {
+ await settingsDb.createDb();
+ }
+ }
+
+ const promise = Promise.all([
+ prepare(), minSplashTime,
+ ]);
+
+ useEffect(() => {
+ promise.then(() => {
+ setReady(true);
+ });
+ })
+
+ if (!ready) {
+ return Loading THE GOOD SHIT...
+ }
+ return (
+ {children}
+ );
+}
+
+export default SplashPage;
diff --git a/src/components/library/AlbumsTab.tsx b/src/components/library/AlbumsTab.tsx
index 8d93194..fa23645 100644
--- a/src/components/library/AlbumsTab.tsx
+++ b/src/components/library/AlbumsTab.tsx
@@ -1,8 +1,117 @@
-import React from 'react';
+import React, { memo, useEffect, useState } from 'react';
+import { View, Image, Text, FlatList, Button, ListRenderItem } from 'react-native';
+import { useRecoilState, useRecoilValue } from 'recoil';
+import { Album } from '../../models/music';
+import { albumsState, albumState, useUpdateAlbums, albumIdsState, useCoverArtUri } from '../../state/albums';
import TopTabContainer from '../common/TopTabContainer';
+import textStyles from '../../styles/text';
+import { ScrollView } from 'react-native-gesture-handler';
+import colors from '../../styles/colors';
+import LinearGradient from 'react-native-linear-gradient';
+
+const AlbumArt: React.FC<{ height: number, width: number, id?: string }> = ({ height, width, id }) => {
+ const coverArtSource = useCoverArtUri(id);
+
+ return (
+
+
+
+ )
+}
+
+const AlbumItem: React.FC<{ id: string } > = ({ id }) => {
+ const album = useRecoilValue(albumState(id));
+
+ // useEffect(() => {
+ // console.log(album.name);
+ // });
+
+ return (
+
+
+ {album.name}
+
+ );
+}
+
+const MemoAlbumItem = memo(AlbumItem, (prev, next) => {
+ // console.log('prev: ' + JSON.stringify(prev) + ' next: ' + JSON.stringify(next))
+ return prev.id == next.id;
+});
+
+const AlbumsList = () => {
+ const albumIds = useRecoilValue(albumIdsState);
+ const updateAlbums = useUpdateAlbums();
+
+ const [refreshing, setRefreshing] = useState(false);
+
+ const renderItem: React.FC<{ item: string }> = ({ item }) => (
+
+ );
+
+ const refresh = async () => {
+ setRefreshing(true);
+ await updateAlbums();
+ setRefreshing(false);
+ }
+
+ useEffect(() => {
+ if (!refreshing && albumIds.length === 0) {
+ refresh();
+ }
+ })
+
+ return (
+
+ {/* */}
+ item}
+ onRefresh={refresh}
+ refreshing={refreshing}
+ />
+ {/*
+ {Object.values(albums).map(item => (
+
+ ))}
+ */}
+
+ );
+}
const AlbumsTab = () => (
+ Loading...}>
+
+
);
diff --git a/src/components/library/ArtistsTab.tsx b/src/components/library/ArtistsTab.tsx
index c0ba630..2a014bf 100644
--- a/src/components/library/ArtistsTab.tsx
+++ b/src/components/library/ArtistsTab.tsx
@@ -27,7 +27,7 @@ const ArtistItem: React.FC<{ item: Artist } > = ({ item }) => (
);
-const ArtistsTab = () => {
+const ArtistsList = () => {
const artists = useRecoilValue(artistsState);
const renderItem: React.FC<{ item: Artist }> = ({ item }) => (
@@ -35,14 +35,18 @@ const ArtistsTab = () => {
);
return (
-
- item.id}
- />
-
+ item.id}
+ />
);
}
+const ArtistsTab = () => (
+
+
+
+);
+
export default ArtistsTab;
diff --git a/src/models/music.ts b/src/models/music.ts
index 7e44757..866ae34 100644
--- a/src/models/music.ts
+++ b/src/models/music.ts
@@ -1,10 +1,15 @@
export interface Artist {
id: string;
name: string;
+ starred?: Date;
coverArt?: string;
}
export interface Album {
id: string;
name: string;
+ starred?: Date;
+ coverArt?: string;
+ coverArtPath?: string;
+ coverArtModified?: Date;
}
diff --git a/src/state/albums.ts b/src/state/albums.ts
index 556c4bd..d20cf67 100644
--- a/src/state/albums.ts
+++ b/src/state/albums.ts
@@ -1,10 +1,12 @@
-import { atom, DefaultValue, selector, useRecoilValue, useSetRecoilState } from 'recoil';
+import { atom, DefaultValue, selector, selectorFamily, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { SubsonicApiClient } from '../subsonic/api';
import { activeServer } from './settings'
-import { Artist } from '../models/music';
+import { Album } from '../models/music';
import { musicDb } from '../clients';
+import { useEffect, useState } from 'react';
+import RNFS from 'react-native-fs';
-export const albumsState = atom({
+export const albumsState = atom<{ [id: string]: Album }>({
key: 'albumsState',
default: selector({
key: 'albumsState/default',
@@ -14,28 +16,99 @@ export const albumsState = atom({
({ onSet }) => {
onSet((newValue) => {
if (!(newValue instanceof DefaultValue)) {
- musicDb.updateAlbums(newValue);
+ musicDb.updateAlbums(Object.values(newValue));
}
});
- }
+ },
],
});
-// export const useUpdateAlbums = () => {
-// const setAlbums = useSetRecoilState(albumsState);
-// const server = useRecoilValue(activeServer);
+export const albumIdsState = selector({
+ key: 'albumIdsState',
+ get: ({get}) => Object.keys(get(albumsState)),
+});
-// return async () => {
-// if (!server) {
-// return;
-// }
+export const albumState = selectorFamily({
+ key: 'albumState',
+ get: id => ({ get }) => {
+ return get(albumsState)[id];
+ },
+ // set: id => ({ set, get }, newValue) => {
+ // if (!(newValue instanceof DefaultValue)) {
+ // set(albumsState, prevState => ({ ...prevState, [id]: newValue }));
+ // }
+ // }
+});
-// const client = new SubsonicApiClient(server.address, server.username, server.token, server.salt);
-// const response = await client.getAlbums();
+export const useUpdateAlbums = () => {
+ const setAlbums = useSetRecoilState(albumsState);
+ const server = useRecoilValue(activeServer);
-// setAlbums(response.data.albums.map(i => ({
-// id: i.id,
-// name: i.name,
-// })));
-// };
-// };
+ return async () => {
+ if (!server) {
+ return;
+ }
+
+ const client = new SubsonicApiClient(server.address, server.username, server.token, server.salt);
+ const response = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 50 });
+
+ const albums = response.data.albums.reduce((acc, x) => {
+ acc[x.id] = {
+ id: x.id,
+ name: x.name,
+ coverArt: x.coverArt,
+ };
+ return acc;
+ }, {} as { [id: string]: Album });
+
+ setAlbums(albums);
+ };
+};
+
+export function useCoverArtUri(id: string | undefined): string | undefined {
+ if (!id) {
+ return undefined;
+ }
+
+ const server = useRecoilValue(activeServer);
+
+ const [downloadAttempted, setdownloadAttempted] = useState(false);
+ const [coverArtSource, setCoverArtSource] = useState(undefined);
+
+ const getCoverArt = async () => {
+ if (coverArtSource) {
+ return;
+ }
+
+ const filePath = `${RNFS.DocumentDirectoryPath}/image_cache/${id}`;
+ const fileUri = `file://${filePath}`;
+
+ if (await RNFS.exists(filePath)) {
+ // file already in cache, return the file
+ setCoverArtSource(fileUri);
+ return;
+ }
+
+ if (!server) {
+ // can't download without server set
+ return;
+ }
+
+ setdownloadAttempted(true);
+ if (downloadAttempted) {
+ // don't try to download more than once using this hook
+ return;
+ }
+
+ const client = new SubsonicApiClient(server.address, server.username, server.token, server.salt);
+ await client.getCoverArt({ id });
+
+ setCoverArtSource(fileUri);
+ }
+
+ useEffect(() => {
+ getCoverArt();
+ });
+
+ return coverArtSource;
+}
diff --git a/src/storage/db.ts b/src/storage/db.ts
index 017009f..7c96778 100644
--- a/src/storage/db.ts
+++ b/src/storage/db.ts
@@ -4,6 +4,7 @@ SQLite.enablePromise(true);
export abstract class DbStorage {
private dbParams: DatabaseParams;
+ private db: SQLiteDatabase | undefined;
constructor(dbParams: DatabaseParams) {
this.dbParams = dbParams;
@@ -36,36 +37,23 @@ export abstract class DbStorage {
return results[0].rows.length > 0;
}
- private async openDb(): Promise {
- return await SQLite.openDatabase({ ...this.dbParams });
+ async openDb(): Promise {
+ this.db = await SQLite.openDatabase({ ...this.dbParams });
}
async deleteDb(): Promise {
+ if (this.db) {
+ await this.db.close();
+ }
await SQLite.deleteDatabase({ ...this.dbParams });
}
async executeSql(sql: string, params?: any[]): Promise<[ResultSet]> {
- const db = await this.openDb();
- try {
- // https://github.com/andpor/react-native-sqlite-storage/issues/410
- return await db.executeSql(sql, params);
- } catch (err) {
- try { await db.close(); } catch {}
- throw err;
- } finally {
- try { await db.close(); } catch {}
- }
+ // https://github.com/andpor/react-native-sqlite-storage/issues/410
+ return await (this.db as SQLiteDatabase).executeSql(sql, params);
}
async transaction(scope: (tx: Transaction) => void): Promise {
- const db = await this.openDb();
- try {
- await db.transaction(scope);
- } catch (err) {
- try { await db.close(); } catch {}
- throw err;
- } finally {
- try { await db.close(); } catch {}
- }
+ await (this.db as SQLiteDatabase).transaction(scope);
}
}
diff --git a/src/storage/music.ts b/src/storage/music.ts
index f69b88d..f5b118c 100644
--- a/src/storage/music.ts
+++ b/src/storage/music.ts
@@ -20,7 +20,8 @@ export class MusicDb extends DbStorage {
CREATE TABLE albums (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
- starred TEXT
+ starred TEXT,
+ coverArt TEXT
);
`);
});
@@ -32,6 +33,7 @@ export class MusicDb extends DbStorage {
`))[0].rows.raw().map(x => ({
id: x.id,
name: x.name,
+ starred: x.starred ? new Date(x.starred) : undefined,
coverArt: x.coverArt || undefined,
}));
}
@@ -43,20 +45,56 @@ export class MusicDb extends DbStorage {
`);
for (const a of artists) {
tx.executeSql(`
- INSERT INTO artists (id, name, starred, coverArt)
+ INSERT INTO artists (
+ id,
+ name,
+ starred,
+ coverArt
+ )
VALUES (?, ?, ?, ?);
- `, [a.id, a.name, null, a.coverArt || null]);
+ `, [
+ a.id,
+ a.name,
+ a.starred ? a.starred.toISOString() : null,
+ a.coverArt || null
+ ]);
}
});
}
- async getAlbums(): Promise {
- return (await this.executeSql(`
- SELECT * FROM albums;
- `))[0].rows.raw().map(x => ({
+ async getAlbum(id: string): Promise {
+ const results = await this.executeSql(`
+ SELECT * FROM albums
+ WHERE id = ?;
+ `, [id]);
+
+ const rows = results[0].rows.raw();
+ return rows.map(x => ({
id: x.id,
name: x.name,
- }));
+ starred: x.starred ? new Date(x.starred) : undefined,
+ coverArt: x.coverArt || undefined,
+ }))[0];
+ }
+
+ async getAlbumIds(): Promise {
+ return (await this.executeSql(`
+ SELECT id FROM albums;
+ `))[0].rows.raw().map(x => x.id);
+ }
+
+ async getAlbums(): Promise<{[id: string]: Album}> {
+ return (await this.executeSql(`
+ SELECT * FROM albums;
+ `))[0].rows.raw().reduce((acc, x) => {
+ acc[x.id] = {
+ id: x.id,
+ name: x.name,
+ starred: x.starred ? new Date(x.starred) : undefined,
+ coverArt: x.coverArt || undefined,
+ };
+ return acc;
+ }, {});
}
async updateAlbums(albums: Album[]): Promise {
@@ -66,9 +104,19 @@ export class MusicDb extends DbStorage {
`);
for (const a of albums) {
tx.executeSql(`
- INSERT INTO albums (id, name, starred)
- VALUES (?, ?, ?);
- `, [a.id, a.name, null]);
+ INSERT INTO albums (
+ id,
+ name,
+ starred,
+ coverArt
+ )
+ VALUES (?, ?, ?, ?);
+ `, [
+ a.id,
+ a.name,
+ a.starred ? a.starred.toISOString() : null,
+ a.coverArt || null
+ ]);
}
});
}
diff --git a/src/styles/colors.ts b/src/styles/colors.ts
index c660fad..25119e5 100644
--- a/src/styles/colors.ts
+++ b/src/styles/colors.ts
@@ -9,4 +9,5 @@ export default {
low: '#000000',
},
accent: '#c260e5',
+ accentLow: '#50285e',
}
diff --git a/src/subsonic/api.ts b/src/subsonic/api.ts
index aa909ef..f1eacb5 100644
--- a/src/subsonic/api.ts
+++ b/src/subsonic/api.ts
@@ -1,5 +1,6 @@
import { DOMParser } from 'xmldom';
-import { GetAlbumList2Params, GetAlbumListParams, GetArtistInfo2Params, GetArtistInfoParams, GetIndexesParams } from './params';
+import RNFS from 'react-native-fs';
+import { GetAlbumList2Params, GetAlbumListParams, GetArtistInfo2Params, GetArtistInfoParams, GetCoverArtParams, GetIndexesParams } from './params';
import { GetAlbumList2Response, GetAlbumListResponse, GetArtistInfo2Response, GetArtistInfoResponse, GetArtistsResponse, GetIndexesResponse, SubsonicResponse } from './responses';
export class SubsonicApiError extends Error {
@@ -35,7 +36,7 @@ export class SubsonicApiClient {
this.params.append('c', 'subsonify-cool-unique-app-string')
}
- private async apiRequest(method: string, params?: {[key: string]: any}): Promise {
+ private buildUrl(method: string, params?: {[key: string]: any}): string {
let query = this.params.toString();
if (params) {
const urlParams = this.obj2Params(params);
@@ -45,10 +46,21 @@ export class SubsonicApiClient {
}
const url = `${this.address}/rest/${method}?${query}`;
-
console.log(url);
+ return url;
+ }
- const response = await fetch(url);
+ private async apiDownload(method: string, path: string, params?: {[key: string]: any}): Promise {
+ await RNFS.downloadFile({
+ fromUrl: this.buildUrl(method, params),
+ toFile: path,
+ }).promise;
+
+ return path;
+ }
+
+ private async apiGetXml(method: string, params?: {[key: string]: any}): Promise {
+ const response = await fetch(this.buildUrl(method, params));
const text = await response.text();
console.log(text);
@@ -80,7 +92,7 @@ export class SubsonicApiClient {
//
async ping(): Promise> {
- const xml = await this.apiRequest('ping');
+ const xml = await this.apiGetXml('ping');
return new SubsonicResponse(xml, null);
}
@@ -89,22 +101,22 @@ export class SubsonicApiClient {
//
async getArtists(): Promise> {
- const xml = await this.apiRequest('getArtists');
+ const xml = await this.apiGetXml('getArtists');
return new SubsonicResponse(xml, new GetArtistsResponse(xml));
}
async getIndexes(params?: GetIndexesParams): Promise> {
- const xml = await this.apiRequest('getIndexes', params);
+ const xml = await this.apiGetXml('getIndexes', params);
return new SubsonicResponse(xml, new GetIndexesResponse(xml));
}
async getArtistInfo(params: GetArtistInfoParams): Promise> {
- const xml = await this.apiRequest('getArtistInfo', params);
+ const xml = await this.apiGetXml('getArtistInfo', params);
return new SubsonicResponse(xml, new GetArtistInfoResponse(xml));
}
async getArtistInfo2(params: GetArtistInfo2Params): Promise> {
- const xml = await this.apiRequest('getArtistInfo2', params);
+ const xml = await this.apiGetXml('getArtistInfo2', params);
return new SubsonicResponse(xml, new GetArtistInfo2Response(xml));
}
@@ -113,12 +125,21 @@ export class SubsonicApiClient {
//
async getAlbumList(params: GetAlbumListParams): Promise> {
- const xml = await this.apiRequest('getAlbumList', params);
+ const xml = await this.apiGetXml('getAlbumList', params);
return new SubsonicResponse(xml, new GetAlbumListResponse(xml));
}
async getAlbumList2(params: GetAlbumList2Params): Promise> {
- const xml = await this.apiRequest('getAlbumList2', params);
+ const xml = await this.apiGetXml('getAlbumList2', params);
return new SubsonicResponse(xml, new GetAlbumList2Response(xml));
}
+
+ //
+ // Media retrieval
+ //
+
+ async getCoverArt(params: GetCoverArtParams): Promise {
+ const path = `${RNFS.DocumentDirectoryPath}/image_cache/${params.id}`;
+ return await this.apiDownload('getCoverArt', path, params);
+ }
}
diff --git a/src/subsonic/params.ts b/src/subsonic/params.ts
index 70242d6..41bb68f 100644
--- a/src/subsonic/params.ts
+++ b/src/subsonic/params.ts
@@ -45,3 +45,12 @@ export type GetAlbumList2Params = {
} | GetAlbumList2TypeByYear | GetAlbumList2TypeByGenre;
export type GetAlbumListParams = GetAlbumList2Params;
+
+//
+// Media retrieval
+//
+
+export type GetCoverArtParams = {
+ id: string;
+ size?: string;
+}