From 011e8edb2828124dcd4e9c6cc4befcec518f6ded Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 20 May 2026 21:52:08 +0300 Subject: [PATCH] pinakes-core: remove extracted modules; trim to storage/scan/scheduler domain Signed-off-by: NotAShelf Change-Id: Ibdce07d2626c1a9541eeed26a17716b46a6a6964 --- Cargo.lock | Bin 244939 -> 256995 bytes crates/pinakes-core/Cargo.toml | 26 +- crates/pinakes-core/src/config.rs | 1669 +--------- crates/pinakes-core/src/enrichment/books.rs | 269 -- .../src/enrichment/googlebooks.rs | 295 -- crates/pinakes-core/src/enrichment/lastfm.rs | 116 - crates/pinakes-core/src/enrichment/mod.rs | 79 - .../src/enrichment/musicbrainz.rs | 148 - .../src/enrichment/openlibrary.rs | 308 -- crates/pinakes-core/src/enrichment/tmdb.rs | 125 - crates/pinakes-core/src/error.rs | 149 +- crates/pinakes-core/src/import.rs | 5 +- crates/pinakes-core/src/lib.rs | 5 +- crates/pinakes-core/src/metadata/audio.rs | 91 - crates/pinakes-core/src/metadata/document.rs | 372 --- crates/pinakes-core/src/metadata/image.rs | 299 -- crates/pinakes-core/src/metadata/markdown.rs | 45 - crates/pinakes-core/src/metadata/mod.rs | 70 - crates/pinakes-core/src/metadata/video.rs | 128 - crates/pinakes-core/src/model.rs | 659 ---- crates/pinakes-core/src/plugin/loader.rs | 432 --- crates/pinakes-core/src/plugin/mod.rs | 931 +----- crates/pinakes-core/src/plugin/pipeline.rs | 25 +- crates/pinakes-core/src/plugin/registry.rs | 309 -- crates/pinakes-core/src/plugin/rpc.rs | 240 -- crates/pinakes-core/src/plugin/runtime.rs | 925 ------ crates/pinakes-core/src/plugin/security.rs | 473 --- crates/pinakes-core/src/plugin/signature.rs | 252 -- crates/pinakes-core/src/scheduler.rs | 119 +- crates/pinakes-core/src/storage/migrations.rs | 14 +- crates/pinakes-core/src/storage/mod.rs | 64 +- crates/pinakes-core/src/storage/postgres.rs | 648 ++-- crates/pinakes-core/src/storage/sqlite.rs | 2754 ++++++++++------- crates/pinakes-core/src/sync/chunked.rs | 325 -- crates/pinakes-core/src/sync/conflict.rs | 147 - crates/pinakes-core/src/sync/mod.rs | 9 +- crates/pinakes-core/src/sync/models.rs | 384 --- crates/pinakes-core/src/sync/protocol.rs | 8 +- crates/pinakes-core/src/upload.rs | 4 +- crates/pinakes-core/src/users.rs | 32 +- crates/pinakes-core/tests/book_metadata.rs | 10 +- crates/pinakes-core/tests/integration.rs | 6 +- .../pinakes-core/tests/plugin_integration.rs | 7 +- crates/pinakes-migrations/Cargo.toml | 1 + crates/pinakes-migrations/src/lib.rs | 24 +- 45 files changed, 2163 insertions(+), 10838 deletions(-) delete mode 100644 crates/pinakes-core/src/enrichment/books.rs delete mode 100644 crates/pinakes-core/src/enrichment/googlebooks.rs delete mode 100644 crates/pinakes-core/src/enrichment/lastfm.rs delete mode 100644 crates/pinakes-core/src/enrichment/mod.rs delete mode 100644 crates/pinakes-core/src/enrichment/musicbrainz.rs delete mode 100644 crates/pinakes-core/src/enrichment/openlibrary.rs delete mode 100644 crates/pinakes-core/src/enrichment/tmdb.rs delete mode 100644 crates/pinakes-core/src/metadata/audio.rs delete mode 100644 crates/pinakes-core/src/metadata/document.rs delete mode 100644 crates/pinakes-core/src/metadata/image.rs delete mode 100644 crates/pinakes-core/src/metadata/markdown.rs delete mode 100644 crates/pinakes-core/src/metadata/mod.rs delete mode 100644 crates/pinakes-core/src/metadata/video.rs delete mode 100644 crates/pinakes-core/src/model.rs delete mode 100644 crates/pinakes-core/src/plugin/loader.rs delete mode 100644 crates/pinakes-core/src/plugin/registry.rs delete mode 100644 crates/pinakes-core/src/plugin/rpc.rs delete mode 100644 crates/pinakes-core/src/plugin/runtime.rs delete mode 100644 crates/pinakes-core/src/plugin/security.rs delete mode 100644 crates/pinakes-core/src/plugin/signature.rs delete mode 100644 crates/pinakes-core/src/sync/chunked.rs delete mode 100644 crates/pinakes-core/src/sync/conflict.rs delete mode 100644 crates/pinakes-core/src/sync/models.rs diff --git a/Cargo.lock b/Cargo.lock index 41cca31ebd16b5d289e13063ec8fe7feece93315..fc842dc01ec17784deeae8e53558f2ae6f62f7ca 100644 GIT binary patch delta 17339 zcmb81d3;`FnfE{UIZ3*aF0@SwP1|&pE$!JCY?GiM^0IXkMS*gkt;D8Dnxw6Tk!=*; z85Jl8ddp@DI*2UVl97i|5ehCigIG{NP?n1NI`Xm!I)kGy-}^jCp}zhxuOC11@FY3s zUatFh{jT5jyZyH}C%^FIl->VnHukLV%d@ikQQ4j6o^M5Q6s3WaXNBd-B#W}xiQ=$G zrIk8y>}RnRrn#LJxh&iu&SdI_b|5_~i2PDcZ#6rHdez0pi#d~IZ}sGkEqSWW|DllT z$~8vAdo4He(kQT;!gb>)46;0nEH{lqnfXp!@KZ-7k{1SU>?CfMNGr?i&<=yRwAZ#Y z<(oFk;r?>f`q?9YG}lg?xGwM8ym8&YV1HV6e{QCF`EDF|c^Z0tV9CsLA}^Cp&Z@W{aZGu7|39!eX~T; zWfiVtXJME+R&K{h?4)j-hhAu>MH~f@?W9TK1%V%Djx2(Z8Ks`<+Iis?cI3w8o_}n) zXmmps95bFr-%{gcJ+7?i_3{e80c zz!$@VvS)ax`qu$DG@Ng%KJwT7ebuMo^LmEz!D}WPQzma&R}9LHdG+-+uM{WFo*=i_ z9qV~QpPK)BLv}@Jk!6V$I(FdMmK%nSYsai=;SqyL)Bx#*-02BS?uSz z<5*!>l-nO@Dcg4+5)b~Z(f4m`kCs|Xt?K>%&s^$*EBD+!yFqU0Dawo1%vSBYjFnwE z@fQ`jUxb+#Sc&aMyf8`PEKQt9x-wv}nUfWc&&peo6{Lx8C%*4z4w3BYF*Qm(pz@MW z%^A6LwrSL#>)J^WN2y=L(z2~0@I%*j-NcG>E6aV)iX!6CN+YL;GB(?nQCy^U6oitE z&tkRrbJ)Nzv{Niv5B2;7ADcP&SHg>QL9;>ehz8|JR(+gT74 zuAgNbwA`^|`cB-J19KWiPFT>_*t)Lh$$PT}6Sw4pLu`8ciuMK8Qrjx$|KsYe+)F$w z4PD12+KSYYo}GG06xp7eXFfv@3Z^8TC}Pl_=Q?rfr=HJ=%R(y+O8fn- zSlPu7I4Lm*5CA-`WDz9K$o()$^Te0FlnK)Y5bQ`F*(hg23V1&{pBSu&Msd$y0L6M`W5xUM8<4M8M&$J3eo~OB~$eM*mi;-31cT_zw^M;3q0~E zOEn5xEj!t4RcfM`raGSzlhtqLilfTY{HBphgW6>ElOA(r7lTb~JIp;dNr-cq7J5Tt zi&geA!X;toSrBDK8i1*Z>w!N2bH>!2DEHN-Uy9a|=T10NRFCT{oHPgK88C4jx~`NQ zX9=Pc;`#*V5#S!jY^a~RJ`gHxHr9%4x8U1SKGiz%%!yZ zCwG*`=BJe>X5Xv-+sLu`EPl=p+RGVo-pI!F4;kvFr^M>6B4S@sLQ;lB6v^1Jd>NAC z9M>ThLYbriDZ*zgdhgOg=62|0fsDN%4;@EsI!v^xdFPld|?DSc*upW(OWQp?r12`$k8Z_;DJsqdBOS zhpFpYp_j3ZL<@1_XLjLPzzoMYkMf}KVjFA-6Nl}N^F;0afzeV93{Nay=sj!niH@II zK^%iiz+)b#1yOBjvj@*?t` zAWpqP`sJe==9O(5rkC3`T~~i%^;((uLiSrdc>n&8+f@fl1{Gn2^bA zQc_YlR*)0waTvuiwF5gW^z2*ISHEE%Ql8q}T2AeqSl%_%S|6+rbbw+W(`5F10OI02 z%|XDNae2ApIhoDSZ9C(sRw7dnMRJ@1j($;1U-d3DCaKHb5>r+Gb>^gU*YKM9xU)h! zlCisSoMm1J>Vx}k3M}aqVdXH0j1-nx9LBch62)VJf%&ojpk6OXv$WPzA=&fp_g#(`~lNmdYezzSK?2lMTWGgxGhA`Shs z%6S_nsFxfb`=c#W>jN%gz&#@QSu7|P8AUW^%@MZ!^0i&-7~rE{W`Kdqs@p!P1s;?XS>o{*LE;2Z zFZL9YlYoDOmz{#2fy-L5GpXgkMqQ`e-c<4FS!MIh%Ng8j<5zZ)D%nnm0f^$WcwUl3 zdNrZ{UM_tOd6H%vU>RFhkR*&MWyU$Ao-^jF7x=*3` zGGR&yWH_=SC-#6g_7jd?xQXX@xywNc2`09PDkgF{@@q9`oK$}MOC{j6^2?uQ-p4gD zZ_QKOAaMCzLc`0zJ^qA^AQ=-sk?Vu~tb=BOlr%w{xDEw|8-#(AgqlFKmKXffWuuSl z3Rs$4CN?v&$v}|Xz$u6i*sEWyq8~BnidJS(?po51gPcqSn6nIVx&4-4EMx-UDuntl z5-2`(OCX^zq%UY)d4cBW%mOz)wmfKdb&d0T`>?G@_1ZqaM^EjV{%?H-QE;0%! zeFS7qX5u*JEH8NmWM^L>c9KEKcL(a?@-JT*Umkb+rUv~M_4eM0WYw=Vz6> z?wnG7<&HIDTR@CTodG#Gz;;{|j_a{@8rl=kfWQZ`Y|Y~xqS%EU7i70IfQ`Bw#&Z7m zjw%1^zLt@P@BF$xzB)=)goWk%5$i&hF9>EYgd|Af3{bQJNygR52qoDC;Ryab-2gC~cBae5-(wsI$P z3h6TcD4;&{psnx^$98S{Z8nJZUlOoY5~%6LjlX62}cMTHAw z40yKdv5$EX@D|7?u%&5?wBa>Qpf*h+sF&WogQxAk6c#LPA4Wy^vqO*fH z=suvBLKhe&M?3VBFo;6E{E$|+Ub+K?t>xMGA5va)&y_s&(O<6YgiA3J&mw~cED~6l z=S7+XVHAPUybAkeR`)x6G1l6 z9mX=|#AYm=mHSXCD`K`3YZUpMgFLpV8Dox@<;2u5U~-kJci!1{)i)0%I8=~H$`U{lR+k2(+&H0LVRT@bt~;cTA6-t5rWV!k zvN1zhjmDJn*WWpw#}oXkmc#gpfc=gv5P=ebW#vHtC7TA@M#QoBCUD%sK_YR3B!!Vk z+vN-w>a}Cd*3ri==2$(?Lv=!2QABQ`cPi8K^Miu<0w!Jzeim?`JP2e2MQ1%oVdM{r z46U_fR?Mj_r*_XSKehKlHcDd_Xe&tp;I;JXu~SIq2%M0LZLo=ewE4Hp62#_I>TXH{fY;s@fF@E1qsi_bq zsO$r1R>D3I@)xp4-QNXP>jc2lh@}52A~U#w#Um*#DD5=p@Hq)nnNzd}6!CFxYb8#i z)sf67Yi%8$RnVXMy}{9A#55xKnHIfBTp^p9QcG*9W9JSpfQL}&Li1R=BB4+UQ;!8E zLpoFpl@dUBo?Y(!{tTYDyXPB0 z>Q|e^gADUTzgGUBP0Y4A?qXvV!@pQnmh-By%B(^jn=|OTVOS=5bcL_=@Ipq;6 zSJxdXt?gLI&LB2@Wdh_5+is6?9f4?(5%j< z+>Qu8$Ud9r!#~_a&Hspb*sM`8M(cOy$=;$8v3lXaN{lzAPikLKDQ+EU|6pkkOrLoE z=Dxh6r?2Rh*?!*4iBt z#Gjf7%qmropb$!YxEXRS#|2D+#zM`IX8|eA1^`OW^;jeZj`j!#jHu?*+$)s$KVnwx zlxbq$Ar$EQADmz7oGXqPr%qoaW~x^{ic(8#u+|)GTly9yjzaJfJ6V%I1qOE2v77HgU%i-zAeNC+HM1=w?6H2yNl6yVW zSyVOXmR2M}>gW(q;Umf+#GybNOH-|q<&d%@Qd2)8S`R7ud$aT8(0Q7rXoXgt?uy^3 zl`aMCtMd&+F!l)up$rB)5RfUaG!YBu#)cg9A`?#bRwYc+Z*1SO6HJ3m%Z!b@f6`O`@fM1HqA%qz6btS|}VOOQj`j zKaJr+)IqK4tdEFUrTOgYE>uubx(5VOSa?Vvc5ZuG_5_gZ0EC7f>QW~|>Y>U&9493P zXcDN^q`^wFP_xezZ6Z{=gwa|%{Y>#CquH+J)zjBM+*52*`!^f!sVzB6ylzY$&Ibqj zd*yJxt`G6Jy!;~Bh3pm)C6vc#cZ4P-5wGVPIC*Tt)Jcyig`-;p08tECfd6`sX$PHF zRy9*=dF9N`TI)LTWl_6DinC@;st*9InkK+SBSu^8$gScvQ$yds+;lVvV%WloW;UpxbE40{YJL0u~MVifR#| z2NJ>rB^s)!zYtBe{!fYT8|uR46@M_y{sE|4 zqwMA^kT(fXKcV$xLyTapth}XLIblZrN|`n^Sfb&`98HHro+0)jXhP`8-{#Dwp6aZX z$BDz0bGdj7G;O>>+#>AS9ao4iH>%UG7Kc?>+v!8+sO`y!v;%^OIsx_o6lvgdN$SW- znU?5PAo}md2q>WTTD!fAGukEv83~`Z2uYGs8+n_}wuvj0S=nK> zsEe-^Et*EyOFcDT0h@PTEB1^l*GS|5as+ULA{DS07G)S22|f;^qCm1>T4{!|;1i3e z&>7tv+DhOxdT4@Rx936Hbmq-smT=W2eWGo`M!7A?>3eM}S6w`P$4@DG=t5BrQDGzT z$CRMx?~pSZLXxQ&&HziPOf9_BEXH1Lw*v4;hQR%hpkV zB^)@U7(@h=VaThUP>lI(yNA{v(mO?uBIq27;yefwbo+WpU3i7rUYmZ8XlfkSKk!ad zcG>%l)!i1FNJ9RT6fascb_rA{DXa?dJBvYavLm7|GJT5LO(vnsK&68sOjVTD(Qe>7 zw=aqzanWhxUis%QXIlYNe%4Bu=kz%!KeORO{$OR9J z33Ni#>HEa7wflY~&NujyKb*xs)fFE#W~$qMBHpKNdrbVO{=|0m)?LP->a?$m_vpvY zR{!@W;w3fzSb8T8iY~$tdxf$>)#*W)BJdYFz=29qQa>?AAqzpLq30l3@)MtvL@7bP z*S*Tq?eI{OV&{pvk*-wO-r$=4E12<6iZ6a6tj-or3KuL3n0ffCKQfExh} zmJQ{H3P!Xs@~X|K-_f9+eML0aC!?6mVdWWjA5rex+p5m~59YM&WwECA#vjC=$DNe- z4fdqxZOr?IE5iu6s+&ua^x&roGg;#x~}t8E=c@%W0R+ zo-+2wqhnNdw>kZqT}E5E@hel*{cnp&&11izA3rqqOhm_dYI-9+?^P+aH?20PovxEIIJDjd~r_z`5oO zweQcOZ9GJ|r!ODere`yA%BJ2P*k$!QdacwGlZ~a#2QL*b4`wC3th8Ib3Ht6+0d-`u zH{Vb#WP|<#b<-opj8>bxG&-J5Tl1k|ou}NTI034GG1+6P$(ox7d)1P+#bWidBgr{K zZx6X8rN*<)|4$ISYqw|q&E5VVj(Vk<`~uCU zSKbnhdPu6#Fz#;9aZ_!LI`l81W!&hVsEfY3O z&A(GDR1cZPt(qKod@;~qoOB>~%MD*`8+}ZTaC=^7!=H~cR@F|JU_97Ro-=u6SJm-G zvw?S`k%YRAAb%7#S`6A9<|52U)VtsYX_#I@4A^^G;{qq?JyxPa?as-@6QY#6pb8nB z1p@-z+JJnZa{$5v;FC@7Rb6p(}9K@XmRMW7a( z6Co8~;`iw&?tgUFr1}dww5v+(KgDRD%%^vKSHAX#>Fihija_gODtPqw1Wigm1r=Do zo@7W_Rv>tz575S?)Thk_`66kc<;LJ73UI0wYPDogEUUdb*Vrf2!WM0~fl~qUC?NWG zZS9bu^Mi|u!VB@32i~_~1v(dCK~XKvAj<&o@pGI6{v5 z;F>PR=AckWnklR;G9H5}NDZht9;&qu@@qzgdY)4B(mF!gq(DY~fWXs$(L&q6@JzLJ zp^;aQT-!;92nj0I!W2EHGyvw+?fg^SQ z7tPr_jyHB1TWod7cnoYLRkjHIfog$TmqL!(n65lLg0dOKoxT*3QJ!jJl|)^&SSLg# z+JkT;^oO-A@?DO?3R~r1(u4PGuAV3 z^ZNQn>2lqErylvXv2c22rK{d^;Pk2eN5Di3$7nfZsAprg?!3Hr0Ap6Uf9K&%b@Q5P zzrZ+VW?dxuE7MjfKfk&=r&mKm4&|1!NzF_vEG+UMLC@ChX=o|EQ!RxjNCO4cT>$5j z8A>Qe6^|Ac&z1!!P#uUQJ?zK%bt_-J7_J7}%Uojh+=IrTNivE^LQZo92q-_k}0w_XR_g{Sf6ywbL<7mau<)&ob@D;>H))70aq8t+$&Pc!bS9=w9uD|L7dhCQ+e z`a@NFBA9wo_nA4P^vKYB(2#*(ifTX%c){jC-K*Ah7)#1muWPQU(~XlktLp5n=wB08 z89#4S_qTIcaf(=$gQDip|Drd8cL%(pyMU*SmNzwROs;ZqMgX)fW2R>Ytf4;8?8bwu z&4T5{dmGe`rx=rJ*M8Vo)G%Xxa!?krmPSk2;$17d$$qpQwY?I%7WKUgB2$C`GO6yi zffPhVPF7DZ>3i3x=sU*YnJZU>S${Vp7I5#}^)6uB(2Q3y6b2>X9)jBgm^1?tsKF<0$MjfPryj`2W) zYCDya5$m>sOti@t3m_H`Ha|i1p%({b(YmO96>=y24PJ@1O8{#RCM6Noj=E$4o5Vx! z)AiZ8>kYTLGSV$BuRnRBT6ZD@bZRoL_LE`b6V2*^%hCG+)JGmqk5iX@T3DxZ5$RTW zh9#3)8&#M%)Lt7JOxmnL>Od>j7EJ6Vb>T+4G*$bjjHX)K-y7pZt?Sdq?1|$>!AxEJ zVKeEXC}h{8kX{d`iek7!wtvQ4)v0qWk`6%_ zLPcoz(L6_2(W+(aali=zmI_s!RvcwEoQ9e%MBmh27+*_|YUv5)B=yEE#$%{ybhlT~ zbEf^RJ+~od5&nq_C!~>2Zx=AsYAf9pydQYSaXP_sP+%Y{P$zm%-hl&!VHzh?Zqc}Ts4NqYO!4Gv%1weGu&=8440Y2P-^Z!Y&gxw;bz5B&mQqVl`3 zTtJSNxByOor)u;f(|W9=1DMd&h7JN+h%g+wLdmsamKyk1ohK3;G8>LTm=1D2(oMk63k=C{t;(xgSpd|}e`Gw} zpv$}3J&zcrE`w~f=W%1Yl0P;2tDm0)WKyKFl>%z=79(y zUJBgi4(Zd%aZcq$NC(5Av-Er8u-YG=GcGdKiYJU!U9fsW1E+?zdEh*xTZsgZWs#lN z&2;T8qrZit22}{}Gk_eVL@fpmP{pe!%^#mK+2kmwuXj?>cK^y~FL%5!T}?NPL(9wO zEh)cn|Ge6xzcPL>Mg8UoOqdV6VXRO$zHS_@Zu)}`XiUi1MLBJ31cqH~f;f;ff}Cm) z(m`n%LvO$UT$qW=-s8-L;h=(|x2xA?QvE%*TuiK;_l9xlgxa3BjOQDa@dBw%8)Iw? zX%tE*S+?$x;E;mJgUeinz>j4EqY*}A-F2p2f-prg=Re$kv4eW+kr+EvefBLeqjpY% zxkl8V9B}If7YD?PW*H!hy9m}!nBo(HH)pv&??xmx4@&hU|>75>vw#%ZOVo<_}%H@K+nB425j_Z%!)^5&LzgS?d zR^Odx?qmFWI*iqwz+MiwCLKWRVZ{&y+}T7w(}Hp9hP3t>(2_x_N$DpgNPTA!q5jU(-(F%qwbDFTvqq~H> z1*XC@>2i}WzyhunLW)tWhzqvT6lY4q5fU67v|?&MQmIA#vb< zRv%vLV6Tq!&1tn;k1+2Vm6tlPL2#Qv`@nQ_iK~-%VTnPY9o9$+!!`p+i%7cEsG7`D zchidkPHC26@|nOs5r4JGM|0c{eqq-!<`tteLq5{hHLe_S4T#x95+@5Z8xe)uh?3HW z5|#kbA`9{g{RZ8Uv}}yy+HtHi{8$}0PF>nzzBKw=LW!)zieq4vqCVs9qtvO(%riikeIbFoZkah{^1HA)g3687 zE`)?O7CZ#G0%bzGA7`sGg9D*Q3IMqIRo0a9j>l2y=?fTK)}iAHQ77j?`v`jNI>qvY z`wFh1?|)#xt=}-D?cJ)bJ;I!-rXFcdP#^Zq73#+>pTgVO^wQ;pdmTH;-4piOk;72c!Iu-AELg za!{-41;8Dvo6R1agc1s)vSx(D4h?;7bZ~PDlNXoQ^kEpg%Fw03@=*2Gk!I812<=WX z7_KKy1viVJ+7g*cH-EA0$LvDEH6{)a`fvzUhAu}WJ1obzuy8bawz9Y{Ro&^jeCr`< z4w8ET9B`dyRuIX|h z7239lYnp_{1ks4t8zf|WJ_!^An8vRkK}sME6g-$Xk%6%R;6||^{P^qit*{e$AQ^V9 zTGGjqR;dR})27G_Z@;$B;sM4Ik>JsQ+*0BCIva7}8Dlk{q3{UPm^x6bBy~p09UU@x z?YT~Kw$`NotJ_>e;;yy7&-~Er`eJobWuVVt-O{ZP?WzJ|Xayq)a*cxun&vPA*4MmX zpc-ijKu#$n9u+WFH`PWrsBJ@YIj4ULcciL~N)O(n(PKWN+N;{Qb>?Hs)Ni{GZU`~% zi4dzC7N8${7wvR1D9oSc1QEl8^+k2s8LR>)lZsvwV?=sD2ZG(yQp0TFiOT5MMVo+Q ztH*R#SEkA31=vyWrU8(OGo7M!XFS-plNjH&o_X``pH=g6Vxm&OG)Po5v+kEbg<^^N(>Dy`kXrke5tlz07 zl4w$rHPQ7QsvVkqR7JR85v90Vgpm#38CP8CiZFLnRqYq!2qk12uFKY4NXOo0{#O5g zHrSSS1lN||Zhn92VU^Q*IR7g>-9b;cO)ipgqn#pOTZ^fi&>mn3G#{zj2_kNd(_%q+ i(B@{+lb#;*g5!ck(Z*1#UURW@ZFzBt`pdV>NB$coAap?h delta 12257 zcmZvid7K_qmG?hY)k$Yh8j|kp>104EOaS~EKYr;3hjrHDs>k7g^1HCjg!0(eicS(;U}4& zX+JhyXSm&F&#PYl;Khfm8&+?d|6_CX$L(82VV0z-%oCjzQWk!Z#=Lo2RC$;ODk-ue zNQDTaN|$kz7iGeOlf0_(Oy+^CZ#|*Ap0Hq;`P84?S;N(e4J+ju^U`id_4}dlqp%p6htaeI#UHN%0q%UPl7@hWxxl*T&g6JX3fvto_fxgd+UW_iWAo2 z?Ir!}B~D{rA=YK4a*iwuWE7*S(+xPpBA|el1#{)XUdS1;t;Y#iu%@@C%fy) zT8^Dor@?#mKODNKFG{kqN{cvEVWm=b#@3TE3)55=iWdq4UnhPkqFiu9q7b6sf+`(V zejo!QZ*qF--QfxKzq>OB506%PgJ)$o40C)l;e4-WaAM!^@#f0kI1Brf$U|AhDoHc0 zDoGQmiZqClL~`!J527-Wi7fIolcI{UNENxSvRLPlABoT$@gl43IDVx1&Q{2^D5VE>tRHl_Xgb`I%B$CiUzecMonq@oS#r4=!19 zj>BI&PI{}Gx4!--?%aCX$`j0#SDo>7dhUHgKO3C3axx#YzwfPoyL^rln1`?QRc5bRR;nyim1astIg6D!SDWTVq%#>1 zta(%hL6z{Qapn?>cHw_t_wn|EiAqRQB{|eUgfdon91@HrCrwZ)q9Y6aJkA7Jm4;EC zrdg3BRge~1h^)S4OMAVo8%Ni-U36GIZ$nRO-31v(6f4qAia^CNdFE@sie*L2k(n_E zkojp4su;x3QORj0RT^0k$>RFb8I#SPo7@REKIx6FJI`C(KGrNIo(nRDYY-{n%j${p zvrHtFAInfgt>_8-oG2v`I06ypxnD>vK^r6g+U>q)<6OrP^}O@1X|GvEVOU9-rMyoW zD~>iw@-m8KkN{5+;8Dhz4RXn4`-;%c1P7J~F46b3xqHy*xpA}C-MXerKmYaisxiOi zOhwM^^2sRDG8IASr(`{cQkFuiB*-{UUz9}{t27R)C{6f}FLPCQeR@iL*M%eNXD^s+ z7hJMH+Y89jB#!N=`K1c|3M31(P*uUVE3GQ-DXKE|t$`z5YMz-FX-alG%iYQK)fXPw zez_Uu^6EJNUBJuC0Q)8QWktQEfTHsN6+?UgGb2*)TQDOo@#h0K5hU-M>h2~dC@igf=uUeXTBe#yNMMOB&0#IJm!CrTn+mWe83Dbgwl!pcHTKzxz(d9Gs6he#56 zE(BHL=Jwus)%qJ>IHUEJ16iC$g-+OL#-cI{@}Lr#;1H51&Ph#`hha%DfNMbzfcwOL zNgaw}o$~Oy>z{iDzjf&;;O*caF0W>q6CZIG_EArGQ`VF^O+`$w#AO2RR%IypRUUKy zl!K(=#I?#3>T)3>Fh5f|3`|!JqOZBFb8yVJcQ~yT`!%TsT8IKn)kJzIVm|~+0FlzC zwrH|D6_vJlq;(P}L_i3Ti6|(FDr%L;HMjk4XfdKJ<)*m^k&76_q0$lpx_|^^F_$B3 z-WM@X)nzJ)*C^1ktm1&`Ue)r{?t0Aa$F}!cQC&;UA`()@QIG*TRm_3yw%dCt<9)*5T zKm1g8z3Gm>8hTO6JjJ!3fcY|xwMfG_=9s|`KZCfWIpvRINTVR8_ySf$925m&#@BN5 z?v22QJ=A;uadKC+>_rj0KG#6%WmNyLpkS8)*#ohKorSing($Kxw*sboO9@?-Z|&3GjhP2To3FD>!lAI-`dCm4dg0f zwuGq%B8bA2OyXcF!mfa@!HOu2M0B2)xgWr$AvK@~a2cz5_HQP)LVWO)2i4F!6JnO@ zz;YoG8H|h54JyteCWvxT0Fa5VDX9V)LqB+)XN^ap?%D3m6{xd zU_(;IRC!yqD|VT|z*?v4t5T*R5e?7>5eJZEen7keaD{Fmnmy+4Z11xRE`nbLkf+KI zGaaIM_)E$#t-!=8E|Q{BbrJa-+s}u zpgvWS+%1zdRVi2~b4q>?WE5`g%L46?a@0i#i~y6dPu3GTBCbN2o3$NIx6PP|gTLRo za_A&cTS)+Gj;W||PRwV}9RwMW9C5}(n_mW$M;KPXv4R(tk@#7uVqJg{uxBu$r{4O5 zW9qZ-8eebx{)G0+qHApOhH$h%f{n2FjQkQ2qE3^=8pNSgW?9bm$wZ_^3Hzc>!cN2B z{Zrlb+>H9#`$yLD(YfH;b&uZOUcQ1fOC_j}%J+dEiZAFTg`Xgq1O;C6LX4s}crjn`=+h+@$ z00IkDA_+p+UP5J2a2yzbY>`9irA3PAq})sGRkIMhQDm>AyvihiRB|~``l=wnR9tfN za3s_(lvxNumpt%&$@dB_&>Zn=s_@-UezCn;91@wZShz5Uhz13ODH>!$hPtevGXR&639033eBm| zUy#EhLRyfJQHIx*8ZOtyneJy!<%-rk(*oUv{jf$Ub3~6-8$v7D*By zvw>)+0`-`4r+E2!J6qc~{#t$7qk$s^Uw(dtC1JP^Rok;uFlrm48R>qXrd1~Qi#z?g+)g2C(e~9fd?XqU-&>) zh~^8qI>3bYmV8a=xL$Ndck{Jr&aN>$=ZhD)f$1ITOlt1to^I&khfJ!jO$a^3nBt5@|d=$#|td8X?ecX2=bjuI>hBk5<<#1v&?iwvg@3m~Pz zzyxbTkY|YwV$K8318|~DuyMfG?*6v@vA$Im*2z`HGQE0T+3U|s=0*E|_W9N8m#_K+>a2R|H&s6chEhpY%t9nHTA!>gfqBFl zxExeOvVe8*G(sCmZS@YvLyaYLLt#VEEPK!!X|{aZ9c4b+;r2A&S>>EQiHCMx?(}rD z<5H)PK-`L$Q$|MV#3Ec3mwC|grvxMuHwPCc5EM%r9~zl~IFzsp(C}tP?L6+91Du?Zqr zj)2x^d^8^pj=`2t&qym?01c6+8lGFWc+mAnj%(if8|SyKdFXGQUBvUXH#xU)Nb$|i zw}+X3`g`ZMt&=(&Sy&yWM9sl4^3Et>2AGzIWt0(M0U=Z|2bm5^@WEnfc9ti|yQ)snDhTujAlo(1C zp$91XC>m@U5~#4{V6u7ZcIUp9jKW6UoM zGr%@L0Bbl+io^}2U*g5Wvy|2JJ!bbk2$M-)nbEvE=$tdc?0V1wKteo81*M2m98c+^ z;R=G7v&aCF8$DD6txtUE*IVUuCKJ9c2GZ*Y~p5%HzzS}v&u>m#ReD8&|dAfV>z7NhY&rf%IoXB+9t7v_G^bGe! z`!eHaT0jTOg+FiK8?8ORT))BoD4ZA6M{J$AZ^`#x;^~?0wAR{h%yjQFr}VlToDY0( z{=UafH~uWL<`c8sgH1fgy~VsV$Nd!dXSX_XXscDR{|SefcaC*Ox4zVD{R?-{2G!oz zyoqM%huj6twhz1C?=V-dz}ts);#GuL>?K?j91Tbjd<^&-CIA#zgwzT+PiXxjR6hQY zM6?Kvt5`&qJ@=TU|K@f#^XIxdoch|IF6@J2S|&za6r#+q&x5Mu<0+gg!=5WC2-qIz zwHVo5szP9sg;+OKOcj=9;8=G~bJKix``G5Fe)oCD{NQyD-b5k8n#fTK5(gm7c}w~x z2p)`=vI;T2{8ECRcmP&lV1r`+B~gyW3jJ$g=eCo^5pGW$;U4Wcq1lG}*Kciz%C~=j z_=rWiE1)LmMy-V2WEhq>3fQ&SoeF-zGcapIi_nM)`UR*->-uiGXta6yNVgnXw~u;* zD@)&`to(vEflsBdS!_PQAwj_Am>~jHL2CiS$;!RFC{#?nRCu3Xy&MR#tr*jDw7b38 zc8u$FIHLK#$GTVJu{K8>=N{E*w)NY#0ga3V(^7;*g;fCi!YE7;uLx=@RWLqd#p%nU z$Wg_jgvsL|Vr2X1x%$l6J+v>}TBG=8n_cA6g1==`#2B z4#IKG3Em8I$1)P`_`H*J;VOffMQy5s*IKan+Ss;7yh)d4Y8aWuR81#e(eFAA2m^ouAkGyA%yQsZnie`n> zDJUBq2y`552tGjvO4qsXspcfdzW>lV8 z}DL0{IMerl^4Z;fflxN6z_91;m z&jhrlP4UBwhfP1l=xZR-E!w$~uh=jL`%wRa(hJRV6okl_2JV9Dg!Jg;aE?e(hMEzxq9Q ziaG5|u5aoup)#+zs^`Y5-HztP%iTvh%=MRh3kO2Piyzt|jV(bN5cZQ}1`@Oab<1tz zia|7~M!pqyFc%6t5gO7Vq#06J)3>$ek(=EIZGrjoHEuYTN7I`+*ev)4{BqMb-3iUQ zZ@8ZyYsMWyTL~HpNg`EHj2NPES>hnKIW+D8r-$CB$4A%C_Q^<9>}M)E4mLh&nzqE= zz80r>?sj*0^Wb;fPG@RSF1tXkJokU-34dNb59V^~VeZm?%t-jJz!O#Q5L&!3mM2^X z5sR(~GK;~!0B-1X(Bn`c9}0DVV}h(xEJ8cWT;owzuiN5I8IRMnYCV}T_uLhV=Nd8E zo7U{u;?5k^Y`@nXa7TYYOqj3V>qst@js_AK>`yJdfj$@Tal8R6GtQQ6a*238&^-h= z?jXVjph7~Zun;z*I)^k(J^jlcYW}lv=ZtPX`l$Q$5pBlaY9538tVCb4NRb8BaX^wv ziT4AS!=vL-C}@r$uP}Y^$9++h;A57yInyTLj5e3_IwQ>&e&9aUUKrtr@k?jNx9l0A zio*y10@%2ewz-7ci0zCFz&fS@h`s4!wBer7D+zKVzlSNh`XP66Gv;~seA|#EJbIdM z{K$RN#>^EzaX&SRzn_2Ay>M*1WfIU^jCn1Ly3)t!uK-X8k)W?my3re?qbk4#%p3$W zj2H;c%L07H8w*;7sd?ue_aW2!yt}wBCh58NiqNw?C2A~R#I2!KDexS4f3$(dm*G=P z2hdhluxJ?~LO>Jq?j{Patq@K98+W2(24^}82f!qW;h9mp@WBX{)N*dLsC6?1^^$%H z9yf4-n?NfGInP1yI}{EPADBfuy>5$UZ<^yGXJj+*Tlbn_aFFN*;Y~6>|AV`-MM%0j zyrumG3(-ZkJ?4s3w-A6If+1RE74Zvn<&vWUBkAc?7Uwk41?<7?>1o@PX-Dpze{g3F zJ>x)6%=67w&zob;`I9@XT?97%7ufgUpWGiid`cMm@V*>8^Ggg>F=dSI2T-&I)?!Bz8b%VNEk-<2cUb1uTD~7ol$2m#f)zkv z+}#4{f_21(NIfnD1SfAqiJ?ecg+t*8g%!>x8YpP>A9i+lquU#Jy~BI?{c{_z{Z+{o zV^rYSQ@n6R0b@Ebb`*o3#z-FKJ=hiToy7*NAtf*AO83Vi<-3C>0V9x=sto_6zBsVBx7epoLu3vtKyKgRh!bK8+Sw z`3Qir>mqkl=ZdzzHv<>DGtBQsd5Jl3gg3oeG}?P)eDk^K-r|unKL9oV8{z_P>h1@e zMg4f4To3h>R=;8%ggUKpIN=CHbd}(1QU=+bVqAnz0fRBb1P}-V1q7Mh2eI3KJlNaQ zQD62Si~H%Ukh~iE1q#ZkQ!|6Kq%95YL-2u265j=O#=5XKKfxG~Sy{pt$xwWGifIck zU+;CNG)q6~Epb}w;>K}Y%tBb(S3+W9bS+I$ZW^zNtA%CKA}OQ-Yb-57*zs&!bl%<1 zEDvlK=~Z*R|KpfN_q607@s}|+bFhz+&F#~N zV^tZp!F}Oj&Tun0-&@*@7v75pII)#x{ZQHf_(cfks9WY&s1XUdKpCb?ShGOUiVGCt zoAC<38eR$W=J&#mm$Z(-Hpq-Q%A3%!63ffndX)DO^NU`0JnmbUNAoQ4O|<{G?`Us<(|U`=eb@?Yo^BR}gI(IbF{PVM8L=gyF^TP)(Z!>9!w9WzCU+PK zV&(zQerVq4lhD9LClh16$9ThStfoaSU;KcGd!j2{#V>t?Ug%X+@=M!K`OJP`Q`x&!$q1W^N zZE+-|eTbx>-;d4DCCI8Uc@!FE6oeram4WyQseo~Hd_`AozsnH zdLzuQ7J1|LNf^4-5}Q2dRRzN)s2&s+f}D`Rp@#EkKJ_}qp|Zmr0lrGk01@DcIAZ;F znEl;i?@+4UuEpMZ=l%0){p0O3y+fO?oaEiJUo$Zf0)+8Lj3YyxffHGlhop<3lz8U8 zrb!r;FkFGjjHm%`z@VJghcDX?w9(hR{Ta^ae?G4Fv)*<4cE;dD#4HbxMb?leY(_DB zfSrm_hVM+@&vt?#5=3Bv&05ieu}yX?ZnDfQ`dhf`b&H)*MxW}vwQtq1WfWU#$^;by zQnVYQn9&a=3?yAuV1&yGIZRYW$jlX zFaw|S#*h1;c^vK2nNkxbw#7kbKSu>~LNvRm@bDl&JtewxIEyBWw|+ zd1%&t&6{GbKb=QhbDGz+Z!0*fG_R42ID6Qgw)e*P0uwd(PF5e&r^128s-tjWy-{B% zXtek7^(of2IN0%1g1u|!RTl! zk?zc40P!@x7>Fa_+POVJx?|0%GrZrwA4uk%le}qW+cNLGp$82V49$tw_!*oKs)BWd zoW+Y{h5}wemyBi$y~~u98RsL;$V{5{K(g&%Fo9EJ#Jq5(*O>XQc_;T(3|253MavWI zWUIYKD#FzyKd)#v!UzFgHc{A`1p6foNKAMWNL^T)zzn?Ub(+JKcg?=TfNju#LI!F$VQJb;urp#2w~;LGbovMbc$9V-bu-PA*I9KsRVZ*j7Tb;@9X-^Q`^L{70|Do><$0_ z1#}-XL5arGXC2z@$Q5Q2nAl`f%$mY|;btgLiXE<~z+_EuSkV@AaEOt@?EWQadCSG! z=FgRP%>MBB=gr<2v*|4Fr1oA;>f;=kM)7GDU?c^Yv|JlUR6?;K#SsVuG&Am`>0Qu; z1dLNQM1dxPGMfD-Og5J;2V}51ANFRO`AgV&=)>BgqiNT%AI3T;av|MWpqegus}*V1ybj;HV}*Bkd;MW&dlOlI zkq6bM32ED7xlPlIK(RAL7)lY)MT--#Vv>-FU-}x5a_BNp0Hq9>kBI1K+bnH%p6yMu z?#avNdIwpP*IDJAJgpV+{q&6CI}Dqlx8c2vYtbT0bNER@q9+g{=nu{<$HV$4Y3>5s z9t(o2iRsl{G>d7-ai(Xpcgg;nAW=Wu{Oe|K*ZlhC5ts~o&z2;L9^fQt8#1f`7Gn|8 z0jD?3xCS>5cA<&`J85jqGkgywzW_4ZwrptcM2-{HT_@lzvuCV=2pt=f@i_+xQ-B%x zikGLw$eb5^fta@p3zvcEMvG*aFf<6vyPw5drr4TCb^?jVe~)T7WXWFtq}S1WPl(2l$TolrIE zKOn>iD6z8BTiv>VF^_pu*w4>)g840K)Y=peyv#AXuJo4l;cAt@z8#Q;i`W4xpTfto z;WM-x$oOAU1X|*7~yhVSm~8y%WvOo!-Qe_*T}Zjz4gU=8uniQ%(QT z-V*ctW8O-0%TYl0J5P8YGb4^AOK*DIJAa7ZV9Q!Ns~})e+ya76qpWpN0!PRWu}OL? z=$t5k09zMXGt7cRC6P>We?=W*=0A>=@&1EahcWL-uc!5oeGJsd?HJkiTpG^hqJ3l( z0?aTAbCC=QLUD;u2DS)OPJsN#81ce}V*<3wD;sWfhiR($GzLksJlJ2=Z7sa2z5G8t Y>pcf|_=)$6X3 crate::error::Result { - expand_env_vars(input, |name| { - std::env::var(name).map_err(|_| { - crate::error::PinakesError::Config(format!( - "environment variable not set: {name}" - )) - }) - }) -} - -/// Expand environment variables in a string using the provided lookup function. -/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax. -fn expand_env_vars( - input: &str, - lookup: impl Fn(&str) -> crate::error::Result, -) -> crate::error::Result { - let mut result = String::new(); - let mut chars = input.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '$' { - // Check if it's ${VAR} or $VAR syntax - let use_braces = chars.peek() == Some(&'{'); - if use_braces { - chars.next(); // consume '{' - } - - // Collect variable name - let mut var_name = String::new(); - while let Some(&next_ch) = chars.peek() { - if use_braces { - if next_ch == '}' { - chars.next(); // consume '}' - break; - } - var_name.push(next_ch); - chars.next(); - } else { - // For $VAR syntax, stop at non-alphanumeric/underscore - if next_ch.is_alphanumeric() || next_ch == '_' { - var_name.push(next_ch); - chars.next(); - } else { - break; - } - } - } - - if var_name.is_empty() { - return Err(crate::error::PinakesError::Config( - "empty environment variable name".to_string(), - )); - } - - result.push_str(&lookup(&var_name)?); - } else if ch == '\\' { - // Handle escaped characters - if let Some(&next_ch) = chars.peek() { - if next_ch == '$' { - chars.next(); // consume the escaped $ - result.push('$'); - } else { - result.push(ch); - } - } else { - result.push(ch); - } - } else { - result.push(ch); - } - } - - Ok(result) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub storage: StorageConfig, - pub directories: DirectoryConfig, - pub scanning: ScanningConfig, - pub server: ServerConfig, - #[serde(default)] - pub ui: UiConfig, - #[serde(default)] - pub accounts: AccountsConfig, - #[serde(default)] - pub rate_limits: RateLimitConfig, - #[serde(default)] - pub jobs: JobsConfig, - #[serde(default)] - pub thumbnails: ThumbnailConfig, - #[serde(default)] - pub webhooks: Vec, - #[serde(default)] - pub scheduled_tasks: Vec, - #[serde(default)] - pub plugins: PluginsConfig, - #[serde(default)] - pub transcoding: TranscodingConfig, - #[serde(default)] - pub enrichment: EnrichmentConfig, - #[serde(default)] - pub cloud: CloudConfig, - #[serde(default)] - pub analytics: AnalyticsConfig, - #[serde(default)] - pub photos: PhotoConfig, - #[serde(default)] - pub managed_storage: ManagedStorageConfig, - #[serde(default)] - pub sync: SyncConfig, - #[serde(default)] - pub sharing: SharingConfig, - #[serde(default)] - pub trash: TrashConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScheduledTaskConfig { - pub id: String, - pub enabled: bool, - pub schedule: crate::scheduler::Schedule, - pub last_run: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimitConfig { - /// Global rate limit: requests per second (token replenish interval). - /// Default: 1 (combined with `burst_size=100` gives ~100 req/sec) - #[serde(default = "default_global_per_second")] - pub global_per_second: u64, - /// Global rate limit: burst size (max concurrent requests per IP) - #[serde(default = "default_global_burst")] - pub global_burst_size: u32, - /// Login rate limit: seconds between token replenishment. - /// Default: 12 (one token every 12s, combined with burst=5 gives ~5 req/min) - #[serde(default = "default_login_per_second")] - pub login_per_second: u64, - /// Login rate limit: burst size - #[serde(default = "default_login_burst")] - pub login_burst_size: u32, - /// Search rate limit: seconds between token replenishment. - /// Default: 6 (one token every 6s, combined with burst=10 gives ~10 req/min) - #[serde(default = "default_search_per_second")] - pub search_per_second: u64, - /// Search rate limit: burst size - #[serde(default = "default_search_burst")] - pub search_burst_size: u32, - /// Streaming rate limit: seconds between token replenishment. - /// Default: 60 (one per minute) - #[serde(default = "default_stream_per_second")] - pub stream_per_second: u64, - /// Streaming rate limit: burst size (max concurrent streams) - #[serde(default = "default_stream_burst")] - pub stream_burst_size: u32, - /// Share token rate limit: seconds between token replenishment. - /// Default: 2 - #[serde(default = "default_share_per_second")] - pub share_per_second: u64, - /// Share token rate limit: burst size - #[serde(default = "default_share_burst")] - pub share_burst_size: u32, -} - -const fn default_global_per_second() -> u64 { - 1 -} -const fn default_global_burst() -> u32 { - 100 -} -const fn default_login_per_second() -> u64 { - 12 -} -const fn default_login_burst() -> u32 { - 5 -} -const fn default_search_per_second() -> u64 { - 6 -} -const fn default_search_burst() -> u32 { - 10 -} -const fn default_stream_per_second() -> u64 { - 60 -} -const fn default_stream_burst() -> u32 { - 5 -} -const fn default_share_per_second() -> u64 { - 2 -} -const fn default_share_burst() -> u32 { - 20 -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - global_per_second: default_global_per_second(), - global_burst_size: default_global_burst(), - login_per_second: default_login_per_second(), - login_burst_size: default_login_burst(), - search_per_second: default_search_per_second(), - search_burst_size: default_search_burst(), - stream_per_second: default_stream_per_second(), - stream_burst_size: default_stream_burst(), - share_per_second: default_share_per_second(), - share_burst_size: default_share_burst(), - } - } -} - -impl RateLimitConfig { - /// Validate that all rate limit values are positive. - /// - /// # Errors - /// - /// Returns an error string if any rate limit value is zero. - pub fn validate(&self) -> Result<(), String> { - for (name, value) in [ - ("global_per_second", self.global_per_second), - ("global_burst_size", u64::from(self.global_burst_size)), - ("login_per_second", self.login_per_second), - ("login_burst_size", u64::from(self.login_burst_size)), - ("search_per_second", self.search_per_second), - ("search_burst_size", u64::from(self.search_burst_size)), - ("stream_per_second", self.stream_per_second), - ("stream_burst_size", u64::from(self.stream_burst_size)), - ("share_per_second", self.share_per_second), - ("share_burst_size", u64::from(self.share_burst_size)), - ] { - if value == 0 { - return Err(format!("{name} must be > 0")); - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JobsConfig { - #[serde(default = "default_worker_count")] - pub worker_count: usize, - #[serde(default = "default_cache_ttl")] - pub cache_ttl_secs: u64, - /// Maximum time a job is allowed to run before being cancelled (in seconds). - /// Set to 0 to disable timeout. Default: 3600 (1 hour). - #[serde(default = "default_job_timeout")] - pub job_timeout_secs: u64, -} - -const fn default_worker_count() -> usize { - 2 -} -const fn default_cache_ttl() -> u64 { - 60 -} -const fn default_job_timeout() -> u64 { - 3600 -} - -impl Default for JobsConfig { - fn default() -> Self { - Self { - worker_count: default_worker_count(), - cache_ttl_secs: default_cache_ttl(), - job_timeout_secs: default_job_timeout(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ThumbnailConfig { - #[serde(default = "default_thumb_size")] - pub size: u32, - #[serde(default = "default_thumb_quality")] - pub quality: u8, - #[serde(default)] - pub ffmpeg_path: Option, - #[serde(default = "default_video_seek")] - pub video_seek_secs: u32, -} - -const fn default_thumb_size() -> u32 { - 320 -} -const fn default_thumb_quality() -> u8 { - 80 -} -const fn default_video_seek() -> u32 { - 2 -} - -impl Default for ThumbnailConfig { - fn default() -> Self { - Self { - size: default_thumb_size(), - quality: default_thumb_quality(), - ffmpeg_path: None, - video_seek_secs: default_video_seek(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WebhookConfig { - pub url: String, - pub events: Vec, - #[serde(default)] - pub secret: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UiConfig { - #[serde(default = "default_theme")] - pub theme: String, - #[serde(default = "default_view")] - pub default_view: String, - #[serde(default = "default_page_size")] - pub default_page_size: usize, - #[serde(default = "default_view_mode")] - pub default_view_mode: String, - #[serde(default)] - pub auto_play_media: bool, - #[serde(default = "default_true")] - pub show_thumbnails: bool, - #[serde(default)] - pub sidebar_collapsed: bool, -} - -fn default_theme() -> String { - "dark".to_string() -} -fn default_view() -> String { - "library".to_string() -} -const fn default_page_size() -> usize { - 50 -} -fn default_view_mode() -> String { - "grid".to_string() -} -const fn default_true() -> bool { - true -} - -impl Default for UiConfig { - fn default() -> Self { - Self { - theme: default_theme(), - default_view: default_view(), - default_page_size: default_page_size(), - default_view_mode: default_view_mode(), - auto_play_media: false, - show_thumbnails: true, - sidebar_collapsed: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub users: Vec, - /// Session expiry in hours. Defaults to 24. - #[serde(default = "default_session_expiry_hours")] - pub session_expiry_hours: u64, -} - -const fn default_session_expiry_hours() -> u64 { - 24 -} - -impl Default for AccountsConfig { - fn default() -> Self { - Self { - enabled: false, - users: Vec::new(), - session_expiry_hours: default_session_expiry_hours(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UserAccount { - pub username: String, - pub password_hash: String, - #[serde(default)] - pub role: UserRole, -} - -#[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, -)] -#[serde(rename_all = "lowercase")] -pub enum UserRole { - Admin, - Editor, - #[default] - Viewer, -} - -impl UserRole { - #[must_use] - pub const fn can_read(self) -> bool { - true - } - - #[must_use] - pub const fn can_write(self) -> bool { - matches!(self, Self::Admin | Self::Editor) - } - - #[must_use] - pub const fn can_admin(self) -> bool { - matches!(self, Self::Admin) - } -} - -impl std::fmt::Display for UserRole { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Admin => write!(f, "admin"), - Self::Editor => write!(f, "editor"), - Self::Viewer => write!(f, "viewer"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginTimeoutConfig { - /// Timeout for capability discovery queries (`supported_types`, - /// `interested_events`) - #[serde(default = "default_capability_query_timeout")] - pub capability_query_secs: u64, - /// Timeout for processing calls (`extract_metadata`, `generate_thumbnail`) - #[serde(default = "default_processing_timeout")] - pub processing_secs: u64, - /// Timeout for event handler calls - #[serde(default = "default_event_handler_timeout")] - pub event_handler_secs: u64, -} - -const fn default_capability_query_timeout() -> u64 { - 2 -} - -const fn default_processing_timeout() -> u64 { - 30 -} - -const fn default_event_handler_timeout() -> u64 { - 10 -} - -impl Default for PluginTimeoutConfig { - fn default() -> Self { - Self { - capability_query_secs: default_capability_query_timeout(), - processing_secs: default_processing_timeout(), - event_handler_secs: default_event_handler_timeout(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_plugin_data_dir")] - pub data_dir: PathBuf, - #[serde(default = "default_plugin_cache_dir")] - pub cache_dir: PathBuf, - #[serde(default)] - pub plugin_dirs: Vec, - #[serde(default)] - pub enable_hot_reload: bool, - #[serde(default)] - pub allow_unsigned: bool, - #[serde(default = "default_max_concurrent_ops")] - pub max_concurrent_ops: usize, - #[serde(default = "default_plugin_timeout")] - pub plugin_timeout_secs: u64, - #[serde(default)] - pub timeouts: PluginTimeoutConfig, - #[serde(default = "default_max_consecutive_failures")] - pub max_consecutive_failures: u32, - - /// Hex-encoded Ed25519 public keys trusted for plugin signature - /// verification. Each entry is 64 hex characters (32 bytes). - #[serde(default)] - pub trusted_keys: Vec, -} - -fn default_plugin_data_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("data") -} - -fn default_plugin_cache_dir() -> PathBuf { - Config::default_data_dir().join("plugins").join("cache") -} - -const fn default_max_concurrent_ops() -> usize { - 4 -} - -const fn default_plugin_timeout() -> u64 { - 30 -} - -const fn default_max_consecutive_failures() -> u32 { - 5 -} - -impl Default for PluginsConfig { - fn default() -> Self { - Self { - enabled: false, - data_dir: default_plugin_data_dir(), - cache_dir: default_plugin_cache_dir(), - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: false, - max_concurrent_ops: default_max_concurrent_ops(), - plugin_timeout_secs: default_plugin_timeout(), - timeouts: PluginTimeoutConfig::default(), - max_consecutive_failures: default_max_consecutive_failures(), - trusted_keys: vec![], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranscodingConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub cache_dir: Option, - #[serde(default = "default_cache_ttl_hours")] - pub cache_ttl_hours: u64, - #[serde(default = "default_max_concurrent_transcodes")] - pub max_concurrent: usize, - #[serde(default)] - pub hardware_acceleration: Option, - #[serde(default)] - pub profiles: Vec, -} - -const fn default_cache_ttl_hours() -> u64 { - 48 -} - -const fn default_max_concurrent_transcodes() -> usize { - 2 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TranscodeProfile { - pub name: String, - pub video_codec: String, - pub audio_codec: String, - pub max_bitrate_kbps: u32, - pub max_resolution: String, -} - -impl Default for TranscodingConfig { - fn default() -> Self { - Self { - enabled: false, - cache_dir: None, - cache_ttl_hours: default_cache_ttl_hours(), - max_concurrent: default_max_concurrent_transcodes(), - hardware_acceleration: None, - profiles: vec![ - TranscodeProfile { - name: "high".to_string(), - video_codec: "h264".to_string(), - audio_codec: "aac".to_string(), - max_bitrate_kbps: 8000, - max_resolution: "1080p".to_string(), - }, - TranscodeProfile { - name: "medium".to_string(), - video_codec: "h264".to_string(), - audio_codec: "aac".to_string(), - max_bitrate_kbps: 4000, - max_resolution: "720p".to_string(), - }, - ], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct EnrichmentConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub auto_enrich_on_import: bool, - #[serde(default)] - pub sources: EnrichmentSources, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct EnrichmentSources { - #[serde(default)] - pub musicbrainz: EnrichmentSource, - #[serde(default)] - pub tmdb: EnrichmentSource, - #[serde(default)] - pub lastfm: EnrichmentSource, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct EnrichmentSource { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub api_key: Option, - #[serde(default)] - pub api_endpoint: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_auto_sync_interval")] - pub auto_sync_interval_mins: u64, - #[serde(default)] - pub accounts: Vec, -} - -const fn default_auto_sync_interval() -> u64 { - 60 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudAccount { - pub id: String, - pub provider: String, - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub sync_rules: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CloudSyncRule { - pub local_path: PathBuf, - pub remote_path: String, - pub direction: CloudSyncDirection, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CloudSyncDirection { - Upload, - Download, - Bidirectional, -} - -impl Default for CloudConfig { - fn default() -> Self { - Self { - enabled: false, - auto_sync_interval_mins: default_auto_sync_interval(), - accounts: vec![], - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnalyticsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_true")] - pub track_usage: bool, - #[serde(default = "default_retention_days")] - pub retention_days: u64, -} - -const fn default_retention_days() -> u64 { - 90 -} - -impl Default for AnalyticsConfig { - fn default() -> Self { - Self { - enabled: false, - track_usage: true, - retention_days: default_retention_days(), - } - } -} - -/// Feature toggles for photo processing (image analysis features). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhotoFeatures { - /// Generate perceptual hashes for image duplicate detection (CPU-intensive) - #[serde(default = "default_true")] - pub generate_perceptual_hash: bool, - - /// Automatically create tags from EXIF keywords - #[serde(default)] - pub auto_tag_from_exif: bool, - - /// Generate multi-resolution thumbnails (tiny, grid, preview) - #[serde(default)] - pub multi_resolution_thumbnails: bool, -} - -impl Default for PhotoFeatures { - fn default() -> Self { - Self { - generate_perceptual_hash: true, - auto_tag_from_exif: false, - multi_resolution_thumbnails: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhotoConfig { - /// Feature toggles for photo processing - #[serde(flatten)] - pub features: PhotoFeatures, - - /// Auto-detect photo events/albums based on time and location - #[serde(default)] - pub enable_event_detection: bool, - - /// Minimum number of photos to form an event - #[serde(default = "default_min_event_photos")] - pub min_event_photos: usize, - - /// Maximum time gap between photos in the same event (in seconds) - #[serde(default = "default_event_time_gap")] - pub event_time_gap_secs: i64, - - /// Maximum distance between photos in the same event (in kilometers) - #[serde(default = "default_event_distance")] - pub event_max_distance_km: f64, -} - -impl PhotoConfig { - /// Returns true if perceptual hashing is enabled. - #[must_use] - pub const fn generate_perceptual_hash(&self) -> bool { - self.features.generate_perceptual_hash - } - - /// Returns true if auto-tagging from EXIF is enabled. - #[must_use] - pub const fn auto_tag_from_exif(&self) -> bool { - self.features.auto_tag_from_exif - } - - /// Returns true if multi-resolution thumbnails are enabled. - #[must_use] - pub const fn multi_resolution_thumbnails(&self) -> bool { - self.features.multi_resolution_thumbnails - } -} - -const fn default_min_event_photos() -> usize { - 5 -} - -const fn default_event_time_gap() -> i64 { - 2 * 60 * 60 // 2 hours -} - -const fn default_event_distance() -> f64 { - 1.0 // 1 km -} - -impl Default for PhotoConfig { - fn default() -> Self { - Self { - features: PhotoFeatures::default(), - enable_event_detection: false, - min_event_photos: default_min_event_photos(), - event_time_gap_secs: default_event_time_gap(), - event_max_distance_km: default_event_distance(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedStorageConfig { - /// Enable managed storage for file uploads - #[serde(default)] - pub enabled: bool, - /// Directory where managed files are stored - #[serde(default = "default_managed_storage_dir")] - pub storage_dir: PathBuf, - /// Maximum upload size in bytes (default: 10GB) - #[serde(default = "default_max_upload_size")] - pub max_upload_size: u64, - /// Allowed MIME types for uploads (empty = allow all) - #[serde(default)] - pub allowed_mime_types: Vec, - /// Automatically clean up orphaned blobs - #[serde(default = "default_true")] - pub auto_cleanup: bool, - /// Verify file integrity on read - #[serde(default)] - pub verify_on_read: bool, -} - -fn default_managed_storage_dir() -> PathBuf { - Config::default_data_dir().join("managed") -} - -const fn default_max_upload_size() -> u64 { - 10 * 1024 * 1024 * 1024 // 10GB -} - -impl Default for ManagedStorageConfig { - fn default() -> Self { - Self { - enabled: false, - storage_dir: default_managed_storage_dir(), - max_upload_size: default_max_upload_size(), - allowed_mime_types: vec![], - auto_cleanup: true, - verify_on_read: false, - } - } -} - -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, -)] -#[serde(rename_all = "snake_case")] -pub enum ConflictResolution { - ServerWins, - ClientWins, - #[default] - KeepBoth, - Manual, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncConfig { - /// Enable cross-device sync functionality - #[serde(default)] - pub enabled: bool, - /// Default conflict resolution strategy - #[serde(default)] - pub default_conflict_resolution: ConflictResolution, - /// Maximum file size for sync in MB - #[serde(default = "default_max_sync_file_size")] - pub max_file_size_mb: u64, - /// Chunk size for chunked uploads in KB - #[serde(default = "default_chunk_size")] - pub chunk_size_kb: u64, - /// Upload session timeout in hours - #[serde(default = "default_upload_timeout")] - pub upload_timeout_hours: u64, - /// Maximum concurrent uploads per device - #[serde(default = "default_max_concurrent_uploads")] - pub max_concurrent_uploads: usize, - /// Sync log retention in days - #[serde(default = "default_sync_log_retention")] - pub sync_log_retention_days: u64, - /// Temporary directory for chunked upload storage - #[serde(default = "default_temp_upload_dir")] - pub temp_upload_dir: PathBuf, -} - -const fn default_max_sync_file_size() -> u64 { - 4096 // 4GB -} - -const fn default_chunk_size() -> u64 { - 4096 // 4MB -} - -const fn default_upload_timeout() -> u64 { - 24 // 24 hours -} - -const fn default_max_concurrent_uploads() -> usize { - 3 -} - -const fn default_sync_log_retention() -> u64 { - 90 // 90 days -} - -fn default_temp_upload_dir() -> PathBuf { - Config::default_data_dir().join("temp_uploads") -} - -impl Default for SyncConfig { - fn default() -> Self { - Self { - enabled: false, - default_conflict_resolution: ConflictResolution::default(), - max_file_size_mb: default_max_sync_file_size(), - chunk_size_kb: default_chunk_size(), - upload_timeout_hours: default_upload_timeout(), - max_concurrent_uploads: default_max_concurrent_uploads(), - sync_log_retention_days: default_sync_log_retention(), - temp_upload_dir: default_temp_upload_dir(), - } - } -} - -/// Core permission flags for the sharing subsystem. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SharingPermissions { - /// Enable sharing functionality - #[serde(default = "default_true")] - pub enabled: bool, - /// Allow creating public share links - #[serde(default = "default_true")] - pub allow_public_links: bool, - /// Allow users to reshare content shared with them - #[serde(default = "default_true")] - pub allow_reshare: bool, -} - -impl Default for SharingPermissions { - fn default() -> Self { - Self { - enabled: true, - allow_public_links: true, - allow_reshare: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SharingConfig { - /// Core permission flags for sharing - #[serde(flatten)] - pub permissions: SharingPermissions, - /// Require password for public share links - #[serde(default)] - pub require_public_link_password: bool, - /// Enable share notifications - #[serde(default = "default_true")] - pub notifications_enabled: bool, - /// Maximum expiry time for public links in hours (0 = unlimited) - #[serde(default)] - pub max_public_link_expiry_hours: u64, - /// Notification retention in days - #[serde(default = "default_notification_retention")] - pub notification_retention_days: u64, - /// Share activity log retention in days - #[serde(default = "default_activity_retention")] - pub activity_retention_days: u64, -} - -impl SharingConfig { - /// Returns true if sharing is enabled. - #[must_use] - pub const fn enabled(&self) -> bool { - self.permissions.enabled - } - - /// Returns true if public links are allowed. - #[must_use] - pub const fn allow_public_links(&self) -> bool { - self.permissions.allow_public_links - } - - /// Returns true if resharing is allowed. - #[must_use] - pub const fn allow_reshare(&self) -> bool { - self.permissions.allow_reshare - } -} - -const fn default_notification_retention() -> u64 { - 30 -} - -const fn default_activity_retention() -> u64 { - 90 -} - -impl Default for SharingConfig { - fn default() -> Self { - Self { - permissions: SharingPermissions::default(), - require_public_link_password: false, - notifications_enabled: true, - max_public_link_expiry_hours: 0, - notification_retention_days: default_notification_retention(), - activity_retention_days: default_activity_retention(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrashConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_trash_retention_days")] - pub retention_days: u64, - #[serde(default)] - pub auto_empty: bool, -} - -const fn default_trash_retention_days() -> u64 { - 30 -} - -impl Default for TrashConfig { - fn default() -> Self { - Self { - enabled: false, - retention_days: default_trash_retention_days(), - auto_empty: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StorageConfig { - pub backend: StorageBackendType, - pub sqlite: Option, - pub postgres: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum StorageBackendType { - Sqlite, - Postgres, -} - -impl StorageBackendType { - #[must_use] - pub const fn as_str(&self) -> &'static str { - match self { - Self::Sqlite => "sqlite", - Self::Postgres => "postgres", - } - } -} - -impl std::fmt::Display for StorageBackendType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SqliteConfig { - pub path: PathBuf, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PostgresConfig { - pub host: String, - pub port: u16, - pub database: String, - pub username: String, - pub password: String, - pub max_connections: usize, - /// Enable TLS for `PostgreSQL` connections - #[serde(default)] - pub tls_enabled: bool, - /// Verify TLS certificates (default: true) - #[serde(default = "default_true")] - pub tls_verify_ca: bool, - /// Path to custom CA certificate file (PEM format) - #[serde(default)] - pub tls_ca_cert_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DirectoryConfig { - pub roots: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScanningConfig { - pub watch: bool, - pub poll_interval_secs: u64, - pub ignore_patterns: Vec, - #[serde(default = "default_import_concurrency")] - pub import_concurrency: usize, -} - -const fn default_import_concurrency() -> usize { - 8 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - pub host: String, - pub port: u16, - /// Optional API key for bearer token authentication. - /// If set, all requests (except /health) must include `Authorization: Bearer - /// `. Can also be set via `PINAKES_API_KEY` environment variable. - pub api_key: Option, - /// Explicitly disable authentication (INSECURE - use only for development). - /// When true, all requests are allowed without authentication. - /// This must be explicitly set to true; empty `api_key` alone is not - /// sufficient. - #[serde(default)] - pub authentication_disabled: bool, - /// Enable CORS (Cross-Origin Resource Sharing). - /// When false, default localhost origins are used. - #[serde(default)] - pub cors_enabled: bool, - /// Allowed CORS origins when `cors_enabled` is true. - /// If empty and `cors_enabled` is true, defaults to localhost origins. - #[serde(default)] - pub cors_origins: Vec, - /// TLS/HTTPS configuration - #[serde(default)] - pub tls: TlsConfig, - /// Enable the Swagger UI at /api/docs. - /// Defaults to true. Set to false to disable in production if desired. - #[serde(default = "default_true")] - pub swagger_ui: bool, -} - -/// TLS/HTTPS configuration for secure connections -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TlsConfig { - /// Enable TLS (HTTPS) - #[serde(default)] - pub enabled: bool, - /// Path to the TLS certificate file (PEM format) - #[serde(default)] - pub cert_path: Option, - /// Path to the TLS private key file (PEM format) - #[serde(default)] - pub key_path: Option, - /// Enable HTTP to HTTPS redirect (starts a second listener on `http_port`) - #[serde(default)] - pub redirect_http: bool, - /// Port for HTTP redirect listener (default: 80) - #[serde(default = "default_http_port")] - pub http_port: u16, - /// Enable HSTS (HTTP Strict Transport Security) header - #[serde(default = "default_true")] - pub hsts_enabled: bool, - /// HSTS max-age in seconds (default: 1 year) - #[serde(default = "default_hsts_max_age")] - pub hsts_max_age: u64, -} - -const fn default_http_port() -> u16 { - 80 -} - -const fn default_hsts_max_age() -> u64 { - 31_536_000 // 1 year in seconds -} - -impl Default for TlsConfig { - fn default() -> Self { - Self { - enabled: false, - cert_path: None, - key_path: None, - redirect_http: false, - http_port: default_http_port(), - hsts_enabled: true, - hsts_max_age: default_hsts_max_age(), - } - } -} - -impl TlsConfig { - /// Validate TLS configuration - /// - /// # Errors - /// - /// Returns an error string if TLS is enabled but required paths are missing - /// or invalid. - pub fn validate(&self) -> Result<(), String> { - if self.enabled { - if self.cert_path.is_none() { - return Err("TLS enabled but cert_path not specified".into()); - } - if self.key_path.is_none() { - return Err("TLS enabled but key_path not specified".into()); - } - if let Some(ref cert_path) = self.cert_path - && !cert_path.exists() - { - return Err(format!( - "TLS certificate file not found: {}", - cert_path.display() - )); - } - if let Some(ref key_path) = self.key_path - && !key_path.exists() - { - return Err(format!("TLS key file not found: {}", key_path.display())); - } - } - Ok(()) - } -} - -impl Config { - /// Load configuration from a TOML file, expanding environment variables in - /// secret fields. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file cannot be read, parsed, - /// or contains invalid environment variable references. - pub fn from_file(path: &Path) -> crate::error::Result { - let content = std::fs::read_to_string(path).map_err(|e| { - crate::error::PinakesError::Config(format!( - "failed to read config file: {e}" - )) - })?; - let mut config: Self = toml::from_str(&content).map_err(|e| { - crate::error::PinakesError::Config(format!("failed to parse config: {e}")) - })?; - config.expand_env_vars()?; - Ok(config) - } - - /// Expand environment variables in secret fields. - /// Supports ${`VAR_NAME`} and $`VAR_NAME` syntax. - fn expand_env_vars(&mut self) -> crate::error::Result<()> { - // Postgres password - if let Some(ref mut postgres) = self.storage.postgres { - postgres.password = expand_env_var_string(&postgres.password)?; - } - - // Server API key - if let Some(ref api_key) = self.server.api_key { - self.server.api_key = Some(expand_env_var_string(api_key)?); - } - - // Webhook secrets - for webhook in &mut self.webhooks { - if let Some(ref secret) = webhook.secret { - webhook.secret = Some(expand_env_var_string(secret)?); - } - } - - // Enrichment API keys - if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key { - self.enrichment.sources.musicbrainz.api_key = - Some(expand_env_var_string(api_key)?); - } - if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key { - self.enrichment.sources.tmdb.api_key = - Some(expand_env_var_string(api_key)?); - } - if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key { - self.enrichment.sources.lastfm.api_key = - Some(expand_env_var_string(api_key)?); - } - - Ok(()) - } - - /// Try loading from file, falling back to defaults if the file doesn't exist. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file exists but cannot be - /// read or parsed. - pub fn load_or_default(path: &Path) -> crate::error::Result { - if path.exists() { - Self::from_file(path) - } else { - let config = Self::default(); - // Ensure the data directory exists for the default SQLite database - config.ensure_dirs()?; - Ok(config) - } - } - - /// Save the current config to a TOML file. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if the file cannot be written or - /// the config cannot be serialized. - pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let content = toml::to_string_pretty(self).map_err(|e| { - crate::error::PinakesError::Config(format!( - "failed to serialize config: {e}" - )) - })?; - std::fs::write(path, content)?; - Ok(()) - } - - /// Ensure all directories needed by this config exist and are writable. - /// - /// # Errors - /// - /// Returns [`crate::error::PinakesError`] if a required directory cannot be - /// created or is read-only. - pub fn ensure_dirs(&self) -> crate::error::Result<()> { - if let Some(ref sqlite) = self.storage.sqlite - && let Some(parent) = sqlite.path.parent() - { - // Skip if parent is empty string (happens with bare filenames like - // "pinakes.db") - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - let metadata = std::fs::metadata(parent)?; - if metadata.permissions().readonly() { - return Err(crate::error::PinakesError::Config(format!( - "directory is not writable: {}", - parent.display() - ))); - } - } - } - Ok(()) - } - - /// Returns the default config file path following XDG conventions. - #[must_use] - pub fn default_config_path() -> PathBuf { - std::env::var("XDG_CONFIG_HOME").map_or_else( - |_| { - std::env::var("HOME").map_or_else( - |_| PathBuf::from("pinakes.toml"), - |home| { - PathBuf::from(home) - .join(".config") - .join("pinakes") - .join("pinakes.toml") - }, - ) - }, - |xdg| PathBuf::from(xdg).join("pinakes").join("pinakes.toml"), - ) - } - - /// Validate configuration values for correctness. - /// - /// # Errors - /// - /// Returns an error string if any configuration value is invalid. - pub fn validate(&self) -> Result<(), String> { - if self.server.port == 0 { - return Err("server port cannot be 0".into()); - } - if self.server.host.is_empty() { - return Err("server host cannot be empty".into()); - } - if self.scanning.poll_interval_secs == 0 { - return Err("poll interval cannot be 0".into()); - } - if self.scanning.import_concurrency == 0 - || self.scanning.import_concurrency > 256 - { - return Err("import_concurrency must be between 1 and 256".into()); - } - - // Validate authentication configuration - let has_api_key = - self.server.api_key.as_ref().is_some_and(|k| !k.is_empty()); - let has_accounts = !self.accounts.users.is_empty(); - let auth_disabled = self.server.authentication_disabled; - - if !auth_disabled && !has_api_key && !has_accounts { - return Err( - "authentication is not configured: set an api_key, configure user \ - accounts, or explicitly set authentication_disabled = true" - .into(), - ); - } - - // Empty API key is not allowed (must use authentication_disabled flag) - if let Some(ref api_key) = self.server.api_key - && api_key.is_empty() - { - return Err( - "empty api_key is not allowed. To disable authentication, set \ - authentication_disabled = true instead" - .into(), - ); - } - - // Require TLS when authentication is enabled on non-localhost - let is_localhost = self.server.host == "127.0.0.1" - || self.server.host == "localhost" - || self.server.host == "::1"; - - if (has_api_key || has_accounts) - && !auth_disabled - && !is_localhost - && !self.server.tls.enabled - { - return Err( - "TLS must be enabled when authentication is used on non-localhost \ - hosts. Set server.tls.enabled = true or bind to localhost only" - .into(), - ); - } - - // Validate rate limits - self.rate_limits.validate()?; - - // Validate TLS configuration - self.server.tls.validate()?; - Ok(()) - } - - /// Returns the default data directory following XDG conventions. - #[must_use] - pub fn default_data_dir() -> PathBuf { - std::env::var("XDG_DATA_HOME").map_or_else( - |_| { - std::env::var("HOME").map_or_else( - |_| PathBuf::from("pinakes-data"), - |home| { - PathBuf::from(home) - .join(".local") - .join("share") - .join("pinakes") - }, - ) - }, - |xdg| PathBuf::from(xdg).join("pinakes"), - ) - } -} - -impl Default for Config { - fn default() -> Self { - let data_dir = Self::default_data_dir(); - Self { - storage: StorageConfig { - backend: StorageBackendType::Sqlite, - sqlite: Some(SqliteConfig { - path: data_dir.join("pinakes.db"), - }), - postgres: None, - }, - directories: DirectoryConfig { roots: vec![] }, - scanning: ScanningConfig { - watch: false, - poll_interval_secs: 300, - ignore_patterns: vec![ - ".*".to_string(), - "node_modules".to_string(), - "__pycache__".to_string(), - "target".to_string(), - ], - import_concurrency: default_import_concurrency(), - }, - server: ServerConfig { - host: "127.0.0.1".to_string(), - port: 3000, - api_key: None, - authentication_disabled: false, - cors_enabled: false, - cors_origins: vec![], - tls: TlsConfig::default(), - swagger_ui: true, - }, - ui: UiConfig::default(), - accounts: AccountsConfig::default(), - rate_limits: RateLimitConfig::default(), - jobs: JobsConfig::default(), - thumbnails: ThumbnailConfig::default(), - webhooks: vec![], - scheduled_tasks: vec![], - plugins: PluginsConfig::default(), - transcoding: TranscodingConfig::default(), - enrichment: EnrichmentConfig::default(), - cloud: CloudConfig::default(), - analytics: AnalyticsConfig::default(), - photos: PhotoConfig::default(), - managed_storage: ManagedStorageConfig::default(), - sync: SyncConfig::default(), - sharing: SharingConfig::default(), - trash: TrashConfig::default(), - } - } -} - -#[cfg(test)] -mod tests { - use rustc_hash::FxHashMap; - - use super::*; - - fn test_config_with_concurrency(concurrency: usize) -> Config { - let mut config = Config::default(); - config.scanning.import_concurrency = concurrency; - config.server.authentication_disabled = true; // Disable auth for concurrency tests - config - } - - #[test] - fn test_validate_import_concurrency_zero() { - let config = test_config_with_concurrency(0); - assert!(config.validate().is_err()); - assert!( - config - .validate() - .unwrap_err() - .contains("import_concurrency") - ); - } - - #[test] - fn test_validate_import_concurrency_too_high() { - let config = test_config_with_concurrency(257); - assert!(config.validate().is_err()); - assert!( - config - .validate() - .unwrap_err() - .contains("import_concurrency") - ); - } - - #[test] - fn test_validate_import_concurrency_valid() { - let config = test_config_with_concurrency(8); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_import_concurrency_boundary_low() { - let config = test_config_with_concurrency(1); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_import_concurrency_boundary_high() { - let config = test_config_with_concurrency(256); - assert!(config.validate().is_ok()); - } - - // Environment variable expansion tests using expand_env_vars with a - // HashMap lookup. This avoids unsafe std::env::set_var and is - // thread-safe for parallel test execution. - fn test_lookup<'a>( - vars: &'a FxHashMap<&str, &str>, - ) -> impl Fn(&str) -> crate::error::Result + 'a { - move |name| { - vars - .get(name) - .map(std::string::ToString::to_string) - .ok_or_else(|| { - crate::error::PinakesError::Config(format!( - "environment variable not set: {name}" - )) - }) - } - } - - #[test] - fn test_expand_env_var_simple() { - let vars = [("TEST_VAR_SIMPLE", "test_value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars)); - assert_eq!(result.unwrap(), "test_value"); - } - - #[test] - fn test_expand_env_var_braces() { - let vars = [("TEST_VAR_BRACES", "test_value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "test_value"); - } - - #[test] - fn test_expand_env_var_embedded() { - let vars = [("TEST_VAR_EMBEDDED", "value")] - .into_iter() - .collect::>(); - let result = - expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars)); - assert_eq!(result.unwrap(), "prefix_value_suffix"); - } - - #[test] - fn test_expand_env_var_multiple() { - let vars = [("VAR1", "value1"), ("VAR2", "value2")] - .into_iter() - .collect::>(); - let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "value1_value2"); - } - - #[test] - fn test_expand_env_var_missing() { - let vars = FxHashMap::default(); - let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars)); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("environment variable not set") - ); - } - - #[test] - fn test_expand_env_var_empty_name() { - let vars = FxHashMap::default(); - let result = expand_env_vars("${}", test_lookup(&vars)); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("empty environment variable name") - ); - } - - #[test] - fn test_expand_env_var_escaped() { - let vars = FxHashMap::default(); - let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars)); - assert_eq!(result.unwrap(), "$NOT_A_VAR"); - } - - #[test] - fn test_expand_env_var_no_vars() { - let vars = FxHashMap::default(); - let result = expand_env_vars("plain_text", test_lookup(&vars)); - assert_eq!(result.unwrap(), "plain_text"); - } - - #[test] - fn test_expand_env_var_underscore() { - let vars = [("TEST_VAR_NAME", "value")] - .into_iter() - .collect::>(); - let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars)); - assert_eq!(result.unwrap(), "value"); - } - - #[test] - fn test_expand_env_var_mixed_syntax() { - let vars = [("VAR1_MIXED", "v1"), ("VAR2_MIXED", "v2")] - .into_iter() - .collect::>(); - let result = - expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars)); - assert_eq!(result.unwrap(), "v1 and v2"); - } -} +pub use pinakes_types::config::*; diff --git a/crates/pinakes-core/src/enrichment/books.rs b/crates/pinakes-core/src/enrichment/books.rs deleted file mode 100644 index 02226f5..0000000 --- a/crates/pinakes-core/src/enrichment/books.rs +++ /dev/null @@ -1,269 +0,0 @@ -use chrono::Utc; -use uuid::Uuid; - -use super::{ - EnrichmentSourceType, - ExternalMetadata, - MetadataEnricher, - googlebooks::GoogleBooksClient, - openlibrary::OpenLibraryClient, -}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -/// Book enricher that tries `OpenLibrary` first, then falls back to Google -/// Books -pub struct BookEnricher { - openlibrary: OpenLibraryClient, - googlebooks: GoogleBooksClient, -} - -impl BookEnricher { - #[must_use] - pub fn new(google_api_key: Option) -> Self { - Self { - openlibrary: OpenLibraryClient::new(), - googlebooks: GoogleBooksClient::new(google_api_key), - } - } - - /// Try to enrich from `OpenLibrary` first - /// - /// # Errors - /// - /// Returns an error if the metadata cannot be serialized. - pub async fn try_openlibrary( - &self, - isbn: &str, - ) -> Result> { - match self.openlibrary.fetch_by_isbn(isbn).await { - Ok(book) => { - let metadata_json = serde_json::to_string(&book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller - source: EnrichmentSourceType::OpenLibrary, - external_id: None, - metadata_json, - confidence: calculate_openlibrary_confidence(&book), - last_updated: Utc::now(), - })) - }, - Err(_) => Ok(None), - } - } - - /// Try to enrich from Google Books - /// - /// # Errors - /// - /// Returns an error if the metadata cannot be serialized. - pub async fn try_googlebooks( - &self, - isbn: &str, - ) -> Result> { - match self.googlebooks.fetch_by_isbn(isbn).await { - Ok(books) if !books.is_empty() => { - let book = &books[0]; - let metadata_json = serde_json::to_string(book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller - source: EnrichmentSourceType::GoogleBooks, - external_id: Some(book.id.clone()), - metadata_json, - confidence: calculate_googlebooks_confidence(&book.volume_info), - last_updated: Utc::now(), - })) - }, - _ => Ok(None), - } - } - - /// Try to enrich by searching with title and author - /// - /// # Errors - /// - /// Returns an error if the metadata cannot be serialized. - pub async fn enrich_by_search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - // Try OpenLibrary search first - if let Ok(results) = self.openlibrary.search(title, author).await - && let Some(result) = results.first() - { - let metadata_json = serde_json::to_string(result).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), - source: EnrichmentSourceType::OpenLibrary, - external_id: result.key.clone(), - metadata_json, - confidence: 0.6, // Lower confidence for search results - last_updated: Utc::now(), - })); - } - - // Fall back to Google Books - if let Ok(results) = self.googlebooks.search(title, author).await - && let Some(book) = results.first() - { - let metadata_json = serde_json::to_string(book).map_err(|e| { - PinakesError::External(format!("Failed to serialize metadata: {e}")) - })?; - - return Ok(Some(ExternalMetadata { - id: Uuid::new_v4(), - media_id: crate::model::MediaId(Uuid::nil()), - source: EnrichmentSourceType::GoogleBooks, - external_id: Some(book.id.clone()), - metadata_json, - confidence: 0.6, - last_updated: Utc::now(), - })); - } - - Ok(None) - } -} - -#[async_trait::async_trait] -impl MetadataEnricher for BookEnricher { - fn source(&self) -> EnrichmentSourceType { - // Returns the preferred source - EnrichmentSourceType::OpenLibrary - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - // Try ISBN-based enrichment first by checking title/description for ISBN - // patterns - if let Some(ref title) = item.title { - if let Some(isbn) = crate::books::extract_isbn_from_text(title) { - if let Some(mut metadata) = self.try_openlibrary(&isbn).await? { - metadata.media_id = item.id; - return Ok(Some(metadata)); - } - if let Some(mut metadata) = self.try_googlebooks(&isbn).await? { - metadata.media_id = item.id; - return Ok(Some(metadata)); - } - } - - // Fall back to title/author search - let author = item.artist.as_deref(); - return self.enrich_by_search(title, author).await; - } - - // No title available - Ok(None) - } -} - -/// Calculate confidence score for `OpenLibrary` metadata -#[must_use] -pub fn calculate_openlibrary_confidence( - book: &super::openlibrary::OpenLibraryBook, -) -> f64 { - let mut score: f64 = 0.5; // Base score - - if book.title.is_some() { - score += 0.1; - } - if !book.authors.is_empty() { - score += 0.1; - } - if !book.publishers.is_empty() { - score += 0.05; - } - if book.publish_date.is_some() { - score += 0.05; - } - if book.description.is_some() { - score += 0.1; - } - if !book.covers.is_empty() { - score += 0.1; - } - - score.min(1.0) -} - -/// Calculate confidence score for Google Books metadata -#[must_use] -pub fn calculate_googlebooks_confidence( - info: &super::googlebooks::VolumeInfo, -) -> f64 { - let mut score: f64 = 0.5; // Base score - - if info.title.is_some() { - score += 0.1; - } - if !info.authors.is_empty() { - score += 0.1; - } - if info.publisher.is_some() { - score += 0.05; - } - if info.published_date.is_some() { - score += 0.05; - } - if info.description.is_some() { - score += 0.1; - } - if info.image_links.is_some() { - score += 0.1; - } - - score.min(1.0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_openlibrary_confidence_calculation() { - let book = super::super::openlibrary::OpenLibraryBook { - title: Some("Test Book".to_string()), - subtitle: None, - authors: vec![], - publishers: vec![], - publish_date: None, - number_of_pages: None, - subjects: vec![], - covers: vec![], - isbn_10: vec![], - isbn_13: vec![], - series: vec![], - description: None, - languages: vec![], - }; - - let confidence = calculate_openlibrary_confidence(&book); - assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title - } - - #[test] - fn test_googlebooks_confidence_calculation() { - let info = super::super::googlebooks::VolumeInfo { - title: Some("Test Book".to_string()), - ..Default::default() - }; - - let confidence = calculate_googlebooks_confidence(&info); - assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title - } -} diff --git a/crates/pinakes-core/src/enrichment/googlebooks.rs b/crates/pinakes-core/src/enrichment/googlebooks.rs deleted file mode 100644 index abfb118..0000000 --- a/crates/pinakes-core/src/enrichment/googlebooks.rs +++ /dev/null @@ -1,295 +0,0 @@ -use std::fmt::Write as _; - -use serde::{Deserialize, Serialize}; - -use crate::error::{PinakesError, Result}; - -/// Google Books API client for book metadata enrichment -pub struct GoogleBooksClient { - client: reqwest::Client, - api_key: Option, -} - -impl GoogleBooksClient { - /// Create a new `GoogleBooksClient`. - #[must_use] - pub fn new(api_key: Option) -> Self { - let client = reqwest::Client::builder() - .user_agent("Pinakes/1.0") - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { client, api_key } - } - - /// Fetch book metadata by ISBN - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn fetch_by_isbn(&self, isbn: &str) -> Result> { - let mut url = - format!("https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}"); - - if let Some(ref key) = self.api_key { - let _ = write!(url, "&key={key}"); - } - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Google Books request failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Google Books returned status: {}", - response.status() - ))); - } - - let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { - PinakesError::External(format!( - "Failed to parse Google Books response: {e}" - )) - })?; - - Ok(volumes.items) - } - - /// Search for books by title and author - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - let mut query = format!("intitle:{}", urlencoding::encode(title)); - - if let Some(author) = author { - let _ = write!(query, "+inauthor:{}", urlencoding::encode(author)); - } - - let mut url = format!( - "https://www.googleapis.com/books/v1/volumes?q={query}&maxResults=5" - ); - - if let Some(ref key) = self.api_key { - let _ = write!(url, "&key={key}"); - } - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Google Books search failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Google Books search returned status: {}", - response.status() - ))); - } - - let volumes: GoogleBooksResponse = response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse search results: {e}")) - })?; - - Ok(volumes.items) - } - - /// Download cover image from Google Books - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// read. - pub async fn fetch_cover(&self, image_link: &str) -> Result> { - // Replace thumbnail link with higher resolution if possible - let high_res_link = image_link - .replace("&zoom=1", "&zoom=2") - .replace("&edge=curl", ""); - - let response = - self.client.get(&high_res_link).send().await.map_err(|e| { - PinakesError::External(format!("Cover download failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response.bytes().await.map(|b| b.to_vec()).map_err(|e| { - PinakesError::External(format!("Failed to read cover data: {e}")) - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleBooksResponse { - #[serde(default)] - pub items: Vec, - - #[serde(default)] - pub total_items: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GoogleBook { - pub id: String, - - #[serde(default)] - pub volume_info: VolumeInfo, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct VolumeInfo { - #[serde(default)] - pub title: Option, - - #[serde(default)] - pub subtitle: Option, - - #[serde(default)] - pub authors: Vec, - - #[serde(default)] - pub publisher: Option, - - #[serde(default)] - pub published_date: Option, - - #[serde(default)] - pub description: Option, - - #[serde(default)] - pub page_count: Option, - - #[serde(default)] - pub categories: Vec, - - #[serde(default)] - pub average_rating: Option, - - #[serde(default)] - pub ratings_count: Option, - - #[serde(default)] - pub image_links: Option, - - #[serde(default)] - pub language: Option, - - #[serde(default)] - pub industry_identifiers: Vec, - - #[serde(default)] - pub main_category: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageLinks { - #[serde(default)] - pub small_thumbnail: Option, - - #[serde(default)] - pub thumbnail: Option, - - #[serde(default)] - pub small: Option, - - #[serde(default)] - pub medium: Option, - - #[serde(default)] - pub large: Option, - - #[serde(default)] - pub extra_large: Option, -} - -impl ImageLinks { - /// Get the best available image link (highest resolution) - #[must_use] - pub fn best_link(&self) -> Option<&String> { - self - .extra_large - .as_ref() - .or(self.large.as_ref()) - .or(self.medium.as_ref()) - .or(self.small.as_ref()) - .or(self.thumbnail.as_ref()) - .or(self.small_thumbnail.as_ref()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IndustryIdentifier { - #[serde(rename = "type")] - pub identifier_type: String, - - pub identifier: String, -} - -impl IndustryIdentifier { - /// Check if this is an ISBN-13 - #[must_use] - pub fn is_isbn13(&self) -> bool { - self.identifier_type == "ISBN_13" - } - - /// Check if this is an ISBN-10 - #[must_use] - pub fn is_isbn10(&self) -> bool { - self.identifier_type == "ISBN_10" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_googlebooks_client_creation() { - let client = GoogleBooksClient::new(None); - assert!(client.api_key.is_none()); - - let client_with_key = GoogleBooksClient::new(Some("test-key".to_string())); - assert_eq!(client_with_key.api_key, Some("test-key".to_string())); - } - - #[test] - fn test_image_links_best_link() { - let links = ImageLinks { - small_thumbnail: Some("small.jpg".to_string()), - thumbnail: Some("thumb.jpg".to_string()), - small: None, - medium: Some("medium.jpg".to_string()), - large: Some("large.jpg".to_string()), - extra_large: None, - }; - - assert_eq!(links.best_link(), Some(&"large.jpg".to_string())); - } - - #[test] - fn test_industry_identifier_type_checks() { - let isbn13 = IndustryIdentifier { - identifier_type: "ISBN_13".to_string(), - identifier: "9780123456789".to_string(), - }; - assert!(isbn13.is_isbn13()); - assert!(!isbn13.is_isbn10()); - - let isbn10 = IndustryIdentifier { - identifier_type: "ISBN_10".to_string(), - identifier: "0123456789".to_string(), - }; - assert!(!isbn10.is_isbn13()); - assert!(isbn10.is_isbn10()); - } -} diff --git a/crates/pinakes-core/src/enrichment/lastfm.rs b/crates/pinakes-core/src/enrichment/lastfm.rs deleted file mode 100644 index 9bde8c5..0000000 --- a/crates/pinakes-core/src/enrichment/lastfm.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Last.fm metadata enrichment for audio files. - -use std::time::Duration; - -use chrono::Utc; -use uuid::Uuid; - -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -pub struct LastFmEnricher { - client: reqwest::Client, - api_key: String, - base_url: String, -} - -impl LastFmEnricher { - /// Create a new `LastFmEnricher`. - #[must_use] - pub fn new(api_key: String) -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, - api_key, - base_url: "https://ws.audioscrobbler.com/2.0".to_string(), - } - } -} - -#[async_trait::async_trait] -impl MetadataEnricher for LastFmEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::LastFm - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - let artist = match &item.artist { - Some(a) if !a.is_empty() => a, - _ => return Ok(None), - }; - - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let url = format!("{}/", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("method", "track.getInfo"), - ("api_key", self.api_key.as_str()), - ("artist", artist.as_str()), - ("track", title.as_str()), - ("format", "json"), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}")) - })?; - - if !resp.status().is_success() { - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!( - "Last.fm response read failed: {e}" - )) - })?; - - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!( - "Last.fm JSON parse failed: {e}" - )) - })?; - - // Check for error response - if json.get("error").is_some() { - return Ok(None); - } - - let Some(track) = json.get("track") else { - return Ok(None); - }; - - let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from); - let listeners = track - .get("listeners") - .and_then(|l| l.as_str()) - .and_then(|l| l.parse::().ok()) - .unwrap_or(0.0); - // Normalize listeners to confidence (arbitrary scale) - let confidence = (listeners / 1_000_000.0).min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::LastFm, - external_id: mbid, - metadata_json: body, - confidence, - last_updated: Utc::now(), - })) - } -} diff --git a/crates/pinakes-core/src/enrichment/mod.rs b/crates/pinakes-core/src/enrichment/mod.rs deleted file mode 100644 index 16de3cb..0000000 --- a/crates/pinakes-core/src/enrichment/mod.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Metadata enrichment from external sources. - -pub mod books; -pub mod googlebooks; -pub mod lastfm; -pub mod musicbrainz; -pub mod openlibrary; -pub mod tmdb; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - error::Result, - model::{MediaId, MediaItem}, -}; - -/// Externally-sourced metadata for a media item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExternalMetadata { - pub id: Uuid, - pub media_id: MediaId, - pub source: EnrichmentSourceType, - pub external_id: Option, - pub metadata_json: String, - pub confidence: f64, - pub last_updated: DateTime, -} - -/// Supported enrichment data sources. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum EnrichmentSourceType { - #[serde(rename = "musicbrainz")] - MusicBrainz, - #[serde(rename = "tmdb")] - Tmdb, - #[serde(rename = "lastfm")] - LastFm, - #[serde(rename = "openlibrary")] - OpenLibrary, - #[serde(rename = "googlebooks")] - GoogleBooks, -} - -impl std::fmt::Display for EnrichmentSourceType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::MusicBrainz => "musicbrainz", - Self::Tmdb => "tmdb", - Self::LastFm => "lastfm", - Self::OpenLibrary => "openlibrary", - Self::GoogleBooks => "googlebooks", - }; - write!(f, "{s}") - } -} - -impl std::str::FromStr for EnrichmentSourceType { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - match s { - "musicbrainz" => Ok(Self::MusicBrainz), - "tmdb" => Ok(Self::Tmdb), - "lastfm" => Ok(Self::LastFm), - "openlibrary" => Ok(Self::OpenLibrary), - "googlebooks" => Ok(Self::GoogleBooks), - _ => Err(format!("unknown enrichment source: {s}")), - } - } -} - -/// Trait for metadata enrichment providers. -#[async_trait::async_trait] -pub trait MetadataEnricher: Send + Sync { - fn source(&self) -> EnrichmentSourceType; - async fn enrich(&self, item: &MediaItem) -> Result>; -} diff --git a/crates/pinakes-core/src/enrichment/musicbrainz.rs b/crates/pinakes-core/src/enrichment/musicbrainz.rs deleted file mode 100644 index 9115f38..0000000 --- a/crates/pinakes-core/src/enrichment/musicbrainz.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! `MusicBrainz` metadata enrichment for audio files. - -use std::{fmt::Write as _, time::Duration}; - -use chrono::Utc; -use uuid::Uuid; - -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -pub struct MusicBrainzEnricher { - client: reqwest::Client, - base_url: String, -} - -impl Default for MusicBrainzEnricher { - fn default() -> Self { - Self::new() - } -} - -impl MusicBrainzEnricher { - /// Create a new `MusicBrainzEnricher`. - #[must_use] - pub fn new() -> Self { - let client = reqwest::Client::builder() - .user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)") - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, - base_url: "https://musicbrainz.org/ws/2".to_string(), - } - } -} - -fn escape_lucene_query(s: &str) -> String { - let special_chars = [ - '+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', - '?', ':', '\\', '/', - ]; - let mut escaped = String::with_capacity(s.len() * 2); - for c in s.chars() { - if special_chars.contains(&c) { - escaped.push('\\'); - } - escaped.push(c); - } - escaped -} - -#[async_trait::async_trait] -impl MetadataEnricher for MusicBrainzEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::MusicBrainz - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let mut query = format!("recording:{}", escape_lucene_query(title)); - if let Some(ref artist) = item.artist { - let _ = write!(query, " AND artist:{}", escape_lucene_query(artist)); - } - - let url = format!("{}/recording/", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("query", &query), - ("fmt", &"json".to_string()), - ("limit", &"1".to_string()), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!( - "MusicBrainz request failed: {e}" - )) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - if status == reqwest::StatusCode::TOO_MANY_REQUESTS - || status == reqwest::StatusCode::SERVICE_UNAVAILABLE - { - return Err(PinakesError::MetadataExtraction(format!( - "MusicBrainz rate limited (HTTP {})", - status.as_u16() - ))); - } - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!( - "MusicBrainz response read failed: {e}" - )) - })?; - - // Parse to check if we got results - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!( - "MusicBrainz JSON parse failed: {e}" - )) - })?; - - let recordings = json.get("recordings").and_then(|r| r.as_array()); - if recordings.is_none_or(std::vec::Vec::is_empty) { - return Ok(None); - } - - let Some(recordings) = recordings else { - return Ok(None); - }; - let recording = &recordings[0]; - let external_id = recording - .get("id") - .and_then(|id| id.as_str()) - .map(String::from); - let score = (recording - .get("score") - .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0) - / 100.0) - .min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::MusicBrainz, - external_id, - metadata_json: body, - confidence: score, - last_updated: Utc::now(), - })) - } -} diff --git a/crates/pinakes-core/src/enrichment/openlibrary.rs b/crates/pinakes-core/src/enrichment/openlibrary.rs deleted file mode 100644 index 02ca965..0000000 --- a/crates/pinakes-core/src/enrichment/openlibrary.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::fmt::Write as _; - -use serde::{Deserialize, Serialize}; - -use crate::error::{PinakesError, Result}; - -/// `OpenLibrary` API client for book metadata enrichment -pub struct OpenLibraryClient { - client: reqwest::Client, - base_url: String, -} - -impl Default for OpenLibraryClient { - fn default() -> Self { - Self::new() - } -} - -impl OpenLibraryClient { - /// Create a new `OpenLibraryClient`. - #[must_use] - pub fn new() -> Self { - let client = reqwest::Client::builder() - .user_agent("Pinakes/1.0") - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, - base_url: "https://openlibrary.org".to_string(), - } - } - - /// Fetch book metadata by ISBN - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn fetch_by_isbn(&self, isbn: &str) -> Result { - let url = format!("{}/isbn/{}.json", self.base_url, isbn); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("OpenLibrary request failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "OpenLibrary returned status: {}", - response.status() - ))); - } - - response.json::().await.map_err(|e| { - PinakesError::External(format!( - "Failed to parse OpenLibrary response: {e}" - )) - }) - } - - /// Search for books by title and author - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// parsed. - pub async fn search( - &self, - title: &str, - author: Option<&str>, - ) -> Result> { - let mut url = format!( - "{}/search.json?title={}", - self.base_url, - urlencoding::encode(title) - ); - - if let Some(author) = author { - let _ = write!(url, "&author={}", urlencoding::encode(author)); - } - - url.push_str("&limit=5"); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("OpenLibrary search failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "OpenLibrary search returned status: {}", - response.status() - ))); - } - - let search_response: OpenLibrarySearchResponse = - response.json().await.map_err(|e| { - PinakesError::External(format!("Failed to parse search results: {e}")) - })?; - - Ok(search_response.docs) - } - - /// Fetch cover image by cover ID - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// read. - pub async fn fetch_cover( - &self, - cover_id: i64, - size: CoverSize, - ) -> Result> { - let size_str = match size { - CoverSize::Small => "S", - CoverSize::Medium => "M", - CoverSize::Large => "L", - }; - - let url = - format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg"); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Cover download failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response.bytes().await.map(|b| b.to_vec()).map_err(|e| { - PinakesError::External(format!("Failed to read cover data: {e}")) - }) - } - - /// Fetch cover by ISBN - /// - /// # Errors - /// - /// Returns an error if the HTTP request fails or the response cannot be - /// read. - pub async fn fetch_cover_by_isbn( - &self, - isbn: &str, - size: CoverSize, - ) -> Result> { - let size_str = match size { - CoverSize::Small => "S", - CoverSize::Medium => "M", - CoverSize::Large => "L", - }; - - let url = - format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg"); - - let response = self.client.get(&url).send().await.map_err(|e| { - PinakesError::External(format!("Cover download failed: {e}")) - })?; - - if !response.status().is_success() { - return Err(PinakesError::External(format!( - "Cover download returned status: {}", - response.status() - ))); - } - - response.bytes().await.map(|b| b.to_vec()).map_err(|e| { - PinakesError::External(format!("Failed to read cover data: {e}")) - }) - } -} - -#[derive(Debug, Clone, Copy)] -pub enum CoverSize { - Small, // 256x256 - Medium, // 600x800 - Large, // Original -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenLibraryBook { - #[serde(default)] - pub title: Option, - - #[serde(default)] - pub subtitle: Option, - - #[serde(default)] - pub authors: Vec, - - #[serde(default)] - pub publishers: Vec, - - #[serde(default)] - pub publish_date: Option, - - #[serde(default)] - pub number_of_pages: Option, - - #[serde(default)] - pub subjects: Vec, - - #[serde(default)] - pub covers: Vec, - - #[serde(default)] - pub isbn_10: Vec, - - #[serde(default)] - pub isbn_13: Vec, - - #[serde(default)] - pub series: Vec, - - #[serde(default)] - pub description: Option, - - #[serde(default)] - pub languages: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthorRef { - pub key: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LanguageRef { - pub key: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StringOrObject { - String(String), - Object { value: String }, -} - -impl StringOrObject { - #[must_use] - pub fn as_str(&self) -> &str { - match self { - Self::String(s) => s, - Self::Object { value } => value, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenLibrarySearchResponse { - #[serde(default)] - pub docs: Vec, - - #[serde(default)] - pub num_found: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenLibrarySearchResult { - #[serde(default)] - pub key: Option, - - #[serde(default)] - pub title: Option, - - #[serde(default)] - pub author_name: Vec, - - #[serde(default)] - pub first_publish_year: Option, - - #[serde(default)] - pub publisher: Vec, - - #[serde(default)] - pub isbn: Vec, - - #[serde(default)] - pub cover_i: Option, - - #[serde(default)] - pub subject: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_openlibrary_client_creation() { - let client = OpenLibraryClient::new(); - assert_eq!(client.base_url, "https://openlibrary.org"); - } - - #[test] - fn test_string_or_object_parsing() { - let string_desc: StringOrObject = - serde_json::from_str(r#""Simple description""#).unwrap(); - assert_eq!(string_desc.as_str(), "Simple description"); - - let object_desc: StringOrObject = - serde_json::from_str(r#"{"value": "Object description"}"#).unwrap(); - assert_eq!(object_desc.as_str(), "Object description"); - } -} diff --git a/crates/pinakes-core/src/enrichment/tmdb.rs b/crates/pinakes-core/src/enrichment/tmdb.rs deleted file mode 100644 index 146deb8..0000000 --- a/crates/pinakes-core/src/enrichment/tmdb.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! TMDB (The Movie Database) metadata enrichment for video files. - -use std::time::Duration; - -use chrono::Utc; -use uuid::Uuid; - -use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher}; -use crate::{ - error::{PinakesError, Result}, - model::MediaItem, -}; - -pub struct TmdbEnricher { - client: reqwest::Client, - api_key: String, - base_url: String, -} - -impl TmdbEnricher { - /// Create a new `TMDb` enricher. - /// - /// # Panics - /// - /// Panics if the HTTP client cannot be built (programming error in client - /// configuration). - #[must_use] - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .connect_timeout(Duration::from_secs(5)) - .build() - .expect("failed to build HTTP client with configured timeouts"), - api_key, - base_url: "https://api.themoviedb.org/3".to_string(), - } - } -} - -#[async_trait::async_trait] -impl MetadataEnricher for TmdbEnricher { - fn source(&self) -> EnrichmentSourceType { - EnrichmentSourceType::Tmdb - } - - async fn enrich(&self, item: &MediaItem) -> Result> { - let title = match &item.title { - Some(t) if !t.is_empty() => t, - _ => return Ok(None), - }; - - let url = format!("{}/search/movie", self.base_url); - - let resp = self - .client - .get(&url) - .query(&[ - ("api_key", &self.api_key), - ("query", &title.clone()), - ("page", &"1".to_string()), - ]) - .send() - .await - .map_err(|e| { - PinakesError::MetadataExtraction(format!("TMDB request failed: {e}")) - })?; - - if !resp.status().is_success() { - let status = resp.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err(PinakesError::MetadataExtraction( - "TMDB API key is invalid (401)".into(), - )); - } - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - tracing::warn!("TMDB rate limit exceeded (429)"); - return Ok(None); - } - tracing::debug!(status = %status, "TMDB search returned non-success status"); - return Ok(None); - } - - let body = resp.text().await.map_err(|e| { - PinakesError::MetadataExtraction(format!( - "TMDB response read failed: {e}" - )) - })?; - - let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}")) - })?; - - let results = json.get("results").and_then(|r| r.as_array()); - if results.is_none_or(std::vec::Vec::is_empty) { - return Ok(None); - } - - let Some(results) = results else { - return Ok(None); - }; - let movie = &results[0]; - let external_id = match movie.get("id").and_then(serde_json::Value::as_i64) - { - Some(id) => id.to_string(), - None => return Ok(None), - }; - let popularity = movie - .get("popularity") - .and_then(serde_json::Value::as_f64) - .unwrap_or(0.0); - // Normalize popularity to 0-1 range (TMDB popularity can be very high) - let confidence = (popularity / 100.0).min(1.0); - - Ok(Some(ExternalMetadata { - id: Uuid::now_v7(), - media_id: item.id, - source: EnrichmentSourceType::Tmdb, - external_id: Some(external_id), - metadata_json: body, - confidence, - last_updated: Utc::now(), - })) - } -} diff --git a/crates/pinakes-core/src/error.rs b/crates/pinakes-core/src/error.rs index 7e43726..f96beac 100644 --- a/crates/pinakes-core/src/error.rs +++ b/crates/pinakes-core/src/error.rs @@ -1,146 +1,9 @@ -use std::path::PathBuf; +//! Error types for pinakes-core. +//! +//! Re-exports from [`pinakes_types::error`] for use within this crate. +pub use pinakes_types::error::{PinakesError, Result}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PinakesError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("database error: {0}")] - Database(String), - - #[error("migration error: {0}")] - Migration(String), - - #[error("configuration error: {0}")] - Config(String), - - #[error("media item not found: {0}")] - NotFound(String), - - #[error("duplicate content hash: {0}")] - DuplicateHash(String), - - #[error("unsupported media type for path: {0}")] - UnsupportedMediaType(PathBuf), - - #[error("metadata extraction failed: {0}")] - MetadataExtraction(String), - - #[error("thumbnail generation failed: {0}")] - ThumbnailGeneration(String), - - #[error("search query parse error: {0}")] - SearchParse(String), - - #[error("file not found at path: {0}")] - FileNotFound(PathBuf), - - #[error("tag not found: {0}")] - TagNotFound(String), - - #[error("collection not found: {0}")] - CollectionNotFound(String), - - #[error("invalid operation: {0}")] - InvalidOperation(String), - - #[error("invalid data: {0}")] - InvalidData(String), - - #[error("authentication error: {0}")] - Authentication(String), - - #[error("authorization error: {0}")] - Authorization(String), - - #[error("path not allowed: {0}")] - PathNotAllowed(String), - - #[error("external API error: {0}")] - External(String), - - // Managed Storage errors - #[error("managed storage not enabled")] - ManagedStorageDisabled, - - #[error("upload too large: {0} bytes exceeds limit")] - UploadTooLarge(u64), - - #[error("blob not found: {0}")] - BlobNotFound(String), - - #[error("storage integrity error: {0}")] - StorageIntegrity(String), - - // Sync errors - #[error("sync not enabled")] - SyncDisabled, - - #[error("device not found: {0}")] - DeviceNotFound(String), - - #[error("sync conflict: {0}")] - SyncConflict(String), - - #[error("upload session expired: {0}")] - UploadSessionExpired(String), - - #[error("upload session not found: {0}")] - UploadSessionNotFound(String), - - #[error("chunk out of order: expected {expected}, got {actual}")] - ChunkOutOfOrder { expected: u64, actual: u64 }, - - // Sharing errors - #[error("share not found: {0}")] - ShareNotFound(String), - - #[error("share expired: {0}")] - ShareExpired(String), - - #[error("share password required")] - SharePasswordRequired, - - #[error("share password invalid")] - SharePasswordInvalid, - - #[error("insufficient share permissions")] - InsufficientSharePermissions, - - #[error("serialization error: {0}")] - Serialization(String), - - #[error("external tool `{tool}` failed: {stderr}")] - ExternalTool { tool: String, stderr: String }, - - #[error("subtitle track {index} not found in media")] - SubtitleTrackNotFound { index: u32 }, - - #[error("invalid language code: {0}")] - InvalidLanguageCode(String), -} - -impl From for PinakesError { - fn from(e: rusqlite::Error) -> Self { - Self::Database(e.to_string()) - } -} - -impl From for PinakesError { - fn from(e: tokio_postgres::Error) -> Self { - Self::Database(e.to_string()) - } -} - -impl From for PinakesError { - fn from(e: serde_json::Error) -> Self { - Self::Serialization(e.to_string()) - } -} - -/// Build a closure that wraps a database error with operation context. +/// Create a curried error mapper with operation context. /// /// Usage: `stmt.execute(params).map_err(db_ctx("insert_media", media_id))?;` pub fn db_ctx( @@ -150,5 +13,3 @@ pub fn db_ctx( let context = format!("{operation} [{entity}]"); move |e| PinakesError::Database(format!("{context}: {e}")) } - -pub type Result = std::result::Result; diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 7bae8a3..0981a96 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -12,7 +12,6 @@ use crate::{ hash::compute_file_hash, links, media_type::{BuiltinMediaType, MediaType}, - metadata, model::{ AuditAction, CustomField, @@ -183,7 +182,7 @@ pub async fn import_file_with_options( let path_clone = path.clone(); let media_type_clone = media_type.clone(); tokio::task::spawn_blocking(move || { - metadata::extract_metadata(&path_clone, &media_type_clone) + pinakes_metadata::extract_metadata(&path_clone, &media_type_clone) }) .await .map_err(|e| PinakesError::MetadataExtraction(e.to_string()))?? @@ -227,7 +226,7 @@ pub async fn import_file_with_options( let perceptual_hash = if options.photo_config.generate_perceptual_hash() && media_type.category() == crate::media_type::MediaCategory::Image { - crate::metadata::image::generate_perceptual_hash(&path) + pinakes_metadata::image::generate_perceptual_hash(&path) } else { None }; diff --git a/crates/pinakes-core/src/lib.rs b/crates/pinakes-core/src/lib.rs index 17fdb19..3920f0f 100644 --- a/crates/pinakes-core/src/lib.rs +++ b/crates/pinakes-core/src/lib.rs @@ -4,7 +4,6 @@ pub mod books; pub mod cache; pub mod collections; pub mod config; -pub mod enrichment; pub mod error; pub mod events; pub mod export; @@ -14,9 +13,7 @@ pub mod integrity; pub mod jobs; pub mod links; pub mod managed_storage; -pub mod media_type; -pub mod metadata; -pub mod model; +pub use pinakes_types::{media_type, model}; pub mod opener; pub mod path_validation; pub mod playlists; diff --git a/crates/pinakes-core/src/metadata/audio.rs b/crates/pinakes-core/src/metadata/audio.rs deleted file mode 100644 index 576f511..0000000 --- a/crates/pinakes-core/src/metadata/audio.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::Path; - -use lofty::{ - file::{AudioFile, TaggedFileExt}, - tag::Accessor, -}; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::{PinakesError, Result}, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct AudioExtractor; - -impl MetadataExtractor for AudioExtractor { - fn extract(&self, path: &Path) -> Result { - let tagged_file = lofty::read_from_path(path).map_err(|e| { - PinakesError::MetadataExtraction(format!("audio metadata: {e}")) - })?; - - let mut meta = ExtractedMetadata::default(); - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - meta.title = tag.title().map(|s| s.to_string()); - meta.artist = tag.artist().map(|s| s.to_string()); - meta.album = tag.album().map(|s| s.to_string()); - meta.genre = tag.genre().map(|s| s.to_string()); - meta.year = tag.date().map(|ts| i32::from(ts.year)); - } - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - if let Some(track) = tag.track() { - meta - .extra - .insert("track_number".to_string(), track.to_string()); - } - if let Some(disc) = tag.disk() { - meta - .extra - .insert("disc_number".to_string(), disc.to_string()); - } - if let Some(comment) = tag.comment() { - meta - .extra - .insert("comment".to_string(), comment.to_string()); - } - } - - let properties = tagged_file.properties(); - let duration = properties.duration(); - if !duration.is_zero() { - meta.duration_secs = Some(duration.as_secs_f64()); - } - - if let Some(bitrate) = properties.audio_bitrate() { - meta - .extra - .insert("bitrate".to_string(), format!("{bitrate} kbps")); - } - if let Some(sample_rate) = properties.sample_rate() { - meta - .extra - .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); - } - if let Some(channels) = properties.channels() { - meta - .extra - .insert("channels".to_string(), channels.to_string()); - } - - Ok(meta) - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Mp3), - MediaType::Builtin(BuiltinMediaType::Flac), - MediaType::Builtin(BuiltinMediaType::Ogg), - MediaType::Builtin(BuiltinMediaType::Wav), - MediaType::Builtin(BuiltinMediaType::Aac), - MediaType::Builtin(BuiltinMediaType::Opus), - ] - } -} diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs deleted file mode 100644 index 395e18b..0000000 --- a/crates/pinakes-core/src/metadata/document.rs +++ /dev/null @@ -1,372 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::{PinakesError, Result}, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct DocumentExtractor; - -impl MetadataExtractor for DocumentExtractor { - fn extract(&self, path: &Path) -> Result { - match MediaType::from_path(path) { - Some(MediaType::Builtin(BuiltinMediaType::Pdf)) => extract_pdf(path), - Some(MediaType::Builtin(BuiltinMediaType::Epub)) => extract_epub(path), - Some(MediaType::Builtin(BuiltinMediaType::Djvu)) => extract_djvu(path), - _ => Ok(ExtractedMetadata::default()), - } - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Pdf), - MediaType::Builtin(BuiltinMediaType::Epub), - MediaType::Builtin(BuiltinMediaType::Djvu), - ] - } -} - -fn extract_pdf(path: &Path) -> Result { - let doc = lopdf::Document::load(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; - - let mut meta = ExtractedMetadata::default(); - let mut book_meta = crate::model::BookMetadata::default(); - - // Find the Info dictionary via the trailer - if let Ok(info_ref) = doc.trailer.get(b"Info") { - let info_obj = info_ref - .as_reference() - .map_or(Some(info_ref), |reference| doc.get_object(reference).ok()); - - if let Some(obj) = info_obj - && let Ok(dict) = obj.as_dict() - { - if let Ok(title) = dict.get(b"Title") { - meta.title = pdf_object_to_string(title); - } - if let Ok(author) = dict.get(b"Author") { - let author_str = pdf_object_to_string(author); - meta.artist.clone_from(&author_str); - - // Parse multiple authors if separated by semicolon, comma, or "and" - if let Some(authors_str) = author_str { - book_meta.authors = authors_str - .split(&[';', ','][..]) - .flat_map(|part| part.split(" and ")) - .map(|name| name.trim().to_string()) - .filter(|name| !name.is_empty()) - .enumerate() - .map(|(pos, name)| { - let mut author = crate::model::AuthorInfo::new(name); - author.position = i32::try_from(pos).unwrap_or(i32::MAX); - author - }) - .collect(); - } - } - if let Ok(subject) = dict.get(b"Subject") { - meta.description = pdf_object_to_string(subject); - } - if let Ok(creator) = dict.get(b"Creator") { - meta.extra.insert( - "creator".to_string(), - pdf_object_to_string(creator).unwrap_or_default(), - ); - } - if let Ok(producer) = dict.get(b"Producer") { - meta.extra.insert( - "producer".to_string(), - pdf_object_to_string(producer).unwrap_or_default(), - ); - } - } - } - - // Page count - let pages = doc.get_pages(); - let page_count = pages.len(); - if page_count > 0 { - book_meta.page_count = Some(i32::try_from(page_count).unwrap_or(i32::MAX)); - } - - // Try to extract ISBN from first few pages - // Extract text from up to the first 5 pages and search for ISBN patterns - let mut extracted_text = String::new(); - let max_pages = page_count.min(5); - - for (_page_num, page_id) in pages.iter().take(max_pages) { - if let Ok(content) = doc.get_page_content(*page_id) { - // PDF content streams contain raw operators, but may have text strings - if let Ok(text) = std::str::from_utf8(&content) { - extracted_text.push_str(text); - extracted_text.push(' '); - } - } - } - - // Extract ISBN from the text - if let Some(isbn) = crate::books::extract_isbn_from_text(&extracted_text) - && let Ok(normalized) = crate::books::normalize_isbn(&isbn) - { - book_meta.isbn13 = Some(normalized); - book_meta.isbn = Some(isbn); - } - - // Set format - book_meta.format = Some("pdf".to_string()); - - meta.book_metadata = Some(book_meta); - Ok(meta) -} - -fn pdf_object_to_string(obj: &lopdf::Object) -> Option { - match obj { - lopdf::Object::String(bytes, _) => { - Some(String::from_utf8_lossy(bytes).into_owned()) - }, - lopdf::Object::Name(name) => { - Some(String::from_utf8_lossy(name).into_owned()) - }, - _ => None, - } -} - -fn extract_epub(path: &Path) -> Result { - let mut doc = epub::doc::EpubDoc::new(path).map_err(|e| { - PinakesError::MetadataExtraction(format!("EPUB parse: {e}")) - })?; - - let mut meta = ExtractedMetadata { - title: doc.mdata("title").map(|item| item.value.clone()), - artist: doc.mdata("creator").map(|item| item.value.clone()), - description: doc.mdata("description").map(|item| item.value.clone()), - ..Default::default() - }; - - let mut book_meta = crate::model::BookMetadata::default(); - - // Extract basic metadata - if let Some(lang) = doc.mdata("language") { - book_meta.language = Some(lang.value.clone()); - } - if let Some(publisher) = doc.mdata("publisher") { - book_meta.publisher = Some(publisher.value.clone()); - } - if let Some(date) = doc.mdata("date") { - // Try to parse as YYYY-MM-DD or just YYYY - if let Ok(parsed_date) = - chrono::NaiveDate::parse_from_str(&date.value, "%Y-%m-%d") - { - book_meta.publication_date = Some(parsed_date); - } else if let Ok(year) = date.value.parse::() { - book_meta.publication_date = chrono::NaiveDate::from_ymd_opt(year, 1, 1); - } - } - - // Extract authors - iterate through all metadata items - let mut authors = Vec::new(); - let mut position = 0; - for item in &doc.metadata { - if item.property == "creator" || item.property == "dc:creator" { - let mut author = crate::model::AuthorInfo::new(item.value.clone()); - author.position = position; - position += 1; - - // Check for file-as in refinements - if let Some(file_as_ref) = item.refinement("file-as") { - author.file_as = Some(file_as_ref.value.clone()); - } - - // Check for role in refinements - if let Some(role_ref) = item.refinement("role") { - author.role.clone_from(&role_ref.value); - } - - authors.push(author); - } - } - book_meta.authors = authors; - - // Extract ISBNs from identifiers - let mut identifiers = rustc_hash::FxHashMap::default(); - for item in &doc.metadata { - if item.property == "identifier" || item.property == "dc:identifier" { - // Try to get scheme from refinements - let scheme = item - .refinement("identifier-type") - .map(|r| r.value.to_lowercase()); - - let id_type = match scheme.as_deref() { - Some("isbn" | "isbn-10" | "isbn10") => "isbn", - Some("isbn-13" | "isbn13") => "isbn13", - Some("asin") => "asin", - Some("doi") => "doi", - _ => { - // Fallback: detect from value pattern. - // ISBN-10 = 10 chars bare, ISBN-13 = 13 chars bare, - // hyphenated ISBN-13 = 17 chars (e.g. 978-0-123-45678-9). - // Parentheses required: && binds tighter than ||. - if (item.value.len() == 10 || item.value.len() == 13) - || (item.value.contains('-') - && (item.value.len() == 13 || item.value.len() == 17)) - { - "isbn" - } else { - "other" - } - }, - }; - - // Try to normalize ISBN - if (id_type == "isbn" || id_type == "isbn13") - && let Ok(normalized) = crate::books::normalize_isbn(&item.value) - { - book_meta.isbn13 = Some(normalized.clone()); - book_meta.isbn = Some(item.value.clone()); - } - - identifiers - .entry(id_type.to_string()) - .or_insert_with(Vec::new) - .push(item.value.clone()); - } - } - book_meta.identifiers = identifiers; - - // Extract Calibre series metadata by parsing the content.opf file - // Try common OPF locations - let opf_paths = vec!["OEBPS/content.opf", "content.opf", "OPS/content.opf"]; - let mut opf_data = None; - for path in opf_paths { - if let Some(data) = doc.get_resource_str_by_path(path) { - opf_data = Some(data); - break; - } - } - - if let Some(opf_content) = opf_data { - // Look for - if let Some(series_start) = opf_content.find("name=\"calibre:series\"") - && let Some(content_start) = - opf_content[series_start..].find("content=\"") - { - let after_content = &opf_content[series_start + content_start + 9..]; - if let Some(quote_end) = after_content.find('"') { - book_meta.series_name = Some(after_content[..quote_end].to_string()); - } - } - - // Look for - if let Some(index_start) = opf_content.find("name=\"calibre:series_index\"") - && let Some(content_start) = opf_content[index_start..].find("content=\"") - { - let after_content = &opf_content[index_start + content_start + 9..]; - if let Some(quote_end) = after_content.find('"') - && let Ok(index) = after_content[..quote_end].parse::() - { - book_meta.series_index = Some(index); - } - } - } - - // Set format - book_meta.format = Some("epub".to_string()); - - meta.book_metadata = Some(book_meta); - Ok(meta) -} - -fn extract_djvu(path: &Path) -> Result { - // DjVu files contain metadata in SEXPR (S-expression) format within - // ANTa/ANTz chunks, or in the DIRM chunk. We parse the raw bytes to - // extract any metadata fields we can find. - - // Guard against loading very large DjVu files into memory. - const MAX_DJVU_SIZE: u64 = 50 * 1024 * 1024; // 50 MB - let file_meta = std::fs::metadata(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu stat: {e}")))?; - if file_meta.len() > MAX_DJVU_SIZE { - return Ok(ExtractedMetadata::default()); - } - - let data = std::fs::read(path) - .map_err(|e| PinakesError::MetadataExtraction(format!("DjVu read: {e}")))?; - - let mut meta = ExtractedMetadata::default(); - - // DjVu files start with "AT&T" magic followed by FORM:DJVU or FORM:DJVM - if data.len() < 16 { - return Ok(meta); - } - - // Search for metadata annotations in the file. DjVu metadata is stored - // as S-expressions like (metadata (key "value") ...) within ANTa chunks. - let content = String::from_utf8_lossy(&data); - - // Look for (metadata ...) blocks - if let Some(meta_start) = content.find("(metadata") { - let remainder = &content[meta_start..]; - // Extract key-value pairs like (title "Some Title") - extract_djvu_field(remainder, "title", &mut meta.title); - extract_djvu_field(remainder, "author", &mut meta.artist); - - let mut desc = None; - extract_djvu_field(remainder, "subject", &mut desc); - if desc.is_none() { - extract_djvu_field(remainder, "description", &mut desc); - } - meta.description = desc; - - let mut year_str = None; - extract_djvu_field(remainder, "year", &mut year_str); - if let Some(ref y) = year_str { - meta.year = y.parse().ok(); - } - - let mut creator = None; - extract_djvu_field(remainder, "creator", &mut creator); - if let Some(c) = creator { - meta.extra.insert("creator".to_string(), c); - } - } - - // Also check for booklet-style metadata that some DjVu encoders write - // outside the metadata SEXPR - if meta.title.is_none() - && let Some(title_start) = content.find("(bookmarks") - { - let remainder = &content[title_start..]; - // First bookmark title is often the document title - if let Some(q1) = remainder.find('"') { - let after_q1 = &remainder[q1 + 1..]; - if let Some(q2) = after_q1.find('"') { - let val = &after_q1[..q2]; - if !val.is_empty() { - meta.title = Some(val.to_string()); - } - } - } - } - - Ok(meta) -} - -fn extract_djvu_field(sexpr: &str, key: &str, out: &mut Option) { - // Look for patterns like (key "value") in the S-expression - let pattern = format!("({key}"); - if let Some(start) = sexpr.find(&pattern) { - let remainder = &sexpr[start + pattern.len()..]; - // Find the quoted value - if let Some(q1) = remainder.find('"') { - let after_q1 = &remainder[q1 + 1..]; - if let Some(q2) = after_q1.find('"') { - let val = &after_q1[..q2]; - if !val.is_empty() { - *out = Some(val.to_string()); - } - } - } - } -} diff --git a/crates/pinakes-core/src/metadata/image.rs b/crates/pinakes-core/src/metadata/image.rs deleted file mode 100644 index 6652a82..0000000 --- a/crates/pinakes-core/src/metadata/image.rs +++ /dev/null @@ -1,299 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::Result, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct ImageExtractor; - -impl MetadataExtractor for ImageExtractor { - fn extract(&self, path: &Path) -> Result { - let mut meta = ExtractedMetadata::default(); - - let file = std::fs::File::open(path)?; - let mut buf_reader = std::io::BufReader::new(&file); - - let Ok(exif_data) = - exif::Reader::new().read_from_container(&mut buf_reader) - else { - return Ok(meta); - }; - - // Image dimensions - if let Some(width) = exif_data - .get_field(exif::Tag::PixelXDimension, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::ImageWidth, exif::In::PRIMARY)) - && let Some(w) = field_to_u32(width) - { - meta.extra.insert("width".to_string(), w.to_string()); - } - if let Some(height) = exif_data - .get_field(exif::Tag::PixelYDimension, exif::In::PRIMARY) - .or_else(|| { - exif_data.get_field(exif::Tag::ImageLength, exif::In::PRIMARY) - }) - && let Some(h) = field_to_u32(height) - { - meta.extra.insert("height".to_string(), h.to_string()); - } - - // Camera make and model - set both in top-level fields and extra - if let Some(make) = exif_data.get_field(exif::Tag::Make, exif::In::PRIMARY) - { - let val = make.display_value().to_string().trim().to_string(); - if !val.is_empty() { - meta.camera_make = Some(val.clone()); - meta.extra.insert("camera_make".to_string(), val); - } - } - if let Some(model) = - exif_data.get_field(exif::Tag::Model, exif::In::PRIMARY) - { - let val = model.display_value().to_string().trim().to_string(); - if !val.is_empty() { - meta.camera_model = Some(val.clone()); - meta.extra.insert("camera_model".to_string(), val); - } - } - - // Date taken - parse EXIF date format (YYYY:MM:DD HH:MM:SS) - if let Some(date) = exif_data - .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) - .or_else(|| exif_data.get_field(exif::Tag::DateTime, exif::In::PRIMARY)) - { - let val = date.display_value().to_string(); - if !val.is_empty() { - // Try parsing EXIF format: "YYYY:MM:DD HH:MM:SS" - if let Some(dt) = parse_exif_datetime(&val) { - meta.date_taken = Some(dt); - } - meta.extra.insert("date_taken".to_string(), val); - } - } - - // GPS coordinates - set both in top-level fields and extra - if let (Some(lat), Some(lat_ref), Some(lon), Some(lon_ref)) = ( - exif_data.get_field(exif::Tag::GPSLatitude, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLatitudeRef, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLongitude, exif::In::PRIMARY), - exif_data.get_field(exif::Tag::GPSLongitudeRef, exif::In::PRIMARY), - ) && let (Some(lat_val), Some(lon_val)) = - (dms_to_decimal(lat, lat_ref), dms_to_decimal(lon, lon_ref)) - { - meta.latitude = Some(lat_val); - meta.longitude = Some(lon_val); - meta - .extra - .insert("gps_latitude".to_string(), format!("{lat_val:.6}")); - meta - .extra - .insert("gps_longitude".to_string(), format!("{lon_val:.6}")); - } - - // Exposure info - if let Some(iso) = - exif_data.get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) - { - let val = iso.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("iso".to_string(), val); - } - } - if let Some(exposure) = - exif_data.get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) - { - let val = exposure.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("exposure_time".to_string(), val); - } - } - if let Some(aperture) = - exif_data.get_field(exif::Tag::FNumber, exif::In::PRIMARY) - { - let val = aperture.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("f_number".to_string(), val); - } - } - if let Some(focal) = - exif_data.get_field(exif::Tag::FocalLength, exif::In::PRIMARY) - { - let val = focal.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("focal_length".to_string(), val); - } - } - - // Lens model - if let Some(lens) = - exif_data.get_field(exif::Tag::LensModel, exif::In::PRIMARY) - { - let val = lens.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta - .extra - .insert("lens_model".to_string(), val.trim_matches('"').to_string()); - } - } - - // Flash - if let Some(flash) = - exif_data.get_field(exif::Tag::Flash, exif::In::PRIMARY) - { - let val = flash.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("flash".to_string(), val); - } - } - - // Orientation - if let Some(orientation) = - exif_data.get_field(exif::Tag::Orientation, exif::In::PRIMARY) - { - let val = orientation.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("orientation".to_string(), val); - } - } - - // Software - if let Some(software) = - exif_data.get_field(exif::Tag::Software, exif::In::PRIMARY) - { - let val = software.display_value().to_string(); - if !val.is_empty() { - meta.extra.insert("software".to_string(), val); - } - } - - // Image description as title - if let Some(desc) = - exif_data.get_field(exif::Tag::ImageDescription, exif::In::PRIMARY) - { - let val = desc.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.title = Some(val.trim_matches('"').to_string()); - } - } - - // Artist - if let Some(artist) = - exif_data.get_field(exif::Tag::Artist, exif::In::PRIMARY) - { - let val = artist.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.artist = Some(val.trim_matches('"').to_string()); - } - } - - // Copyright as description - if let Some(copyright) = - exif_data.get_field(exif::Tag::Copyright, exif::In::PRIMARY) - { - let val = copyright.display_value().to_string(); - if !val.is_empty() && val != "\"\"" { - meta.description = Some(val.trim_matches('"').to_string()); - } - } - - Ok(meta) - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Jpeg), - MediaType::Builtin(BuiltinMediaType::Png), - MediaType::Builtin(BuiltinMediaType::Gif), - MediaType::Builtin(BuiltinMediaType::Webp), - MediaType::Builtin(BuiltinMediaType::Avif), - MediaType::Builtin(BuiltinMediaType::Tiff), - MediaType::Builtin(BuiltinMediaType::Bmp), - // RAW formats (TIFF-based, kamadak-exif handles these) - MediaType::Builtin(BuiltinMediaType::Cr2), - MediaType::Builtin(BuiltinMediaType::Nef), - MediaType::Builtin(BuiltinMediaType::Arw), - MediaType::Builtin(BuiltinMediaType::Dng), - MediaType::Builtin(BuiltinMediaType::Orf), - MediaType::Builtin(BuiltinMediaType::Rw2), - // HEIC - MediaType::Builtin(BuiltinMediaType::Heic), - ] - } -} - -fn field_to_u32(field: &exif::Field) -> Option { - match &field.value { - exif::Value::Long(v) => v.first().copied(), - exif::Value::Short(v) => v.first().map(|&x| u32::from(x)), - _ => None, - } -} - -fn dms_to_decimal( - dms_field: &exif::Field, - ref_field: &exif::Field, -) -> Option { - if let exif::Value::Rational(ref rationals) = dms_field.value - && rationals.len() >= 3 - { - let degrees = rationals[0].to_f64(); - let minutes = rationals[1].to_f64(); - let seconds = rationals[2].to_f64(); - let mut decimal = degrees + minutes / 60.0 + seconds / 3600.0; - - let ref_str = ref_field.display_value().to_string(); - if ref_str.contains('S') || ref_str.contains('W') { - decimal = -decimal; - } - - return Some(decimal); - } - None -} - -/// Parse EXIF datetime format: "YYYY:MM:DD HH:MM:SS" -fn parse_exif_datetime(s: &str) -> Option> { - use chrono::NaiveDateTime; - - // EXIF format is "YYYY:MM:DD HH:MM:SS" - let s = s.trim().trim_matches('"'); - - // Try standard EXIF format - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y:%m:%d %H:%M:%S") { - return Some(dt.and_utc()); - } - - // Try ISO format as fallback - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return Some(dt.and_utc()); - } - - None -} - -/// Generate a perceptual hash for an image file. -/// -/// Uses DCT (Discrete Cosine Transform) hash algorithm for robust similarity -/// detection. Returns a hex-encoded hash string, or None if the image cannot be -/// processed. -#[must_use] -pub fn generate_perceptual_hash(path: &Path) -> Option { - use image_hasher::{HashAlg, HasherConfig}; - - // Open and decode the image - let img = image::open(path).ok()?; - - // Create hasher with DCT algorithm (good for finding similar images) - let hasher = HasherConfig::new() - .hash_alg(HashAlg::DoubleGradient) - .hash_size(8, 8) // 64-bit hash - .to_hasher(); - - // Generate hash - let hash = hasher.hash_image(&img); - - // Convert to hex string for storage - Some(hash.to_base64()) -} diff --git a/crates/pinakes-core/src/metadata/markdown.rs b/crates/pinakes-core/src/metadata/markdown.rs deleted file mode 100644 index 155a7e6..0000000 --- a/crates/pinakes-core/src/metadata/markdown.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::Result, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct MarkdownExtractor; - -impl MetadataExtractor for MarkdownExtractor { - fn extract(&self, path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - let parsed = - gray_matter::Matter::::new().parse(&content); - - let mut meta = ExtractedMetadata::default(); - - if let Some(data) = parsed.ok().and_then(|p| p.data) - && let gray_matter::Pod::Hash(map) = data - { - if let Some(gray_matter::Pod::String(title)) = map.get("title") { - meta.title = Some(title.clone()); - } - if let Some(gray_matter::Pod::String(author)) = map.get("author") { - meta.artist = Some(author.clone()); - } - if let Some(gray_matter::Pod::String(desc)) = map.get("description") { - meta.description = Some(desc.clone()); - } - if let Some(gray_matter::Pod::String(date)) = map.get("date") { - meta.extra.insert("date".to_string(), date.clone()); - } - } - - Ok(meta) - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Markdown), - MediaType::Builtin(BuiltinMediaType::PlainText), - ] - } -} diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs deleted file mode 100644 index b4e91e5..0000000 --- a/crates/pinakes-core/src/metadata/mod.rs +++ /dev/null @@ -1,70 +0,0 @@ -pub mod audio; -pub mod document; -pub mod image; -pub mod markdown; -pub mod video; - -use std::path::Path; - -use rustc_hash::FxHashMap; - -use crate::{error::Result, media_type::MediaType, model::BookMetadata}; - -#[derive(Debug, Clone, Default)] -pub struct ExtractedMetadata { - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub extra: FxHashMap, - pub book_metadata: Option, - - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, -} - -pub trait MetadataExtractor: Send + Sync { - /// Extract metadata from a file at the given path. - /// - /// # Errors - /// - /// Returns an error if the file cannot be read or parsed. - fn extract(&self, path: &Path) -> Result; - fn supported_types(&self) -> Vec; -} - -/// Extract metadata from a file using the appropriate extractor for the given -/// media type. -/// -/// # Errors -/// -/// Returns an error if no extractor supports the media type, or if extraction -/// fails. -pub fn extract_metadata( - path: &Path, - media_type: &MediaType, -) -> Result { - let extractors: Vec> = vec![ - Box::new(audio::AudioExtractor), - Box::new(document::DocumentExtractor), - Box::new(video::VideoExtractor), - Box::new(markdown::MarkdownExtractor), - Box::new(image::ImageExtractor), - ]; - - for extractor in &extractors { - if extractor.supported_types().contains(media_type) { - return extractor.extract(path); - } - } - - Ok(ExtractedMetadata::default()) -} diff --git a/crates/pinakes-core/src/metadata/video.rs b/crates/pinakes-core/src/metadata/video.rs deleted file mode 100644 index a0c26f5..0000000 --- a/crates/pinakes-core/src/metadata/video.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::path::Path; - -use super::{ExtractedMetadata, MetadataExtractor}; -use crate::{ - error::{PinakesError, Result}, - media_type::{BuiltinMediaType, MediaType}, -}; - -pub struct VideoExtractor; - -impl MetadataExtractor for VideoExtractor { - fn extract(&self, path: &Path) -> Result { - match MediaType::from_path(path) { - Some(MediaType::Builtin(BuiltinMediaType::Mkv)) => extract_mkv(path), - Some(MediaType::Builtin(BuiltinMediaType::Mp4)) => extract_mp4(path), - _ => Ok(ExtractedMetadata::default()), - } - } - - fn supported_types(&self) -> Vec { - vec![ - MediaType::Builtin(BuiltinMediaType::Mp4), - MediaType::Builtin(BuiltinMediaType::Mkv), - ] - } -} - -fn extract_mkv(path: &Path) -> Result { - let file = std::fs::File::open(path)?; - let mkv = matroska::Matroska::open(file) - .map_err(|e| PinakesError::MetadataExtraction(format!("MKV parse: {e}")))?; - - let mut meta = ExtractedMetadata { - title: mkv.info.title.clone(), - duration_secs: mkv.info.duration.map(|dur| dur.as_secs_f64()), - ..Default::default() - }; - - // Extract resolution and codec info from tracks - for track in &mkv.tracks { - match &track.settings { - matroska::Settings::Video(v) => { - meta.extra.insert( - "resolution".to_string(), - format!("{}x{}", v.pixel_width, v.pixel_height), - ); - if !track.codec_id.is_empty() { - meta - .extra - .insert("video_codec".to_string(), track.codec_id.clone()); - } - }, - matroska::Settings::Audio(a) => { - meta.extra.insert( - "sample_rate".to_string(), - format!("{:.0} Hz", a.sample_rate), - ); - meta - .extra - .insert("channels".to_string(), a.channels.to_string()); - if !track.codec_id.is_empty() { - meta - .extra - .insert("audio_codec".to_string(), track.codec_id.clone()); - } - }, - matroska::Settings::None => {}, - } - } - - Ok(meta) -} - -fn extract_mp4(path: &Path) -> Result { - use lofty::{ - file::{AudioFile, TaggedFileExt}, - tag::Accessor, - }; - - let tagged_file = lofty::read_from_path(path).map_err(|e| { - PinakesError::MetadataExtraction(format!("MP4 metadata: {e}")) - })?; - - let mut meta = ExtractedMetadata::default(); - - if let Some(tag) = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()) - { - meta.title = tag - .title() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.artist = tag - .artist() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.album = tag - .album() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.genre = tag - .genre() - .map(|s: std::borrow::Cow<'_, str>| s.to_string()); - meta.year = tag.date().map(|ts| i32::from(ts.year)); - } - - let properties = tagged_file.properties(); - let duration = properties.duration(); - if !duration.is_zero() { - meta.duration_secs = Some(duration.as_secs_f64()); - } - - if let Some(bitrate) = properties.audio_bitrate() { - meta - .extra - .insert("audio_bitrate".to_string(), format!("{bitrate} kbps")); - } - if let Some(sample_rate) = properties.sample_rate() { - meta - .extra - .insert("sample_rate".to_string(), format!("{sample_rate} Hz")); - } - if let Some(channels) = properties.channels() { - meta - .extra - .insert("channels".to_string(), channels.to_string()); - } - - Ok(meta) -} diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs deleted file mode 100644 index f37e7a1..0000000 --- a/crates/pinakes-core/src/model.rs +++ /dev/null @@ -1,659 +0,0 @@ -use std::{fmt, path::PathBuf}; - -use chrono::{DateTime, Utc}; -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::media_type::MediaType; - -/// Unique identifier for a media item. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct MediaId(pub Uuid); - -impl MediaId { - /// Creates a new media ID using `UUIDv7`. - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl fmt::Display for MediaId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for MediaId { - fn default() -> Self { - Self(uuid::Uuid::nil()) - } -} - -/// BLAKE3 content hash for deduplication. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ContentHash(pub String); - -impl ContentHash { - /// Creates a new content hash from a hex string. - #[must_use] - pub const fn new(hex: String) -> Self { - Self(hex) - } -} - -impl fmt::Display for ContentHash { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Storage mode for media items -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, -)] -#[serde(rename_all = "lowercase")] -pub enum StorageMode { - /// File exists on disk, referenced by path - #[default] - External, - /// File is stored in managed content-addressable storage - Managed, -} - -impl fmt::Display for StorageMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::External => write!(f, "external"), - Self::Managed => write!(f, "managed"), - } - } -} - -impl std::str::FromStr for StorageMode { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "external" => Ok(Self::External), - "managed" => Ok(Self::Managed), - _ => Err(format!("unknown storage mode: {s}")), - } - } -} - -/// A blob stored in managed storage (content-addressable) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ManagedBlob { - pub content_hash: ContentHash, - pub file_size: u64, - pub mime_type: String, - pub reference_count: u32, - pub stored_at: DateTime, - pub last_verified: Option>, -} - -/// Result of uploading a file to managed storage -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UploadResult { - pub media_id: MediaId, - pub content_hash: ContentHash, - pub was_duplicate: bool, - pub file_size: u64, -} - -/// Statistics about managed storage -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ManagedStorageStats { - pub total_blobs: u64, - pub total_size_bytes: u64, - pub unique_size_bytes: u64, - pub deduplication_ratio: f64, - pub managed_media_count: u64, - pub orphaned_blobs: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MediaItem { - pub id: MediaId, - pub path: PathBuf, - pub file_name: String, - pub media_type: MediaType, - pub content_hash: ContentHash, - pub file_size: u64, - pub title: Option, - pub artist: Option, - pub album: Option, - pub genre: Option, - pub year: Option, - pub duration_secs: Option, - pub description: Option, - pub thumbnail_path: Option, - pub custom_fields: FxHashMap, - /// File modification time (Unix timestamp in seconds), used for incremental - /// scanning - pub file_mtime: Option, - - // Photo-specific metadata - pub date_taken: Option>, - pub latitude: Option, - pub longitude: Option, - pub camera_make: Option, - pub camera_model: Option, - pub rating: Option, - pub perceptual_hash: Option, - - // Managed storage fields - /// How the file is stored (external on disk or managed in - /// content-addressable storage) - #[serde(default)] - pub storage_mode: StorageMode, - /// Original filename for uploaded files (preserved separately from - /// `file_name`) - pub original_filename: Option, - /// When the file was uploaded to managed storage - pub uploaded_at: Option>, - /// Storage key for looking up the blob (usually same as `content_hash`) - pub storage_key: Option, - - pub created_at: DateTime, - pub updated_at: DateTime, - - /// Soft delete timestamp. If set, the item is in the trash. - pub deleted_at: Option>, - - /// When markdown links were last extracted from this file. - pub links_extracted_at: Option>, -} - -/// A custom field attached to a media item. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CustomField { - pub field_type: CustomFieldType, - pub value: String, -} - -/// Type of custom field value. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CustomFieldType { - Text, - Number, - Date, - Boolean, -} - -impl CustomFieldType { - #[must_use] - pub const fn as_str(&self) -> &'static str { - match self { - Self::Text => "text", - Self::Number => "number", - Self::Date => "date", - Self::Boolean => "boolean", - } - } -} - -impl std::fmt::Display for CustomFieldType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// A tag that can be applied to media items. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Tag { - pub id: Uuid, - pub name: String, - pub parent_id: Option, - pub created_at: DateTime, -} - -/// A collection of media items. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Collection { - pub id: Uuid, - pub name: String, - pub description: Option, - pub kind: CollectionKind, - pub filter_query: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Kind of collection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CollectionKind { - Manual, - Virtual, -} - -impl CollectionKind { - #[must_use] - pub const fn as_str(&self) -> &'static str { - match self { - Self::Manual => "manual", - Self::Virtual => "virtual", - } - } -} - -impl std::fmt::Display for CollectionKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -/// A member of a collection with position tracking. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CollectionMember { - pub collection_id: Uuid, - pub media_id: MediaId, - pub position: i32, - pub added_at: DateTime, -} - -/// An audit trail entry. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuditEntry { - pub id: Uuid, - pub media_id: Option, - pub action: AuditAction, - pub details: Option, - pub timestamp: DateTime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AuditAction { - // Media actions - Imported, - Updated, - Deleted, - Tagged, - Untagged, - AddedToCollection, - RemovedFromCollection, - Opened, - Scanned, - - // Authentication actions - LoginSuccess, - LoginFailed, - Logout, - SessionExpired, - - // Authorization actions - PermissionDenied, - RoleChanged, - LibraryAccessGranted, - LibraryAccessRevoked, - - // User management - UserCreated, - UserUpdated, - UserDeleted, - - // Plugin actions - PluginInstalled, - PluginUninstalled, - PluginEnabled, - PluginDisabled, - - // Configuration actions - ConfigChanged, - RootDirectoryAdded, - RootDirectoryRemoved, - - // Social/Sharing actions - ShareLinkCreated, - ShareLinkAccessed, - - // System actions - DatabaseVacuumed, - DatabaseCleared, - ExportCompleted, - IntegrityCheckCompleted, -} - -impl fmt::Display for AuditAction { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - // Media actions - Self::Imported => "imported", - Self::Updated => "updated", - Self::Deleted => "deleted", - Self::Tagged => "tagged", - Self::Untagged => "untagged", - Self::AddedToCollection => "added_to_collection", - Self::RemovedFromCollection => "removed_from_collection", - Self::Opened => "opened", - Self::Scanned => "scanned", - - // Authentication actions - Self::LoginSuccess => "login_success", - Self::LoginFailed => "login_failed", - Self::Logout => "logout", - Self::SessionExpired => "session_expired", - - // Authorization actions - Self::PermissionDenied => "permission_denied", - Self::RoleChanged => "role_changed", - Self::LibraryAccessGranted => "library_access_granted", - Self::LibraryAccessRevoked => "library_access_revoked", - - // User management - Self::UserCreated => "user_created", - Self::UserUpdated => "user_updated", - Self::UserDeleted => "user_deleted", - - // Plugin actions - Self::PluginInstalled => "plugin_installed", - Self::PluginUninstalled => "plugin_uninstalled", - Self::PluginEnabled => "plugin_enabled", - Self::PluginDisabled => "plugin_disabled", - - // Configuration actions - Self::ConfigChanged => "config_changed", - Self::RootDirectoryAdded => "root_directory_added", - Self::RootDirectoryRemoved => "root_directory_removed", - - // Social/Sharing actions - Self::ShareLinkCreated => "share_link_created", - Self::ShareLinkAccessed => "share_link_accessed", - - // System actions - Self::DatabaseVacuumed => "database_vacuumed", - Self::DatabaseCleared => "database_cleared", - Self::ExportCompleted => "export_completed", - Self::IntegrityCheckCompleted => "integrity_check_completed", - }; - write!(f, "{s}") - } -} - -/// Pagination parameters for list queries. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Pagination { - pub offset: u64, - pub limit: u64, - pub sort: Option, -} - -impl Pagination { - /// Creates a new pagination instance. - #[must_use] - pub const fn new(offset: u64, limit: u64, sort: Option) -> Self { - Self { - offset, - limit, - sort, - } - } -} - -impl Default for Pagination { - fn default() -> Self { - Self { - offset: 0, - limit: 50, - sort: None, - } - } -} - -/// A saved search query. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SavedSearch { - pub id: Uuid, - pub name: String, - pub query: String, - pub sort_order: Option, - pub created_at: DateTime, -} - -// Book Management Types - -/// Metadata for book-type media. -/// -/// Used both as a DB record (with populated `media_id`, `created_at`, -/// `updated_at`) and as an extraction result (with placeholder values for -/// those fields when the record has not yet been persisted). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BookMetadata { - pub media_id: MediaId, - pub isbn: Option, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: FxHashMap>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl Default for BookMetadata { - fn default() -> Self { - let now = Utc::now(); - Self { - media_id: MediaId(uuid::Uuid::nil()), - isbn: None, - isbn13: None, - publisher: None, - language: None, - page_count: None, - publication_date: None, - series_name: None, - series_index: None, - format: None, - authors: Vec::new(), - identifiers: FxHashMap::default(), - created_at: now, - updated_at: now, - } - } -} - -/// Information about a book author. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AuthorInfo { - pub name: String, - pub role: String, - pub file_as: Option, - pub position: i32, -} - -impl AuthorInfo { - /// Creates a new author with the given name. - #[must_use] - pub fn new(name: String) -> Self { - Self { - name, - role: "author".to_string(), - file_as: None, - position: 0, - } - } - - /// Sets the author's role. - #[must_use] - pub fn with_role(mut self, role: String) -> Self { - self.role = role; - self - } - - #[must_use] - pub fn with_file_as(mut self, file_as: String) -> Self { - self.file_as = Some(file_as); - self - } - - #[must_use] - pub const fn with_position(mut self, position: i32) -> Self { - self.position = position; - self - } -} - -/// Reading progress for a book. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReadingProgress { - pub media_id: MediaId, - pub user_id: Uuid, - pub current_page: i32, - pub total_pages: Option, - pub progress_percent: f64, - pub last_read_at: DateTime, -} - -impl ReadingProgress { - /// Creates a new reading progress entry. - #[must_use] - pub fn new( - media_id: MediaId, - user_id: Uuid, - current_page: i32, - total_pages: Option, - ) -> Self { - let progress_percent = total_pages.map_or(0.0, |total| { - if total > 0 { - (f64::from(current_page) / f64::from(total) * 100.0).min(100.0) - } else { - 0.0 - } - }); - - Self { - media_id, - user_id, - current_page, - total_pages, - progress_percent, - last_read_at: Utc::now(), - } - } -} - -/// Reading status for a book. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReadingStatus { - ToRead, - Reading, - Completed, - Abandoned, -} - -impl fmt::Display for ReadingStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ToRead => write!(f, "to_read"), - Self::Reading => write!(f, "reading"), - Self::Completed => write!(f, "completed"), - Self::Abandoned => write!(f, "abandoned"), - } - } -} - -/// Type of markdown link -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LinkType { - /// Wikilink: [[target]] or [[target|display]] - Wikilink, - /// Markdown link: [text](path) - MarkdownLink, - /// Embed: ![[target]] - Embed, -} - -impl fmt::Display for LinkType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Wikilink => write!(f, "wikilink"), - Self::MarkdownLink => write!(f, "markdown_link"), - Self::Embed => write!(f, "embed"), - } - } -} - -impl std::str::FromStr for LinkType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "wikilink" => Ok(Self::Wikilink), - "markdown_link" => Ok(Self::MarkdownLink), - "embed" => Ok(Self::Embed), - _ => Err(format!("unknown link type: {s}")), - } - } -} - -/// A markdown link extracted from a file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MarkdownLink { - pub id: Uuid, - pub source_media_id: MediaId, - /// Raw link target as written in the source (wikilink name or path) - pub target_path: String, - /// Resolved target `media_id` (None if unresolved) - pub target_media_id: Option, - pub link_type: LinkType, - /// Display text for the link - pub link_text: Option, - /// Line number in source file (1-indexed) - pub line_number: Option, - /// Surrounding text for backlink preview - pub context: Option, - pub created_at: DateTime, -} - -/// Information about a backlink (incoming link). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BacklinkInfo { - pub link_id: Uuid, - pub source_id: MediaId, - pub source_title: Option, - pub source_path: String, - pub link_text: Option, - pub line_number: Option, - pub context: Option, - pub link_type: LinkType, -} - -/// Graph data for visualization. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GraphData { - pub nodes: Vec, - pub edges: Vec, -} - -/// A node in the graph visualization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GraphNode { - pub id: String, - pub label: String, - pub title: Option, - pub media_type: String, - /// Number of outgoing links from this node - pub link_count: u32, - /// Number of incoming links to this node - pub backlink_count: u32, -} - -/// An edge (link) in the graph visualization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GraphEdge { - pub source: String, - pub target: String, - pub link_type: LinkType, -} diff --git a/crates/pinakes-core/src/plugin/loader.rs b/crates/pinakes-core/src/plugin/loader.rs deleted file mode 100644 index f8242e8..0000000 --- a/crates/pinakes-core/src/plugin/loader.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! Plugin loader for discovering and loading plugins from the filesystem - -use std::path::{Path, PathBuf}; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::PluginManifest; -use tracing::{debug, info, warn}; -use walkdir::WalkDir; - -/// Plugin loader handles discovery and loading of plugins from directories -pub struct PluginLoader { - /// Directories to search for plugins - plugin_dirs: Vec, -} - -impl PluginLoader { - /// Create a new plugin loader - #[must_use] - pub const fn new(plugin_dirs: Vec) -> Self { - Self { plugin_dirs } - } - - /// Discover all plugins in configured directories - /// - /// # Errors - /// - /// Returns an error if a plugin directory cannot be searched. - pub fn discover_plugins(&self) -> Result> { - let mut manifests = Vec::new(); - - for dir in &self.plugin_dirs { - if !dir.exists() { - warn!("Plugin directory does not exist: {:?}", dir); - continue; - } - - info!("Discovering plugins in: {:?}", dir); - - let found = Self::discover_in_directory(dir); - info!("Found {} plugins in {:?}", found.len(), dir); - manifests.extend(found); - } - - Ok(manifests) - } - - /// Discover plugins in a specific directory - fn discover_in_directory(dir: &Path) -> Vec { - let mut manifests = Vec::new(); - - // Walk the directory looking for plugin.toml files - for entry in WalkDir::new(dir) - .max_depth(3) // Don't go too deep - .follow_links(false) - { - let entry = match entry { - Ok(e) => e, - Err(e) => { - warn!("Error reading directory entry: {}", e); - continue; - }, - }; - - let path = entry.path(); - - // Look for plugin.toml files - if path.file_name() == Some(std::ffi::OsStr::new("plugin.toml")) { - debug!("Found plugin manifest: {:?}", path); - - match PluginManifest::from_file(path) { - Ok(manifest) => { - info!("Loaded manifest for plugin: {}", manifest.plugin.name); - manifests.push(manifest); - }, - Err(e) => { - warn!("Failed to load manifest from {:?}: {}", path, e); - }, - } - } - } - - manifests - } - - /// Resolve the WASM binary path from a manifest - /// - /// # Errors - /// - /// Returns an error if the WASM binary is not found or its path escapes the - /// plugin directory. - pub fn resolve_wasm_path( - &self, - manifest: &PluginManifest, - ) -> Result { - // The WASM path in the manifest is relative to the manifest file - // We need to search for it in the plugin directories - - for dir in &self.plugin_dirs { - // Look for a directory matching the plugin name - let plugin_dir = dir.join(&manifest.plugin.name); - if !plugin_dir.exists() { - continue; - } - - // Check for plugin.toml in this directory - let manifest_path = plugin_dir.join("plugin.toml"); - if !manifest_path.exists() { - continue; - } - - // Resolve WASM path relative to this directory - let wasm_path = plugin_dir.join(&manifest.plugin.binary.wasm); - if wasm_path.exists() { - // Verify the resolved path is within the plugin directory (prevent path - // traversal) - let canonical_wasm = wasm_path - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize WASM path: {e}"))?; - let canonical_plugin_dir = plugin_dir - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize plugin dir: {e}"))?; - if !canonical_wasm.starts_with(&canonical_plugin_dir) { - return Err(anyhow!( - "WASM binary path escapes plugin directory: {}", - wasm_path.display() - )); - } - return Ok(canonical_wasm); - } - } - - Err(anyhow!( - "WASM binary not found for plugin: {}", - manifest.plugin.name - )) - } - - /// Download a plugin from a URL - /// - /// # Errors - /// - /// Returns an error if the URL is not HTTPS, no plugin directories are - /// configured, the download fails, the archive is too large, or extraction - /// fails. - pub async fn download_plugin(&self, url: &str) -> Result { - const MAX_PLUGIN_SIZE: u64 = 100 * 1024 * 1024; // 100 MB - - // Only allow HTTPS downloads - if !url.starts_with("https://") { - return Err(anyhow!( - "Only HTTPS URLs are allowed for plugin downloads: {url}" - )); - } - - let dest_dir = self - .plugin_dirs - .first() - .ok_or_else(|| anyhow!("No plugin directories configured"))?; - - std::fs::create_dir_all(dest_dir)?; - - // Download the archive with timeout and size limits - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_mins(5)) - .build() - .map_err(|e| anyhow!("Failed to build HTTP client: {e}"))?; - - let response = client - .get(url) - .send() - .await - .map_err(|e| anyhow!("Failed to download plugin: {e}"))?; - - if !response.status().is_success() { - return Err(anyhow!( - "Plugin download failed with status: {}", - response.status() - )); - } - - // Check content-length header before downloading - if let Some(content_length) = response.content_length() - && content_length > MAX_PLUGIN_SIZE - { - return Err(anyhow!( - "Plugin archive too large: {content_length} bytes (max \ - {MAX_PLUGIN_SIZE} bytes)" - )); - } - - let bytes = response - .bytes() - .await - .map_err(|e| anyhow!("Failed to read plugin response: {e}"))?; - - // Check actual size after download - if bytes.len() as u64 > MAX_PLUGIN_SIZE { - return Err(anyhow!( - "Plugin archive too large: {} bytes (max {} bytes)", - bytes.len(), - MAX_PLUGIN_SIZE - )); - } - - // Write archive to a unique temp file - let temp_archive = - dest_dir.join(format!(".download-{}.tar.gz", uuid::Uuid::now_v7())); - std::fs::write(&temp_archive, &bytes)?; - - // Extract using tar with -C to target directory - let canonical_dest = dest_dir - .canonicalize() - .map_err(|e| anyhow!("Failed to canonicalize dest dir: {e}"))?; - let output = std::process::Command::new("tar") - .args([ - "xzf", - &temp_archive.to_string_lossy(), - "-C", - &canonical_dest.to_string_lossy(), - ]) - .output() - .map_err(|e| anyhow!("Failed to extract plugin archive: {e}"))?; - - // Clean up the archive - let _ = std::fs::remove_file(&temp_archive); - - if !output.status.success() { - return Err(anyhow!( - "Failed to extract plugin archive: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - - // Validate that all extracted files are within dest_dir - for entry in WalkDir::new(&canonical_dest).follow_links(false) { - let entry = entry?; - let entry_canonical = entry.path().canonicalize()?; - if !entry_canonical.starts_with(&canonical_dest) { - return Err(anyhow!( - "Extracted file escapes destination directory: {}", - entry.path().display() - )); - } - } - - // Find the extracted plugin directory by looking for plugin.toml - for entry in WalkDir::new(dest_dir).max_depth(2).follow_links(false) { - let entry = entry?; - if entry.file_name() == "plugin.toml" { - let plugin_dir = entry - .path() - .parent() - .ok_or_else(|| anyhow!("Invalid plugin.toml location"))?; - - // Validate the manifest - let manifest = PluginManifest::from_file(entry.path())?; - info!("Downloaded and extracted plugin: {}", manifest.plugin.name); - - return Ok(plugin_dir.to_path_buf()); - } - } - - Err(anyhow!( - "No plugin.toml found after extracting archive from: {url}" - )) - } - - /// Validate a plugin package - /// - /// # Errors - /// - /// Returns an error if the path does not exist, is missing `plugin.toml`, - /// the WASM binary is not found, or the WASM file is invalid. - pub fn validate_plugin_package(&self, path: &Path) -> Result<()> { - // Check that the path exists - if !path.exists() { - return Err(anyhow!("Plugin path does not exist: {}", path.display())); - } - - // Check for plugin.toml - let manifest_path = path.join("plugin.toml"); - if !manifest_path.exists() { - return Err(anyhow!("Missing plugin.toml in {}", path.display())); - } - - // Parse and validate manifest - let manifest = PluginManifest::from_file(&manifest_path)?; - - // Check that WASM binary exists - let wasm_path = path.join(&manifest.plugin.binary.wasm); - if !wasm_path.exists() { - return Err(anyhow!( - "WASM binary not found: {}", - manifest.plugin.binary.wasm - )); - } - - // Verify the WASM path is within the plugin directory (prevent path - // traversal) - let canonical_wasm = wasm_path.canonicalize()?; - let canonical_path = path.canonicalize()?; - if !canonical_wasm.starts_with(&canonical_path) { - return Err(anyhow!( - "WASM binary path escapes plugin directory: {}", - wasm_path.display() - )); - } - - // Validate WASM file - let wasm_bytes = std::fs::read(&wasm_path)?; - if wasm_bytes.len() < 4 || &wasm_bytes[0..4] != b"\0asm" { - return Err(anyhow!("Invalid WASM file: {}", wasm_path.display())); - } - - Ok(()) - } - - /// Get plugin directory path for a given plugin name - #[must_use] - pub fn get_plugin_dir(&self, plugin_name: &str) -> Option { - for dir in &self.plugin_dirs { - let plugin_dir = dir.join(plugin_name); - if plugin_dir.exists() { - return Some(plugin_dir); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[test] - fn test_discover_plugins_empty() { - let temp_dir = TempDir::new().unwrap(); - let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - - let manifests = loader.discover_plugins().unwrap(); - assert_eq!(manifests.len(), 0); - } - - #[test] - fn test_discover_plugins_with_manifest() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); - - // Create a valid manifest - let manifest_content = r#" -[plugin] -name = "test-plugin" -version = "1.0.0" -api_version = "1.0" -kind = ["media_type"] - -[plugin.binary] -wasm = "plugin.wasm" -"#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - - // Create dummy WASM file - std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") - .unwrap(); - - let loader = PluginLoader::new(vec![temp_dir.path().to_path_buf()]); - let manifests = loader.discover_plugins().unwrap(); - - assert_eq!(manifests.len(), 1); - assert_eq!(manifests[0].plugin.name, "test-plugin"); - } - - #[test] - fn test_validate_plugin_package() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); - - // Create a valid manifest - let manifest_content = r#" -[plugin] -name = "test-plugin" -version = "1.0.0" -api_version = "1.0" -kind = ["media_type"] - -[plugin.binary] -wasm = "plugin.wasm" -"#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - - let loader = PluginLoader::new(vec![]); - - // Should fail without WASM file - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); - - // Create valid WASM file (magic number only) - std::fs::write(plugin_dir.join("plugin.wasm"), b"\0asm\x01\x00\x00\x00") - .unwrap(); - - // Should succeed now - assert!(loader.validate_plugin_package(&plugin_dir).is_ok()); - } - - #[test] - fn test_validate_invalid_wasm() { - let temp_dir = TempDir::new().unwrap(); - let plugin_dir = temp_dir.path().join("test-plugin"); - std::fs::create_dir(&plugin_dir).unwrap(); - - let manifest_content = r#" -[plugin] -name = "test-plugin" -version = "1.0.0" -api_version = "1.0" -kind = ["media_type"] - -[plugin.binary] -wasm = "plugin.wasm" -"#; - std::fs::write(plugin_dir.join("plugin.toml"), manifest_content).unwrap(); - - // Create invalid WASM file - std::fs::write(plugin_dir.join("plugin.wasm"), b"not wasm").unwrap(); - - let loader = PluginLoader::new(vec![]); - assert!(loader.validate_plugin_package(&plugin_dir).is_err()); - } -} diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index f8ae6cc..0a8286f 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -1,932 +1,3 @@ -//! Plugin system for Pinakes -//! -//! This module provides a comprehensive plugin architecture that allows -//! extending Pinakes with custom media types, metadata extractors, search -//! backends, and more. -//! -//! # Architecture -//! -//! - Plugins are compiled to WASM and run in a sandboxed environment -//! - Capability-based security controls what plugins can access -//! - Hot-reload support for development -//! - Automatic plugin discovery from configured directories - -use std::{path::PathBuf, sync::Arc}; - -use anyhow::Result; -use pinakes_plugin_api::{PluginContext, PluginMetadata}; -use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; - -pub mod loader; +//! Plugin pipeline for Pinakes. pub mod pipeline; -pub mod registry; -pub mod rpc; -pub mod runtime; -pub mod security; -pub mod signature; - -pub use loader::PluginLoader; pub use pipeline::PluginPipeline; -pub use registry::{PluginRegistry, RegisteredPlugin}; -pub use runtime::{WasmPlugin, WasmRuntime}; -pub use security::CapabilityEnforcer; -pub use signature::{SignatureStatus, verify_plugin_signature}; - -/// Plugin manager coordinates plugin lifecycle and operations -pub struct PluginManager { - /// Plugin registry - registry: Arc>, - - /// WASM runtime for executing plugins - runtime: Arc, - - /// Plugin loader for discovery and loading - loader: PluginLoader, - - /// Capability enforcer for security - enforcer: CapabilityEnforcer, - - /// Plugin data directory - data_dir: PathBuf, - - /// Plugin cache directory - cache_dir: PathBuf, - - /// Configuration - config: PluginManagerConfig, -} - -/// Configuration for the plugin manager -#[derive(Debug, Clone)] -pub struct PluginManagerConfig { - /// Directories to search for plugins - pub plugin_dirs: Vec, - - /// Whether to enable hot-reload (for development) - pub enable_hot_reload: bool, - - /// Whether to allow unsigned plugins - pub allow_unsigned: bool, - - /// Maximum number of concurrent plugin operations - pub max_concurrent_ops: usize, - - /// Plugin timeout in seconds - pub plugin_timeout_secs: u64, - - /// Timeout configuration for different call types - pub timeouts: crate::config::PluginTimeoutConfig, - - /// Max consecutive failures before circuit breaker disables plugin - pub max_consecutive_failures: u32, - - /// Trusted Ed25519 public keys for signature verification (hex-encoded) - pub trusted_keys: Vec, -} - -impl Default for PluginManagerConfig { - fn default() -> Self { - Self { - plugin_dirs: vec![], - enable_hot_reload: false, - allow_unsigned: false, - max_concurrent_ops: 4, - plugin_timeout_secs: 30, - timeouts: crate::config::PluginTimeoutConfig::default(), - max_consecutive_failures: 5, - trusted_keys: vec![], - } - } -} - -impl From for PluginManagerConfig { - fn from(cfg: crate::config::PluginsConfig) -> Self { - Self { - plugin_dirs: cfg.plugin_dirs, - enable_hot_reload: cfg.enable_hot_reload, - allow_unsigned: cfg.allow_unsigned, - max_concurrent_ops: cfg.max_concurrent_ops, - plugin_timeout_secs: cfg.plugin_timeout_secs, - timeouts: cfg.timeouts, - max_consecutive_failures: cfg.max_consecutive_failures, - trusted_keys: cfg.trusted_keys, - } - } -} - -impl PluginManager { - /// Create a new plugin manager - /// - /// # Errors - /// - /// Returns an error if the data or cache directories cannot be created, or - /// if the WASM runtime cannot be initialized. - pub fn new( - data_dir: PathBuf, - cache_dir: PathBuf, - config: PluginManagerConfig, - ) -> Result { - // Ensure directories exist - std::fs::create_dir_all(&data_dir)?; - std::fs::create_dir_all(&cache_dir)?; - - let runtime = Arc::new(WasmRuntime::new()?); - let registry = Arc::new(RwLock::new(PluginRegistry::new())); - let loader = PluginLoader::new(config.plugin_dirs.clone()); - let enforcer = CapabilityEnforcer::new(); - - Ok(Self { - registry, - runtime, - loader, - enforcer, - data_dir, - cache_dir, - config, - }) - } - - /// Discover and load all plugins from configured directories. - /// - /// Plugins are loaded in dependency order: if plugin A declares a - /// dependency on plugin B, B is loaded first. Cycles and missing - /// dependencies are detected and reported as warnings; affected plugins - /// are skipped rather than causing a hard failure. - /// - /// # Errors - /// - /// Returns an error if plugin discovery fails. - pub async fn discover_and_load_all(&self) -> Result> { - info!("Discovering plugins from {:?}", self.config.plugin_dirs); - - let manifests = self.loader.discover_plugins()?; - let ordered = Self::resolve_load_order(&manifests); - let mut loaded_plugins = Vec::new(); - - for manifest in ordered { - match self.load_plugin_from_manifest(&manifest).await { - Ok(plugin_id) => { - info!("Loaded plugin: {}", plugin_id); - loaded_plugins.push(plugin_id); - }, - Err(e) => { - warn!("Failed to load plugin {}: {}", manifest.plugin.name, e); - }, - } - } - - Ok(loaded_plugins) - } - - /// Topological sort of manifests by their declared `dependencies`. - /// - /// Uses Kahn's algorithm. Plugins whose dependencies are missing or form - /// a cycle are logged as warnings and excluded from the result. - fn resolve_load_order( - manifests: &[pinakes_plugin_api::PluginManifest], - ) -> Vec { - use std::collections::VecDeque; - - use rustc_hash::{FxHashMap, FxHashSet}; - - // Index manifests by name for O(1) lookup - let by_name: FxHashMap<&str, usize> = manifests - .iter() - .enumerate() - .map(|(i, m)| (m.plugin.name.as_str(), i)) - .collect(); - - // Check for missing dependencies and warn early - let known: FxHashSet<&str> = by_name.keys().copied().collect(); - for manifest in manifests { - for dep in &manifest.plugin.dependencies { - if !known.contains(dep.as_str()) { - warn!( - "Plugin '{}' depends on '{}' which was not discovered; it will be \ - skipped", - manifest.plugin.name, dep - ); - } - } - } - - // Build adjacency: in_degree[i] = number of deps that must load before i - let mut in_degree = vec![0usize; manifests.len()]; - // dependents[i] = indices that depend on i (i must load before them) - let mut dependents: Vec> = vec![vec![]; manifests.len()]; - - for (i, manifest) in manifests.iter().enumerate() { - for dep in &manifest.plugin.dependencies { - if let Some(&dep_idx) = by_name.get(dep.as_str()) { - in_degree[i] += 1; - dependents[dep_idx].push(i); - } else { - // Missing dep: set in_degree impossibly high so it never resolves - in_degree[i] = usize::MAX; - } - } - } - - // Kahn's algorithm - let mut queue: VecDeque = VecDeque::new(); - for (i, °) in in_degree.iter().enumerate() { - if deg == 0 { - queue.push_back(i); - } - } - - let mut result = Vec::with_capacity(manifests.len()); - while let Some(idx) = queue.pop_front() { - result.push(manifests[idx].clone()); - for &dependent in &dependents[idx] { - if in_degree[dependent] == usize::MAX { - continue; // already poisoned by missing dep - } - in_degree[dependent] -= 1; - if in_degree[dependent] == 0 { - queue.push_back(dependent); - } - } - } - - // Anything not in `result` is part of a cycle or has a missing dep - if result.len() < manifests.len() { - let loaded: FxHashSet<&str> = - result.iter().map(|m| m.plugin.name.as_str()).collect(); - for manifest in manifests { - if !loaded.contains(manifest.plugin.name.as_str()) { - warn!( - "Plugin '{}' was skipped due to unresolved dependencies or a \ - dependency cycle", - manifest.plugin.name - ); - } - } - } - - result - } - - /// Load a plugin from a manifest file - /// - /// # Errors - /// - /// Returns an error if the plugin ID is invalid, capability validation - /// fails, the WASM binary cannot be loaded, or the plugin cannot be - /// registered. - async fn load_plugin_from_manifest( - &self, - manifest: &pinakes_plugin_api::PluginManifest, - ) -> Result { - let plugin_id = manifest.plugin_id(); - - // Validate plugin_id to prevent path traversal - if plugin_id.contains('/') - || plugin_id.contains('\\') - || plugin_id.contains("..") - { - return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}")); - } - - // Check if already loaded - { - let registry = self.registry.read().await; - if registry.is_loaded(&plugin_id) { - return Ok(plugin_id); - } - } - - // Validate capabilities - let capabilities = manifest.to_capabilities(); - self.enforcer.validate_capabilities(&capabilities)?; - - // Create plugin context - let plugin_data_dir = self.data_dir.join(&plugin_id); - let plugin_cache_dir = self.cache_dir.join(&plugin_id); - tokio::fs::create_dir_all(&plugin_data_dir).await?; - tokio::fs::create_dir_all(&plugin_cache_dir).await?; - - let context = PluginContext { - data_dir: plugin_data_dir, - cache_dir: plugin_cache_dir, - config: manifest - .config - .iter() - .map(|(k, v)| { - ( - k.clone(), - serde_json::to_value(v).unwrap_or_else(|e| { - tracing::warn!( - "failed to serialize config value for key {}: {}", - k, - e - ); - serde_json::Value::Null - }), - ) - }) - .collect(), - capabilities: capabilities.clone(), - }; - - // Load WASM binary - let wasm_path = self.loader.resolve_wasm_path(manifest)?; - - // Verify plugin signature unless unsigned plugins are allowed - if !self.config.allow_unsigned { - let plugin_dir = wasm_path - .parent() - .ok_or_else(|| anyhow::anyhow!("WASM path has no parent directory"))?; - - let trusted_keys: Vec = self - .config - .trusted_keys - .iter() - .filter_map(|hex| { - signature::parse_public_key(hex) - .map_err(|e| warn!("Ignoring malformed trusted key: {e}")) - .ok() - }) - .collect(); - - match signature::verify_plugin_signature( - plugin_dir, - &wasm_path, - &trusted_keys, - )? { - SignatureStatus::Valid => { - debug!("Plugin '{plugin_id}' signature verified"); - }, - SignatureStatus::Unsigned => { - return Err(anyhow::anyhow!( - "Plugin '{plugin_id}' is unsigned and allow_unsigned is false" - )); - }, - SignatureStatus::Invalid(reason) => { - return Err(anyhow::anyhow!( - "Plugin '{plugin_id}' has an invalid signature: {reason}" - )); - }, - } - } - - let wasm_plugin = self.runtime.load_plugin(&wasm_path, context)?; - - // Initialize plugin - let init_succeeded = match wasm_plugin - .call_function("initialize", &[]) - .await - { - Ok(_) => true, - Err(e) => { - tracing::warn!(plugin_id = %plugin_id, "plugin initialization failed: {}", e); - false - }, - }; - - // Register plugin - let metadata = PluginMetadata { - id: plugin_id.clone(), - name: manifest.plugin.name.clone(), - version: manifest.plugin.version.clone(), - author: manifest.plugin.author.clone().unwrap_or_default(), - description: manifest - .plugin - .description - .clone() - .unwrap_or_default(), - api_version: manifest.plugin.api_version.clone(), - capabilities_required: capabilities, - }; - - // Derive manifest_path from the loader's plugin directories - let manifest_path = self - .loader - .get_plugin_dir(&manifest.plugin.name) - .map(|dir| dir.join("plugin.toml")); - - let registered = RegisteredPlugin { - id: plugin_id.clone(), - metadata, - wasm_plugin, - manifest: manifest.clone(), - manifest_path, - enabled: init_succeeded, - }; - - { - let mut registry = self.registry.write().await; - registry.register(registered)?; - } - - Ok(plugin_id) - } - - /// Install a plugin from a file or URL - /// - /// # Errors - /// - /// Returns an error if the plugin cannot be downloaded, the manifest cannot - /// be read, or the plugin cannot be loaded. - pub async fn install_plugin(&self, source: &str) -> Result { - info!("Installing plugin from: {}", source); - - // Download/copy plugin to plugins directory - let plugin_path = - if source.starts_with("http://") || source.starts_with("https://") { - // Download from URL - self.loader.download_plugin(source).await? - } else { - // Copy from local file - PathBuf::from(source) - }; - - // Load the manifest - let manifest_path = plugin_path.join("plugin.toml"); - let manifest = - pinakes_plugin_api::PluginManifest::from_file(&manifest_path)?; - - // Load the plugin - self.load_plugin_from_manifest(&manifest).await - } - - /// Uninstall a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is invalid, the plugin cannot be shut - /// down, cannot be unregistered, or its data directories cannot be removed. - pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { - // Validate plugin_id to prevent path traversal - if plugin_id.contains('/') - || plugin_id.contains('\\') - || plugin_id.contains("..") - { - return Err(anyhow::anyhow!("Invalid plugin ID: {plugin_id}")); - } - - info!("Uninstalling plugin: {}", plugin_id); - - // Shutdown plugin first - self.shutdown_plugin(plugin_id).await?; - - // Remove from registry - { - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - } - - // Remove plugin data and cache - let plugin_data_dir = self.data_dir.join(plugin_id); - let plugin_cache_dir = self.cache_dir.join(plugin_id); - - if plugin_data_dir.exists() { - std::fs::remove_dir_all(&plugin_data_dir)?; - } - if plugin_cache_dir.exists() { - std::fs::remove_dir_all(&plugin_cache_dir)?; - } - - Ok(()) - } - - /// Enable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn enable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.enable(plugin_id) - } - - /// Disable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn disable_plugin(&self, plugin_id: &str) -> Result<()> { - let mut registry = self.registry.write().await; - registry.disable(plugin_id) - } - - /// Shutdown a specific plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found in the registry. - pub async fn shutdown_plugin(&self, plugin_id: &str) -> Result<()> { - debug!("Shutting down plugin: {}", plugin_id); - - let registry = self.registry.read().await; - if let Some(plugin) = registry.get(plugin_id) { - let _ = plugin.wasm_plugin.call_function("shutdown", &[]).await; - Ok(()) - } else { - Err(anyhow::anyhow!("Plugin not found: {plugin_id}")) - } - } - - /// Shutdown all plugins - /// - /// # Errors - /// - /// This function always returns `Ok(())`. Individual plugin shutdown errors - /// are logged but do not cause the overall operation to fail. - pub async fn shutdown_all(&self) -> Result<()> { - info!("Shutting down all plugins"); - - let plugin_ids: Vec = { - let registry = self.registry.read().await; - registry.list_all().iter().map(|p| p.id.clone()).collect() - }; - - for plugin_id in plugin_ids { - if let Err(e) = self.shutdown_plugin(&plugin_id).await { - error!("Failed to shutdown plugin {}: {}", plugin_id, e); - } - } - - Ok(()) - } - - /// Get list of all registered plugins - pub async fn list_plugins(&self) -> Vec { - let registry = self.registry.read().await; - registry - .list_all() - .iter() - .map(|p| p.metadata.clone()) - .collect() - } - - /// Get plugin metadata by ID - pub async fn get_plugin(&self, plugin_id: &str) -> Option { - let registry = self.registry.read().await; - registry.get(plugin_id).map(|p| p.metadata.clone()) - } - - /// Get enabled plugins of a specific kind, sorted by priority (ascending). - /// - /// # Returns - /// - /// `(plugin_id, priority, kinds, wasm_plugin)` tuples. - pub async fn get_enabled_by_kind_sorted( - &self, - kind: &str, - ) -> Vec<(String, u16, Vec, WasmPlugin)> { - let registry = self.registry.read().await; - let mut plugins: Vec<_> = registry - .get_by_kind(kind) - .into_iter() - .filter(|p| p.enabled) - .map(|p| { - ( - p.id.clone(), - p.manifest.plugin.priority, - p.manifest.plugin.kind.clone(), - p.wasm_plugin.clone(), - ) - }) - .collect(); - drop(registry); - plugins.sort_by_key(|(_, priority, ..)| *priority); - plugins - } - - /// Get a reference to the capability enforcer. - #[must_use] - pub const fn enforcer(&self) -> &CapabilityEnforcer { - &self.enforcer - } - - /// List all UI pages provided by loaded plugins. - /// - /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins - /// that provide pages in their manifests. Both inline and file-referenced - /// page entries are resolved. - pub async fn list_ui_pages( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiPage)> { - self - .list_ui_pages_with_endpoints() - .await - .into_iter() - .map(|(id, page, _)| (id, page)) - .collect() - } - - /// List all UI pages provided by loaded plugins, including each plugin's - /// declared endpoint allowlist. - /// - /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The - /// `allowed_endpoints` list mirrors the `required_endpoints` field from the - /// plugin manifest's `[ui]` section. - pub async fn list_ui_pages_with_endpoints( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiPage, Vec)> { - let registry = self.registry.read().await; - let mut pages = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - let allowed = plugin.manifest.ui.required_endpoints.clone(); - let plugin_dir = plugin - .manifest_path - .as_ref() - .and_then(|p| p.parent()) - .map(std::path::Path::to_path_buf); - let Some(plugin_dir) = plugin_dir else { - for entry in &plugin.manifest.ui.pages { - if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry - { - pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); - } - } - continue; - }; - match plugin.manifest.load_ui_pages(&plugin_dir) { - Ok(loaded) => { - for page in loaded { - pages.push((plugin.id.clone(), page, allowed.clone())); - } - }, - Err(e) => { - tracing::warn!( - "Failed to load UI pages for plugin '{}': {e}", - plugin.id - ); - }, - } - } - pages - } - - /// Collect CSS custom property overrides declared by all enabled plugins. - /// - /// When multiple plugins declare the same property name, later-loaded plugins - /// overwrite earlier ones. Returns an empty map if no plugins are loaded or - /// none declare theme extensions. - pub async fn list_ui_theme_extensions( - &self, - ) -> rustc_hash::FxHashMap { - let registry = self.registry.read().await; - let mut merged = rustc_hash::FxHashMap::default(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - for (k, v) in &plugin.manifest.ui.theme_extensions { - merged.insert(k.clone(), v.clone()); - } - } - merged - } - - /// List all UI widgets provided by loaded plugins. - /// - /// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins - /// that provide widgets in their manifests. - pub async fn list_ui_widgets( - &self, - ) -> Vec<(String, pinakes_plugin_api::UiWidget)> { - let registry = self.registry.read().await; - let mut widgets = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - for widget in &plugin.manifest.ui.widgets { - widgets.push((plugin.id.clone(), widget.clone())); - } - } - widgets - } - - /// Check if a plugin is loaded and enabled - pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { - let registry = self.registry.read().await; - registry.is_enabled(plugin_id).unwrap_or(false) - } - - /// Reload a plugin (for hot-reload during development) - /// - /// # Errors - /// - /// Returns an error if hot-reload is disabled, the plugin is not found, it - /// cannot be shut down, or the reloaded plugin cannot be registered. - pub async fn reload_plugin(&self, plugin_id: &str) -> Result<()> { - if !self.config.enable_hot_reload { - return Err(anyhow::anyhow!("Hot-reload is disabled")); - } - - info!("Reloading plugin: {}", plugin_id); - - // Re-read the manifest from disk if possible, falling back to cached - // version - let manifest = { - let registry = self.registry.read().await; - let plugin = registry - .get(plugin_id) - .ok_or_else(|| anyhow::anyhow!("Plugin not found"))?; - let manifest = plugin.manifest_path.as_ref().map_or_else( - || plugin.manifest.clone(), - |manifest_path| { - pinakes_plugin_api::PluginManifest::from_file(manifest_path) - .unwrap_or_else(|e| { - warn!( - "Failed to re-read manifest from disk, using cached: {}", - e - ); - plugin.manifest.clone() - }) - }, - ); - drop(registry); - manifest - }; - - // Shutdown and unload current version - self.shutdown_plugin(plugin_id).await?; - { - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - } - - // Reload from manifest - self.load_plugin_from_manifest(&manifest).await?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use tempfile::TempDir; - - use super::*; - - #[tokio::test] - async fn test_plugin_manager_creation() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); - - let config = PluginManagerConfig::default(); - let manager = - PluginManager::new(data_dir.clone(), cache_dir.clone(), config); - - assert!(manager.is_ok()); - assert!(data_dir.exists()); - assert!(cache_dir.exists()); - } - - #[tokio::test] - async fn test_list_plugins_empty() { - let temp_dir = TempDir::new().unwrap(); - let data_dir = temp_dir.path().join("data"); - let cache_dir = temp_dir.path().join("cache"); - - let config = PluginManagerConfig::default(); - let manager = PluginManager::new(data_dir, cache_dir, config).unwrap(); - - let plugins = manager.list_plugins().await; - assert_eq!(plugins.len(), 0); - } - - /// Build a minimal manifest for dependency resolution tests - fn test_manifest( - name: &str, - deps: Vec, - ) -> pinakes_plugin_api::PluginManifest { - use pinakes_plugin_api::manifest::{PluginBinary, PluginInfo}; - - pinakes_plugin_api::PluginManifest { - plugin: PluginInfo { - name: name.to_string(), - version: "1.0.0".to_string(), - api_version: "1.0".to_string(), - author: None, - description: None, - homepage: None, - license: None, - priority: 500, - kind: vec!["media_type".to_string()], - binary: PluginBinary { - wasm: "plugin.wasm".to_string(), - entrypoint: None, - }, - dependencies: deps, - }, - capabilities: Default::default(), - config: Default::default(), - ui: Default::default(), - } - } - - #[test] - fn test_resolve_load_order_no_deps() { - let manifests = vec![ - test_manifest("alpha", vec![]), - test_manifest("beta", vec![]), - test_manifest("gamma", vec![]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 3); - } - - #[test] - fn test_resolve_load_order_linear_chain() { - // gamma depends on beta, beta depends on alpha - let manifests = vec![ - test_manifest("gamma", vec!["beta".to_string()]), - test_manifest("alpha", vec![]), - test_manifest("beta", vec!["alpha".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 3); - - let names: Vec<&str> = - ordered.iter().map(|m| m.plugin.name.as_str()).collect(); - let alpha_pos = names.iter().position(|&n| n == "alpha").unwrap(); - let beta_pos = names.iter().position(|&n| n == "beta").unwrap(); - let gamma_pos = names.iter().position(|&n| n == "gamma").unwrap(); - assert!(alpha_pos < beta_pos, "alpha must load before beta"); - assert!(beta_pos < gamma_pos, "beta must load before gamma"); - } - - #[test] - fn test_resolve_load_order_cycle_detected() { - // A -> B -> C -> A (cycle) - let manifests = vec![ - test_manifest("a", vec!["c".to_string()]), - test_manifest("b", vec!["a".to_string()]), - test_manifest("c", vec!["b".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - // All three should be excluded due to cycle - assert_eq!(ordered.len(), 0); - } - - #[test] - fn test_resolve_load_order_missing_dependency() { - let manifests = vec![ - test_manifest("good", vec![]), - test_manifest("bad", vec!["nonexistent".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - // Only "good" should be loaded; "bad" depends on something missing - assert_eq!(ordered.len(), 1); - assert_eq!(ordered[0].plugin.name, "good"); - } - - #[test] - fn test_resolve_load_order_partial_cycle() { - // "ok" has no deps, "cycle_a" and "cycle_b" form a cycle - let manifests = vec![ - test_manifest("ok", vec![]), - test_manifest("cycle_a", vec!["cycle_b".to_string()]), - test_manifest("cycle_b", vec!["cycle_a".to_string()]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 1); - assert_eq!(ordered[0].plugin.name, "ok"); - } - - #[test] - fn test_resolve_load_order_diamond() { - // Man look at how beautiful my diamond is... - // A - // / \ - // B C - // \ / - // D - let manifests = vec![ - test_manifest("d", vec!["b".to_string(), "c".to_string()]), - test_manifest("b", vec!["a".to_string()]), - test_manifest("c", vec!["a".to_string()]), - test_manifest("a", vec![]), - ]; - - let ordered = PluginManager::resolve_load_order(&manifests); - assert_eq!(ordered.len(), 4); - - let names: Vec<&str> = - ordered.iter().map(|m| m.plugin.name.as_str()).collect(); - let a_pos = names.iter().position(|&n| n == "a").unwrap(); - let b_pos = names.iter().position(|&n| n == "b").unwrap(); - let c_pos = names.iter().position(|&n| n == "c").unwrap(); - let d_pos = names.iter().position(|&n| n == "d").unwrap(); - assert!(a_pos < b_pos); - assert!(a_pos < c_pos); - assert!(b_pos < d_pos); - assert!(c_pos < d_pos); - } -} diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index f094e73..3a8439e 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -18,17 +18,10 @@ use std::{ time::{Duration, Instant}, }; -use rustc_hash::FxHashMap; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; - -use super::PluginManager; -use crate::{ - config::PluginTimeoutConfig, - media_type::MediaType, - metadata::ExtractedMetadata, - model::MediaId, - plugin::rpc::{ +use pinakes_metadata::ExtractedMetadata; +use pinakes_plugin::{ + PluginManager, + rpc::{ CanHandleRequest, CanHandleResponse, ExtractMetadataRequest, @@ -46,6 +39,12 @@ use crate::{ SearchResultItem, }, }; +use pinakes_types::config::PluginTimeoutConfig; +use rustc_hash::FxHashMap; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +use crate::{media_type::MediaType, model::MediaId}; /// Built-in handlers run at this implicit priority. const BUILTIN_PRIORITY: u16 = 100; @@ -529,7 +528,7 @@ impl PluginPipeline { let path = path.to_path_buf(); let mt = media_type.clone(); let builtin = tokio::task::spawn_blocking(move || { - crate::metadata::extract_metadata(&path, &mt) + pinakes_metadata::extract_metadata(&path, &mt) }) .await .map_err(|e| { @@ -1174,10 +1173,10 @@ fn merge_extracted(base: &mut ExtractedMetadata, source: ExtractedMetadata) { #[cfg(test)] mod tests { + use pinakes_plugin::{PluginManager, PluginManagerConfig}; use tempfile::TempDir; use super::*; - use crate::plugin::{PluginManager, PluginManagerConfig}; /// Create a `PluginPipeline` backed by an empty `PluginManager`. fn create_test_pipeline() -> (TempDir, Arc) { diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs deleted file mode 100644 index ce13d86..0000000 --- a/crates/pinakes-core/src/plugin/registry.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Plugin registry for managing loaded plugins - -use std::path::PathBuf; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::{PluginManifest, PluginMetadata}; -use rustc_hash::FxHashMap; - -use super::runtime::WasmPlugin; - -/// A registered plugin with its metadata and runtime state -#[derive(Clone)] -pub struct RegisteredPlugin { - pub id: String, - pub metadata: PluginMetadata, - pub wasm_plugin: WasmPlugin, - pub manifest: PluginManifest, - pub manifest_path: Option, - pub enabled: bool, -} - -/// Plugin registry maintains the state of all loaded plugins -pub struct PluginRegistry { - /// Map of plugin ID to registered plugin - plugins: FxHashMap, -} - -impl PluginRegistry { - /// Create a new empty registry - #[must_use] - pub fn new() -> Self { - Self { - plugins: FxHashMap::default(), - } - } - - /// Register a new plugin - /// - /// # Errors - /// - /// Returns an error if a plugin with the same ID is already registered. - pub fn register(&mut self, plugin: RegisteredPlugin) -> Result<()> { - if self.plugins.contains_key(&plugin.id) { - return Err(anyhow!("Plugin already registered: {}", plugin.id)); - } - - self.plugins.insert(plugin.id.clone(), plugin); - Ok(()) - } - - /// Unregister a plugin by ID - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found. - pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { - self - .plugins - .remove(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; - Ok(()) - } - - /// Get a plugin by ID - #[must_use] - pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> { - self.plugins.get(plugin_id) - } - - /// Get a mutable reference to a plugin by ID - pub fn get_mut(&mut self, plugin_id: &str) -> Option<&mut RegisteredPlugin> { - self.plugins.get_mut(plugin_id) - } - - /// Check if a plugin is loaded - #[must_use] - pub fn is_loaded(&self, plugin_id: &str) -> bool { - self.plugins.contains_key(plugin_id) - } - - /// Check if a plugin is enabled. Returns `None` if the plugin is not found. - #[must_use] - pub fn is_enabled(&self, plugin_id: &str) -> Option { - self.plugins.get(plugin_id).map(|p| p.enabled) - } - - /// Enable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found. - pub fn enable(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .get_mut(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; - - plugin.enabled = true; - Ok(()) - } - - /// Disable a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin ID is not found. - pub fn disable(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .get_mut(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {plugin_id}"))?; - - plugin.enabled = false; - Ok(()) - } - - /// List all registered plugins - #[must_use] - pub fn list_all(&self) -> Vec<&RegisteredPlugin> { - self.plugins.values().collect() - } - - /// List all enabled plugins - #[must_use] - pub fn list_enabled(&self) -> Vec<&RegisteredPlugin> { - self.plugins.values().filter(|p| p.enabled).collect() - } - - /// Get plugins by kind (e.g., "`media_type`", "`metadata_extractor`") - #[must_use] - pub fn get_by_kind(&self, kind: &str) -> Vec<&RegisteredPlugin> { - self - .plugins - .values() - .filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind)) - .collect() - } - - /// Get count of registered plugins - #[must_use] - pub fn count(&self) -> usize { - self.plugins.len() - } - - /// Get count of enabled plugins - #[must_use] - pub fn count_enabled(&self) -> usize { - self.plugins.values().filter(|p| p.enabled).count() - } -} - -impl Default for PluginRegistry { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities}; - use rustc_hash::FxHashMap; - - use super::*; - - fn create_test_plugin(id: &str, kind: Vec) -> RegisteredPlugin { - let manifest = PluginManifest { - plugin: pinakes_plugin_api::manifest::PluginInfo { - name: id.to_string(), - version: "1.0.0".to_string(), - api_version: "1.0".to_string(), - author: Some("Test".to_string()), - description: Some("Test plugin".to_string()), - homepage: None, - license: None, - kind, - binary: pinakes_plugin_api::manifest::PluginBinary { - wasm: "test.wasm".to_string(), - entrypoint: None, - }, - dependencies: vec![], - priority: 0, - }, - capabilities: ManifestCapabilities::default(), - config: FxHashMap::default(), - ui: Default::default(), - }; - - RegisteredPlugin { - id: id.to_string(), - metadata: PluginMetadata { - id: id.to_string(), - name: id.to_string(), - version: "1.0.0".to_string(), - author: "Test".to_string(), - description: "Test plugin".to_string(), - api_version: "1.0".to_string(), - capabilities_required: Capabilities::default(), - }, - wasm_plugin: WasmPlugin::default(), - manifest, - manifest_path: None, - enabled: true, - } - } - - #[test] - fn test_registry_register_and_get() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin).unwrap(); - - assert!(registry.is_loaded("test-plugin")); - assert!(registry.get("test-plugin").is_some()); - } - - #[test] - fn test_registry_duplicate_register() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin.clone()).unwrap(); - let result = registry.register(plugin); - - assert!(result.is_err()); - } - - #[test] - fn test_registry_unregister() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin).unwrap(); - registry.unregister("test-plugin").unwrap(); - - assert!(!registry.is_loaded("test-plugin")); - } - - #[test] - fn test_registry_enable_disable() { - let mut registry = PluginRegistry::new(); - let plugin = - create_test_plugin("test-plugin", vec!["media_type".to_string()]); - - registry.register(plugin).unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(true)); - - registry.disable("test-plugin").unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(false)); - - registry.enable("test-plugin").unwrap(); - assert_eq!(registry.is_enabled("test-plugin"), Some(true)); - - assert_eq!(registry.is_enabled("nonexistent"), None); - } - - #[test] - fn test_registry_get_by_kind() { - let mut registry = PluginRegistry::new(); - - registry - .register(create_test_plugin("plugin1", vec![ - "media_type".to_string(), - ])) - .unwrap(); - registry - .register(create_test_plugin("plugin2", vec![ - "metadata_extractor".to_string(), - ])) - .unwrap(); - registry - .register(create_test_plugin("plugin3", vec![ - "media_type".to_string(), - ])) - .unwrap(); - - let media_type_plugins = registry.get_by_kind("media_type"); - assert_eq!(media_type_plugins.len(), 2); - - let extractor_plugins = registry.get_by_kind("metadata_extractor"); - assert_eq!(extractor_plugins.len(), 1); - } - - #[test] - fn test_registry_counts() { - let mut registry = PluginRegistry::new(); - - registry - .register(create_test_plugin("plugin1", vec![ - "media_type".to_string(), - ])) - .unwrap(); - registry - .register(create_test_plugin("plugin2", vec![ - "media_type".to_string(), - ])) - .unwrap(); - - assert_eq!(registry.count(), 2); - assert_eq!(registry.count_enabled(), 2); - - registry.disable("plugin1").unwrap(); - assert_eq!(registry.count(), 2); - assert_eq!(registry.count_enabled(), 1); - } -} diff --git a/crates/pinakes-core/src/plugin/rpc.rs b/crates/pinakes-core/src/plugin/rpc.rs deleted file mode 100644 index e875d11..0000000 --- a/crates/pinakes-core/src/plugin/rpc.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! JSON RPC types for structured plugin function calls. -//! -//! Each extension point maps to well-known exported function names. -//! Requests are serialized to JSON, passed to the plugin, and responses -//! are deserialized from JSON written by the plugin via `host_set_result`. - -use std::path::PathBuf; - -use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; - -/// Request to check if a plugin can handle a file -#[derive(Debug, Serialize)] -pub struct CanHandleRequest { - pub path: PathBuf, - pub mime_type: Option, -} - -/// Response from `can_handle` -#[derive(Debug, Deserialize)] -pub struct CanHandleResponse { - pub can_handle: bool, -} - -/// Media type definition returned by `supported_media_types` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginMediaTypeDefinition { - pub id: String, - pub name: String, - pub category: Option, - pub extensions: Vec, - pub mime_types: Vec, -} - -/// Request to extract metadata from a file -#[derive(Debug, Serialize)] -pub struct ExtractMetadataRequest { - pub path: PathBuf, -} - -/// Metadata response from a plugin (all fields optional for partial results) -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -pub struct ExtractMetadataResponse { - #[serde(default)] - pub title: Option, - #[serde(default)] - pub artist: Option, - #[serde(default)] - pub album: Option, - #[serde(default)] - pub genre: Option, - #[serde(default)] - pub year: Option, - #[serde(default)] - pub duration_secs: Option, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub extra: FxHashMap, -} - -/// Request to generate a thumbnail -#[derive(Debug, Serialize)] -pub struct GenerateThumbnailRequest { - pub source_path: PathBuf, - pub output_path: PathBuf, - pub max_width: u32, - pub max_height: u32, - pub format: String, -} - -/// Response from thumbnail generation -#[derive(Debug, Deserialize)] -pub struct GenerateThumbnailResponse { - pub path: PathBuf, - pub width: u32, - pub height: u32, - pub format: String, -} - -/// Event sent to event handler plugins -#[derive(Debug, Serialize)] -pub struct HandleEventRequest { - pub event_type: String, - pub payload: serde_json::Value, -} - -/// Search request for search backend plugins -#[derive(Debug, Serialize)] -pub struct SearchRequest { - pub query: String, - pub limit: usize, - pub offset: usize, -} - -/// Search response -#[derive(Debug, Clone, Deserialize)] -pub struct SearchResponse { - pub results: Vec, - #[serde(default)] - pub total_count: Option, -} - -/// Individual search result -#[derive(Debug, Clone, Deserialize)] -pub struct SearchResultItem { - pub id: String, - pub score: f64, - pub snippet: Option, -} - -/// Request to index a media item in a search backend -#[derive(Debug, Serialize)] -pub struct IndexItemRequest { - pub id: String, - pub title: Option, - pub artist: Option, - pub album: Option, - pub description: Option, - pub tags: Vec, - pub media_type: String, - pub path: PathBuf, -} - -/// Request to remove a media item from a search backend -#[derive(Debug, Serialize)] -pub struct RemoveItemRequest { - pub id: String, -} - -/// A theme definition returned by a theme provider plugin -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PluginThemeDefinition { - pub id: String, - pub name: String, - pub description: Option, - pub dark: bool, -} - -/// Response from `load_theme` -#[derive(Debug, Clone, Deserialize)] -pub struct LoadThemeResponse { - pub css: Option, - pub colors: FxHashMap, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_metadata_request_serialization() { - let req = ExtractMetadataRequest { - path: "/tmp/test.mp3".into(), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("/tmp/test.mp3")); - } - - #[test] - fn test_extract_metadata_response_partial() { - let json = r#"{"title":"My Song","extra":{"bpm":"120"}}"#; - let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.title.as_deref(), Some("My Song")); - assert_eq!(resp.artist, None); - assert_eq!(resp.extra.get("bpm").map(String::as_str), Some("120")); - } - - #[test] - fn test_extract_metadata_response_empty() { - let json = "{}"; - let resp: ExtractMetadataResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.title, None); - assert!(resp.extra.is_empty()); - } - - #[test] - fn test_can_handle_response() { - let json = r#"{"can_handle":true}"#; - let resp: CanHandleResponse = serde_json::from_str(json).unwrap(); - assert!(resp.can_handle); - } - - #[test] - fn test_can_handle_response_false() { - let json = r#"{"can_handle":false}"#; - let resp: CanHandleResponse = serde_json::from_str(json).unwrap(); - assert!(!resp.can_handle); - } - - #[test] - fn test_plugin_media_type_definition_round_trip() { - let def = PluginMediaTypeDefinition { - id: "heif".to_string(), - name: "HEIF Image".to_string(), - category: Some("image".to_string()), - extensions: vec!["heif".to_string(), "heic".to_string()], - mime_types: vec!["image/heif".to_string()], - }; - let json = serde_json::to_string(&def).unwrap(); - let parsed: PluginMediaTypeDefinition = - serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.id, "heif"); - assert_eq!(parsed.extensions.len(), 2); - } - - #[test] - fn test_search_response() { - let json = - r#"{"results":[{"id":"abc","score":0.95,"snippet":"match here"}]}"#; - let resp: SearchResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.results.len(), 1); - assert_eq!(resp.results[0].id, "abc"); - } - - #[test] - fn test_generate_thumbnail_request_serialization() { - let req = GenerateThumbnailRequest { - source_path: "/media/photo.heif".into(), - output_path: "/tmp/thumb.jpg".into(), - max_width: 256, - max_height: 256, - format: "jpeg".to_string(), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("photo.heif")); - assert!(json.contains("256")); - } - - #[test] - fn test_handle_event_request_serialization() { - let req = HandleEventRequest { - event_type: "MediaImported".to_string(), - payload: serde_json::json!({"id": "abc-123"}), - }; - let json = serde_json::to_string(&req).unwrap(); - assert!(json.contains("MediaImported")); - assert!(json.contains("abc-123")); - } -} diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs deleted file mode 100644 index e07a1c4..0000000 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ /dev/null @@ -1,925 +0,0 @@ -//! WASM runtime for executing plugins - -use std::{path::Path, sync::Arc}; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::PluginContext; -use wasmtime::{ - Caller, - Config, - Engine, - Linker, - Module, - Store, - StoreLimitsBuilder, - Val, - anyhow, -}; - -/// WASM runtime wrapper for executing plugins -pub struct WasmRuntime { - engine: Engine, -} - -impl WasmRuntime { - /// Create a new WASM runtime - /// - /// # Errors - /// - /// Returns an error if the WASM engine cannot be created with the given - /// configuration. - pub fn new() -> Result { - let mut config = Config::new(); - config.wasm_component_model(true); - config.max_wasm_stack(1024 * 1024); // 1MB stack - config.consume_fuel(true); // enable fuel metering for CPU limits - - let engine = Engine::new(&config)?; - - Ok(Self { engine }) - } - - /// Load a plugin from a WASM file - /// - /// # Errors - /// - /// Returns an error if the WASM file does not exist, cannot be read, or - /// cannot be compiled. - pub fn load_plugin( - &self, - wasm_path: &Path, - context: PluginContext, - ) -> Result { - if !wasm_path.exists() { - return Err(anyhow!("WASM file not found: {}", wasm_path.display())); - } - - let wasm_bytes = std::fs::read(wasm_path)?; - let module = Module::new(&self.engine, &wasm_bytes)?; - - Ok(WasmPlugin { - module: Arc::new(module), - context, - }) - } -} - -/// Store data passed to each WASM invocation -pub struct PluginStoreData { - pub context: PluginContext, - pub exchange_buffer: Vec, - pub pending_events: Vec<(String, String)>, - pub limiter: wasmtime::StoreLimits, -} - -/// A loaded WASM plugin instance -#[derive(Clone)] -pub struct WasmPlugin { - module: Arc, - context: PluginContext, -} - -impl WasmPlugin { - /// Get the plugin context - #[must_use] - pub const fn context(&self) -> &PluginContext { - &self.context - } - - /// Execute a plugin function, returning both the result bytes and any - /// events the plugin queued via `host_emit_event`. - /// - /// Creates a fresh store and instance per invocation with host functions - /// linked, calls the requested exported function, drains both the exchange - /// buffer and the pending events list before the store is dropped, and - /// returns both. - /// - /// # Errors - /// - /// Returns an error if the function cannot be found, instantiation fails, - /// or the function call returns an error. - pub async fn call_function_with_events( - &self, - function_name: &str, - params: &[u8], - ) -> Result<(Vec, Vec<(String, String)>)> { - let engine = self.module.engine(); - - // Build memory limiter from capabilities - let memory_limit = self - .context - .capabilities - .max_memory_bytes - .unwrap_or(512 * 1024 * 1024); // default 512 MB - - let limiter = StoreLimitsBuilder::new().memory_size(memory_limit).build(); - - let store_data = PluginStoreData { - context: self.context.clone(), - exchange_buffer: Vec::new(), - pending_events: Vec::new(), - limiter, - }; - let mut store = Store::new(engine, store_data); - store.limiter(|data| &mut data.limiter); - - // Set fuel limit based on capabilities - if let Some(max_cpu_time_ms) = self.context.capabilities.max_cpu_time_ms { - let fuel = max_cpu_time_ms * 100_000; - store.set_fuel(fuel)?; - } else { - store.set_fuel(1_000_000_000)?; - } - - let mut linker = Linker::new(engine); - HostFunctions::setup_linker(&mut linker)?; - - let instance = linker.instantiate_async(&mut store, &self.module).await?; - - let memory = instance.get_memory(&mut store, "memory"); - - // If there are params and memory is available, write them to the module - let mut alloc_offset: i32 = 0; - if !params.is_empty() - && let Some(mem) = &memory - { - // Call the plugin's alloc function if available, otherwise write at - // offset 0 - let offset = if let Ok(alloc) = - instance.get_typed_func::(&mut store, "alloc") - { - let result = alloc - .call_async( - &mut store, - i32::try_from(params.len()).unwrap_or(i32::MAX), - ) - .await?; - if result < 0 { - return Err(anyhow!( - "plugin alloc returned negative offset: {result}" - )); - } - u32::try_from(result).unwrap_or(0) as usize - } else { - 0 - }; - - alloc_offset = i32::try_from(offset).unwrap_or(i32::MAX); - let mem_data = mem.data_mut(&mut store); - if offset + params.len() <= mem_data.len() { - mem_data[offset..offset + params.len()].copy_from_slice(params); - } - } - - let func = - instance - .get_func(&mut store, function_name) - .ok_or_else(|| { - anyhow!("exported function '{function_name}' not found") - })?; - - let func_ty = func.ty(&store); - let param_count = func_ty.params().len(); - let result_count = func_ty.results().len(); - - let mut results = vec![Val::I32(0); result_count]; - - // Call with appropriate params based on function signature; convention: - // (ptr, len) - if param_count == 2 && !params.is_empty() { - func - .call_async( - &mut store, - &[ - Val::I32(alloc_offset), - Val::I32(i32::try_from(params.len()).unwrap_or(i32::MAX)), - ], - &mut results, - ) - .await?; - } else if param_count == 0 { - func.call_async(&mut store, &[], &mut results).await?; - } else { - // Generic: fill with zeroes - let params_vals: Vec = - std::iter::repeat_n(Val::I32(0), param_count).collect(); - func - .call_async(&mut store, ¶ms_vals, &mut results) - .await?; - } - - // Drain both buffers before the store is dropped. - let pending_events = std::mem::take(&mut store.data_mut().pending_events); - let exchange = std::mem::take(&mut store.data_mut().exchange_buffer); - - let result = if !exchange.is_empty() { - exchange - } else if let Some(Val::I32(ret)) = results.first() { - ret.to_le_bytes().to_vec() - } else { - Vec::new() - }; - - Ok((result, pending_events)) - } - - /// Execute a plugin function, discarding any events the plugin queued. - /// - /// This is a thin wrapper around [`Self::call_function_with_events`]. - /// - /// # Errors - /// - /// Returns an error if the function cannot be found, instantiation fails, - /// or the function call returns an error. - pub async fn call_function( - &self, - function_name: &str, - params: &[u8], - ) -> Result> { - let (data, _events) = self - .call_function_with_events(function_name, params) - .await?; - Ok(data) - } - - /// Call a plugin function with JSON request/response serialization. - /// - /// Serializes `request` to JSON, calls the named function, deserializes - /// the response. Wraps the call with `tokio::time::timeout`. - /// - /// # Errors - /// - /// Returns an error if serialization fails, the call times out, the plugin - /// traps, or the response is malformed JSON. - #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks - pub async fn call_function_json( - &self, - function_name: &str, - request: &Req, - timeout: std::time::Duration, - ) -> anyhow::Result - where - Req: serde::Serialize, - Resp: serde::de::DeserializeOwned, - { - let request_bytes = serde_json::to_vec(request) - .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; - - let result = tokio::time::timeout( - timeout, - self.call_function(function_name, &request_bytes), - ) - .await - .map_err(|_| { - anyhow::anyhow!( - "plugin call '{function_name}' timed out after {timeout:?}" - ) - })??; - - serde_json::from_slice(&result).map_err(|e| { - anyhow::anyhow!( - "failed to deserialize response from '{function_name}': {e}" - ) - }) - } - - /// Call a plugin function with JSON serialization, also returning any - /// events the plugin queued via `host_emit_event`. - /// - /// Mirrors [`Self::call_function_json`] but delegates to - /// [`Self::call_function_with_events`] so the pending events list is not - /// discarded before returning. - /// - /// # Errors - /// - /// Returns an error if serialization fails, the call times out, the plugin - /// traps, or the response is malformed JSON. - #[allow(clippy::future_not_send)] // Req doesn't need Sync; called within local tasks - pub async fn call_function_json_with_events( - &self, - function_name: &str, - request: &Req, - timeout: std::time::Duration, - ) -> anyhow::Result<(Resp, Vec<(String, String)>)> - where - Req: serde::Serialize, - Resp: serde::de::DeserializeOwned, - { - let request_bytes = serde_json::to_vec(request) - .map_err(|e| anyhow::anyhow!("failed to serialize request: {e}"))?; - - let (result, pending_events) = tokio::time::timeout( - timeout, - self.call_function_with_events(function_name, &request_bytes), - ) - .await - .map_err(|_| { - anyhow::anyhow!( - "plugin call '{function_name}' timed out after {timeout:?}" - ) - })??; - - let resp = serde_json::from_slice(&result).map_err(|e| { - anyhow::anyhow!( - "failed to deserialize response from '{function_name}': {e}" - ) - })?; - - Ok((resp, pending_events)) - } -} - -#[cfg(test)] -impl Default for WasmPlugin { - fn default() -> Self { - let engine = Engine::default(); - let module = Module::new(&engine, br"(module)").unwrap(); - - Self { - module: Arc::new(module), - context: PluginContext { - data_dir: std::env::temp_dir(), - cache_dir: std::env::temp_dir(), - config: Default::default(), - capabilities: Default::default(), - }, - } - } -} - -/// Host functions that plugins can call -pub struct HostFunctions; - -impl HostFunctions { - /// Registers all host ABI functions (`host_log`, `host_read_file`, - /// `host_write_file`, `host_http_request`, `host_get_config`, - /// `host_get_env`, `host_get_buffer`, `host_set_result`, - /// `host_emit_event`) into the given linker. - /// - /// # Errors - /// - /// Returns an error if any host function cannot be registered in the linker. - pub fn setup_linker(linker: &mut Linker) -> Result<()> { - linker.func_wrap( - "env", - "host_log", - |mut caller: Caller<'_, PluginStoreData>, - level: i32, - ptr: i32, - len: i32| { - if ptr < 0 || len < 0 { - return; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - if let Some(mem) = memory { - let data = mem.data(&caller); - let start = u32::try_from(ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(len).unwrap_or(0) as usize; - if end <= data.len() - && let Ok(msg) = std::str::from_utf8(&data[start..end]) - { - match level { - 0 => tracing::error!(plugin = true, "{}", msg), - 1 => tracing::warn!(plugin = true, "{}", msg), - 2 => tracing::info!(plugin = true, "{}", msg), - _ => tracing::debug!(plugin = true, "{}", msg), - } - } - } - }, - )?; - - linker.func_wrap( - "env", - "host_read_file", - |mut caller: Caller<'_, PluginStoreData>, - path_ptr: i32, - path_len: i32| - -> i32 { - if path_ptr < 0 || path_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(path_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(path_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let path_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Canonicalize path before checking permissions to prevent traversal - let Ok(path) = std::path::Path::new(&path_str).canonicalize() else { - return -1; - }; - - // Check read permission against canonicalized path - let can_read = caller - .data() - .context - .capabilities - .filesystem - .read - .iter() - .any(|allowed| { - allowed.canonicalize().is_ok_and(|a| path.starts_with(a)) - }); - - if !can_read { - tracing::warn!(path = %path_str, "plugin read access denied"); - return -2; - } - - std::fs::read(&path).map_or(-1, |contents| { - let len = i32::try_from(contents.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = contents; - len - }) - }, - )?; - - linker.func_wrap( - "env", - "host_write_file", - |mut caller: Caller<'_, PluginStoreData>, - path_ptr: i32, - path_len: i32, - data_ptr: i32, - data_len: i32| - -> i32 { - if path_ptr < 0 || path_len < 0 || data_ptr < 0 || data_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let mem_data = mem.data(&caller); - let path_start = u32::try_from(path_ptr).unwrap_or(0) as usize; - let path_end = - path_start + u32::try_from(path_len).unwrap_or(0) as usize; - let data_start = u32::try_from(data_ptr).unwrap_or(0) as usize; - let data_end = - data_start + u32::try_from(data_len).unwrap_or(0) as usize; - - if path_end > mem_data.len() || data_end > mem_data.len() { - return -1; - } - - let path_str = - match std::str::from_utf8(&mem_data[path_start..path_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - let file_data = mem_data[data_start..data_end].to_vec(); - - // Canonicalize path for write (file may not exist yet) - let path = std::path::Path::new(&path_str); - let canonical = if path.exists() { - path.canonicalize().ok() - } else { - path - .parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(path.file_name().unwrap_or_default())) - }; - let Some(canonical) = canonical else { - return -1; - }; - - // Check write permission against canonicalized path - let can_write = caller - .data() - .context - .capabilities - .filesystem - .write - .iter() - .any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }); - - if !can_write { - tracing::warn!(path = %path_str, "plugin write access denied"); - return -2; - } - - match std::fs::write(&canonical, &file_data) { - Ok(()) => 0, - Err(_) => -1, - } - }, - )?; - - linker.func_wrap( - "env", - "host_http_request", - |mut caller: Caller<'_, PluginStoreData>, - url_ptr: i32, - url_len: i32| - -> i32 { - if url_ptr < 0 || url_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(url_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(url_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let url_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Check network permission - if !caller.data().context.capabilities.network.enabled { - tracing::warn!(url = %url_str, "plugin network access denied"); - return -2; - } - - // Check domain whitelist if configured - if let Some(ref allowed) = - caller.data().context.capabilities.network.allowed_domains - { - let parsed = if let Ok(u) = url::Url::parse(&url_str) { - u - } else { - tracing::warn!(url = %url_str, "plugin provided invalid URL"); - return -1; - }; - let domain = parsed.host_str().unwrap_or(""); - - if !allowed.iter().any(|d| d.eq_ignore_ascii_case(domain)) { - tracing::warn!( - url = %url_str, - domain = domain, - "plugin domain not in allowlist" - ); - return -3; - } - } - - // Use block_in_place to avoid blocking the async runtime's thread pool. - // Falls back to a blocking client with timeout if block_in_place is - // unavailable. - let result = std::panic::catch_unwind(|| { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .get(&url_str) - .send() - .await - .map_err(|e| e.to_string())?; - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - Ok::<_, String>(bytes) - }) - }) - }); - - match result { - Ok(Ok(bytes)) => { - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes.to_vec(); - len - }, - Ok(Err(_)) => -1, - Err(_) => { - // block_in_place panicked (e.g. current-thread runtime); - // fall back to blocking client with timeout - let Ok(client) = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - else { - return -1; - }; - client.get(&url_str).send().map_or(-1, |resp| { - resp.bytes().map_or(-1, |bytes| { - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes.to_vec(); - len - }) - }) - }, - } - }, - )?; - - linker.func_wrap( - "env", - "host_get_config", - |mut caller: Caller<'_, PluginStoreData>, - key_ptr: i32, - key_len: i32| - -> i32 { - if key_ptr < 0 || key_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(key_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(key_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let key_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - let bytes = caller - .data() - .context - .config - .get(&key_str) - .map(|value| value.to_string().into_bytes()); - bytes.map_or(-1, |b| { - let len = i32::try_from(b.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = b; - len - }) - }, - )?; - - linker.func_wrap( - "env", - "host_get_env", - |mut caller: Caller<'_, PluginStoreData>, - key_ptr: i32, - key_len: i32| - -> i32 { - if key_ptr < 0 || key_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let data = mem.data(&caller); - let start = u32::try_from(key_ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(key_len).unwrap_or(0) as usize; - if end > data.len() { - return -1; - } - - let key_str = match std::str::from_utf8(&data[start..end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - - // Check environment capability - let env_cap = &caller.data().context.capabilities.environment; - if !env_cap.enabled { - tracing::warn!( - var = %key_str, - "plugin environment access denied" - ); - return -2; - } - - // Check against allowed variables list if configured - if let Some(ref allowed) = env_cap.allowed_vars - && !allowed.iter().any(|v| v == &key_str) - { - tracing::warn!( - var = %key_str, - "plugin env var not in allowlist" - ); - return -2; - } - - match std::env::var(&key_str) { - Ok(value) => { - let bytes = value.into_bytes(); - let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX); - caller.data_mut().exchange_buffer = bytes; - len - }, - Err(_) => -1, - } - }, - )?; - - linker.func_wrap( - "env", - "host_get_buffer", - |mut caller: Caller<'_, PluginStoreData>, - dest_ptr: i32, - dest_len: i32| - -> i32 { - if dest_ptr < 0 || dest_len < 0 { - return -1; - } - let buf = caller.data().exchange_buffer.clone(); - let copy_len = - buf.len().min(u32::try_from(dest_len).unwrap_or(0) as usize); - - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let mem_data = mem.data_mut(&mut caller); - let start = u32::try_from(dest_ptr).unwrap_or(0) as usize; - if start + copy_len > mem_data.len() { - return -1; - } - - mem_data[start..start + copy_len].copy_from_slice(&buf[..copy_len]); - i32::try_from(copy_len).unwrap_or(i32::MAX) - }, - )?; - - linker.func_wrap( - "env", - "host_set_result", - |mut caller: Caller<'_, PluginStoreData>, ptr: i32, len: i32| { - if ptr < 0 || len < 0 { - return; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return }; - - let data = mem.data(&caller); - let start = u32::try_from(ptr).unwrap_or(0) as usize; - let end = start + u32::try_from(len).unwrap_or(0) as usize; - if end <= data.len() { - caller.data_mut().exchange_buffer = data[start..end].to_vec(); - } - }, - )?; - - linker.func_wrap( - "env", - "host_emit_event", - |mut caller: Caller<'_, PluginStoreData>, - type_ptr: i32, - type_len: i32, - payload_ptr: i32, - payload_len: i32| - -> i32 { - const MAX_PENDING_EVENTS: usize = 1000; - - if type_ptr < 0 || type_len < 0 || payload_ptr < 0 || payload_len < 0 { - return -1; - } - let memory = caller - .get_export("memory") - .and_then(wasmtime::Extern::into_memory); - let Some(mem) = memory else { return -1 }; - - let type_start = u32::try_from(type_ptr).unwrap_or(0) as usize; - let type_end = - type_start + u32::try_from(type_len).unwrap_or(0) as usize; - let payload_start = u32::try_from(payload_ptr).unwrap_or(0) as usize; - let payload_end = - payload_start + u32::try_from(payload_len).unwrap_or(0) as usize; - - // Extract owned strings in a block so the immutable borrow of - // `caller` (via `mem.data`) is dropped before `caller.data_mut()`. - let (event_type, payload) = { - let data = mem.data(&caller); - if type_end > data.len() || payload_end > data.len() { - return -1; - } - let event_type = - match std::str::from_utf8(&data[type_start..type_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - let payload = - match std::str::from_utf8(&data[payload_start..payload_end]) { - Ok(s) => s.to_string(), - Err(_) => return -1, - }; - (event_type, payload) - }; - - if caller.data().pending_events.len() >= MAX_PENDING_EVENTS { - tracing::warn!("plugin exceeded max pending events limit"); - return -4; - } - - caller.data_mut().pending_events.push((event_type, payload)); - 0 - }, - )?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use pinakes_plugin_api::PluginContext; - use rustc_hash::FxHashMap; - - use super::*; - - #[test] - fn test_wasm_runtime_creation() { - let runtime = WasmRuntime::new(); - assert!(runtime.is_ok()); - } - - #[test] - fn test_host_functions_file_access() { - let mut capabilities = pinakes_plugin_api::Capabilities::default(); - capabilities.filesystem.read.push("/tmp".into()); - capabilities.filesystem.write.push("/tmp/output".into()); - - let context = PluginContext { - data_dir: "/tmp/data".into(), - cache_dir: "/tmp/cache".into(), - config: Default::default(), - capabilities, - }; - - // Verify capability checks work via context fields - let can_read = context - .capabilities - .filesystem - .read - .iter() - .any(|p| Path::new("/tmp/test.txt").starts_with(p)); - assert!(can_read); - - let cant_read = context - .capabilities - .filesystem - .read - .iter() - .any(|p| Path::new("/etc/passwd").starts_with(p)); - assert!(!cant_read); - - let can_write = context - .capabilities - .filesystem - .write - .iter() - .any(|p| Path::new("/tmp/output/file.txt").starts_with(p)); - assert!(can_write); - - let cant_write = context - .capabilities - .filesystem - .write - .iter() - .any(|p| Path::new("/tmp/file.txt").starts_with(p)); - assert!(!cant_write); - } - - #[test] - fn test_host_functions_network_access() { - let mut context = PluginContext { - data_dir: "/tmp/data".into(), - cache_dir: "/tmp/cache".into(), - config: FxHashMap::default(), - capabilities: Default::default(), - }; - - assert!(!context.capabilities.network.enabled); - - context.capabilities.network.enabled = true; - assert!(context.capabilities.network.enabled); - } - - #[test] - fn test_linker_setup() { - let engine = Engine::default(); - let mut linker = Linker::::new(&engine); - let result = HostFunctions::setup_linker(&mut linker); - assert!(result.is_ok()); - } -} diff --git a/crates/pinakes-core/src/plugin/security.rs b/crates/pinakes-core/src/plugin/security.rs deleted file mode 100644 index 6bebb94..0000000 --- a/crates/pinakes-core/src/plugin/security.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! Capability-based security for plugins - -use std::path::{Path, PathBuf}; - -use anyhow::{Result, anyhow}; -use pinakes_plugin_api::Capabilities; - -/// Capability enforcer validates and enforces plugin capabilities -pub struct CapabilityEnforcer { - /// Maximum allowed memory per plugin (bytes) - max_memory_limit: usize, - - /// Maximum allowed CPU time per plugin (milliseconds) - max_cpu_time_limit: u64, - - /// Allowed filesystem read paths (system-wide) - allowed_read_paths: Vec, - - /// Allowed filesystem write paths (system-wide) - allowed_write_paths: Vec, - - /// Whether to allow network access by default - allow_network_default: bool, -} - -impl CapabilityEnforcer { - /// Create a new capability enforcer with default limits - #[must_use] - pub const fn new() -> Self { - Self { - max_memory_limit: 512 * 1024 * 1024, // 512 MB - max_cpu_time_limit: 60 * 1000, // 60 seconds - allowed_read_paths: vec![], - allowed_write_paths: vec![], - allow_network_default: false, - } - } - - /// Set maximum memory limit - #[must_use] - pub const fn with_max_memory(mut self, bytes: usize) -> Self { - self.max_memory_limit = bytes; - self - } - - /// Set maximum CPU time limit - #[must_use] - pub const fn with_max_cpu_time(mut self, milliseconds: u64) -> Self { - self.max_cpu_time_limit = milliseconds; - self - } - - /// Add allowed read path - #[must_use] - pub fn allow_read_path(mut self, path: PathBuf) -> Self { - self.allowed_read_paths.push(path); - self - } - - /// Add allowed write path - #[must_use] - pub fn allow_write_path(mut self, path: PathBuf) -> Self { - self.allowed_write_paths.push(path); - self - } - - /// Set default network access policy - #[must_use] - pub const fn with_network_default(mut self, allow: bool) -> Self { - self.allow_network_default = allow; - self - } - - /// Validate capabilities requested by a plugin - /// - /// # Errors - /// - /// Returns an error if the plugin requests capabilities that exceed the - /// configured system limits, such as memory, CPU time, filesystem paths, or - /// network access. - pub fn validate_capabilities( - &self, - capabilities: &Capabilities, - ) -> Result<()> { - // Validate memory limit - if let Some(memory) = capabilities.max_memory_bytes - && memory > self.max_memory_limit - { - return Err(anyhow!( - "Requested memory ({} bytes) exceeds limit ({} bytes)", - memory, - self.max_memory_limit - )); - } - - // Validate CPU time limit - if let Some(cpu_time) = capabilities.max_cpu_time_ms - && cpu_time > self.max_cpu_time_limit - { - return Err(anyhow!( - "Requested CPU time ({} ms) exceeds limit ({} ms)", - cpu_time, - self.max_cpu_time_limit - )); - } - - // Validate filesystem access - self.validate_filesystem_access(capabilities)?; - - // Validate network access - if capabilities.network.enabled && !self.allow_network_default { - return Err(anyhow!( - "Plugin requests network access, but network access is disabled by \ - policy" - )); - } - - Ok(()) - } - - /// Validate filesystem access capabilities - fn validate_filesystem_access( - &self, - capabilities: &Capabilities, - ) -> Result<()> { - // Check read paths - for path in &capabilities.filesystem.read { - if !self.is_read_allowed(path) { - return Err(anyhow!( - "Plugin requests read access to {} which is not in allowed paths", - path.display() - )); - } - } - - // Check write paths - for path in &capabilities.filesystem.write { - if !self.is_write_allowed(path) { - return Err(anyhow!( - "Plugin requests write access to {} which is not in allowed paths", - path.display() - )); - } - } - - Ok(()) - } - - /// Check if a path is allowed for reading - #[must_use] - pub fn is_read_allowed(&self, path: &Path) -> bool { - if self.allowed_read_paths.is_empty() { - return false; // deny-all when unconfigured - } - let Ok(canonical) = path.canonicalize() else { - return false; - }; - self.allowed_read_paths.iter().any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }) - } - - /// Check if a path is allowed for writing - #[must_use] - pub fn is_write_allowed(&self, path: &Path) -> bool { - if self.allowed_write_paths.is_empty() { - return false; // deny-all when unconfigured - } - let canonical = if path.exists() { - path.canonicalize().ok() - } else { - path - .parent() - .and_then(|p| p.canonicalize().ok()) - .map(|p| p.join(path.file_name().unwrap_or_default())) - }; - let Some(canonical) = canonical else { - return false; - }; - self.allowed_write_paths.iter().any(|allowed| { - allowed - .canonicalize() - .is_ok_and(|a| canonical.starts_with(a)) - }) - } - - /// Check if network access is allowed for a plugin - #[must_use] - pub const fn is_network_allowed(&self, capabilities: &Capabilities) -> bool { - capabilities.network.enabled && self.allow_network_default - } - - /// Check if a specific domain is allowed - #[must_use] - pub fn is_domain_allowed( - &self, - capabilities: &Capabilities, - domain: &str, - ) -> bool { - if !capabilities.network.enabled { - return false; - } - - // If no domain restrictions, allow all domains - if capabilities.network.allowed_domains.is_none() { - return self.allow_network_default; - } - - // Check against allowed domains list - capabilities - .network - .allowed_domains - .as_ref() - .is_some_and(|domains| { - domains.iter().any(|d| d.eq_ignore_ascii_case(domain)) - }) - } - - /// Get effective memory limit for a plugin - #[must_use] - pub fn get_memory_limit(&self, capabilities: &Capabilities) -> usize { - capabilities - .max_memory_bytes - .unwrap_or(self.max_memory_limit) - .min(self.max_memory_limit) - } - - /// Get effective CPU time limit for a plugin - #[must_use] - pub fn get_cpu_time_limit(&self, capabilities: &Capabilities) -> u64 { - capabilities - .max_cpu_time_ms - .unwrap_or(self.max_cpu_time_limit) - .min(self.max_cpu_time_limit) - } - - /// Validate that a function call is allowed for a plugin's declared kinds. - /// - /// Defense-in-depth: even though the pipeline filters by kind, this prevents - /// bugs from calling wrong functions on plugins. Returns `true` if allowed. - #[must_use] - pub fn validate_function_call( - &self, - plugin_kinds: &[String], - function_name: &str, - ) -> bool { - match function_name { - // Lifecycle functions are always allowed - "initialize" | "shutdown" | "health_check" => true, - // MediaTypeProvider - "supported_media_types" | "can_handle" => { - plugin_kinds.iter().any(|k| k == "media_type") - }, - // supported_types is shared by metadata_extractor and thumbnail_generator - "supported_types" => { - plugin_kinds - .iter() - .any(|k| k == "metadata_extractor" || k == "thumbnail_generator") - }, - // MetadataExtractor - "extract_metadata" => { - plugin_kinds.iter().any(|k| k == "metadata_extractor") - }, - // ThumbnailGenerator - "generate_thumbnail" => { - plugin_kinds.iter().any(|k| k == "thumbnail_generator") - }, - // SearchBackend - "search" | "index_item" | "remove_item" | "get_stats" => { - plugin_kinds.iter().any(|k| k == "search_backend") - }, - // EventHandler - "interested_events" | "handle_event" => { - plugin_kinds.iter().any(|k| k == "event_handler") - }, - // ThemeProvider - "get_themes" | "load_theme" => { - plugin_kinds.iter().any(|k| k == "theme_provider") - }, - // Unknown function names are not allowed - _ => false, - } - } -} - -impl Default for CapabilityEnforcer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - #[allow(unused_imports)] - use pinakes_plugin_api::{FilesystemCapability, NetworkCapability}; - - use super::*; - - #[test] - fn test_validate_memory_limit() { - let enforcer = CapabilityEnforcer::new().with_max_memory(100 * 1024 * 1024); // 100 MB - - let mut caps = Capabilities::default(); - caps.max_memory_bytes = Some(50 * 1024 * 1024); // 50 MB - OK - assert!(enforcer.validate_capabilities(&caps).is_ok()); - - caps.max_memory_bytes = Some(200 * 1024 * 1024); // 200 MB - exceeds limit - assert!(enforcer.validate_capabilities(&caps).is_err()); - } - - #[test] - fn test_validate_cpu_time_limit() { - let enforcer = CapabilityEnforcer::new().with_max_cpu_time(30_000); // 30 seconds - - let mut caps = Capabilities::default(); - caps.max_cpu_time_ms = Some(10_000); // 10 seconds - OK - assert!(enforcer.validate_capabilities(&caps).is_ok()); - - caps.max_cpu_time_ms = Some(60_000); // 60 seconds - exceeds limit - assert!(enforcer.validate_capabilities(&caps).is_err()); - } - - #[test] - fn test_filesystem_read_allowed() { - // Use real temp directories so canonicalize works - let tmp = tempfile::tempdir().unwrap(); - let allowed_dir = tmp.path().join("allowed"); - std::fs::create_dir_all(&allowed_dir).unwrap(); - let test_file = allowed_dir.join("test.txt"); - std::fs::write(&test_file, "test").unwrap(); - - let enforcer = CapabilityEnforcer::new().allow_read_path(allowed_dir); - - assert!(enforcer.is_read_allowed(&test_file)); - assert!(!enforcer.is_read_allowed(Path::new("/etc/passwd"))); - } - - #[test] - fn test_filesystem_read_denied_when_empty() { - let enforcer = CapabilityEnforcer::new(); - assert!(!enforcer.is_read_allowed(Path::new("/tmp/test.txt"))); - } - - #[test] - fn test_filesystem_write_allowed() { - let tmp = tempfile::tempdir().unwrap(); - let output_dir = tmp.path().join("output"); - std::fs::create_dir_all(&output_dir).unwrap(); - // Existing file in allowed dir - let existing = output_dir.join("file.txt"); - std::fs::write(&existing, "test").unwrap(); - - let enforcer = - CapabilityEnforcer::new().allow_write_path(output_dir.clone()); - - assert!(enforcer.is_write_allowed(&existing)); - // New file in allowed dir (parent exists) - assert!(enforcer.is_write_allowed(&output_dir.join("new_file.txt"))); - assert!(!enforcer.is_write_allowed(Path::new("/etc/config"))); - } - - #[test] - fn test_filesystem_write_denied_when_empty() { - let enforcer = CapabilityEnforcer::new(); - assert!(!enforcer.is_write_allowed(Path::new("/tmp/file.txt"))); - } - - #[test] - fn test_network_allowed() { - let enforcer = CapabilityEnforcer::new().with_network_default(true); - - let mut caps = Capabilities::default(); - caps.network.enabled = true; - - assert!(enforcer.is_network_allowed(&caps)); - - caps.network.enabled = false; - assert!(!enforcer.is_network_allowed(&caps)); - } - - #[test] - fn test_domain_restrictions() { - let enforcer = CapabilityEnforcer::new().with_network_default(true); - - let mut caps = Capabilities::default(); - caps.network.enabled = true; - caps.network.allowed_domains = Some(vec![ - "api.example.com".to_string(), - "cdn.example.com".to_string(), - ]); - - assert!(enforcer.is_domain_allowed(&caps, "api.example.com")); - assert!(enforcer.is_domain_allowed(&caps, "cdn.example.com")); - assert!(!enforcer.is_domain_allowed(&caps, "evil.com")); - } - - #[test] - fn test_get_effective_limits() { - let enforcer = CapabilityEnforcer::new() - .with_max_memory(100 * 1024 * 1024) - .with_max_cpu_time(30_000); - - let mut caps = Capabilities::default(); - - // No limits specified, use the defaults - assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); - - // Plugin requests lower limits, use plugin's - caps.max_memory_bytes = Some(50 * 1024 * 1024); - caps.max_cpu_time_ms = Some(10_000); - assert_eq!(enforcer.get_memory_limit(&caps), 50 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 10_000); - - // Plugin requests higher limits, cap at system max - caps.max_memory_bytes = Some(200 * 1024 * 1024); - caps.max_cpu_time_ms = Some(60_000); - assert_eq!(enforcer.get_memory_limit(&caps), 100 * 1024 * 1024); - assert_eq!(enforcer.get_cpu_time_limit(&caps), 30_000); - } - - #[test] - fn test_validate_function_call_lifecycle_always_allowed() { - let enforcer = CapabilityEnforcer::new(); - let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "initialize")); - assert!(enforcer.validate_function_call(&kinds, "shutdown")); - assert!(enforcer.validate_function_call(&kinds, "health_check")); - } - - #[test] - fn test_validate_function_call_metadata_extractor() { - let enforcer = CapabilityEnforcer::new(); - let kinds = vec!["metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(enforcer.validate_function_call(&kinds, "supported_types")); - assert!(!enforcer.validate_function_call(&kinds, "search")); - assert!(!enforcer.validate_function_call(&kinds, "generate_thumbnail")); - assert!(!enforcer.validate_function_call(&kinds, "can_handle")); - } - - #[test] - fn test_validate_function_call_multi_kind() { - let enforcer = CapabilityEnforcer::new(); - let kinds = - vec!["media_type".to_string(), "metadata_extractor".to_string()]; - assert!(enforcer.validate_function_call(&kinds, "can_handle")); - assert!(enforcer.validate_function_call(&kinds, "supported_media_types")); - assert!(enforcer.validate_function_call(&kinds, "extract_metadata")); - assert!(!enforcer.validate_function_call(&kinds, "search")); - } - - #[test] - fn test_validate_function_call_unknown_function() { - let enforcer = CapabilityEnforcer::new(); - let kinds = vec!["metadata_extractor".to_string()]; - assert!(!enforcer.validate_function_call(&kinds, "unknown_func")); - assert!(!enforcer.validate_function_call(&kinds, "")); - } - - #[test] - fn test_validate_function_call_shared_supported_types() { - let enforcer = CapabilityEnforcer::new(); - let extractor = vec!["metadata_extractor".to_string()]; - let generator = vec!["thumbnail_generator".to_string()]; - let search = vec!["search_backend".to_string()]; - assert!(enforcer.validate_function_call(&extractor, "supported_types")); - assert!(enforcer.validate_function_call(&generator, "supported_types")); - assert!(!enforcer.validate_function_call(&search, "supported_types")); - } -} diff --git a/crates/pinakes-core/src/plugin/signature.rs b/crates/pinakes-core/src/plugin/signature.rs deleted file mode 100644 index 64f9dc5..0000000 --- a/crates/pinakes-core/src/plugin/signature.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! Plugin signature verification using Ed25519 + BLAKE3 -//! -//! Each plugin directory may contain a `plugin.sig` file alongside its -//! `plugin.toml`. The signature covers the BLAKE3 hash of the WASM binary -//! referenced by the manifest. Verification uses Ed25519 public keys -//! configured as trusted in the server's plugin settings. -//! -//! When `allow_unsigned` is false, plugins _must_ carry a valid signature -//! from one of the trusted keys or they will be rejected at load time. - -use std::path::Path; - -use anyhow::{Result, anyhow}; -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - -/// Outcome of a signature check on a plugin package. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SignatureStatus { - /// Signature is present and valid against a trusted key. - Valid, - /// No signature file found. - Unsigned, - /// Signature file exists but does not match any trusted key. - Invalid(String), -} - -/// Verify the signature of a plugin's WASM binary. -/// -/// Reads `plugin.sig` from `plugin_dir`, computes the BLAKE3 hash of the -/// WASM binary at `wasm_path`, and verifies the signature against each of -/// the `trusted_keys`. The signature file is raw 64-byte Ed25519 signature -/// over the 32-byte BLAKE3 digest. -/// -/// # Errors -/// -/// Returns an error only on I/O failures, never for cryptographic rejection, -/// which is reported via [`SignatureStatus`] instead. -pub fn verify_plugin_signature( - plugin_dir: &Path, - wasm_path: &Path, - trusted_keys: &[VerifyingKey], -) -> Result { - let sig_path = plugin_dir.join("plugin.sig"); - if !sig_path.exists() { - return Ok(SignatureStatus::Unsigned); - } - - let sig_bytes = std::fs::read(&sig_path) - .map_err(|e| anyhow!("failed to read plugin.sig: {e}"))?; - - let signature = Signature::from_slice(&sig_bytes).map_err(|e| { - // Malformed signature file is an invalid signature, not an I/O error - tracing::warn!(path = %sig_path.display(), "malformed plugin.sig: {e}"); - anyhow!("malformed plugin.sig: {e}") - }); - let Ok(signature) = signature else { - return Ok(SignatureStatus::Invalid( - "malformed signature file".to_string(), - )); - }; - - // BLAKE3 hash of the WASM binary is the signed message - let wasm_bytes = std::fs::read(wasm_path) - .map_err(|e| anyhow!("failed to read WASM binary for verification: {e}"))?; - let digest = blake3::hash(&wasm_bytes); - let message = digest.as_bytes(); - - for key in trusted_keys { - if key.verify(message, &signature).is_ok() { - return Ok(SignatureStatus::Valid); - } - } - - Ok(SignatureStatus::Invalid( - "signature did not match any trusted key".to_string(), - )) -} - -/// Parse a hex-encoded Ed25519 public key (64 hex characters = 32 bytes). -/// -/// # Errors -/// -/// Returns an error if the string is not valid hex or is the wrong length. -pub fn parse_public_key(hex_str: &str) -> Result { - let hex_str = hex_str.trim(); - if hex_str.len() != 64 { - return Err(anyhow!( - "expected 64 hex characters for Ed25519 public key, got {}", - hex_str.len() - )); - } - - let mut bytes = [0u8; 32]; - for (i, byte) in bytes.iter_mut().enumerate() { - *byte = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16) - .map_err(|e| anyhow!("invalid hex in public key: {e}"))?; - } - - VerifyingKey::from_bytes(&bytes) - .map_err(|e| anyhow!("invalid Ed25519 public key: {e}")) -} - -#[cfg(test)] -mod tests { - use ed25519_dalek::{Signer, SigningKey}; - use rand::RngExt; - - use super::*; - - fn make_keypair() -> (SigningKey, VerifyingKey) { - let secret_bytes: [u8; 32] = rand::rng().random(); - let signing = SigningKey::from_bytes(&secret_bytes); - let verifying = signing.verifying_key(); - (signing, verifying) - } - - #[test] - fn test_verify_unsigned_plugin() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); - - let (_, vk) = make_keypair(); - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert_eq!(status, SignatureStatus::Unsigned); - } - - #[test] - fn test_verify_valid_signature() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code_here"; - std::fs::write(&wasm_path, wasm_bytes).unwrap(); - - let (sk, vk) = make_keypair(); - - // Sign the BLAKE3 hash of the WASM binary - let digest = blake3::hash(wasm_bytes); - let signature = sk.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert_eq!(status, SignatureStatus::Valid); - } - - #[test] - fn test_verify_wrong_key() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let wasm_bytes = b"\0asm\x01\x00\x00\x00some_code"; - std::fs::write(&wasm_path, wasm_bytes).unwrap(); - - let (sk, _) = make_keypair(); - let (_, wrong_vk) = make_keypair(); - - let digest = blake3::hash(wasm_bytes); - let signature = sk.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[wrong_vk]).unwrap(); - assert!(matches!(status, SignatureStatus::Invalid(_))); - } - - #[test] - fn test_verify_tampered_wasm() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let original = b"\0asm\x01\x00\x00\x00original"; - std::fs::write(&wasm_path, original).unwrap(); - - let (sk, vk) = make_keypair(); - let digest = blake3::hash(original); - let signature = sk.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - // Tamper with the WASM file after signing - std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00tampered").unwrap(); - - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert!(matches!(status, SignatureStatus::Invalid(_))); - } - - #[test] - fn test_verify_malformed_sig_file() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - std::fs::write(&wasm_path, b"\0asm\x01\x00\x00\x00").unwrap(); - - // Write garbage to plugin.sig (wrong length) - std::fs::write(dir.path().join("plugin.sig"), b"not a signature").unwrap(); - - let (_, vk) = make_keypair(); - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk]).unwrap(); - assert!(matches!(status, SignatureStatus::Invalid(_))); - } - - #[test] - fn test_verify_multiple_trusted_keys() { - let dir = tempfile::tempdir().unwrap(); - let wasm_path = dir.path().join("plugin.wasm"); - let wasm_bytes = b"\0asm\x01\x00\x00\x00multi_key_test"; - std::fs::write(&wasm_path, wasm_bytes).unwrap(); - - let (sk2, vk2) = make_keypair(); - let (_, vk1) = make_keypair(); - let (_, vk3) = make_keypair(); - - // Sign with key 2 - let digest = blake3::hash(wasm_bytes); - let signature = sk2.sign(digest.as_bytes()); - std::fs::write(dir.path().join("plugin.sig"), signature.to_bytes()) - .unwrap(); - - // Verify against [vk1, vk2, vk3]; should find vk2 - let status = - verify_plugin_signature(dir.path(), &wasm_path, &[vk1, vk2, vk3]) - .unwrap(); - assert_eq!(status, SignatureStatus::Valid); - } - - #[test] - fn test_parse_public_key_valid() { - let (_, vk) = make_keypair(); - let hex = hex_encode(vk.as_bytes()); - let parsed = parse_public_key(&hex).unwrap(); - assert_eq!(parsed, vk); - } - - #[test] - fn test_parse_public_key_wrong_length() { - assert!(parse_public_key("abcdef").is_err()); - } - - #[test] - fn test_parse_public_key_invalid_hex() { - let bad = - "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; - assert!(parse_public_key(bad).is_err()); - } - - fn hex_encode(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() - } -} diff --git a/crates/pinakes-core/src/scheduler.rs b/crates/pinakes-core/src/scheduler.rs index fa66f37..0dc4b18 100644 --- a/crates/pinakes-core/src/scheduler.rs +++ b/crates/pinakes-core/src/scheduler.rs @@ -1,6 +1,7 @@ use std::{path::PathBuf, sync::Arc}; -use chrono::{DateTime, Datelike, Utc}; +use chrono::{DateTime, Utc}; +pub use pinakes_types::config::Schedule; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -11,102 +12,6 @@ use crate::{ jobs::{JobKind, JobQueue}, }; -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "type")] -pub enum Schedule { - Interval { - secs: u64, - }, - Daily { - hour: u32, - minute: u32, - }, - Weekly { - day: u32, - hour: u32, - minute: u32, - }, -} - -impl Schedule { - #[must_use] - pub fn next_run(&self, from: DateTime) -> DateTime { - match self { - Self::Interval { secs } => { - from - + chrono::Duration::seconds(i64::try_from(*secs).unwrap_or(i64::MAX)) - }, - Self::Daily { hour, minute } => { - let today = from - .date_naive() - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default(); - let today_utc = today.and_utc(); - if today_utc > from { - today_utc - } else { - today_utc + chrono::Duration::days(1) - } - }, - Self::Weekly { day, hour, minute } => { - let current_day = from.weekday().num_days_from_monday(); - let target_day = *day; - let days_ahead = match target_day.cmp(¤t_day) { - std::cmp::Ordering::Greater => target_day - current_day, - std::cmp::Ordering::Less => 7 - (current_day - target_day), - std::cmp::Ordering::Equal => { - let today = from - .date_naive() - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default() - .and_utc(); - if today > from { - return today; - } - 7 - }, - }; - let target_date = - from.date_naive() + chrono::Duration::days(i64::from(days_ahead)); - target_date - .and_hms_opt(*hour, *minute, 0) - .unwrap_or_default() - .and_utc() - }, - } - } - - #[must_use] - pub fn display_string(&self) -> String { - match self { - Self::Interval { secs } => { - if *secs >= 3600 { - format!("Every {}h", secs / 3600) - } else if *secs >= 60 { - format!("Every {}m", secs / 60) - } else { - format!("Every {secs}s") - } - }, - Self::Daily { hour, minute } => { - format!("Daily {hour:02}:{minute:02}") - }, - Self::Weekly { day, hour, minute } => { - let day_name = match day { - 0 => "Mon", - 1 => "Tue", - 2 => "Wed", - 3 => "Thu", - 4 => "Fri", - 5 => "Sat", - _ => "Sun", - }; - format!("{day_name} {hour:02}:{minute:02}") - }, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledTask { pub id: String, @@ -251,7 +156,7 @@ impl TaskScheduler { } if task.enabled { let from = task.last_run.unwrap_or_else(Utc::now); - task.next_run = Some(task.schedule.next_run(from)); + task.next_run = task.schedule.next_run(from); } else { task.next_run = None; } @@ -298,7 +203,7 @@ impl TaskScheduler { if let Some(task) = tasks.iter_mut().find(|t| t.id == id) { task.enabled = !task.enabled; if task.enabled { - task.next_run = Some(task.schedule.next_run(Utc::now())); + task.next_run = task.schedule.next_run(Utc::now()); } else { task.next_run = None; } @@ -331,7 +236,7 @@ impl TaskScheduler { task.running = true; task.last_job_id = Some(job_id); if task.enabled { - task.next_run = Some(task.schedule.next_run(Utc::now())); + task.next_run = task.schedule.next_run(Utc::now()); } drop(tasks); } @@ -403,7 +308,7 @@ impl TaskScheduler { task.last_run = Some(now); task.last_status = Some("running".to_string()); task.running = true; - task.next_run = Some(task.schedule.next_run(now)); + task.next_run = task.schedule.next_run(now); } } } @@ -431,7 +336,7 @@ mod tests { fn test_interval_next_run() { let from = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(); let schedule = Schedule::Interval { secs: 3600 }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 13, 0, 0).unwrap()); } @@ -443,7 +348,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); } @@ -455,7 +360,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 14, 0, 0).unwrap()); } @@ -468,7 +373,7 @@ mod tests { hour: 3, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 16, 3, 0, 0).unwrap()); } @@ -482,7 +387,7 @@ mod tests { hour: 14, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap()); } @@ -496,7 +401,7 @@ mod tests { hour: 8, minute: 0, }; - let next = schedule.next_run(from); + let next = schedule.next_run(from).unwrap(); assert_eq!(next, Utc.with_ymd_and_hms(2025, 6, 22, 8, 0, 0).unwrap()); } diff --git a/crates/pinakes-core/src/storage/migrations.rs b/crates/pinakes-core/src/storage/migrations.rs index 196397f..ac78968 100644 --- a/crates/pinakes-core/src/storage/migrations.rs +++ b/crates/pinakes-core/src/storage/migrations.rs @@ -1,17 +1,19 @@ -use crate::error::{PinakesError, Result}; - -pub fn run_sqlite_migrations(conn: &mut rusqlite::Connection) -> Result<()> { +#[cfg(feature = "sqlite")] +pub fn run_sqlite_migrations( + conn: &mut rusqlite::Connection, +) -> crate::error::Result<()> { pinakes_migrations::sqlite_migrations() .to_latest(conn) - .map_err(|e| PinakesError::Migration(e.to_string())) + .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) } +#[cfg(feature = "postgres")] pub async fn run_postgres_migrations( client: &mut tokio_postgres::Client, -) -> Result<()> { +) -> crate::error::Result<()> { pinakes_migrations::postgres_runner() .run_async(client) .await .map(|_| ()) - .map_err(|e| PinakesError::Migration(e.to_string())) + .map_err(|e| crate::error::PinakesError::Migration(e.to_string())) } diff --git a/crates/pinakes-core/src/storage/mod.rs b/crates/pinakes-core/src/storage/mod.rs index e1d93bc..968e3f2 100644 --- a/crates/pinakes-core/src/storage/mod.rs +++ b/crates/pinakes-core/src/storage/mod.rs @@ -1,16 +1,16 @@ pub mod migrations; -pub mod postgres; -pub mod sqlite; +#[cfg(feature = "postgres")] pub mod postgres; +#[cfg(feature = "sqlite")] pub mod sqlite; use std::{path::PathBuf, sync::Arc}; use chrono::{DateTime, Utc}; +use pinakes_enrichment::ExternalMetadata; use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ analytics::UsageEvent, - enrichment::ExternalMetadata, error::Result, model::{ AuditEntry, @@ -412,7 +412,7 @@ pub trait StorageBackend: Send + Sync + 'static { } } - Err(crate::error::PinakesError::Authorization(format!( + Err(pinakes_types::error::PinakesError::Authorization(format!( "user {user_id} has no access to media {media_id}" ))) } @@ -841,42 +841,44 @@ pub trait StorageBackend: Send + Sync + 'static { /// Register a new sync device async fn register_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, token_hash: &str, - ) -> Result; + ) -> Result; /// Get a sync device by ID async fn get_device( &self, - id: crate::sync::DeviceId, - ) -> Result; + id: pinakes_sync::DeviceId, + ) -> Result; /// Get a sync device by its token hash async fn get_device_by_token( &self, token_hash: &str, - ) -> Result>; + ) -> Result>; /// List all devices for a user async fn list_user_devices( &self, user_id: UserId, - ) -> Result>; + ) -> Result>; /// Update a sync device - async fn update_device(&self, device: &crate::sync::SyncDevice) - -> Result<()>; + async fn update_device( + &self, + device: &pinakes_sync::SyncDevice, + ) -> Result<()>; /// Delete a sync device - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()>; + async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()>; /// Update the `last_seen_at` timestamp for a device - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()>; + async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()>; /// Record a change in the sync log async fn record_sync_change( &self, - change: &crate::sync::SyncLogEntry, + change: &pinakes_sync::SyncLogEntry, ) -> Result<()>; /// Get changes since a cursor position @@ -884,7 +886,7 @@ pub trait StorageBackend: Send + Sync + 'static { &self, cursor: i64, limit: u64, - ) -> Result>; + ) -> Result>; /// Get the current sync cursor (highest sequence number) async fn get_current_sync_cursor(&self) -> Result; @@ -895,52 +897,52 @@ pub trait StorageBackend: Send + Sync + 'static { /// Get sync state for a device and path async fn get_device_sync_state( &self, - device_id: crate::sync::DeviceId, + device_id: pinakes_sync::DeviceId, path: &str, - ) -> Result>; + ) -> Result>; /// Insert or update device sync state async fn upsert_device_sync_state( &self, - state: &crate::sync::DeviceSyncState, + state: &pinakes_sync::DeviceSyncState, ) -> Result<()>; /// List all pending sync items for a device async fn list_pending_sync( &self, - device_id: crate::sync::DeviceId, - ) -> Result>; + device_id: pinakes_sync::DeviceId, + ) -> Result>; /// Create a new upload session async fn create_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()>; /// Get an upload session by ID async fn get_upload_session( &self, id: Uuid, - ) -> Result; + ) -> Result; /// Update an upload session async fn update_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()>; /// Record a received chunk async fn record_chunk( &self, upload_id: Uuid, - chunk: &crate::sync::ChunkInfo, + chunk: &pinakes_sync::ChunkInfo, ) -> Result<()>; /// Get all chunks for an upload async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result>; + ) -> Result>; /// Clean up expired upload sessions async fn cleanup_expired_uploads(&self) -> Result; @@ -948,20 +950,20 @@ pub trait StorageBackend: Send + Sync + 'static { /// Record a sync conflict async fn record_conflict( &self, - conflict: &crate::sync::SyncConflict, + conflict: &pinakes_sync::SyncConflict, ) -> Result<()>; /// Get unresolved conflicts for a device async fn get_unresolved_conflicts( &self, - device_id: crate::sync::DeviceId, - ) -> Result>; + device_id: pinakes_sync::DeviceId, + ) -> Result>; /// Resolve a conflict async fn resolve_conflict( &self, id: Uuid, - resolution: crate::config::ConflictResolution, + resolution: pinakes_types::config::ConflictResolution, ) -> Result<()>; /// Create a new share @@ -1176,7 +1178,7 @@ pub trait StorageBackend: Send + Sync + 'static { /// deployments should use `pg_dump` directly; this method returns /// `PinakesError::InvalidOperation` for unsupported backends. async fn backup(&self, _dest: &std::path::Path) -> Result<()> { - Err(crate::error::PinakesError::InvalidOperation( + Err(pinakes_types::error::PinakesError::InvalidOperation( "backup not supported for this storage backend; use pg_dump for \ PostgreSQL" .to_string(), diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index dbeb0e2..6935ff7 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ config::PostgresConfig, - error::{PinakesError, Result}, + error::{PinakesError, Result, db_ctx}, media_type::MediaType, model::{ AuditAction, @@ -629,7 +629,8 @@ impl StorageBackend for PostgresBackend { NOTHING", &[&path.to_string_lossy().as_ref()], ) - .await?; + .await + .map_err(db_ctx("insert_root_dirs", path.display()))?; Ok(()) } @@ -643,7 +644,8 @@ impl StorageBackend for PostgresBackend { let rows = client .query("SELECT path FROM root_dirs ORDER BY path", &[]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -664,7 +666,8 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM root_dirs WHERE path = $1", &[&path .to_string_lossy() .as_ref()]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -724,7 +727,8 @@ impl StorageBackend for PostgresBackend { &item.updated_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert custom fields for (name, field) in &item.custom_fields { @@ -739,7 +743,8 @@ impl StorageBackend for PostgresBackend { EXCLUDED.field_value", &[&item.id.0, &name, &ft, &field.value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) @@ -756,7 +761,8 @@ impl StorageBackend for PostgresBackend { "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i64 = row.get(0); Ok(count.cast_unsigned()) } @@ -782,7 +788,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE id = $1", &[&id.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::NotFound(format!("media item {id}")))?; let mut item = row_to_media_item(&row)?; @@ -814,7 +821,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE content_hash = $1", &[&hash.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; match row { Some(r) => { @@ -851,7 +859,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE path = $1", &[&path_str], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; match row { Some(r) => { @@ -904,7 +913,8 @@ impl StorageBackend for PostgresBackend { &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -921,7 +931,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1008,7 +1019,8 @@ impl StorageBackend for PostgresBackend { &item.updated_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {}", item.id))); @@ -1019,7 +1031,8 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM custom_fields WHERE media_id = $1", &[&item .id .0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; for (name, field) in &item.custom_fields { let ft = custom_field_type_to_string(field.field_type); @@ -1030,7 +1043,8 @@ impl StorageBackend for PostgresBackend { VALUES ($1, $2, $3, $4)", &[&item.id.0, &name, &ft, &field.value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } txn.commit().await.map_err(|e| { @@ -1049,7 +1063,8 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM media_items WHERE id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); @@ -1067,10 +1082,14 @@ impl StorageBackend for PostgresBackend { let count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); - client.execute("DELETE FROM media_items", &[]).await?; + client + .execute("DELETE FROM media_items", &[]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(count.cast_unsigned()) } @@ -1090,7 +1109,8 @@ impl StorageBackend for PostgresBackend { let uuids: Vec = ids.iter().map(|id| id.0).collect(); let rows = client .execute("DELETE FROM media_items WHERE id = ANY($1)", &[&uuids]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows) } @@ -1126,7 +1146,8 @@ impl StorageBackend for PostgresBackend { ON CONFLICT DO NOTHING", &[&media_uuids, &tag_uuids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows) } @@ -1209,7 +1230,10 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| p.as_ref() as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - let rows = client.execute(&sql, ¶m_refs).await?; + let rows = client + .execute(&sql, ¶m_refs) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows) } @@ -1234,7 +1258,8 @@ impl StorageBackend for PostgresBackend { $3, $4)", &[&id, &name, &parent_id, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(Tag { id, @@ -1256,7 +1281,8 @@ impl StorageBackend for PostgresBackend { "SELECT id, name, parent_id, created_at FROM tags WHERE id = $1", &[&id], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::TagNotFound(id.to_string()))?; Ok(row_to_tag(&row)) @@ -1274,7 +1300,8 @@ impl StorageBackend for PostgresBackend { "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1288,7 +1315,8 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM tags WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1310,7 +1338,8 @@ impl StorageBackend for PostgresBackend { ON CONFLICT (media_id, tag_id) DO NOTHING", &[&media_id.0, &tag_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1327,7 +1356,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM media_tags WHERE media_id = $1 AND tag_id = $2", &[&media_id.0, &tag_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1348,7 +1378,8 @@ impl StorageBackend for PostgresBackend { ORDER BY t.name", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1375,7 +1406,8 @@ impl StorageBackend for PostgresBackend { BY name", &[&tag_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.iter().map(row_to_tag).collect()) } @@ -1413,7 +1445,8 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(Collection { id, @@ -1440,7 +1473,8 @@ impl StorageBackend for PostgresBackend { FROM collections WHERE id = $1", &[&id], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::CollectionNotFound(id.to_string()))?; row_to_collection(&row) @@ -1460,7 +1494,8 @@ impl StorageBackend for PostgresBackend { FROM collections ORDER BY name", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(row_to_collection).collect() } @@ -1474,7 +1509,8 @@ impl StorageBackend for PostgresBackend { let rows_affected = client .execute("DELETE FROM collections WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows_affected == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1506,7 +1542,8 @@ impl StorageBackend for PostgresBackend { = EXCLUDED.position", &[&collection_id, &media_id.0, &position, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Update the collection's updated_at timestamp client @@ -1514,7 +1551,8 @@ impl StorageBackend for PostgresBackend { &collection_id, &now, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1536,7 +1574,8 @@ impl StorageBackend for PostgresBackend { = $2", &[&collection_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let now = Utc::now(); client @@ -1544,7 +1583,8 @@ impl StorageBackend for PostgresBackend { &collection_id, &now, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1579,7 +1619,8 @@ impl StorageBackend for PostgresBackend { ORDER BY cm.position ASC", &[&collection_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1595,7 +1636,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1730,7 +1772,10 @@ impl StorageBackend for PostgresBackend { .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); - let count_row = client.query_one(&count_sql, &count_params).await?; + let count_row = client + .query_one(&count_sql, &count_params) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let total_count: i64 = count_row.get(0); // Add pagination params @@ -1742,7 +1787,10 @@ impl StorageBackend for PostgresBackend { .map(|p| p.as_ref() as &(dyn ToSql + Sync)) .collect(); - let rows = client.query(&select_sql, &select_params).await?; + let rows = client + .query(&select_sql, &select_params) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1758,7 +1806,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -1810,7 +1859,8 @@ impl StorageBackend for PostgresBackend { &entry.timestamp, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1841,7 +1891,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, None => { client @@ -1855,7 +1906,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, }; @@ -1887,7 +1939,8 @@ impl StorageBackend for PostgresBackend { EXCLUDED.field_value", &[&media_id.0, &name, &ft, &field.value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1908,7 +1961,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut map = FxHashMap::default(); for row in &rows { @@ -1938,7 +1992,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM custom_fields WHERE media_id = $1 AND field_name = $2", &[&media_id.0, &name], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -1971,7 +2026,8 @@ impl StorageBackend for PostgresBackend { ORDER BY content_hash, created_at", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -1987,7 +2043,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -2049,7 +2106,8 @@ impl StorageBackend for PostgresBackend { FROM media_items WHERE perceptual_hash IS NOT NULL ORDER BY id", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::with_capacity(rows.len()); for row in &rows { @@ -2065,7 +2123,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); @@ -2152,23 +2211,28 @@ impl StorageBackend for PostgresBackend { let media_count: i64 = client .query_one("SELECT COUNT(*) FROM media_items", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let tag_count: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let collection_count: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let audit_count: i64 = client .query_one("SELECT COUNT(*) FROM audit_log", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let database_size_bytes: i64 = client .query_one("SELECT pg_database_size(current_database())", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); Ok(crate::storage::DatabaseStats { @@ -2188,7 +2252,10 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - client.execute("VACUUM ANALYZE", &[]).await?; + client + .execute("VACUUM ANALYZE", &[]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2206,7 +2273,8 @@ impl StorageBackend for PostgresBackend { media_items, tags, collections CASCADE", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2221,7 +2289,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let rows = client .query("SELECT id, path, content_hash FROM media_items", &[]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::with_capacity(rows.len()); for row in rows { let id: Uuid = row.get(0); @@ -2253,7 +2322,8 @@ impl StorageBackend for PostgresBackend { sort_order = $4", &[&id, &name, &query, &sort_order, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2271,7 +2341,8 @@ impl StorageBackend for PostgresBackend { ORDER BY created_at DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::with_capacity(rows.len()); for row in rows { results.push(crate::model::SavedSearch { @@ -2300,7 +2371,8 @@ impl StorageBackend for PostgresBackend { WHERE id = $1", &[&id], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::NotFound(format!("saved search {id}")))?; Ok(crate::model::SavedSearch { id: row.get(0), @@ -2319,7 +2391,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM saved_searches WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2338,7 +2411,10 @@ impl StorageBackend for PostgresBackend { } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; - let rows = client.query(sql, &[]).await?; + let rows = client + .query(sql, &[]) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids = rows .iter() .map(|r| { @@ -2372,7 +2448,8 @@ impl StorageBackend for PostgresBackend { FROM users ORDER BY created_at DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut users = Vec::with_capacity(rows.len()); for row in rows { let user_id: uuid::Uuid = row.get::<_, uuid::Uuid>(0); @@ -2406,7 +2483,8 @@ impl StorageBackend for PostgresBackend { FROM users WHERE id = $1", &[&id.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| PinakesError::NotFound(format!("user {}", id.0)))?; let profile = self.load_user_profile(id.0).await?; Ok(crate::users::User { @@ -2436,7 +2514,8 @@ impl StorageBackend for PostgresBackend { FROM users WHERE username = $1", &[&username], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .ok_or_else(|| { PinakesError::NotFound(format!("user with username {username}")) })?; @@ -2476,7 +2555,8 @@ impl StorageBackend for PostgresBackend { updated_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &username, &password_hash, &role_json, &now, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = serde_json::to_value(&prof.preferences)?; @@ -2487,7 +2567,8 @@ impl StorageBackend for PostgresBackend { $5, $6)", &[&id, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; prof } else { crate::users::UserProfile { @@ -2564,7 +2645,10 @@ impl StorageBackend for PostgresBackend { } params.push(&id.0); - client.execute(&sql, ¶ms).await?; + client + .execute(&sql, ¶ms) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Update profile if provided @@ -2579,7 +2663,8 @@ impl StorageBackend for PostgresBackend { = $3, preferences_json = $4, updated_at = $6", &[&id.0, &prof.avatar_path, &prof.bio, &prefs_json, &now, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Fetch updated user @@ -2596,15 +2681,18 @@ impl StorageBackend for PostgresBackend { // Delete profile first due to foreign key client .execute("DELETE FROM user_profiles WHERE user_id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete library access client .execute("DELETE FROM user_libraries WHERE user_id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete user let affected = client .execute("DELETE FROM users WHERE id = $1", &[&id.0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {}", id.0))); } @@ -2626,7 +2714,8 @@ impl StorageBackend for PostgresBackend { user_libraries WHERE user_id = $1", &[&user_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut libraries = Vec::with_capacity(rows.len()); for row in rows { libraries.push(crate::users::UserLibraryAccess { @@ -2661,7 +2750,8 @@ impl StorageBackend for PostgresBackend { $3, granted_at = $4", &[&user_id.0, &root_path, &perm_json, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2680,7 +2770,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM user_libraries WHERE user_id = $1 AND root_path = $2", &[&user_id.0, &root_path], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2707,7 +2798,8 @@ impl StorageBackend for PostgresBackend { created_at", &[&id, &user_id.0, &media_id.0, &stars_i32, &review, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let actual_id: Uuid = row.get(0); let actual_created_at: chrono::DateTime = row.get(1); Ok(crate::social::Rating { @@ -2735,7 +2827,8 @@ impl StorageBackend for PostgresBackend { ratings WHERE media_id = $1 ORDER BY created_at DESC", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -2769,7 +2862,8 @@ impl StorageBackend for PostgresBackend { ratings WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.first().map(|row| { crate::social::Rating { id: row.get("id"), @@ -2790,7 +2884,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM ratings WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2814,7 +2909,8 @@ impl StorageBackend for PostgresBackend { text, created_at) VALUES ($1, $2, $3, $4, $5, $6)", &[&id, &user_id.0, &media_id.0, &parent_id, &text, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::Comment { id, user_id, @@ -2840,7 +2936,8 @@ impl StorageBackend for PostgresBackend { FROM comments WHERE media_id = $1 ORDER BY created_at ASC", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -2866,7 +2963,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM comments WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2887,7 +2985,8 @@ impl StorageBackend for PostgresBackend { $2, $3) ON CONFLICT DO NOTHING", &[&user_id.0, &media_id.0, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2906,7 +3005,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -2937,7 +3037,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -2952,7 +3053,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -2991,7 +3093,8 @@ impl StorageBackend for PostgresBackend { "SELECT COUNT(*) FROM favorites WHERE user_id = $1 AND media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let count: i64 = row.get(0); Ok(count > 0) } @@ -3028,7 +3131,8 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::ShareLink { id, media_id, @@ -3056,7 +3160,8 @@ impl StorageBackend for PostgresBackend { view_count, created_at FROM share_links WHERE token = $1", &[&token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound("share link not found".into()))?; @@ -3085,7 +3190,8 @@ impl StorageBackend for PostgresBackend { "UPDATE share_links SET view_count = view_count + 1 WHERE token = $1", &[&token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3097,7 +3203,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM share_links WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3134,7 +3241,8 @@ impl StorageBackend for PostgresBackend { &now, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::playlists::Playlist { id, owner_id, @@ -3160,7 +3268,8 @@ impl StorageBackend for PostgresBackend { filter_query, created_at, updated_at FROM playlists WHERE id = $1", &[&id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let row = rows .first() .ok_or_else(|| PinakesError::NotFound(format!("playlist {id}")))?; @@ -3195,7 +3304,8 @@ impl StorageBackend for PostgresBackend { owner_id = $1 OR is_public = true ORDER BY updated_at DESC", &[&uid.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, None => { client @@ -3205,7 +3315,8 @@ impl StorageBackend for PostgresBackend { updated_at DESC", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, }; Ok( @@ -3270,7 +3381,10 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - client.execute(&sql, ¶m_refs).await?; + client + .execute(&sql, ¶m_refs) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; self.get_playlist(id).await } @@ -3282,7 +3396,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM playlists WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3305,7 +3420,8 @@ impl StorageBackend for PostgresBackend { media_id) DO UPDATE SET position = $3", &[&playlist_id, &media_id.0, &position, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3324,7 +3440,8 @@ impl StorageBackend for PostgresBackend { "DELETE FROM playlist_items WHERE playlist_id = $1 AND media_id = $2", &[&playlist_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3350,7 +3467,8 @@ impl StorageBackend for PostgresBackend { $1 ORDER BY pi.position ASC", &[&playlist_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3365,7 +3483,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3406,7 +3525,8 @@ impl StorageBackend for PostgresBackend { media_id = $3", &[&new_position, &playlist_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3441,7 +3561,8 @@ impl StorageBackend for PostgresBackend { &context, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3485,7 +3606,10 @@ impl StorageBackend for PostgresBackend { .iter() .map(|p| &**p as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - let rows = client.query(&sql, ¶m_refs).await?; + let rows = client + .query(&sql, ¶m_refs) + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -3536,7 +3660,8 @@ impl StorageBackend for PostgresBackend { m.deleted_at, m.links_extracted_at ORDER BY view_count DESC LIMIT $1", &[&limit.cast_signed()], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in &rows { let item = row_to_media_item(row)?; @@ -3553,7 +3678,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3608,7 +3734,8 @@ impl StorageBackend for PostgresBackend { LIMIT $2", &[&user_id.0, &limit.cast_signed()], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = rows .iter() .map(row_to_media_item) @@ -3623,7 +3750,8 @@ impl StorageBackend for PostgresBackend { FROM custom_fields WHERE media_id = ANY($1)", &[&ids], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut cf_map: FxHashMap> = FxHashMap::default(); for row in &cf_rows { @@ -3667,7 +3795,8 @@ impl StorageBackend for PostgresBackend { media_id) DO UPDATE SET progress_secs = $4, last_watched = $5", &[&id, &user_id.0, &media_id.0, &progress_secs, &now], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3687,7 +3816,8 @@ impl StorageBackend for PostgresBackend { media_id = $2", &[&user_id.0, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(rows.first().map(|row| row.get("progress_secs"))) } @@ -3702,7 +3832,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; let affected = client .execute("DELETE FROM usage_events WHERE timestamp < $1", &[&before]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -3753,7 +3884,8 @@ impl StorageBackend for PostgresBackend { &subtitle.created_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3772,7 +3904,8 @@ impl StorageBackend for PostgresBackend { track_index, offset_ms, created_at FROM subtitles WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() @@ -3808,7 +3941,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM subtitles WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3831,13 +3965,14 @@ impl StorageBackend for PostgresBackend { .execute("UPDATE subtitles SET offset_ms = $1 WHERE id = $2", &[ &offset, &id, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn store_external_metadata( &self, - meta: &crate::enrichment::ExternalMetadata, + meta: &pinakes_enrichment::ExternalMetadata, ) -> Result<()> { let client = self .pool @@ -3870,14 +4005,15 @@ impl StorageBackend for PostgresBackend { &meta.last_updated, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } async fn get_external_metadata( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let client = self .pool .get() @@ -3889,19 +4025,20 @@ impl StorageBackend for PostgresBackend { last_updated FROM external_metadata WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows .iter() .map(|row| { let source_str: String = row.get("source"); let metadata_json: serde_json::Value = row.get("metadata_json"); - crate::enrichment::ExternalMetadata { + pinakes_enrichment::ExternalMetadata { id: row.get("id"), media_id: MediaId(row.get("media_id")), source: source_str .parse() - .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), + .unwrap_or(pinakes_enrichment::EnrichmentSourceType::MusicBrainz), external_id: row.get("external_id"), metadata_json: metadata_json.to_string(), confidence: row.get("confidence"), @@ -3920,7 +4057,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; client .execute("DELETE FROM external_metadata WHERE id = $1", &[&id]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3956,7 +4094,8 @@ impl StorageBackend for PostgresBackend { &session.expires_at, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -3976,7 +4115,8 @@ impl StorageBackend for PostgresBackend { transcode_sessions WHERE id = $1", &[&id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let row = rows.first().ok_or_else(|| { PinakesError::NotFound(format!("transcode session {id}")) })?; @@ -4022,7 +4162,8 @@ impl StorageBackend for PostgresBackend { transcode_sessions WHERE media_id = $1 ORDER BY created_at DESC", &[&mid.0], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, None => { client @@ -4032,7 +4173,8 @@ impl StorageBackend for PostgresBackend { transcode_sessions ORDER BY created_at DESC", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }, }; Ok( @@ -4086,7 +4228,8 @@ impl StorageBackend for PostgresBackend { error_message = $3 WHERE id = $4", &[&status_str, &progress_f64, &error_message, &id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4105,7 +4248,8 @@ impl StorageBackend for PostgresBackend { expires_at < $1", &[&before], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -4134,7 +4278,8 @@ impl StorageBackend for PostgresBackend { &session.last_accessed, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4155,7 +4300,8 @@ impl StorageBackend for PostgresBackend { FROM sessions WHERE session_token = $1", &[&session_token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { crate::storage::SessionData { @@ -4183,7 +4329,8 @@ impl StorageBackend for PostgresBackend { "UPDATE sessions SET last_accessed = $1 WHERE session_token = $2", &[&now, &session_token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4205,7 +4352,8 @@ impl StorageBackend for PostgresBackend { session_token = $3 AND expires_at > NOW()", &[&new_expires_at, &now, &session_token], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows > 0 { Ok(Some(new_expires_at)) } else { @@ -4224,7 +4372,8 @@ impl StorageBackend for PostgresBackend { .execute("DELETE FROM sessions WHERE session_token = $1", &[ &session_token, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4237,7 +4386,8 @@ impl StorageBackend for PostgresBackend { let affected = client .execute("DELETE FROM sessions WHERE username = $1", &[&username]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -4251,7 +4401,8 @@ impl StorageBackend for PostgresBackend { let now = chrono::Utc::now(); let affected = client .execute("DELETE FROM sessions WHERE expires_at < $1", &[&now]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected) } @@ -4275,7 +4426,8 @@ impl StorageBackend for PostgresBackend { ORDER BY last_accessed DESC", &[&now, &user], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else { client .query( @@ -4285,7 +4437,8 @@ impl StorageBackend for PostgresBackend { ORDER BY last_accessed DESC", &[&now], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? }; Ok( @@ -4323,7 +4476,10 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(format!("pool error: {e}")))?; - let tx = client.transaction().await?; + let tx = client + .transaction() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Upsert book_metadata tx.execute( @@ -4348,17 +4504,20 @@ impl StorageBackend for PostgresBackend { &metadata.format, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Clear existing authors and identifiers tx.execute("DELETE FROM book_authors WHERE media_id = $1", &[&metadata .media_id .0]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; tx.execute("DELETE FROM book_identifiers WHERE media_id = $1", &[ &metadata.media_id.0, ]) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert authors for author in &metadata.authors { @@ -4374,7 +4533,8 @@ impl StorageBackend for PostgresBackend { &author.position, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Insert identifiers @@ -4386,11 +4546,14 @@ impl StorageBackend for PostgresBackend { VALUES ($1, $2, $3)", &[&metadata.media_id.0, &id_type, &value], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; } } - tx.commit().await?; + tx.commit() + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4413,7 +4576,8 @@ impl StorageBackend for PostgresBackend { FROM book_metadata WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let Some(row) = row else { return Ok(None); @@ -4426,7 +4590,8 @@ impl StorageBackend for PostgresBackend { FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec = author_rows .iter() @@ -4447,7 +4612,8 @@ impl StorageBackend for PostgresBackend { FROM book_identifiers WHERE media_id = $1", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut identifiers: FxHashMap> = FxHashMap::default(); for r in id_rows { @@ -4500,7 +4666,8 @@ impl StorageBackend for PostgresBackend { &author.position, ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4520,7 +4687,8 @@ impl StorageBackend for PostgresBackend { FROM book_authors WHERE media_id = $1 ORDER BY position", &[&media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -4559,7 +4727,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -4585,7 +4754,8 @@ impl StorageBackend for PostgresBackend { ORDER BY series_name", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok( rows @@ -4625,7 +4795,8 @@ impl StorageBackend for PostgresBackend { ORDER BY b.series_index, m.title", &[&series_name], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; rows.iter().map(row_to_media_item).collect() } @@ -4651,7 +4822,8 @@ impl StorageBackend for PostgresBackend { progress_secs = $3, last_watched_at = NOW()", &[&user_id, &media_id.0, &f64::from(current_page)], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) } @@ -4675,7 +4847,8 @@ impl StorageBackend for PostgresBackend { WHERE wh.user_id = $1 AND wh.media_id = $2", &[&user_id, &media_id.0], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { let current_page: i32 = r.get(0); @@ -4821,7 +4994,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else if isbn.is_none() && author.is_none() && series.is_none() @@ -4852,7 +5026,8 @@ impl StorageBackend for PostgresBackend { &(pagination.offset.cast_signed()), ], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else { // For other combinations, use dynamic query (simplified - just filter by // what's provided) @@ -4879,14 +5054,16 @@ impl StorageBackend for PostgresBackend { &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } else { client .query(&query, &[ &(pagination.limit.cast_signed()), &(pagination.offset.cast_signed()), ]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? } }; @@ -5181,9 +5358,9 @@ impl StorageBackend for PostgresBackend { async fn register_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, token_hash: &str, - ) -> Result { + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5218,8 +5395,8 @@ impl StorageBackend for PostgresBackend { async fn get_device( &self, - id: crate::sync::DeviceId, - ) -> Result { + id: pinakes_sync::DeviceId, + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5235,9 +5412,9 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(row.get(0)), - user_id: crate::users::UserId(row.get(1)), + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(row.get(0)), + user_id: pinakes_types::model::UserId(row.get(1)), name: row.get(2), device_type: row.get::<_, String>(3).parse().unwrap_or_default(), client_version: row.get(4), @@ -5254,7 +5431,7 @@ impl StorageBackend for PostgresBackend { async fn get_device_by_token( &self, token_hash: &str, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5271,9 +5448,9 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { - crate::sync::SyncDevice { - id: crate::sync::DeviceId(r.get(0)), - user_id: crate::users::UserId(r.get(1)), + pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(r.get(0)), + user_id: pinakes_types::model::UserId(r.get(1)), name: r.get(2), device_type: r.get::<_, String>(3).parse().unwrap_or_default(), client_version: r.get(4), @@ -5291,7 +5468,7 @@ impl StorageBackend for PostgresBackend { async fn list_user_devices( &self, user_id: crate::users::UserId, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5312,9 +5489,9 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::SyncDevice { - id: crate::sync::DeviceId(r.get(0)), - user_id: crate::users::UserId(r.get(1)), + pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(r.get(0)), + user_id: pinakes_types::model::UserId(r.get(1)), name: r.get(2), device_type: r.get::<_, String>(3).parse().unwrap_or_default(), client_version: r.get(4), @@ -5333,7 +5510,7 @@ impl StorageBackend for PostgresBackend { async fn update_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5365,7 +5542,7 @@ impl StorageBackend for PostgresBackend { Ok(()) } - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5378,7 +5555,7 @@ impl StorageBackend for PostgresBackend { Ok(()) } - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5398,7 +5575,7 @@ impl StorageBackend for PostgresBackend { async fn record_sync_change( &self, - change: &crate::sync::SyncLogEntry, + change: &pinakes_sync::SyncLogEntry, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5444,7 +5621,7 @@ impl StorageBackend for PostgresBackend { &self, cursor: i64, limit: u64, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5463,13 +5640,13 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::SyncLogEntry { + pinakes_sync::SyncLogEntry { id: r.get(0), sequence: r.get(1), change_type: r .get::<_, String>(2) .parse() - .unwrap_or(crate::sync::SyncChangeType::Modified), + .unwrap_or(pinakes_sync::SyncChangeType::Modified), media_id: r.get::<_, Option>(3).map(MediaId), path: r.get(4), content_hash: r.get::<_, Option>(5).map(ContentHash), @@ -5479,7 +5656,7 @@ impl StorageBackend for PostgresBackend { metadata_json: r.get(7), changed_by_device: r .get::<_, Option>(8) - .map(crate::sync::DeviceId), + .map(pinakes_sync::DeviceId), timestamp: r.get(9), } }) @@ -5518,9 +5695,9 @@ impl StorageBackend for PostgresBackend { async fn get_device_sync_state( &self, - device_id: crate::sync::DeviceId, + device_id: pinakes_sync::DeviceId, path: &str, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5537,8 +5714,8 @@ impl StorageBackend for PostgresBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map(|r| { - crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(r.get(0)), + pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(r.get(0)), path: r.get(1), local_hash: r.get(2), server_hash: r.get(3), @@ -5547,7 +5724,7 @@ impl StorageBackend for PostgresBackend { sync_status: r .get::<_, String>(6) .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), } @@ -5556,7 +5733,7 @@ impl StorageBackend for PostgresBackend { async fn upsert_device_sync_state( &self, - state: &crate::sync::DeviceSyncState, + state: &pinakes_sync::DeviceSyncState, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5597,8 +5774,8 @@ impl StorageBackend for PostgresBackend { async fn list_pending_sync( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5620,8 +5797,8 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(r.get(0)), + pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(r.get(0)), path: r.get(1), local_hash: r.get(2), server_hash: r.get(3), @@ -5630,7 +5807,7 @@ impl StorageBackend for PostgresBackend { sync_status: r .get::<_, String>(6) .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: r.get(7), conflict_info_json: r.get(8), } @@ -5641,7 +5818,7 @@ impl StorageBackend for PostgresBackend { async fn create_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5677,7 +5854,7 @@ impl StorageBackend for PostgresBackend { async fn get_upload_session( &self, id: Uuid, - ) -> Result { + ) -> Result { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5694,9 +5871,9 @@ impl StorageBackend for PostgresBackend { .await .map_err(|e| PinakesError::Database(e.to_string()))?; - Ok(crate::sync::UploadSession { + Ok(pinakes_sync::UploadSession { id: row.get(0), - device_id: crate::sync::DeviceId(row.get(1)), + device_id: pinakes_sync::DeviceId(row.get(1)), target_path: row.get(2), expected_hash: ContentHash(row.get(3)), expected_size: row.get::<_, i64>(4).cast_unsigned(), @@ -5705,7 +5882,7 @@ impl StorageBackend for PostgresBackend { status: row .get::<_, String>(7) .parse() - .unwrap_or(crate::sync::UploadStatus::Pending), + .unwrap_or(pinakes_sync::UploadStatus::Pending), created_at: row.get(8), expires_at: row.get(9), last_activity: row.get(10), @@ -5714,7 +5891,7 @@ impl StorageBackend for PostgresBackend { async fn update_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5739,7 +5916,7 @@ impl StorageBackend for PostgresBackend { async fn record_chunk( &self, upload_id: Uuid, - chunk: &crate::sync::ChunkInfo, + chunk: &pinakes_sync::ChunkInfo, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5771,7 +5948,7 @@ impl StorageBackend for PostgresBackend { async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result> { + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5789,7 +5966,7 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::ChunkInfo { + pinakes_sync::ChunkInfo { upload_id: r.get(0), chunk_index: r.get::<_, i64>(1).cast_unsigned(), offset: r.get::<_, i64>(2).cast_unsigned(), @@ -5818,7 +5995,7 @@ impl StorageBackend for PostgresBackend { async fn record_conflict( &self, - conflict: &crate::sync::SyncConflict, + conflict: &pinakes_sync::SyncConflict, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5849,8 +6026,8 @@ impl StorageBackend for PostgresBackend { async fn get_unresolved_conflicts( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) })?; @@ -5871,9 +6048,9 @@ impl StorageBackend for PostgresBackend { rows .iter() .map(|r| { - crate::sync::SyncConflict { + pinakes_sync::SyncConflict { id: r.get(0), - device_id: crate::sync::DeviceId(r.get(1)), + device_id: pinakes_sync::DeviceId(r.get(1)), path: r.get(2), local_hash: r.get(3), local_mtime: r.get(4), @@ -5884,15 +6061,17 @@ impl StorageBackend for PostgresBackend { resolution: r.get::<_, Option>(9).and_then(|s| { match s.as_str() { "server_wins" => { - Some(crate::config::ConflictResolution::ServerWins) + Some(pinakes_types::config::ConflictResolution::ServerWins) }, "client_wins" => { - Some(crate::config::ConflictResolution::ClientWins) + Some(pinakes_types::config::ConflictResolution::ClientWins) }, "keep_both" => { - Some(crate::config::ConflictResolution::KeepBoth) + Some(pinakes_types::config::ConflictResolution::KeepBoth) + }, + "manual" => { + Some(pinakes_types::config::ConflictResolution::Manual) }, - "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, } }), @@ -5905,7 +6084,7 @@ impl StorageBackend for PostgresBackend { async fn resolve_conflict( &self, id: Uuid, - resolution: crate::config::ConflictResolution, + resolution: pinakes_types::config::ConflictResolution, ) -> Result<()> { let client = self.pool.get().await.map_err(|e| { PinakesError::Database(format!("failed to get connection: {e}")) @@ -5913,10 +6092,10 @@ impl StorageBackend for PostgresBackend { let now = chrono::Utc::now(); let resolution_str = match resolution { - crate::config::ConflictResolution::ServerWins => "server_wins", - crate::config::ConflictResolution::ClientWins => "client_wins", - crate::config::ConflictResolution::KeepBoth => "keep_both", - crate::config::ConflictResolution::Manual => "manual", + pinakes_types::config::ConflictResolution::ServerWins => "server_wins", + pinakes_types::config::ConflictResolution::ClientWins => "client_wins", + pinakes_types::config::ConflictResolution::KeepBoth => "keep_both", + pinakes_types::config::ConflictResolution::Manual => "manual", }; client @@ -7302,7 +7481,8 @@ impl PostgresBackend { user_id = $1", &[&user_id], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(row.map_or_else( || { crate::users::UserProfile { @@ -7337,7 +7517,8 @@ impl PostgresBackend { deleted_at IS NULL", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let total_media: i64 = row.get(0); let total_size: i64 = row.get(1); let avg_size = if total_media > 0 { @@ -7352,7 +7533,8 @@ impl PostgresBackend { NULL GROUP BY media_type ORDER BY COUNT(*) DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let media_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7369,7 +7551,8 @@ impl PostgresBackend { DESC", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let storage_by_type: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7385,7 +7568,8 @@ impl PostgresBackend { ORDER BY created_at DESC LIMIT 1", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .map(|r| r.get(0)); let oldest: Option = client .query_opt( @@ -7393,7 +7577,8 @@ impl PostgresBackend { ORDER BY created_at ASC LIMIT 1", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .map(|r| r.get(0)); let rows = client @@ -7402,7 +7587,8 @@ impl PostgresBackend { mt.tag_id = t.id GROUP BY t.id, t.name ORDER BY cnt DESC LIMIT 10", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_tags: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7419,7 +7605,8 @@ impl PostgresBackend { BY cnt DESC LIMIT 10", &[], ) - .await?; + .await + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_collections: Vec<(String, u64)> = rows .iter() .map(|r| { @@ -7431,11 +7618,13 @@ impl PostgresBackend { let total_tags: i64 = client .query_one("SELECT COUNT(*) FROM tags", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let total_collections: i64 = client .query_one("SELECT COUNT(*) FROM collections", &[]) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); let total_duplicates: i64 = client .query_one( @@ -7443,7 +7632,8 @@ impl PostgresBackend { content_hash HAVING COUNT(*) > 1) sub", &[], ) - .await? + .await + .map_err(|e| PinakesError::Database(e.to_string()))? .get(0); Ok(super::LibraryStatistics { diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 4b72f95..82987f0 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -9,7 +9,7 @@ use rustc_hash::FxHashMap; use uuid::Uuid; use crate::{ - error::{PinakesError, Result}, + error::{PinakesError, Result, db_ctx}, media_type::MediaType, model::{ AuditAction, @@ -58,7 +58,8 @@ impl SqliteBackend { /// /// Returns an error if the database cannot be opened or configured. pub fn new(path: &Path) -> Result { - let conn = Connection::open(path)?; + let conn = Connection::open(path) + .map_err(|e| PinakesError::Database(e.to_string()))?; Self::configure(conn) } @@ -69,13 +70,15 @@ impl SqliteBackend { /// Returns an error if the in-memory database cannot be created or /// configured. pub fn in_memory() -> Result { - let conn = Connection::open_in_memory()?; + let conn = Connection::open_in_memory() + .map_err(|e| PinakesError::Database(e.to_string()))?; Self::configure(conn) } fn configure(conn: Connection) -> Result { conn - .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?; + .execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;") + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -692,7 +695,8 @@ impl StorageBackend for SqliteBackend { db.execute( "INSERT OR IGNORE INTO root_dirs (path) VALUES (?1)", params![path.to_string_lossy().as_ref()], - )?; + ) + .map_err(db_ctx("insert_root_dirs", "path"))?; } Ok(()) }) @@ -709,14 +713,17 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = - db.prepare("SELECT path FROM root_dirs ORDER BY path")?; + let mut stmt = db + .prepare("SELECT path FROM root_dirs ORDER BY path") + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt .query_map([], |row| { let p: String = row.get(0)?; Ok(PathBuf::from(p)) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -738,7 +745,8 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; db.execute("DELETE FROM root_dirs WHERE path = ?1", params![ path.to_string_lossy().as_ref() - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -794,7 +802,7 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("insert_media", &item.id))?; + .map_err(db_ctx("insert_media", &item.id))?; } Ok(()) }) @@ -809,11 +817,13 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", - [], - |row| row.get(0), - )?; + let count: i64 = db + .query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NULL", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; count }; Ok(count.cast_unsigned()) @@ -829,15 +839,17 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, \ - title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, date_taken, latitude, longitude, \ - camera_make, camera_model, rating, perceptual_hash, storage_mode, \ - original_filename, uploaded_at, storage_key, created_at, \ - updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ - id = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, file_mtime, date_taken, latitude, longitude, \ + camera_make, camera_model, rating, perceptual_hash, \ + storage_mode, original_filename, uploaded_at, storage_key, \ + created_at, updated_at, deleted_at, links_extracted_at FROM \ + media_items WHERE id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut item = stmt .query_row(params![id.0.to_string()], row_to_media_item) .map_err(|e| { @@ -845,11 +857,12 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::NotFound(format!("media item {id}")) }, - other => PinakesError::from(other), + other => PinakesError::Database(other.to_string()), } })?; drop(stmt); - item.custom_fields = load_custom_fields_sync(&db, item.id)?; + item.custom_fields = load_custom_fields_sync(&db, item.id) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); item }; @@ -870,21 +883,25 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, \ - title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, date_taken, latitude, longitude, \ - camera_make, camera_model, rating, perceptual_hash, storage_mode, \ - original_filename, uploaded_at, storage_key, created_at, \ - updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ - content_hash = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, file_mtime, date_taken, latitude, longitude, \ + camera_make, camera_model, rating, perceptual_hash, \ + storage_mode, original_filename, uploaded_at, storage_key, \ + created_at, updated_at, deleted_at, links_extracted_at FROM \ + media_items WHERE content_hash = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let result = stmt .query_row(params![hash.0], row_to_media_item) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id)?; + item.custom_fields = load_custom_fields_sync(&db, item.id) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); Some(item) } else { @@ -909,21 +926,25 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, \ - title, artist, album, genre, year, duration_secs, description, \ - thumbnail_path, file_mtime, date_taken, latitude, longitude, \ - camera_make, camera_model, rating, perceptual_hash, storage_mode, \ - original_filename, uploaded_at, storage_key, created_at, \ - updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ - path = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, \ + title, artist, album, genre, year, duration_secs, description, \ + thumbnail_path, file_mtime, date_taken, latitude, longitude, \ + camera_make, camera_model, rating, perceptual_hash, \ + storage_mode, original_filename, uploaded_at, storage_key, \ + created_at, updated_at, deleted_at, links_extracted_at FROM \ + media_items WHERE path = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let result = stmt .query_row(params![path_str], row_to_media_item) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); if let Some(mut item) = result { - item.custom_fields = load_custom_fields_sync(&db, item.id)?; + item.custom_fields = load_custom_fields_sync(&db, item.id) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); Some(item) } else { @@ -968,7 +989,9 @@ impl StorageBackend for SqliteBackend { updated_at, deleted_at, links_extracted_at FROM media_items WHERE \ deleted_at IS NULL ORDER BY {order_by} LIMIT ?1 OFFSET ?2" ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut rows = stmt .query_map( params![ @@ -976,10 +999,13 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_media_item, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); rows }; @@ -1035,7 +1061,7 @@ impl StorageBackend for SqliteBackend { item.updated_at.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("update_media", &item.id))?; + .map_err(db_ctx("update_media", &item.id))?; drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!( @@ -1061,7 +1087,7 @@ impl StorageBackend for SqliteBackend { .execute("DELETE FROM media_items WHERE id = ?1", params![ id.0.to_string() ]) - .map_err(crate::error::db_ctx("delete_media", id))?; + .map_err(db_ctx("delete_media", id))?; drop(db); if changed == 0 { return Err(PinakesError::NotFound(format!("media item {id}"))); @@ -1080,11 +1106,14 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let count: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |row| { + let count: u64 = db + .query_row("SELECT COUNT(*) FROM media_items", [], |row| { row.get::<_, i64>(0) - })?.cast_unsigned(); - db.execute("DELETE FROM media_items", [])?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); + db.execute("DELETE FROM media_items", []) + .map_err(|e| PinakesError::Database(e.to_string()))?; count }; Ok(count) @@ -1117,7 +1146,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("create_tag", &name))?; + .map_err(db_ctx("create_tag", &name))?; drop(db); Tag { id, @@ -1139,9 +1168,11 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, parent_id, created_at FROM tags WHERE id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let tag = stmt .query_row(params![id.to_string()], row_to_tag) .map_err(|e| { @@ -1149,7 +1180,7 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::TagNotFound(id.to_string()) }, - other => PinakesError::from(other), + other => PinakesError::Database(other.to_string()), } })?; drop(stmt); @@ -1169,12 +1200,16 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, parent_id, created_at FROM tags ORDER BY name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map([], row_to_tag)? - .collect::>>()?; + .query_map([], row_to_tag) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1194,7 +1229,7 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; let changed = db .execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()]) - .map_err(crate::error::db_ctx("delete_tag", id))?; + .map_err(db_ctx("delete_tag", id))?; drop(db); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1217,10 +1252,7 @@ impl StorageBackend for SqliteBackend { "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", params![media_id.0.to_string(), tag_id.to_string()], ) - .map_err(crate::error::db_ctx( - "tag_media", - format!("{media_id} x {tag_id}"), - ))?; + .map_err(db_ctx("tag_media", format!("{media_id} x {tag_id}")))?; } Ok(()) }) @@ -1239,10 +1271,7 @@ impl StorageBackend for SqliteBackend { "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", params![media_id.0.to_string(), tag_id.to_string()], ) - .map_err(crate::error::db_ctx( - "untag_media", - format!("{media_id} x {tag_id}"), - ))?; + .map_err(db_ctx("untag_media", format!("{media_id} x {tag_id}")))?; } Ok(()) }) @@ -1257,14 +1286,18 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT t.id, t.name, t.parent_id, t.created_at FROM tags t JOIN \ - media_tags mt ON mt.tag_id = t.id WHERE mt.media_id = ?1 ORDER BY \ - t.name", - )?; + let mut stmt = db + .prepare( + "SELECT t.id, t.name, t.parent_id, t.created_at FROM tags t JOIN \ + media_tags mt ON mt.tag_id = t.id WHERE mt.media_id = ?1 ORDER \ + BY t.name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map(params![media_id.0.to_string()], row_to_tag)? - .collect::>>()?; + .query_map(params![media_id.0.to_string()], row_to_tag) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1282,16 +1315,20 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "WITH RECURSIVE descendants(id, name, parent_id, created_at) AS ( \ - SELECT id, name, parent_id, created_at FROM tags WHERE parent_id = \ - ?1 UNION ALL SELECT t.id, t.name, t.parent_id, t.created_at FROM \ - tags t JOIN descendants d ON t.parent_id = d.id ) SELECT id, name, \ - parent_id, created_at FROM descendants ORDER BY name", - )?; + let mut stmt = db + .prepare( + "WITH RECURSIVE descendants(id, name, parent_id, created_at) AS ( \ + SELECT id, name, parent_id, created_at FROM tags WHERE parent_id \ + = ?1 UNION ALL SELECT t.id, t.name, t.parent_id, t.created_at \ + FROM tags t JOIN descendants d ON t.parent_id = d.id ) SELECT \ + id, name, parent_id, created_at FROM descendants ORDER BY name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map(params![tag_id.to_string()], row_to_tag)? - .collect::>>()?; + .query_map(params![tag_id.to_string()], row_to_tag) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1334,7 +1371,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx("create_collection", &name))?; + .map_err(db_ctx("create_collection", &name))?; drop(db); Collection { id, @@ -1359,10 +1396,12 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, description, kind, filter_query, created_at, \ - updated_at FROM collections WHERE id = ?1", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections WHERE id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let collection = stmt .query_row(params![id.to_string()], row_to_collection) .map_err(|e| { @@ -1370,7 +1409,7 @@ impl StorageBackend for SqliteBackend { rusqlite::Error::QueryReturnedNoRows => { PinakesError::CollectionNotFound(id.to_string()) }, - other => PinakesError::from(other), + other => PinakesError::Database(other.to_string()), } })?; drop(stmt); @@ -1390,13 +1429,17 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, description, kind, filter_query, created_at, \ - updated_at FROM collections ORDER BY name", - )?; + let mut stmt = db + .prepare( + "SELECT id, name, description, kind, filter_query, created_at, \ + updated_at FROM collections ORDER BY name", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt - .query_map([], row_to_collection)? - .collect::>>()?; + .query_map([], row_to_collection) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); drop(db); rows @@ -1418,7 +1461,7 @@ impl StorageBackend for SqliteBackend { .execute("DELETE FROM collections WHERE id = ?1", params![ id.to_string() ]) - .map_err(crate::error::db_ctx("delete_collection", id))?; + .map_err(db_ctx("delete_collection", id))?; drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1453,7 +1496,7 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), ], ) - .map_err(crate::error::db_ctx( + .map_err(db_ctx( "add_to_collection", format!("{collection_id} <- {media_id}"), ))?; @@ -1480,7 +1523,7 @@ impl StorageBackend for SqliteBackend { media_id = ?2", params![collection_id.to_string(), media_id.0.to_string()], ) - .map_err(crate::error::db_ctx( + .map_err(db_ctx( "remove_from_collection", format!("{collection_id} <- {media_id}"), ))?; @@ -1501,22 +1544,27 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, \ - m.date_taken, m.latitude, m.longitude, m.camera_make, \ - m.camera_model, m.rating, m.perceptual_hash, m.storage_mode, \ - m.original_filename, m.uploaded_at, m.storage_key, m.created_at, \ - m.updated_at, m.deleted_at, m.links_extracted_at FROM media_items \ - m JOIN collection_members cm ON cm.media_id = m.id WHERE \ - cm.collection_id = ?1 ORDER BY cm.position", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.file_mtime, \ + m.date_taken, m.latitude, m.longitude, m.camera_make, \ + m.camera_model, m.rating, m.perceptual_hash, m.storage_mode, \ + m.original_filename, m.uploaded_at, m.storage_key, m.created_at, \ + m.updated_at, m.deleted_at, m.links_extracted_at FROM \ + media_items m JOIN collection_members cm ON cm.media_id = m.id \ + WHERE cm.collection_id = ?1 ORDER BY cm.position", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut rows = stmt - .query_map(params![collection_id.to_string()], row_to_media_item)? - .collect::>>()?; + .query_map(params![collection_id.to_string()], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); rows }; @@ -1584,16 +1632,21 @@ impl StorageBackend for SqliteBackend { all_params.push(request.pagination.limit.to_string()); all_params.push(request.pagination.offset.to_string()); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let param_refs: Vec<&dyn rusqlite::types::ToSql> = all_params .iter() .map(|s| s as &dyn rusqlite::types::ToSql) .collect(); let mut items = stmt - .query_map(param_refs.as_slice(), row_to_media_item)? - .collect::>>()?; + .query_map(param_refs.as_slice(), row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(stmt); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Count query (same filters, no LIMIT/OFFSET) let mut count_sql = String::from("SELECT COUNT(*) FROM media_items m "); @@ -1619,10 +1672,9 @@ impl StorageBackend for SqliteBackend { .iter() .map(|s| s as &dyn rusqlite::types::ToSql) .collect(); - let total_count: i64 = - db.query_row(&count_sql, count_param_refs.as_slice(), |row| { - row.get(0) - })?; + let total_count: i64 = db + .query_row(&count_sql, count_param_refs.as_slice(), |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; drop(db); SearchResults { @@ -1654,7 +1706,8 @@ impl StorageBackend for SqliteBackend { entry.details, entry.timestamp.to_rfc3339(), ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -1694,7 +1747,9 @@ impl StorageBackend for SqliteBackend { }, ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = if let Some(ref mid_str) = bind_media_id { stmt .query_map( @@ -1704,8 +1759,10 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_audit_entry, - )? - .collect::>>()? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))? } else { stmt .query_map( @@ -1714,8 +1771,10 @@ impl StorageBackend for SqliteBackend { pagination.offset.cast_signed() ], row_to_audit_entry, - )? - .collect::>>()? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))? }; drop(stmt); drop(db); @@ -1751,7 +1810,8 @@ impl StorageBackend for SqliteBackend { custom_field_type_to_str(field.field_type), field.value, ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -1769,23 +1829,28 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT field_name, field_type, field_value FROM custom_fields \ - WHERE media_id = ?1", - )?; - let rows = stmt.query_map(params![media_id.0.to_string()], |row| { - let name: String = row.get(0)?; - let ft_str: String = row.get(1)?; - let value: String = row.get(2)?; - Ok((name, CustomField { - field_type: str_to_custom_field_type(&ft_str), - value, - })) - })?; + let mut stmt = db + .prepare( + "SELECT field_name, field_type, field_value FROM custom_fields \ + WHERE media_id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map(params![media_id.0.to_string()], |row| { + let name: String = row.get(0)?; + let ft_str: String = row.get(1)?; + let value: String = row.get(2)?; + Ok((name, CustomField { + field_type: str_to_custom_field_type(&ft_str), + value, + })) + }) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut map = FxHashMap::default(); for r in rows { - let (name, field) = r?; + let (name, field) = + r.map_err(|e| PinakesError::Database(e.to_string()))?; map.insert(name, field); } drop(stmt); @@ -1813,7 +1878,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM custom_fields WHERE media_id = ?1 AND field_name = ?2", params![media_id.0.to_string(), name], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -1837,7 +1903,7 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{n} items"); let tx = db .unchecked_transaction() - .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; + .map_err(db_ctx("batch_delete_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { let placeholders: Vec = @@ -1850,11 +1916,10 @@ impl StorageBackend for SqliteBackend { chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); let rows = tx .execute(&sql, params.as_slice()) - .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; + .map_err(db_ctx("batch_delete_media", &ctx))?; count += rows as u64; } - tx.commit() - .map_err(crate::error::db_ctx("batch_delete_media", &ctx))?; + tx.commit().map_err(db_ctx("batch_delete_media", &ctx))?; count }; Ok(count) @@ -1886,26 +1951,25 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{} media x {} tags", media_ids.len(), tag_ids.len()); let tx = db .unchecked_transaction() - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + .map_err(db_ctx("batch_tag_media", &ctx))?; // Prepare statement once for reuse let mut stmt = tx .prepare_cached( "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, \ ?2)", ) - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + .map_err(db_ctx("batch_tag_media", &ctx))?; let mut count = 0u64; for mid in &media_ids { for tid in &tag_ids { let rows = stmt .execute(params![mid, tid]) - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + .map_err(db_ctx("batch_tag_media", &ctx))?; count += rows as u64; // INSERT OR IGNORE: rows=1 if new, 0 if existed } } drop(stmt); - tx.commit() - .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; + tx.commit().map_err(db_ctx("batch_tag_media", &ctx))?; count }; Ok(count) @@ -1991,7 +2055,7 @@ impl StorageBackend for SqliteBackend { let ctx = format!("{} items", ids.len()); let tx = db .unchecked_transaction() - .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + .map_err(db_ctx("batch_update_media", &ctx))?; let mut count = 0u64; for chunk in ids.chunks(CHUNK_SIZE) { @@ -2011,12 +2075,11 @@ impl StorageBackend for SqliteBackend { let rows = tx .execute(&sql, all_params.as_slice()) - .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + .map_err(db_ctx("batch_update_media", &ctx))?; count += rows as u64; } - tx.commit() - .map_err(crate::error::db_ctx("batch_update_media", &ctx))?; + tx.commit().map_err(db_ctx("batch_update_media", &ctx))?; count }; Ok(count) @@ -2032,19 +2095,24 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT * FROM media_items WHERE deleted_at IS NULL AND \ - content_hash IN ( + let mut stmt = db + .prepare( + "SELECT * FROM media_items WHERE deleted_at IS NULL AND \ + content_hash IN ( SELECT content_hash FROM media_items WHERE deleted_at IS \ - NULL + NULL GROUP BY content_hash HAVING COUNT(*) > 1 ) ORDER BY content_hash, created_at", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut rows: Vec = stmt - .query_map([], row_to_media_item)? - .collect::>>()?; + .query_map([], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; - load_custom_fields_batch(&db, &mut rows)?; + load_custom_fields_batch(&db, &mut rows) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Group by content_hash let mut groups: Vec> = Vec::new(); @@ -2079,15 +2147,20 @@ impl StorageBackend for SqliteBackend { .map_err(|e| PinakesError::Database(e.to_string()))?; // Get all images with perceptual hashes - let mut stmt = db.prepare( - "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL ORDER \ - BY id", - )?; + let mut stmt = db + .prepare( + "SELECT * FROM media_items WHERE perceptual_hash IS NOT NULL \ + ORDER BY id", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt - .query_map([], row_to_media_item)? - .collect::>>()?; + .query_map([], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; items }; @@ -2159,22 +2232,24 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let media_count: i64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |row| { - row.get(0) - })?; - let tag_count: i64 = - db.query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0))?; - let collection_count: i64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |row| { - row.get(0) - })?; - let audit_count: i64 = - db.query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0))?; - let page_count: i64 = - db.query_row("PRAGMA page_count", [], |row| row.get(0))?; - let page_size: i64 = - db.query_row("PRAGMA page_size", [], |row| row.get(0))?; + let media_count: i64 = db + .query_row("SELECT COUNT(*) FROM media_items", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let tag_count: i64 = db + .query_row("SELECT COUNT(*) FROM tags", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let collection_count: i64 = db + .query_row("SELECT COUNT(*) FROM collections", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let audit_count: i64 = db + .query_row("SELECT COUNT(*) FROM audit_log", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let page_count: i64 = db + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let page_size: i64 = db + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))?; let database_size_bytes = (page_count * page_size).cast_unsigned(); crate::storage::DatabaseStats { media_count: media_count.cast_unsigned(), @@ -2198,7 +2273,8 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute_batch("VACUUM")?; + db.execute_batch("VACUUM") + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2221,7 +2297,8 @@ impl StorageBackend for SqliteBackend { DELETE FROM media_items; DELETE FROM tags; DELETE FROM collections;", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2238,24 +2315,28 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, path, content_hash FROM media_items WHERE deleted_at IS \ - NULL", - )?; - let rows = stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let path_str: String = row.get(1)?; - let hash_str: String = row.get(2)?; - let id = parse_uuid(&id_str)?; - Ok(( - MediaId(id), - PathBuf::from(path_str), - ContentHash::new(hash_str), - )) - })?; + let mut stmt = db + .prepare( + "SELECT id, path, content_hash FROM media_items WHERE deleted_at \ + IS NULL", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let path_str: String = row.get(1)?; + let hash_str: String = row.get(2)?; + let id = parse_uuid(&id_str)?; + Ok(( + MediaId(id), + PathBuf::from(path_str), + ContentHash::new(hash_str), + )) + }) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { - results.push(row?); + results.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } results }; @@ -2287,7 +2368,8 @@ impl StorageBackend for SqliteBackend { "INSERT OR REPLACE INTO saved_searches (id, name, query, \ sort_order, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![id_str, name, query, sort_order, now], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2304,28 +2386,32 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let mut stmt = db.prepare( - "SELECT id, name, query, sort_order, created_at FROM saved_searches \ - ORDER BY created_at DESC", - )?; - let rows = stmt.query_map([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - let query: String = row.get(2)?; - let sort_order: Option = row.get(3)?; - let created_at_str: String = row.get(4)?; - let id = parse_uuid(&id_str)?; - Ok(crate::model::SavedSearch { - id, - name, - query, - sort_order, - created_at: parse_datetime(&created_at_str), + let mut stmt = db + .prepare( + "SELECT id, name, query, sort_order, created_at FROM \ + saved_searches ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map([], |row| { + let id_str: String = row.get(0)?; + let name: String = row.get(1)?; + let query: String = row.get(2)?; + let sort_order: Option = row.get(3)?; + let created_at_str: String = row.get(4)?; + let id = parse_uuid(&id_str)?; + Ok(crate::model::SavedSearch { + id, + name, + query, + sort_order, + created_at: parse_datetime(&created_at_str), + }) }) - })?; + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { - results.push(row?); + results.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } results }; @@ -2389,9 +2475,8 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - db.execute("DELETE FROM saved_searches WHERE id = ?1", params![ - id_str - ])?; + db.execute("DELETE FROM saved_searches WHERE id = ?1", params![id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } Ok(()) }) @@ -2415,14 +2500,23 @@ impl StorageBackend for SqliteBackend { } else { "SELECT id FROM media_items ORDER BY created_at DESC" }; - let mut stmt = db.prepare(sql)?; + let mut stmt = db + .prepare(sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids: Vec = stmt .query_map([], |r| { let s: String = r.get(0)?; - Ok(MediaId(uuid::Uuid::parse_str(&s).unwrap_or_default())) - })? - .filter_map(std::result::Result::ok) - .collect(); + uuid::Uuid::parse_str(&s).map(MediaId).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(e), + ) + }) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(ids) }) .await @@ -2440,32 +2534,49 @@ impl StorageBackend for SqliteBackend { .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; - let total_media: u64 = - db.query_row("SELECT COUNT(*) FROM media_items", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); - let total_size: u64 = db.query_row( - "SELECT COALESCE(SUM(file_size), 0) FROM media_items", - [], - |r| r.get::<_, i64>(0), - )?.cast_unsigned(); + let total_media: u64 = db + .query_row("SELECT COUNT(*) FROM media_items", [], |r| { + r.get::<_, i64>(0) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); + let total_size: u64 = db + .query_row( + "SELECT COALESCE(SUM(file_size), 0) FROM media_items", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); let avg_size: u64 = total_size.checked_div(total_media).unwrap_or(0); // Media count by type - let mut stmt = db.prepare( - "SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type \ - ORDER BY COUNT(*) DESC", - )?; + let mut stmt = db + .prepare( + "SELECT media_type, COUNT(*) FROM media_items GROUP BY media_type \ + ORDER BY COUNT(*) DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let media_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Storage by type - let mut stmt = db.prepare( - "SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items \ - GROUP BY media_type ORDER BY SUM(file_size) DESC", - )?; + let mut stmt = db + .prepare( + "SELECT media_type, COALESCE(SUM(file_size), 0) FROM media_items \ + GROUP BY media_type ORDER BY SUM(file_size) DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let storage_by_type: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); @@ -2476,48 +2587,69 @@ impl StorageBackend for SqliteBackend { [], |r| r.get(0), ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; let oldest: Option = db .query_row( "SELECT created_at FROM media_items ORDER BY created_at ASC LIMIT 1", [], |r| r.get(0), ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Top tags - let mut stmt = db.prepare( - "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON \ - mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10", - )?; + let mut stmt = db + .prepare( + "SELECT t.name, COUNT(*) as cnt FROM media_tags mt JOIN tags t ON \ + mt.tag_id = t.id GROUP BY t.id ORDER BY cnt DESC LIMIT 10", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_tags: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Top collections - let mut stmt = db.prepare( - "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN \ - collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY cnt \ - DESC LIMIT 10", - )?; + let mut stmt = db + .prepare( + "SELECT c.name, COUNT(*) as cnt FROM collection_members cm JOIN \ + collections c ON cm.collection_id = c.id GROUP BY c.id ORDER BY \ + cnt DESC LIMIT 10", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let top_collections: Vec<(String, u64)> = stmt - .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())))? + .query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.cast_unsigned())) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - let total_tags: u64 = - db.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); - let total_collections: u64 = - db.query_row("SELECT COUNT(*) FROM collections", [], |r| r.get::<_, i64>(0))?.cast_unsigned(); + let total_tags: u64 = db + .query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0)) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); + let total_collections: u64 = db + .query_row("SELECT COUNT(*) FROM collections", [], |r| { + r.get::<_, i64>(0) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); // Duplicates: count of hashes that appear more than once - let total_duplicates: u64 = db.query_row( - "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP BY \ - content_hash HAVING COUNT(*) > 1)", - [], - |r| r.get::<_, i64>(0), - )?.cast_unsigned(); + let total_duplicates: u64 = db + .query_row( + "SELECT COUNT(*) FROM (SELECT content_hash FROM media_items GROUP \ + BY content_hash HAVING COUNT(*) > 1)", + [], + |r| r.get::<_, i64>(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .cast_unsigned(); Ok(super::LibraryStatistics { total_media, @@ -2548,10 +2680,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, username, password_hash, role, created_at, updated_at \ - FROM users ORDER BY created_at DESC", - )?; + let mut stmt = db + .prepare( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let users = stmt .query_map([], |row| { let id_str: String = row.get(0)?; @@ -2574,8 +2708,10 @@ impl StorageBackend for SqliteBackend { .unwrap_or_else(|_| chrono::Utc::now().into()) .with_timezone(&chrono::Utc), }) - })? - .collect::, _>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::, _>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(users) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2627,7 +2763,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; opt.ok_or_else(|| PinakesError::NotFound(format!("user {id_str}"))) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2681,7 +2818,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; opt.ok_or_else(|| { PinakesError::NotFound(format!("user with username {username}")) }) @@ -2718,7 +2856,9 @@ impl StorageBackend for SqliteBackend { )) })?; - let tx = db.unchecked_transaction()?; + let tx = db + .unchecked_transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; let id = crate::users::UserId(uuid::Uuid::now_v7()); let id_str = id.0.to_string(); @@ -2738,7 +2878,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let user_profile = if let Some(prof) = profile.clone() { let prefs_json = @@ -2759,7 +2900,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; prof } else { crate::users::UserProfile { @@ -2769,7 +2911,8 @@ impl StorageBackend for SqliteBackend { } }; - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::users::User { id, @@ -2809,7 +2952,9 @@ impl StorageBackend for SqliteBackend { )) })?; - let tx = db.unchecked_transaction()?; + let tx = db + .unchecked_transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; let now = chrono::Utc::now(); // Update password and/or role if provided @@ -2838,7 +2983,8 @@ impl StorageBackend for SqliteBackend { format!("UPDATE users SET {} WHERE id = ?", updates.join(", ")); let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(std::convert::AsRef::as_ref).collect(); - tx.execute(&sql, param_refs.as_slice())?; + tx.execute(&sql, param_refs.as_slice()) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Update profile if provided @@ -2863,39 +3009,44 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Fetch updated user - Ok(db.query_row( - "SELECT id, username, password_hash, role, created_at, updated_at \ - FROM users WHERE id = ?", - [&id_str], - |row| { - let id_str: String = row.get(0)?; - let profile = load_user_profile_sync(&db, &id_str)?; - Ok(crate::users::User { - id: crate::users::UserId(parse_uuid(&id_str)?), - username: row.get(1)?, - password_hash: row.get(2)?, - role: serde_json::from_str(&row.get::<_, String>(3)?) - .unwrap_or(crate::config::UserRole::Viewer), - profile, - created_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(4)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - updated_at: chrono::DateTime::parse_from_rfc3339( - &row.get::<_, String>(5)?, - ) - .unwrap_or_else(|_| chrono::Utc::now().into()) - .with_timezone(&chrono::Utc), - }) - }, - )?) + Ok( + db.query_row( + "SELECT id, username, password_hash, role, created_at, updated_at \ + FROM users WHERE id = ?", + [&id_str], + |row| { + let id_str: String = row.get(0)?; + let profile = load_user_profile_sync(&db, &id_str)?; + Ok(crate::users::User { + id: crate::users::UserId(parse_uuid(&id_str)?), + username: row.get(1)?, + password_hash: row.get(2)?, + role: serde_json::from_str(&row.get::<_, String>(3)?) + .unwrap_or(crate::config::UserRole::Viewer), + profile, + created_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(4)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339( + &row.get::<_, String>(5)?, + ) + .unwrap_or_else(|_| chrono::Utc::now().into()) + .with_timezone(&chrono::Utc), + }) + }, + ) + .map_err(|e| PinakesError::Database(e.to_string()))?, + ) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -2915,19 +3066,26 @@ impl StorageBackend for SqliteBackend { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let tx = db.unchecked_transaction()?; + let tx = db + .unchecked_transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete profile first due to foreign key - tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str])?; + tx.execute("DELETE FROM user_profiles WHERE user_id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete library access - tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str])?; + tx.execute("DELETE FROM user_libraries WHERE user_id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete user - let affected = tx.execute("DELETE FROM users WHERE id = ?", [&id_str])?; + let affected = tx + .execute("DELETE FROM users WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; if affected == 0 { return Err(PinakesError::NotFound(format!("user {id_str}"))); } - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -2950,10 +3108,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT user_id, root_path, permission, granted_at FROM \ - user_libraries WHERE user_id = ?", - )?; + let mut stmt = db + .prepare( + "SELECT user_id, root_path, permission, granted_at FROM \ + user_libraries WHERE user_id = ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let libraries = stmt .query_map([&user_id_str], |row| { let id_str: String = row.get(0)?; @@ -2968,7 +3128,8 @@ impl StorageBackend for SqliteBackend { .unwrap_or_else(|_| chrono::Utc::now().into()) .with_timezone(&chrono::Utc), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect::>(); Ok(libraries) @@ -3011,7 +3172,8 @@ impl StorageBackend for SqliteBackend { &perm_str, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3041,7 +3203,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM user_libraries WHERE user_id = ? AND root_path = ?", rusqlite::params![&user_id_str, &root_path], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3085,18 +3248,22 @@ impl StorageBackend for SqliteBackend { &review, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // SELECT the actual row to get the real id and created_at (INSERT OR // REPLACE may have kept existing values) - let (actual_id, actual_created_at) = db.query_row( - "SELECT id, created_at FROM ratings WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| { - let rid_str: String = row.get(0)?; - let created_str: String = row.get(1)?; - Ok((parse_uuid(&rid_str)?, parse_datetime(&created_str))) - }, - )?; + let (actual_id, actual_created_at) = db + .query_row( + "SELECT id, created_at FROM ratings WHERE user_id = ? AND media_id \ + = ?", + params![&user_id_str, &media_id_str], + |row| { + let rid_str: String = row.get(0)?; + let created_str: String = row.get(1)?; + Ok((parse_uuid(&rid_str)?, parse_datetime(&created_str))) + }, + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::Rating { id: actual_id, user_id, @@ -3124,10 +3291,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, user_id, media_id, stars, review_text, created_at FROM \ - ratings WHERE media_id = ? ORDER BY created_at DESC", - )?; + let mut stmt = db + .prepare( + "SELECT id, user_id, media_id, stars, review_text, created_at FROM \ + ratings WHERE media_id = ? ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ratings = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; @@ -3142,7 +3311,8 @@ impl StorageBackend for SqliteBackend { review_text: row.get(4)?, created_at: parse_datetime(&created_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(ratings) @@ -3189,7 +3359,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3207,7 +3378,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM ratings WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM ratings WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3248,7 +3420,8 @@ impl StorageBackend for SqliteBackend { &text, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::Comment { id, user_id, @@ -3276,10 +3449,12 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, user_id, media_id, parent_comment_id, text, created_at \ - FROM comments WHERE media_id = ? ORDER BY created_at ASC", - )?; + let mut stmt = db + .prepare( + "SELECT id, user_id, media_id, parent_comment_id, text, created_at \ + FROM comments WHERE media_id = ? ORDER BY created_at ASC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let comments = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; @@ -3296,7 +3471,8 @@ impl StorageBackend for SqliteBackend { text: row.get(4)?, created_at: parse_datetime(&created_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(comments) @@ -3318,7 +3494,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM comments WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM comments WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3346,7 +3523,8 @@ impl StorageBackend for SqliteBackend { "INSERT OR IGNORE INTO favorites (user_id, media_id, created_at) \ VALUES (?, ?, ?)", params![&user_id_str, &media_id_str, now.to_rfc3339()], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3372,7 +3550,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM favorites WHERE user_id = ? AND media_id = ?", params![&user_id_str, &media_id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3396,19 +3575,23 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at FROM media_items m JOIN favorites f ON m.id = \ - f.media_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? \ - OFFSET ?", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN favorites f ON m.id = \ + f.media_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ? \ + OFFSET ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt - .query_map(params![&user_id_str, limit, offset], row_to_media_item)? + .query_map(params![&user_id_str, limit, offset], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3433,11 +3616,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let count: i64 = db.query_row( - "SELECT COUNT(*) FROM favorites WHERE user_id = ? AND media_id = ?", - params![&user_id_str, &media_id_str], - |row| row.get(0), - )?; + let count: i64 = db + .query_row( + "SELECT COUNT(*) FROM favorites WHERE user_id = ? AND media_id = ?", + params![&user_id_str, &media_id_str], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(count > 0) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3482,7 +3667,8 @@ impl StorageBackend for SqliteBackend { &expires_str, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::social::ShareLink { id, media_id, @@ -3563,7 +3749,8 @@ impl StorageBackend for SqliteBackend { db.execute( "UPDATE share_links SET view_count = view_count + 1 WHERE token = ?", [&token], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3583,7 +3770,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM share_links WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM share_links WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3632,7 +3820,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(crate::playlists::Playlist { id, owner_id, @@ -3728,7 +3917,9 @@ impl StorageBackend for SqliteBackend { ) }, ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let rows = if let Some(ref p) = param { stmt .query_map([p], |row| { @@ -3747,7 +3938,8 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() } else { @@ -3768,7 +3960,8 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&created_str), updated_at: parse_datetime(&updated_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() }; @@ -3818,7 +4011,8 @@ impl StorageBackend for SqliteBackend { format!("UPDATE playlists SET {} WHERE id = ?", updates.join(", ")); let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(std::convert::AsRef::as_ref).collect(); - db.execute(&sql, param_refs.as_slice())?; + db.execute(&sql, param_refs.as_slice()) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Fetch updated db.query_row( "SELECT id, owner_id, name, description, is_public, is_smart, \ @@ -3866,7 +4060,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM playlists WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM playlists WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3895,7 +4090,8 @@ impl StorageBackend for SqliteBackend { "INSERT OR REPLACE INTO playlist_items (playlist_id, media_id, \ position, added_at) VALUES (?, ?, ?, ?)", params![&playlist_id_str, &media_id_str, position, now.to_rfc3339()], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3921,7 +4117,8 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM playlist_items WHERE playlist_id = ? AND media_id = ?", params![&playlist_id_str, &media_id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3944,18 +4141,22 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = \ - pi.media_id WHERE pi.playlist_id = ? ORDER BY pi.position ASC", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN playlist_items pi ON m.id = \ + pi.media_id WHERE pi.playlist_id = ? ORDER BY pi.position ASC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt - .query_map([&playlist_id_str], row_to_media_item)? + .query_map([&playlist_id_str], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -3985,7 +4186,8 @@ impl StorageBackend for SqliteBackend { "UPDATE playlist_items SET position = ? WHERE playlist_id = ? AND \ media_id = ?", params![new_position, &playlist_id_str, &media_id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4024,7 +4226,8 @@ impl StorageBackend for SqliteBackend { &duration, &context ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4069,7 +4272,9 @@ impl StorageBackend for SqliteBackend { context_json FROM usage_events {where_clause} ORDER BY timestamp \ DESC LIMIT ?" ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(std::convert::AsRef::as_ref).collect(); let events = stmt @@ -4094,7 +4299,8 @@ impl StorageBackend for SqliteBackend { duration_secs: row.get(5)?, context_json: row.get(6)?, }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(events) @@ -4113,26 +4319,30 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN \ - usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN \ - ('view', 'play') GROUP BY m.id ORDER BY view_count DESC LIMIT ?", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at, COUNT(ue.id) as view_count FROM media_items m JOIN \ + usage_events ue ON m.id = ue.media_id WHERE ue.event_type IN \ + ('view', 'play') GROUP BY m.id ORDER BY view_count DESC LIMIT ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec<(MediaItem, u64)> = stmt .query_map([limit.cast_signed()], |row| { let item = row_to_media_item(row)?; let count: i64 = row.get(16)?; Ok((item, count.cast_unsigned())) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Load custom fields for each item let mut media_items: Vec = items.iter().map(|(i, _)| i.clone()).collect(); - load_custom_fields_batch(&db, &mut media_items)?; + load_custom_fields_batch(&db, &mut media_items) + .map_err(|e| PinakesError::Database(e.to_string()))?; for (i, (item, _)) in items.iter_mut().enumerate() { item.custom_fields = std::mem::take(&mut media_items[i].custom_fields); } @@ -4157,22 +4367,26 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ - m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ - m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ - m.updated_at FROM media_items m JOIN usage_events ue ON m.id = \ - ue.media_id WHERE ue.user_id = ? AND ue.event_type IN ('view', \ - 'play') GROUP BY m.id ORDER BY MAX(ue.timestamp) DESC LIMIT ?", - )?; + let mut stmt = db + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, \ + m.file_size, m.title, m.artist, m.album, m.genre, m.year, \ + m.duration_secs, m.description, m.thumbnail_path, m.created_at, \ + m.updated_at FROM media_items m JOIN usage_events ue ON m.id = \ + ue.media_id WHERE ue.user_id = ? AND ue.event_type IN ('view', \ + 'play') GROUP BY m.id ORDER BY MAX(ue.timestamp) DESC LIMIT ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items: Vec = stmt .query_map( params![&user_id_str, limit.cast_signed()], row_to_media_item, - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); - load_custom_fields_batch(&db, &mut items)?; + load_custom_fields_batch(&db, &mut items) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(items) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4212,7 +4426,8 @@ impl StorageBackend for SqliteBackend { progress_secs, now.to_rfc3339() ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4244,7 +4459,8 @@ impl StorageBackend for SqliteBackend { params![&user_id_str, &media_id_str], |row| row.get(0), ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4270,7 +4486,8 @@ impl StorageBackend for SqliteBackend { let affected = db .execute("DELETE FROM usage_events WHERE timestamp < ?", [ &before_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4319,7 +4536,8 @@ impl StorageBackend for SqliteBackend { offset_ms, &now ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4340,10 +4558,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, media_id, language, format, file_path, is_embedded, \ - track_index, offset_ms, created_at FROM subtitles WHERE media_id = ?", - )?; + let mut stmt = db + .prepare( + "SELECT id, media_id, language, format, file_path, is_embedded, \ + track_index, offset_ms, created_at FROM subtitles WHERE media_id = \ + ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let subtitles = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; @@ -4367,7 +4588,8 @@ impl StorageBackend for SqliteBackend { offset_ms: row.get(7)?, created_at: parse_datetime(&created_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(subtitles) @@ -4389,7 +4611,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM subtitles WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4413,7 +4636,8 @@ impl StorageBackend for SqliteBackend { })?; db.execute("UPDATE subtitles SET offset_ms = ? WHERE id = ?", params![ offset_ms, &id_str - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4428,7 +4652,7 @@ impl StorageBackend for SqliteBackend { async fn store_external_metadata( &self, - meta: &crate::enrichment::ExternalMetadata, + meta: &pinakes_enrichment::ExternalMetadata, ) -> Result<()> { let conn = Arc::clone(&self.conn); let id_str = meta.id.to_string(); @@ -4455,7 +4679,8 @@ impl StorageBackend for SqliteBackend { confidence, &last_updated ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4471,35 +4696,38 @@ impl StorageBackend for SqliteBackend { async fn get_external_metadata( &self, media_id: MediaId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let media_id_str = media_id.0.to_string(); let fut = tokio::task::spawn_blocking(move || { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let mut stmt = db.prepare( - "SELECT id, media_id, source, external_id, metadata_json, confidence, \ - last_updated FROM external_metadata WHERE media_id = ?", - )?; + let mut stmt = db + .prepare( + "SELECT id, media_id, source, external_id, metadata_json, \ + confidence, last_updated FROM external_metadata WHERE media_id = ?", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let metas = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; let mid_str: String = row.get(1)?; let source_str: String = row.get(2)?; let updated_str: String = row.get(6)?; - Ok(crate::enrichment::ExternalMetadata { + Ok(pinakes_enrichment::ExternalMetadata { id: parse_uuid(&id_str)?, media_id: MediaId(parse_uuid(&mid_str)?), source: source_str .parse() - .unwrap_or(crate::enrichment::EnrichmentSourceType::MusicBrainz), + .unwrap_or(pinakes_enrichment::EnrichmentSourceType::MusicBrainz), external_id: row.get(3)?, metadata_json: row.get(4)?, confidence: row.get(5)?, last_updated: parse_datetime(&updated_str), }) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); Ok(metas) @@ -4521,7 +4749,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str])?; + db.execute("DELETE FROM external_metadata WHERE id = ?", [&id_str]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4569,7 +4798,8 @@ impl StorageBackend for SqliteBackend { &created_at, &expires_at ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4673,7 +4903,9 @@ impl StorageBackend for SqliteBackend { ) }, ); - let mut stmt = db.prepare(&sql)?; + let mut stmt = db + .prepare(&sql) + .map_err(|e| PinakesError::Database(e.to_string()))?; let parse_row = |row: &Row| -> rusqlite::Result { let id_str: String = row.get(0)?; @@ -4704,12 +4936,14 @@ impl StorageBackend for SqliteBackend { }; let sessions: Vec<_> = if let Some(ref p) = param { stmt - .query_map([p], parse_row)? + .query_map([p], parse_row) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() } else { stmt - .query_map([], parse_row)? + .query_map([], parse_row) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect() }; @@ -4743,7 +4977,8 @@ impl StorageBackend for SqliteBackend { "UPDATE transcode_sessions SET status = ?, progress = ?, \ error_message = ? WHERE id = ?", params![&status_str, progress, &error_message, &id_str], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4766,11 +5001,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let affected = db.execute( - "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND \ - expires_at < ?", - [&before_str], - )?; + let affected = db + .execute( + "DELETE FROM transcode_sessions WHERE expires_at IS NOT NULL AND \ + expires_at < ?", + [&before_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4813,7 +5050,8 @@ impl StorageBackend for SqliteBackend { &expires_at, &last_accessed ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4876,7 +5114,8 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(result) }); @@ -4900,7 +5139,8 @@ impl StorageBackend for SqliteBackend { db.execute( "UPDATE sessions SET last_accessed = ? WHERE session_token = ?", params![&now, &token], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4925,11 +5165,13 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let rows = db.execute( - "UPDATE sessions SET expires_at = ?, last_accessed = ? WHERE \ - session_token = ? AND expires_at > datetime('now')", - params![&expires, &now, &token], - )?; + let rows = db + .execute( + "UPDATE sessions SET expires_at = ?, last_accessed = ? WHERE \ + session_token = ? AND expires_at > datetime('now')", + params![&expires, &now, &token], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; if rows > 0 { Ok(Some(new_expires_at)) } else { @@ -4952,7 +5194,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("DELETE FROM sessions WHERE session_token = ?", [&token])?; + db.execute("DELETE FROM sessions WHERE session_token = ?", [&token]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4971,8 +5214,9 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let affected = - db.execute("DELETE FROM sessions WHERE username = ?", [&user])?; + let affected = db + .execute("DELETE FROM sessions WHERE username = ?", [&user]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -4993,8 +5237,9 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - let affected = - db.execute("DELETE FROM sessions WHERE expires_at < ?", [&now])?; + let affected = db + .execute("DELETE FROM sessions WHERE expires_at < ?", [&now]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(affected as u64) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) @@ -5039,36 +5284,44 @@ impl StorageBackend for SqliteBackend { ) }; - let mut stmt = db.prepare(query)?; + let mut stmt = db + .prepare(query) + .map_err(|e| PinakesError::Database(e.to_string()))?; let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); - let rows = stmt.query_map(¶m_refs[..], |row| { - let created_at_str: String = row.get(4)?; - let expires_at_str: String = row.get(5)?; - let last_accessed_str: String = row.get(6)?; + let rows = stmt + .query_map(¶m_refs[..], |row| { + let created_at_str: String = row.get(4)?; + let expires_at_str: String = row.get(5)?; + let last_accessed_str: String = row.get(6)?; - Ok(crate::storage::SessionData { - session_token: row.get(0)?, - user_id: row.get(1)?, - username: row.get(2)?, - role: row.get(3)?, - created_at: chrono::DateTime::parse_from_rfc3339(&created_at_str) + Ok(crate::storage::SessionData { + session_token: row.get(0)?, + user_id: row.get(1)?, + username: row.get(2)?, + role: row.get(3)?, + created_at: chrono::DateTime::parse_from_rfc3339( + &created_at_str, + ) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? .with_timezone(&chrono::Utc), - expires_at: chrono::DateTime::parse_from_rfc3339(&expires_at_str) + expires_at: chrono::DateTime::parse_from_rfc3339( + &expires_at_str, + ) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? .with_timezone(&chrono::Utc), - last_accessed: chrono::DateTime::parse_from_rfc3339( - &last_accessed_str, - ) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? - .with_timezone(&chrono::Utc), + last_accessed: chrono::DateTime::parse_from_rfc3339( + &last_accessed_str, + ) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))? + .with_timezone(&chrono::Utc), + }) }) - })?; + .map_err(|e| PinakesError::Database(e.to_string()))?; rows .collect::, _>>() - .map_err(std::convert::Into::into) + .map_err(|e| PinakesError::Database(e.to_string())) }); tokio::time::timeout(std::time::Duration::from_secs(10), fut) .await @@ -5109,7 +5362,9 @@ impl StorageBackend for SqliteBackend { let mut conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let tx = conn.transaction()?; + let tx = conn + .transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Upsert book_metadata tx.execute( @@ -5134,15 +5389,18 @@ impl StorageBackend for SqliteBackend { series_index, format ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Clear existing authors and identifiers tx.execute("DELETE FROM book_authors WHERE media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; tx.execute("DELETE FROM book_identifiers WHERE media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert authors for author in &authors { @@ -5157,7 +5415,8 @@ impl StorageBackend for SqliteBackend { author.role, author.position ], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Insert identifiers @@ -5168,11 +5427,13 @@ impl StorageBackend for SqliteBackend { identifier_value) VALUES (?1, ?2, ?3)", rusqlite::params![media_id_str, id_type, value], - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; } } - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }); @@ -5223,7 +5484,8 @@ impl StorageBackend for SqliteBackend { )) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; let Some(( isbn, @@ -5243,10 +5505,12 @@ impl StorageBackend for SqliteBackend { }; // Get authors - let mut stmt = conn.prepare( - "SELECT author_name, author_sort, role, position + let mut stmt = conn + .prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec = stmt .query_map([&media_id_str], |row| { Ok(crate::model::AuthorInfo { @@ -5255,20 +5519,28 @@ impl StorageBackend for SqliteBackend { role: row.get(2)?, position: row.get(3)?, }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Get identifiers - let mut stmt = conn.prepare( - "SELECT identifier_type, identifier_value + let mut stmt = conn + .prepare( + "SELECT identifier_type, identifier_value FROM book_identifiers WHERE media_id = ?1", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut identifiers: FxHashMap> = FxHashMap::default(); - for row in stmt.query_map([&media_id_str], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })? { - let (id_type, value) = row?; + for row in stmt + .query_map([&media_id_str], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + { + let (id_type, value) = + row.map_err(|e| PinakesError::Database(e.to_string()))?; identifiers.entry(id_type).or_default().push(value); } @@ -5326,20 +5598,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO book_authors (media_id, author_name, author_sort, role, \ - position) + conn + .execute( + "INSERT INTO book_authors (media_id, author_name, author_sort, \ + role, position) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(media_id, author_name, role) DO UPDATE SET author_sort = ?3, position = ?5", - rusqlite::params![ - media_id_str, - author_clone.name, - author_clone.file_as, - author_clone.role, - author_clone.position - ], - )?; + rusqlite::params![ + media_id_str, + author_clone.name, + author_clone.file_as, + author_clone.role, + author_clone.position + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }); @@ -5363,10 +5637,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT author_name, author_sort, role, position + let mut stmt = conn + .prepare( + "SELECT author_name, author_sort, role, position FROM book_authors WHERE media_id = ?1 ORDER BY position", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec = stmt .query_map([&media_id_str], |row| { Ok(crate::model::AuthorInfo { @@ -5375,8 +5651,10 @@ impl StorageBackend for SqliteBackend { role: row.get(2)?, position: row.get(3)?, }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(authors) }); @@ -5404,18 +5682,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT author_name, COUNT(DISTINCT media_id) as book_count + let mut stmt = conn + .prepare( + "SELECT author_name, COUNT(DISTINCT media_id) as book_count FROM book_authors GROUP BY author_name ORDER BY book_count DESC, author_name LIMIT ?1 OFFSET ?2", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let authors: Vec<(String, u64)> = stmt .query_map([limit.cast_signed(), offset.cast_signed()], |row| { Ok((row.get(0)?, row.get::<_, i64>(1)?.cast_unsigned())) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(authors) }); @@ -5438,18 +5720,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT series_name, COUNT(*) as book_count + let mut stmt = conn + .prepare( + "SELECT series_name, COUNT(*) as book_count FROM book_metadata WHERE series_name IS NOT NULL GROUP BY series_name ORDER BY series_name", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let series: Vec<(String, u64)> = stmt .query_map([], |row| { Ok((row.get(0)?, row.get::<_, i64>(1)?.cast_unsigned())) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(series) }); @@ -5474,21 +5760,25 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, + let mut stmt = conn + .prepare( + "SELECT m.id, m.path, m.file_name, m.media_type, m.content_hash, m.file_size, m.title, m.artist, m.album, m.genre, \ - m.year, + m.year, m.duration_secs, m.description, m.thumbnail_path, \ - m.file_mtime, + m.file_mtime, m.created_at, m.updated_at FROM media_items m INNER JOIN book_metadata b ON m.id = b.media_id WHERE b.series_name = ?1 ORDER BY b.series_index, m.title", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let items = stmt - .query_map([&series], row_to_media_item)? - .collect::>>()?; + .query_map([&series], row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(items) }); @@ -5519,14 +5809,16 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO watch_history (user_id, media_id, progress_secs, \ - last_watched) + conn + .execute( + "INSERT INTO watch_history (user_id, media_id, progress_secs, \ + last_watched) VALUES (?1, ?2, ?3, datetime('now')) ON CONFLICT(user_id, media_id) DO UPDATE SET progress_secs = ?3, last_watched = datetime('now')", - rusqlite::params![user_id_str, media_id_str, f64::from(current_page)], - )?; + rusqlite::params![user_id_str, media_id_str, f64::from(current_page)], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }); @@ -5570,7 +5862,8 @@ impl StorageBackend for SqliteBackend { Ok((current_page, total_pages, last_read_str)) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; let progress = match result { Some((current_page, total_pages, last_read_str)) => { @@ -5630,26 +5923,30 @@ impl StorageBackend for SqliteBackend { // Query books with reading progress for this user // Join with book_metadata to get page counts and media_items for the // items - let mut stmt = conn.prepare( - "SELECT m.*, wh.progress_secs, bm.page_count + let mut stmt = conn + .prepare( + "SELECT m.*, wh.progress_secs, bm.page_count FROM media_items m INNER JOIN watch_history wh ON m.id = wh.media_id LEFT JOIN book_metadata bm ON m.id = bm.media_id WHERE wh.user_id = ?1 ORDER BY wh.last_watched DESC", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt.query_map([&user_id_str], |row| { - // Parse the media item - let item = row_to_media_item(row)?; - // Read the extra columns by name, this is safe *regardless* of column - // count. - let current_page = row - .get::<_, Option>("progress_secs")? - .map_or(0, |v| i32::try_from(v).unwrap_or(0)); - let total_pages = row.get::<_, Option>("page_count")?; - Ok((item, current_page, total_pages)) - })?; + let rows = stmt + .query_map([&user_id_str], |row| { + // Parse the media item + let item = row_to_media_item(row)?; + // Read the extra columns by name, this is safe *regardless* of column + // count. + let current_page = row + .get::<_, Option>("progress_secs")? + .map_or(0, |v| i32::try_from(v).unwrap_or(0)); + let total_pages = row.get::<_, Option>("page_count")?; + Ok((item, current_page, total_pages)) + }) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut results = Vec::new(); for row in rows { @@ -5776,10 +6073,14 @@ impl StorageBackend for SqliteBackend { let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(std::convert::AsRef::as_ref).collect(); - let mut stmt = conn.prepare(&query)?; + let mut stmt = conn + .prepare(&query) + .map_err(|e| PinakesError::Database(e.to_string()))?; let items = stmt - .query_map(&*params_refs, row_to_media_item)? - .collect::>>()?; + .query_map(&*params_refs, row_to_media_item) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(items) }); @@ -5800,41 +6101,43 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO media_items (id, path, file_name, media_type, \ - content_hash, file_size, + conn + .execute( + "INSERT INTO media_items (id, path, file_name, media_type, \ + content_hash, file_size, title, artist, album, genre, year, duration_secs, \ - description, thumbnail_path, + description, thumbnail_path, storage_mode, original_filename, uploaded_at, storage_key, \ - created_at, updated_at) + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, \ - ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", - params![ - item.id.0.to_string(), - item.path.to_string_lossy().to_string(), - item.file_name, - media_type_to_str(&item.media_type), - item.content_hash.0, - item.file_size.cast_signed(), - item.title, - item.artist, - item.album, - item.genre, - item.year, - item.duration_secs, - item.description, - item - .thumbnail_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()), - item.storage_mode.to_string(), - item.original_filename, - item.uploaded_at.map(|dt| dt.to_rfc3339()), - item.storage_key, - item.created_at.to_rfc3339(), - item.updated_at.to_rfc3339(), - ], - )?; + ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", + params![ + item.id.0.to_string(), + item.path.to_string_lossy().to_string(), + item.file_name, + media_type_to_str(&item.media_type), + item.content_hash.0, + item.file_size.cast_signed(), + item.title, + item.artist, + item.album, + item.genre, + item.year, + item.duration_secs, + item.description, + item + .thumbnail_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()), + item.storage_mode.to_string(), + item.original_filename, + item.uploaded_at.map(|dt| dt.to_rfc3339()), + item.storage_key, + item.created_at.to_rfc3339(), + item.updated_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -5880,19 +6183,22 @@ impl StorageBackend for SqliteBackend { }) }, ) - .optional()?; + .optional() + .map_err(|e| PinakesError::Database(e.to_string()))?; if let Some(blob) = existing { return Ok(blob); } // Create new blob - conn.execute( - "INSERT INTO managed_blobs (content_hash, file_size, mime_type, \ - reference_count, stored_at) + conn + .execute( + "INSERT INTO managed_blobs (content_hash, file_size, mime_type, \ + reference_count, stored_at) VALUES (?1, ?2, ?3, 1, ?4)", - params![&hash_str, size.cast_signed(), &mime, &now], - )?; + params![&hash_str, size.cast_signed(), &mime, &now], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(ManagedBlob { content_hash: ContentHash(hash_str), @@ -5949,11 +6255,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE managed_blobs SET reference_count = reference_count + 1 WHERE \ - content_hash = ?1", - params![&hash_str], - )?; + conn + .execute( + "UPDATE managed_blobs SET reference_count = reference_count + 1 \ + WHERE content_hash = ?1", + params![&hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -5971,11 +6279,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE managed_blobs SET reference_count = reference_count - 1 WHERE \ - content_hash = ?1", - params![&hash_str], - )?; + conn + .execute( + "UPDATE managed_blobs SET reference_count = reference_count - 1 \ + WHERE content_hash = ?1", + params![&hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Check if reference count is now 0 let count: i32 = conn @@ -6006,10 +6316,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", - params![&now, &hash_str], - )?; + conn + .execute( + "UPDATE managed_blobs SET last_verified = ?1 WHERE content_hash = ?2", + params![&now, &hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6026,11 +6338,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT content_hash, file_size, mime_type, reference_count, \ - stored_at, last_verified + let mut stmt = conn + .prepare( + "SELECT content_hash, file_size, mime_type, reference_count, \ + stored_at, last_verified FROM managed_blobs WHERE reference_count <= 0", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let blobs = stmt .query_map([], |row| { Ok(ManagedBlob { @@ -6043,8 +6357,10 @@ impl StorageBackend for SqliteBackend { .get::<_, Option>(5)? .map(|s| parse_datetime(&s)), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(blobs) }) .await @@ -6062,10 +6378,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "DELETE FROM managed_blobs WHERE content_hash = ?1", - params![&hash_str], - )?; + conn + .execute( + "DELETE FROM managed_blobs WHERE content_hash = ?1", + params![&hash_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6084,7 +6402,8 @@ impl StorageBackend for SqliteBackend { let total_blobs: u64 = conn .query_row("SELECT COUNT(*) FROM managed_blobs", [], |row| { row.get::<_, i64>(0) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let total_size: u64 = conn @@ -6092,7 +6411,8 @@ impl StorageBackend for SqliteBackend { "SELECT COALESCE(SUM(file_size), 0) FROM managed_blobs", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let unique_size: u64 = conn @@ -6101,7 +6421,8 @@ impl StorageBackend for SqliteBackend { reference_count = 1", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let managed_media_count: u64 = conn @@ -6109,7 +6430,8 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM media_items WHERE storage_mode = 'managed'", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let orphaned_blobs: u64 = conn @@ -6117,7 +6439,8 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM managed_blobs WHERE reference_count <= 0", [], |row| row.get::<_, i64>(0), - )? + ) + .map_err(|e| PinakesError::Database(e.to_string()))? .cast_unsigned(); let dedup_ratio = if total_size > 0 { @@ -6149,9 +6472,9 @@ impl StorageBackend for SqliteBackend { async fn register_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, token_hash: &str, - ) -> Result { + ) -> Result { let conn = Arc::clone(&self.conn); let device = device.clone(); let token_hash = token_hash.to_string(); @@ -6160,27 +6483,29 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO sync_devices (id, user_id, name, device_type, \ - client_version, os_info, + conn + .execute( + "INSERT INTO sync_devices (id, user_id, name, device_type, \ + client_version, os_info, device_token_hash, last_seen_at, sync_cursor, enabled, \ - created_at, updated_at) + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", - params![ - device.id.0.to_string(), - device.user_id.0.to_string(), - device.name, - device.device_type.to_string(), - device.client_version, - device.os_info, - token_hash, - device.last_seen_at.to_rfc3339(), - device.sync_cursor, - device.enabled, - device.created_at.to_rfc3339(), - device.updated_at.to_rfc3339(), - ], - )?; + params![ + device.id.0.to_string(), + device.user_id.0.to_string(), + device.name, + device.device_type.to_string(), + device.client_version, + device.os_info, + token_hash, + device.last_seen_at.to_rfc3339(), + device.sync_cursor, + device.enabled, + device.created_at.to_rfc3339(), + device.updated_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(device) }) .await @@ -6190,8 +6515,8 @@ impl StorageBackend for SqliteBackend { async fn get_device( &self, - id: crate::sync::DeviceId, - ) -> Result { + id: pinakes_sync::DeviceId, + ) -> Result { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -6206,11 +6531,11 @@ impl StorageBackend for SqliteBackend { FROM sync_devices WHERE id = ?1", params![id.0.to_string()], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: crate::users::UserId(parse_uuid( + user_id: pinakes_types::model::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6240,7 +6565,7 @@ impl StorageBackend for SqliteBackend { async fn get_device_by_token( &self, token_hash: &str, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let token_hash = token_hash.to_string(); @@ -6256,11 +6581,11 @@ impl StorageBackend for SqliteBackend { FROM sync_devices WHERE device_token_hash = ?1", params![&token_hash], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: crate::users::UserId(parse_uuid( + user_id: pinakes_types::model::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6293,27 +6618,29 @@ impl StorageBackend for SqliteBackend { async fn list_user_devices( &self, user_id: crate::users::UserId, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, user_id, name, device_type, client_version, os_info, + let mut stmt = conn + .prepare( + "SELECT id, user_id, name, device_type, client_version, os_info, last_sync_at, last_seen_at, sync_cursor, enabled, \ - created_at, updated_at + created_at, updated_at FROM sync_devices WHERE user_id = ?1 ORDER BY last_seen_at \ - DESC", - )?; + DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let devices = stmt .query_map(params![user_id.0.to_string()], |row| { - Ok(crate::sync::SyncDevice { - id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::SyncDevice { + id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), - user_id: crate::users::UserId(parse_uuid( + user_id: pinakes_types::model::UserId(parse_uuid( &row.get::<_, String>(1)?, )?), name: row.get(2)?, @@ -6332,8 +6659,10 @@ impl StorageBackend for SqliteBackend { created_at: parse_datetime(&row.get::<_, String>(10)?), updated_at: parse_datetime(&row.get::<_, String>(11)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(devices) }) .await @@ -6345,7 +6674,7 @@ impl StorageBackend for SqliteBackend { async fn update_device( &self, - device: &crate::sync::SyncDevice, + device: &pinakes_sync::SyncDevice, ) -> Result<()> { let conn = Arc::clone(&self.conn); let device = device.clone(); @@ -6354,25 +6683,27 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE sync_devices SET name = ?1, device_type = ?2, client_version \ - = ?3, + conn + .execute( + "UPDATE sync_devices SET name = ?1, device_type = ?2, \ + client_version = ?3, os_info = ?4, last_sync_at = ?5, last_seen_at = ?6, \ - sync_cursor = ?7, + sync_cursor = ?7, enabled = ?8, updated_at = ?9 WHERE id = ?10", - params![ - device.name, - device.device_type.to_string(), - device.client_version, - device.os_info, - device.last_sync_at.map(|dt| dt.to_rfc3339()), - device.last_seen_at.to_rfc3339(), - device.sync_cursor, - device.enabled, - device.updated_at.to_rfc3339(), - device.id.0.to_string(), - ], - )?; + params![ + device.name, + device.device_type.to_string(), + device.client_version, + device.os_info, + device.last_sync_at.map(|dt| dt.to_rfc3339()), + device.last_seen_at.to_rfc3339(), + device.sync_cursor, + device.enabled, + device.updated_at.to_rfc3339(), + device.id.0.to_string(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6380,16 +6711,18 @@ impl StorageBackend for SqliteBackend { Ok(()) } - async fn delete_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn delete_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute("DELETE FROM sync_devices WHERE id = ?1", params![ - id.0.to_string() - ])?; + conn + .execute("DELETE FROM sync_devices WHERE id = ?1", params![ + id.0.to_string() + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6397,7 +6730,7 @@ impl StorageBackend for SqliteBackend { Ok(()) } - async fn touch_device(&self, id: crate::sync::DeviceId) -> Result<()> { + async fn touch_device(&self, id: pinakes_sync::DeviceId) -> Result<()> { let conn = Arc::clone(&self.conn); let now = chrono::Utc::now().to_rfc3339(); @@ -6405,11 +6738,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE id \ - = ?2", - params![&now, id.0.to_string()], - )?; + conn + .execute( + "UPDATE sync_devices SET last_seen_at = ?1, updated_at = ?1 WHERE \ + id = ?2", + params![&now, id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6419,7 +6754,7 @@ impl StorageBackend for SqliteBackend { async fn record_sync_change( &self, - change: &crate::sync::SyncLogEntry, + change: &pinakes_sync::SyncLogEntry, ) -> Result<()> { let conn = Arc::clone(&self.conn); let change = change.clone(); @@ -6430,31 +6765,35 @@ impl StorageBackend for SqliteBackend { })?; // Get and increment sequence - let seq: i64 = conn.query_row( - "UPDATE sync_sequence SET current_value = current_value + 1 WHERE id \ - = 1 RETURNING current_value", - [], - |row| row.get(0), - )?; + let seq: i64 = conn + .query_row( + "UPDATE sync_sequence SET current_value = current_value + 1 WHERE \ + id = 1 RETURNING current_value", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - conn.execute( - "INSERT INTO sync_log (id, sequence, change_type, media_id, path, \ - content_hash, + conn + .execute( + "INSERT INTO sync_log (id, sequence, change_type, media_id, path, \ + content_hash, file_size, metadata_json, changed_by_device, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - change.id.to_string(), - seq, - change.change_type.to_string(), - change.media_id.map(|m| m.0.to_string()), - change.path, - change.content_hash.as_ref().map(|h| h.0.clone()), - change.file_size.map(u64::cast_signed), - change.metadata_json, - change.changed_by_device.map(|d| d.0.to_string()), - change.timestamp.to_rfc3339(), - ], - )?; + params![ + change.id.to_string(), + seq, + change.change_type.to_string(), + change.media_id.map(|m| m.0.to_string()), + change.path, + change.content_hash.as_ref().map(|h| h.0.clone()), + change.file_size.map(u64::cast_signed), + change.metadata_json, + change.changed_by_device.map(|d| d.0.to_string()), + change.timestamp.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6468,27 +6807,29 @@ impl StorageBackend for SqliteBackend { &self, cursor: i64, limit: u64, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, sequence, change_type, media_id, path, content_hash, + let mut stmt = conn + .prepare( + "SELECT id, sequence, change_type, media_id, path, content_hash, file_size, metadata_json, changed_by_device, timestamp FROM sync_log WHERE sequence > ?1 ORDER BY sequence LIMIT ?2", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let entries = stmt .query_map(params![cursor, limit.cast_signed()], |row| { - Ok(crate::sync::SyncLogEntry { + Ok(pinakes_sync::SyncLogEntry { id: parse_uuid(&row.get::<_, String>(0)?)?, sequence: row.get(1)?, change_type: row .get::<_, String>(2)? .parse() - .unwrap_or(crate::sync::SyncChangeType::Modified), + .unwrap_or(pinakes_sync::SyncChangeType::Modified), media_id: row .get::<_, Option>(3)? .and_then(|s| Uuid::parse_str(&s).ok().map(MediaId)), @@ -6501,12 +6842,14 @@ impl StorageBackend for SqliteBackend { .map(i64::cast_unsigned), metadata_json: row.get(7)?, changed_by_device: row.get::<_, Option>(8)?.and_then(|s| { - Uuid::parse_str(&s).ok().map(crate::sync::DeviceId) + Uuid::parse_str(&s).ok().map(pinakes_sync::DeviceId) }), timestamp: parse_datetime(&row.get::<_, String>(9)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(entries) }) .await @@ -6562,9 +6905,9 @@ impl StorageBackend for SqliteBackend { async fn get_device_sync_state( &self, - device_id: crate::sync::DeviceId, + device_id: pinakes_sync::DeviceId, path: &str, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); let path = path.to_string(); @@ -6580,8 +6923,8 @@ impl StorageBackend for SqliteBackend { FROM device_sync_state WHERE device_id = ?1 AND path = ?2", params![device_id.0.to_string(), &path], |row| { - Ok(crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), path: row.get(1)?, @@ -6592,7 +6935,7 @@ impl StorageBackend for SqliteBackend { sync_status: row .get::<_, String>(6)? .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: row .get::<_, Option>(7)? .map(|s| parse_datetime(&s)), @@ -6613,7 +6956,7 @@ impl StorageBackend for SqliteBackend { async fn upsert_device_sync_state( &self, - state: &crate::sync::DeviceSyncState, + state: &pinakes_sync::DeviceSyncState, ) -> Result<()> { let conn = Arc::clone(&self.conn); let state = state.clone(); @@ -6622,11 +6965,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO device_sync_state (device_id, path, local_hash, \ - server_hash, + conn + .execute( + "INSERT INTO device_sync_state (device_id, path, local_hash, \ + server_hash, local_mtime, server_mtime, sync_status, last_synced_at, \ - conflict_info_json) + conflict_info_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(device_id, path) DO UPDATE SET local_hash = excluded.local_hash, @@ -6636,18 +6980,19 @@ impl StorageBackend for SqliteBackend { sync_status = excluded.sync_status, last_synced_at = excluded.last_synced_at, conflict_info_json = excluded.conflict_info_json", - params![ - state.device_id.0.to_string(), - state.path, - state.local_hash, - state.server_hash, - state.local_mtime, - state.server_mtime, - state.sync_status.to_string(), - state.last_synced_at.map(|dt| dt.to_rfc3339()), - state.conflict_info_json, - ], - )?; + params![ + state.device_id.0.to_string(), + state.path, + state.local_hash, + state.server_hash, + state.local_mtime, + state.server_mtime, + state.sync_status.to_string(), + state.last_synced_at.map(|dt| dt.to_rfc3339()), + state.conflict_info_json, + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6659,26 +7004,28 @@ impl StorageBackend for SqliteBackend { async fn list_pending_sync( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT device_id, path, local_hash, server_hash, local_mtime, \ - server_mtime, + let mut stmt = conn + .prepare( + "SELECT device_id, path, local_hash, server_hash, local_mtime, \ + server_mtime, sync_status, last_synced_at, conflict_info_json FROM device_sync_state WHERE device_id = ?1 AND sync_status IN ('pending_upload', \ - 'pending_download', 'conflict')", - )?; + 'pending_download', 'conflict')", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let states = stmt .query_map(params![device_id.0.to_string()], |row| { - Ok(crate::sync::DeviceSyncState { - device_id: crate::sync::DeviceId(parse_uuid( + Ok(pinakes_sync::DeviceSyncState { + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(0)?, )?), path: row.get(1)?, @@ -6689,14 +7036,16 @@ impl StorageBackend for SqliteBackend { sync_status: row .get::<_, String>(6)? .parse() - .unwrap_or(crate::sync::FileSyncStatus::Synced), + .unwrap_or(pinakes_sync::FileSyncStatus::Synced), last_synced_at: row .get::<_, Option>(7)? .map(|s| parse_datetime(&s)), conflict_info_json: row.get(8)?, }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(states) }) .await @@ -6708,7 +7057,7 @@ impl StorageBackend for SqliteBackend { async fn create_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let conn = Arc::clone(&self.conn); let session = session.clone(); @@ -6717,26 +7066,28 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO upload_sessions (id, device_id, target_path, \ - expected_hash, + conn + .execute( + "INSERT INTO upload_sessions (id, device_id, target_path, \ + expected_hash, expected_size, chunk_size, chunk_count, status, \ - created_at, expires_at, last_activity) + created_at, expires_at, last_activity) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - params![ - session.id.to_string(), - session.device_id.0.to_string(), - session.target_path, - session.expected_hash.0, - session.expected_size.cast_signed(), - session.chunk_size.cast_signed(), - session.chunk_count.cast_signed(), - session.status.to_string(), - session.created_at.to_rfc3339(), - session.expires_at.to_rfc3339(), - session.last_activity.to_rfc3339(), - ], - )?; + params![ + session.id.to_string(), + session.device_id.0.to_string(), + session.target_path, + session.expected_hash.0, + session.expected_size.cast_signed(), + session.chunk_size.cast_signed(), + session.chunk_count.cast_signed(), + session.status.to_string(), + session.created_at.to_rfc3339(), + session.expires_at.to_rfc3339(), + session.last_activity.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6749,7 +7100,7 @@ impl StorageBackend for SqliteBackend { async fn get_upload_session( &self, id: Uuid, - ) -> Result { + ) -> Result { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { @@ -6765,9 +7116,9 @@ impl StorageBackend for SqliteBackend { FROM upload_sessions WHERE id = ?1", params![id.to_string()], |row| { - Ok(crate::sync::UploadSession { + Ok(pinakes_sync::UploadSession { id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: crate::sync::DeviceId(parse_uuid( + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(1)?, )?), target_path: row.get(2)?, @@ -6778,7 +7129,7 @@ impl StorageBackend for SqliteBackend { status: row .get::<_, String>(7)? .parse() - .unwrap_or(crate::sync::UploadStatus::Pending), + .unwrap_or(pinakes_sync::UploadStatus::Pending), created_at: parse_datetime(&row.get::<_, String>(8)?), expires_at: parse_datetime(&row.get::<_, String>(9)?), last_activity: parse_datetime(&row.get::<_, String>(10)?), @@ -6795,7 +7146,7 @@ impl StorageBackend for SqliteBackend { async fn update_upload_session( &self, - session: &crate::sync::UploadSession, + session: &pinakes_sync::UploadSession, ) -> Result<()> { let conn = Arc::clone(&self.conn); let session = session.clone(); @@ -6804,15 +7155,17 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE id \ - = ?3", - params![ - session.status.to_string(), - session.last_activity.to_rfc3339(), - session.id.to_string(), - ], - )?; + conn + .execute( + "UPDATE upload_sessions SET status = ?1, last_activity = ?2 WHERE \ + id = ?3", + params![ + session.status.to_string(), + session.last_activity.to_rfc3339(), + session.id.to_string(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6825,7 +7178,7 @@ impl StorageBackend for SqliteBackend { async fn record_chunk( &self, upload_id: Uuid, - chunk: &crate::sync::ChunkInfo, + chunk: &pinakes_sync::ChunkInfo, ) -> Result<()> { let conn = Arc::clone(&self.conn); let chunk = chunk.clone(); @@ -6834,22 +7187,24 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, \ - hash, received_at) + conn + .execute( + "INSERT INTO upload_chunks (upload_id, chunk_index, offset, size, \ + hash, received_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(upload_id, chunk_index) DO UPDATE SET offset = excluded.offset, size = excluded.size, hash = excluded.hash, received_at = excluded.received_at", - params![ - upload_id.to_string(), - chunk.chunk_index.cast_signed(), - chunk.offset.cast_signed(), - chunk.size.cast_signed(), - chunk.hash, - chunk.received_at.to_rfc3339(), - ], - )?; + params![ + upload_id.to_string(), + chunk.chunk_index.cast_signed(), + chunk.offset.cast_signed(), + chunk.size.cast_signed(), + chunk.hash, + chunk.received_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6860,20 +7215,22 @@ impl StorageBackend for SqliteBackend { async fn get_upload_chunks( &self, upload_id: Uuid, - ) -> Result> { + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT upload_id, chunk_index, offset, size, hash, received_at + let mut stmt = conn + .prepare( + "SELECT upload_id, chunk_index, offset, size, hash, received_at FROM upload_chunks WHERE upload_id = ?1 ORDER BY chunk_index", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let chunks = stmt .query_map(params![upload_id.to_string()], |row| { - Ok(crate::sync::ChunkInfo { + Ok(pinakes_sync::ChunkInfo { upload_id: parse_uuid(&row.get::<_, String>(0)?)?, chunk_index: row.get::<_, i64>(1)?.cast_unsigned(), offset: row.get::<_, i64>(2)?.cast_unsigned(), @@ -6881,8 +7238,10 @@ impl StorageBackend for SqliteBackend { hash: row.get(4)?, received_at: parse_datetime(&row.get::<_, String>(5)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(chunks) }) .await @@ -6918,7 +7277,7 @@ impl StorageBackend for SqliteBackend { async fn record_conflict( &self, - conflict: &crate::sync::SyncConflict, + conflict: &pinakes_sync::SyncConflict, ) -> Result<()> { let conn = Arc::clone(&self.conn); let conflict = conflict.clone(); @@ -6927,22 +7286,24 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO sync_conflicts (id, device_id, path, local_hash, \ - local_mtime, + conn + .execute( + "INSERT INTO sync_conflicts (id, device_id, path, local_hash, \ + local_mtime, server_hash, server_mtime, detected_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - conflict.id.to_string(), - conflict.device_id.0.to_string(), - conflict.path, - conflict.local_hash, - conflict.local_mtime, - conflict.server_hash, - conflict.server_mtime, - conflict.detected_at.to_rfc3339(), - ], - )?; + params![ + conflict.id.to_string(), + conflict.device_id.0.to_string(), + conflict.path, + conflict.local_hash, + conflict.local_mtime, + conflict.server_hash, + conflict.server_mtime, + conflict.detected_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -6952,26 +7313,28 @@ impl StorageBackend for SqliteBackend { async fn get_unresolved_conflicts( &self, - device_id: crate::sync::DeviceId, - ) -> Result> { + device_id: pinakes_sync::DeviceId, + ) -> Result> { let conn = Arc::clone(&self.conn); tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, device_id, path, local_hash, local_mtime, server_hash, \ - server_mtime, + let mut stmt = conn + .prepare( + "SELECT id, device_id, path, local_hash, local_mtime, server_hash, \ + server_mtime, detected_at, resolved_at, resolution FROM sync_conflicts WHERE device_id = ?1 AND resolved_at IS \ - NULL", - )?; + NULL", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let conflicts = stmt .query_map(params![device_id.0.to_string()], |row| { - Ok(crate::sync::SyncConflict { + Ok(pinakes_sync::SyncConflict { id: parse_uuid(&row.get::<_, String>(0)?)?, - device_id: crate::sync::DeviceId(parse_uuid( + device_id: pinakes_sync::DeviceId(parse_uuid( &row.get::<_, String>(1)?, )?), path: row.get(2)?, @@ -6986,21 +7349,25 @@ impl StorageBackend for SqliteBackend { resolution: row.get::<_, Option>(9)?.and_then(|s| { match s.as_str() { "server_wins" => { - Some(crate::config::ConflictResolution::ServerWins) + Some(pinakes_types::config::ConflictResolution::ServerWins) }, "client_wins" => { - Some(crate::config::ConflictResolution::ClientWins) + Some(pinakes_types::config::ConflictResolution::ClientWins) }, "keep_both" => { - Some(crate::config::ConflictResolution::KeepBoth) + Some(pinakes_types::config::ConflictResolution::KeepBoth) + }, + "manual" => { + Some(pinakes_types::config::ConflictResolution::Manual) }, - "manual" => Some(crate::config::ConflictResolution::Manual), _ => None, } }), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(conflicts) }) .await @@ -7015,26 +7382,28 @@ impl StorageBackend for SqliteBackend { async fn resolve_conflict( &self, id: Uuid, - resolution: crate::config::ConflictResolution, + resolution: pinakes_types::config::ConflictResolution, ) -> Result<()> { let conn = Arc::clone(&self.conn); let now = chrono::Utc::now().to_rfc3339(); let resolution_str = match resolution { - crate::config::ConflictResolution::ServerWins => "server_wins", - crate::config::ConflictResolution::ClientWins => "client_wins", - crate::config::ConflictResolution::KeepBoth => "keep_both", - crate::config::ConflictResolution::Manual => "manual", + pinakes_types::config::ConflictResolution::ServerWins => "server_wins", + pinakes_types::config::ConflictResolution::ClientWins => "client_wins", + pinakes_types::config::ConflictResolution::KeepBoth => "keep_both", + pinakes_types::config::ConflictResolution::Manual => "manual", }; tokio::task::spawn_blocking(move || { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE id \ - = ?3", - params![&now, resolution_str, id.to_string()], - )?; + conn + .execute( + "UPDATE sync_conflicts SET resolved_at = ?1, resolution = ?2 WHERE \ + id = ?3", + params![&now, resolution_str, id.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7078,41 +7447,43 @@ impl StorageBackend for SqliteBackend { }, }; - conn.execute( - "INSERT INTO shares (id, target_type, target_id, owner_id, \ - recipient_type, + conn + .execute( + "INSERT INTO shares (id, target_type, target_id, owner_id, \ + recipient_type, recipient_user_id, public_token, public_password_hash, perm_view, perm_download, perm_edit, perm_delete, \ - perm_reshare, perm_add, + perm_reshare, perm_add, note, expires_at, access_count, inherit_to_children, \ - parent_share_id, + parent_share_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, \ - ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", - params![ - share.id.0.to_string(), - share.target.target_type(), - share.target.target_id().to_string(), - share.owner_id.0.to_string(), - recipient_type, - recipient_user_id, - public_token, - password_hash, - share.permissions.view.can_view, - share.permissions.view.can_download, - share.permissions.mutate.can_edit, - share.permissions.mutate.can_delete, - share.permissions.view.can_reshare, - share.permissions.mutate.can_add, - share.note, - share.expires_at.map(|dt| dt.to_rfc3339()), - share.access_count.cast_signed(), - share.inherit_to_children, - share.parent_share_id.map(|s| s.0.to_string()), - share.created_at.to_rfc3339(), - share.updated_at.to_rfc3339(), - ], - )?; + ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)", + params![ + share.id.0.to_string(), + share.target.target_type(), + share.target.target_id().to_string(), + share.owner_id.0.to_string(), + recipient_type, + recipient_user_id, + public_token, + password_hash, + share.permissions.view.can_view, + share.permissions.view.can_download, + share.permissions.mutate.can_edit, + share.permissions.mutate.can_delete, + share.permissions.view.can_reshare, + share.permissions.mutate.can_add, + share.note, + share.expires_at.map(|dt| dt.to_rfc3339()), + share.access_count.cast_signed(), + share.inherit_to_children, + share.parent_share_id.map(|s| s.0.to_string()), + share.created_at.to_rfc3339(), + share.updated_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(share) }) .await @@ -7196,18 +7567,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, owner_id, recipient_type, \ + recipient_user_id, public_token, public_password_hash, perm_view, \ - perm_download, perm_edit, + perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE owner_id = ?1 ORDER BY created_at DESC \ - LIMIT ?2 OFFSET ?3", - )?; + LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let shares = stmt .query_map( params![ @@ -7216,8 +7589,10 @@ impl StorageBackend for SqliteBackend { offset.cast_signed() ], row_to_share, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(shares) }) .await @@ -7240,18 +7615,20 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, owner_id, recipient_type, \ + recipient_user_id, public_token, public_password_hash, perm_view, \ - perm_download, perm_edit, + perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE recipient_user_id = ?1 ORDER BY created_at \ - DESC LIMIT ?2 OFFSET ?3", - )?; + DESC LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let shares = stmt .query_map( params![ @@ -7260,8 +7637,10 @@ impl StorageBackend for SqliteBackend { offset.cast_signed() ], row_to_share, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(shares) }) .await @@ -7283,20 +7662,24 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, target_type, target_id, owner_id, recipient_type, \ - recipient_user_id, + let mut stmt = conn + .prepare( + "SELECT id, target_type, target_id, owner_id, recipient_type, \ + recipient_user_id, public_token, public_password_hash, perm_view, \ - perm_download, perm_edit, + perm_download, perm_edit, perm_delete, perm_reshare, perm_add, note, expires_at, \ - access_count, + access_count, last_accessed, inherit_to_children, parent_share_id, \ - created_at, updated_at + created_at, updated_at FROM shares WHERE target_type = ?1 AND target_id = ?2", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let shares = stmt - .query_map(params![&target_type, &target_id], row_to_share)? - .collect::>>()?; + .query_map(params![&target_type, &target_id], row_to_share) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(shares) }) .await @@ -7319,28 +7702,30 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE shares SET + conn + .execute( + "UPDATE shares SET perm_view = ?1, perm_download = ?2, perm_edit = ?3, \ - perm_delete = ?4, + perm_delete = ?4, perm_reshare = ?5, perm_add = ?6, note = ?7, expires_at = \ - ?8, + ?8, inherit_to_children = ?9, updated_at = ?10 WHERE id = ?11", - params![ - share.permissions.view.can_view, - share.permissions.view.can_download, - share.permissions.mutate.can_edit, - share.permissions.mutate.can_delete, - share.permissions.view.can_reshare, - share.permissions.mutate.can_add, - share.note, - share.expires_at.map(|dt| dt.to_rfc3339()), - share.inherit_to_children, - share.updated_at.to_rfc3339(), - share.id.0.to_string(), - ], - )?; + params![ + share.permissions.view.can_view, + share.permissions.view.can_download, + share.permissions.mutate.can_edit, + share.permissions.mutate.can_delete, + share.permissions.view.can_reshare, + share.permissions.mutate.can_add, + share.note, + share.expires_at.map(|dt| dt.to_rfc3339()), + share.inherit_to_children, + share.updated_at.to_rfc3339(), + share.id.0.to_string(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(share) }) .await @@ -7355,9 +7740,11 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute("DELETE FROM shares WHERE id = ?1", params![ - id.0.to_string() - ])?; + conn + .execute("DELETE FROM shares WHERE id = ?1", params![ + id.0.to_string() + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7376,11 +7763,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE shares SET access_count = access_count + 1, last_accessed = \ - ?1 WHERE id = ?2", - params![&now, id.0.to_string()], - )?; + conn + .execute( + "UPDATE shares SET access_count = access_count + 1, last_accessed = \ + ?1 WHERE id = ?2", + params![&now, id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7446,14 +7835,17 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT collection_id FROM collection_items WHERE media_id = ?1", - )?; + let mut stmt = conn + .prepare( + "SELECT collection_id FROM collection_members WHERE media_id = ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; Ok(Uuid::parse_str(&id_str).ok()) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(|r| r.ok().flatten()) .collect::>(); Ok::<_, PinakesError>(ids) @@ -7485,13 +7877,15 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = - conn.prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1")?; + let mut stmt = conn + .prepare("SELECT tag_id FROM media_tags WHERE media_id = ?1") + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids = stmt .query_map([&media_id_str], |row| { let id_str: String = row.get(0)?; Ok(Uuid::parse_str(&id_str).ok()) - })? + }) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(|r| r.ok().flatten()) .collect::>(); Ok::<_, PinakesError>(ids) @@ -7587,20 +7981,22 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ - action, details, timestamp) + conn + .execute( + "INSERT INTO share_activity (id, share_id, actor_id, actor_ip, \ + action, details, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - activity.id.to_string(), - activity.share_id.0.to_string(), - activity.actor_id.map(|u| u.0.to_string()), - activity.actor_ip, - activity.action.to_string(), - activity.details, - activity.timestamp.to_rfc3339(), - ], - )?; + params![ + activity.id.to_string(), + activity.share_id.0.to_string(), + activity.actor_id.map(|u| u.0.to_string()), + activity.actor_ip, + activity.action.to_string(), + activity.details, + activity.timestamp.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7623,11 +8019,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp + let mut stmt = conn + .prepare( + "SELECT id, share_id, actor_id, actor_ip, action, details, timestamp FROM share_activity WHERE share_id = ?1 ORDER BY timestamp \ - DESC LIMIT ?2 OFFSET ?3", - )?; + DESC LIMIT ?2 OFFSET ?3", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let activities = stmt .query_map( params![ @@ -7653,8 +8051,10 @@ impl StorageBackend for SqliteBackend { timestamp: parse_datetime(&row.get::<_, String>(6)?), }) }, - )? - .collect::>>()?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(activities) }) .await @@ -7675,19 +8075,21 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "INSERT INTO share_notifications (id, user_id, share_id, \ - notification_type, is_read, created_at) + conn + .execute( + "INSERT INTO share_notifications (id, user_id, share_id, \ + notification_type, is_read, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - notification.id.to_string(), - notification.user_id.0.to_string(), - notification.share_id.0.to_string(), - notification.notification_type.to_string(), - notification.is_read, - notification.created_at.to_rfc3339(), - ], - )?; + params![ + notification.id.to_string(), + notification.user_id.0.to_string(), + notification.share_id.0.to_string(), + notification.notification_type.to_string(), + notification.is_read, + notification.created_at.to_rfc3339(), + ], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7707,11 +8109,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, user_id, share_id, notification_type, is_read, created_at + let mut stmt = conn + .prepare( + "SELECT id, user_id, share_id, notification_type, is_read, \ + created_at FROM share_notifications WHERE user_id = ?1 AND is_read = 0 \ - ORDER BY created_at DESC", - )?; + ORDER BY created_at DESC", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let notifications = stmt .query_map(params![user_id.0.to_string()], |row| { Ok(crate::sharing::ShareNotification { @@ -7729,8 +8134,10 @@ impl StorageBackend for SqliteBackend { is_read: row.get(4)?, created_at: parse_datetime(&row.get::<_, String>(5)?), }) - })? - .collect::>>()?; + }) + .map_err(|e| PinakesError::Database(e.to_string()))? + .collect::>>() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(notifications) }) .await @@ -7753,11 +8160,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE share_notifications SET is_read = 1 WHERE id = ?1 AND user_id \ - = ?2", - params![id.to_string(), user_id.0.to_string()], - )?; + conn + .execute( + "UPDATE share_notifications SET is_read = 1 WHERE id = ?1 AND \ + user_id = ?2", + params![id.to_string(), user_id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7777,10 +8186,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", - params![user_id.0.to_string()], - )?; + conn + .execute( + "UPDATE share_notifications SET is_read = 1 WHERE user_id = ?1", + params![user_id.0.to_string()], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7811,12 +8222,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let row: (String, String) = conn.query_row( - "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND \ - deleted_at IS NULL", - params![id_str], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; + let row: (String, String) = conn + .query_row( + "SELECT path, storage_mode FROM media_items WHERE id = ?1 AND \ + deleted_at IS NULL", + params![id_str], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(row) } }) @@ -7848,11 +8261,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 \ - WHERE id = ?4", - params![new_name, new_path_str, now, id_str], - )?; + conn + .execute( + "UPDATE media_items SET file_name = ?1, path = ?2, updated_at = ?3 \ + WHERE id = ?4", + params![new_name, new_path_str, now, id_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -7879,12 +8294,14 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let row: (String, String, String) = conn.query_row( - "SELECT path, file_name, storage_mode FROM media_items WHERE id = \ - ?1 AND deleted_at IS NULL", - params![id_str], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - )?; + let row: (String, String, String) = conn + .query_row( + "SELECT path, file_name, storage_mode FROM media_items WHERE id = \ + ?1 AND deleted_at IS NULL", + params![id_str], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(row) } }) @@ -7920,10 +8337,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", - params![new_path_str, now, id_str], - )?; + conn + .execute( + "UPDATE media_items SET path = ?1, updated_at = ?2 WHERE id = ?3", + params![new_path_str, now, id_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -8008,28 +8427,32 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, path, file_name, media_type, content_hash, file_size, + let mut stmt = conn + .prepare( + "SELECT id, path, file_name, media_type, content_hash, file_size, title, artist, album, genre, year, duration_secs, \ - description, + description, thumbnail_path, created_at, updated_at, file_mtime, date_taken, latitude, longitude, camera_make, \ - camera_model, rating, + camera_model, rating, storage_mode, original_filename, uploaded_at, \ - storage_key, + storage_key, perceptual_hash, deleted_at FROM media_items WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT ?1 OFFSET ?2", - )?; - let rows = stmt.query_map( - params![limit.cast_signed(), offset.cast_signed()], - row_to_media_item, - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + let rows = stmt + .query_map( + params![limit.cast_signed(), offset.cast_signed()], + row_to_media_item, + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut items = Vec::new(); for row in rows { - items.push(row?); + items.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } Ok::<_, PinakesError>(items) }) @@ -8049,29 +8472,34 @@ impl StorageBackend for SqliteBackend { // First, get the IDs to clean up related data let mut stmt = conn - .prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL")?; + .prepare("SELECT id FROM media_items WHERE deleted_at IS NOT NULL") + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids: Vec = stmt - .query_map([], |row| row.get(0))? + .query_map([], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Delete related data for id in &ids { conn - .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; - conn.execute( - "DELETE FROM collection_members WHERE media_id = ?1", - params![id], - )?; + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; conn - .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![ - id - ])?; + .execute( + "DELETE FROM collection_members WHERE media_id = ?1", + params![id], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + conn + .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Delete the media items let count = conn - .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", [])?; + .execute("DELETE FROM media_items WHERE deleted_at IS NOT NULL", []) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count as u64) }) .await @@ -8093,35 +8521,42 @@ impl StorageBackend for SqliteBackend { })?; // First, get the IDs to clean up related data - let mut stmt = conn.prepare( - "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND \ - deleted_at < ?1", - )?; + let mut stmt = conn + .prepare( + "SELECT id FROM media_items WHERE deleted_at IS NOT NULL AND \ + deleted_at < ?1", + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; let ids: Vec = stmt - .query_map(params![before_str], |row| row.get(0))? + .query_map(params![before_str], |row| row.get(0)) + .map_err(|e| PinakesError::Database(e.to_string()))? .filter_map(std::result::Result::ok) .collect(); // Delete related data for id in &ids { conn - .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id])?; - conn.execute( - "DELETE FROM collection_members WHERE media_id = ?1", - params![id], - )?; + .execute("DELETE FROM media_tags WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; conn - .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![ - id - ])?; + .execute( + "DELETE FROM collection_members WHERE media_id = ?1", + params![id], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; + conn + .execute("DELETE FROM custom_fields WHERE media_id = ?1", params![id]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Delete the media items - let count = conn.execute( - "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND deleted_at \ - < ?1", - params![before_str], - )?; + let count = conn + .execute( + "DELETE FROM media_items WHERE deleted_at IS NOT NULL AND \ + deleted_at < ?1", + params![before_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count as u64) }) .await @@ -8137,11 +8572,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", - [], - |row| row.get(0), - )?; + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM media_items WHERE deleted_at IS NOT NULL", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count.cast_unsigned()) }) .await @@ -8165,38 +8602,46 @@ impl StorageBackend for SqliteBackend { })?; // Wrap DELETE + INSERT in transaction to ensure atomicity - let tx = conn.transaction()?; + let tx = conn + .transaction() + .map_err(|e| PinakesError::Database(e.to_string()))?; // Delete existing links for this source tx.execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Insert new links - let mut stmt = tx.prepare( - "INSERT INTO markdown_links ( + let mut stmt = tx + .prepare( + "INSERT INTO markdown_links ( id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; for link in &links { - stmt.execute(params![ - link.id.to_string(), - media_id_str, - link.target_path, - link.target_media_id.map(|id| id.0.to_string()), - link.link_type.to_string(), - link.link_text, - link.line_number, - link.context, - link.created_at.to_rfc3339(), - ])?; + stmt + .execute(params![ + link.id.to_string(), + media_id_str, + link.target_path, + link.target_media_id.map(|id| id.0.to_string()), + link.link_type.to_string(), + link.link_text, + link.line_number, + link.context, + link.created_at.to_rfc3339(), + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; } // Commit transaction - if this fails, all changes are rolled back drop(stmt); - tx.commit()?; + tx.commit() + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) @@ -8219,19 +8664,23 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT id, source_media_id, target_path, target_media_id, + let mut stmt = conn + .prepare( + "SELECT id, source_media_id, target_path, target_media_id, link_type, link_text, line_number, context, created_at FROM markdown_links WHERE source_media_id = ?1 ORDER BY line_number", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt.query_map([&media_id_str], row_to_markdown_link)?; + let rows = stmt + .query_map([&media_id_str], row_to_markdown_link) + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut links = Vec::new(); for row in rows { - links.push(row?); + links.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } Ok::<_, PinakesError>(links) }) @@ -8254,42 +8703,46 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let mut stmt = conn.prepare( - "SELECT l.id, l.source_media_id, m.title, m.path, + let mut stmt = conn + .prepare( + "SELECT l.id, l.source_media_id, m.title, m.path, l.link_text, l.line_number, l.context, l.link_type FROM markdown_links l JOIN media_items m ON l.source_media_id = m.id WHERE l.target_media_id = ?1 ORDER BY m.title, l.line_number", - )?; + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; - let rows = stmt.query_map([&media_id_str], |row| { - let link_id_str: String = row.get(0)?; - let source_id_str: String = row.get(1)?; - let source_title: Option = row.get(2)?; - let source_path: String = row.get(3)?; - let link_text: Option = row.get(4)?; - let line_number: Option = row.get(5)?; - let context: Option = row.get(6)?; - let link_type_str: String = row.get(7)?; + let rows = stmt + .query_map([&media_id_str], |row| { + let link_id_str: String = row.get(0)?; + let source_id_str: String = row.get(1)?; + let source_title: Option = row.get(2)?; + let source_path: String = row.get(3)?; + let link_text: Option = row.get(4)?; + let line_number: Option = row.get(5)?; + let context: Option = row.get(6)?; + let link_type_str: String = row.get(7)?; - Ok(crate::model::BacklinkInfo { - link_id: parse_uuid(&link_id_str)?, - source_id: MediaId(parse_uuid(&source_id_str)?), - source_title, - source_path, - link_text, - line_number, - context, - link_type: link_type_str - .parse() - .unwrap_or(crate::model::LinkType::Wikilink), + Ok(crate::model::BacklinkInfo { + link_id: parse_uuid(&link_id_str)?, + source_id: MediaId(parse_uuid(&source_id_str)?), + source_title, + source_path, + link_text, + line_number, + context, + link_type: link_type_str + .parse() + .unwrap_or(crate::model::LinkType::Wikilink), + }) }) - })?; + .map_err(|e| PinakesError::Database(e.to_string()))?; let mut backlinks = Vec::new(); for row in rows { - backlinks.push(row?); + backlinks.push(row.map_err(|e| PinakesError::Database(e.to_string()))?); } Ok::<_, PinakesError>(backlinks) }) @@ -8310,7 +8763,8 @@ impl StorageBackend for SqliteBackend { conn .execute("DELETE FROM markdown_links WHERE source_media_id = ?1", [ &media_id_str, - ])?; + ]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -8351,13 +8805,13 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT target_media_id FROM markdown_links WHERE source_media_id = ?1 AND target_media_id IS NOT NULL", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([node_id], |row| { let id: String = row.get(0)?; Ok(id) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - let id = row?; + let id = row.map_err(|e| PinakesError::Database(e.to_string()))?; if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); @@ -8368,13 +8822,13 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT source_media_id FROM markdown_links WHERE target_media_id = ?1", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([node_id], |row| { let id: String = row.get(0)?; Ok(id) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - let id = row?; + let id = row.map_err(|e| PinakesError::Database(e.to_string()))?; if !visited.contains(&id) { visited.insert(id.clone()); next_frontier.push(id); @@ -8392,13 +8846,14 @@ impl StorageBackend for SqliteBackend { "SELECT DISTINCT id FROM media_items WHERE media_type = 'markdown' AND deleted_at IS NULL LIMIT 500", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([], |row| { let id: String = row.get(0)?; Ok(id) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - node_ids.insert(row?); + node_ids.insert(row.map_err(|e| PinakesError::Database(e.to_string()))?); + } } @@ -8407,7 +8862,7 @@ impl StorageBackend for SqliteBackend { let mut stmt = conn.prepare( "SELECT id, COALESCE(title, file_name) as label, title, media_type FROM media_items WHERE id = ?1", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; if let Ok((id, label, title, media_type)) = stmt.query_row([node_id], |row| { Ok(( row.get::<_, String>(0)?, @@ -8421,14 +8876,14 @@ impl StorageBackend for SqliteBackend { "SELECT COUNT(*) FROM markdown_links WHERE source_media_id = ?1", [&id], |row| row.get(0), - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; // Count incoming links let backlink_count: i64 = conn.query_row( "SELECT COUNT(*) FROM markdown_links WHERE target_media_id = ?1", [&id], |row| row.get(0), - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; nodes.push(crate::model::GraphNode { id: id.clone(), @@ -8447,15 +8902,15 @@ impl StorageBackend for SqliteBackend { "SELECT source_media_id, target_media_id, link_type FROM markdown_links WHERE source_media_id = ?1 AND target_media_id IS NOT NULL", - )?; + ).map_err(|e| PinakesError::Database(e.to_string()))?; let rows = stmt.query_map([node_id], |row| { let source: String = row.get(0)?; let target: String = row.get(1)?; let link_type_str: String = row.get(2)?; Ok((source, target, link_type_str)) - })?; + }).map_err(|e| PinakesError::Database(e.to_string()))?; for row in rows { - let (source, target, link_type_str) = row?; + let (source, target, link_type_str) = row.map_err(|e| PinakesError::Database(e.to_string()))?; if node_ids.contains(&target) { edges.push(crate::model::GraphEdge { source, @@ -8486,8 +8941,9 @@ impl StorageBackend for SqliteBackend { // Find unresolved links and try to resolve them // Strategy 1: Exact path match - let updated1 = conn.execute( - "UPDATE markdown_links + let updated1 = conn + .execute( + "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE path = markdown_links.target_path @@ -8500,19 +8956,21 @@ impl StorageBackend for SqliteBackend { WHERE path = markdown_links.target_path AND deleted_at IS NULL )", - [], - )?; + [], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; // Strategy 2: Filename match (Obsidian-style) // Match target_path to file_name (with or without .md extension) - let updated2 = conn.execute( - "UPDATE markdown_links + let updated2 = conn + .execute( + "UPDATE markdown_links SET target_media_id = ( SELECT id FROM media_items WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = \ - markdown_links.target_path) + markdown_links.target_path) AND deleted_at IS NULL LIMIT 1 ) @@ -8522,11 +8980,12 @@ impl StorageBackend for SqliteBackend { WHERE (file_name = markdown_links.target_path OR file_name = markdown_links.target_path || '.md' OR REPLACE(file_name, '.md', '') = \ - markdown_links.target_path) + markdown_links.target_path) AND deleted_at IS NULL )", - [], - )?; + [], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>((updated1 + updated2) as u64) }) @@ -8545,10 +9004,12 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - conn.execute( - "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", - params![now, media_id_str], - )?; + conn + .execute( + "UPDATE media_items SET links_extracted_at = ?1 WHERE id = ?2", + params![now, media_id_str], + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(()) }) .await @@ -8566,11 +9027,13 @@ impl StorageBackend for SqliteBackend { let conn = conn.lock().map_err(|e| { PinakesError::Database(format!("connection mutex poisoned: {e}")) })?; - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", - [], - |row| row.get(0), - )?; + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL", + [], + |row| row.get(0), + ) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok::<_, PinakesError>(count.cast_unsigned()) }) .await @@ -8589,7 +9052,8 @@ impl StorageBackend for SqliteBackend { let db = conn.lock().map_err(|e| { PinakesError::Database(format!("failed to acquire database lock: {e}")) })?; - db.execute("VACUUM INTO ?1", params![dest.to_string_lossy()])?; + db.execute("VACUUM INTO ?1", params![dest.to_string_lossy()]) + .map_err(|e| PinakesError::Database(e.to_string()))?; Ok(()) }); tokio::time::timeout(std::time::Duration::from_mins(5), fut) diff --git a/crates/pinakes-core/src/sync/chunked.rs b/crates/pinakes-core/src/sync/chunked.rs deleted file mode 100644 index e3e29a2..0000000 --- a/crates/pinakes-core/src/sync/chunked.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Chunked upload handling for large file sync. - -use std::path::{Path, PathBuf}; - -use chrono::Utc; -use tokio::{ - fs, - io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, -}; -use tracing::{debug, info}; -use uuid::Uuid; - -use super::{ChunkInfo, UploadSession}; -use crate::error::{PinakesError, Result}; - -/// Manager for chunked uploads. -#[derive(Debug, Clone)] -pub struct ChunkedUploadManager { - temp_dir: PathBuf, -} - -impl ChunkedUploadManager { - /// Create a new chunked upload manager. - #[must_use] - pub const fn new(temp_dir: PathBuf) -> Self { - Self { temp_dir } - } - - /// Initialize the temp directory. - /// - /// # Errors - /// - /// Returns an error if the directory cannot be created. - pub async fn init(&self) -> Result<()> { - fs::create_dir_all(&self.temp_dir).await?; - Ok(()) - } - - /// Get the temp file path for an upload session. - #[must_use] - pub fn temp_path(&self, session_id: Uuid) -> PathBuf { - self.temp_dir.join(format!("{session_id}.upload")) - } - - /// Create the temp file for a new upload session. - /// - /// # Errors - /// - /// Returns an error if the file cannot be created or sized. - pub async fn create_temp_file(&self, session: &UploadSession) -> Result<()> { - let path = self.temp_path(session.id); - - // Create a sparse file of the expected size - let file = fs::File::create(&path).await?; - file.set_len(session.expected_size).await?; - - debug!( - session_id = %session.id, - size = session.expected_size, - "created temp file for upload" - ); - - Ok(()) - } - - /// Write a chunk to the temp file. - /// - /// # Errors - /// - /// Returns an error if the session file is not found, the chunk index is out - /// of range, the chunk size is wrong, or the write fails. - pub async fn write_chunk( - &self, - session: &UploadSession, - chunk_index: u64, - data: &[u8], - ) -> Result { - let path = self.temp_path(session.id); - - if !path.exists() { - return Err(PinakesError::UploadSessionNotFound(session.id.to_string())); - } - - // Calculate offset - let offset = chunk_index * session.chunk_size; - - // Validate chunk - if offset >= session.expected_size { - return Err(PinakesError::ChunkOutOfOrder { - expected: session.chunk_count - 1, - actual: chunk_index, - }); - } - - // Calculate expected chunk size - let expected_size = if chunk_index == session.chunk_count - 1 { - // Last chunk may be smaller - session.expected_size - offset - } else { - session.chunk_size - }; - - if data.len() as u64 != expected_size { - return Err(PinakesError::InvalidData(format!( - "chunk {} has wrong size: expected {}, got {}", - chunk_index, - expected_size, - data.len() - ))); - } - - // Write chunk to file at offset - let mut file = fs::OpenOptions::new().write(true).open(&path).await?; - - file.seek(std::io::SeekFrom::Start(offset)).await?; - file.write_all(data).await?; - file.flush().await?; - - // Compute chunk hash - let hash = blake3::hash(data).to_hex().to_string(); - - debug!( - session_id = %session.id, - chunk_index, - offset, - size = data.len(), - "wrote chunk" - ); - - Ok(ChunkInfo { - upload_id: session.id, - chunk_index, - offset, - size: data.len() as u64, - hash, - received_at: Utc::now(), - }) - } - - /// Verify and finalize the upload. - /// - /// Checks that: - /// 1. All chunks are received - /// 2. File size matches expected - /// 3. Content hash matches expected - /// - /// # Errors - /// - /// Returns an error if chunks are missing, the file size does not match, the - /// hash does not match, or the file metadata cannot be read. - pub async fn finalize( - &self, - session: &UploadSession, - received_chunks: &[ChunkInfo], - ) -> Result { - let path = self.temp_path(session.id); - - // Check all chunks received - if received_chunks.len() as u64 != session.chunk_count { - return Err(PinakesError::InvalidData(format!( - "missing chunks: expected {}, got {}", - session.chunk_count, - received_chunks.len() - ))); - } - - // Verify chunk indices - let mut indices: Vec = - received_chunks.iter().map(|c| c.chunk_index).collect(); - indices.sort_unstable(); - for (i, idx) in indices.iter().enumerate() { - if *idx != i as u64 { - return Err(PinakesError::InvalidData(format!( - "chunk {i} missing or out of order" - ))); - } - } - - // Verify file size - let metadata = fs::metadata(&path).await?; - if metadata.len() != session.expected_size { - return Err(PinakesError::InvalidData(format!( - "file size mismatch: expected {}, got {}", - session.expected_size, - metadata.len() - ))); - } - - // Verify content hash - let computed_hash = compute_file_hash(&path).await?; - if computed_hash != session.expected_hash.0 { - return Err(PinakesError::StorageIntegrity(format!( - "hash mismatch: expected {}, computed {}", - session.expected_hash, computed_hash - ))); - } - - info!( - session_id = %session.id, - hash = %session.expected_hash, - size = session.expected_size, - "finalized chunked upload" - ); - - Ok(path) - } - - /// Cancel an upload and clean up temp file. - /// - /// # Errors - /// - /// Returns an error if the temp file cannot be removed. - pub async fn cancel(&self, session_id: Uuid) -> Result<()> { - let path = self.temp_path(session_id); - if path.exists() { - fs::remove_file(&path).await?; - debug!(session_id = %session_id, "cancelled upload, removed temp file"); - } - Ok(()) - } - - /// Clean up expired temp files. - /// - /// # Errors - /// - /// Returns an error if the temp directory cannot be read. - pub async fn cleanup_expired(&self, max_age_hours: u64) -> Result { - let mut count = 0u64; - let max_age = std::time::Duration::from_secs(max_age_hours * 3600); - - let mut entries = fs::read_dir(&self.temp_dir).await?; - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if path.extension().is_some_and(|e| e == "upload") - && let Ok(metadata) = fs::metadata(&path).await - && let Ok(modified) = metadata.modified() - { - let age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - if age > max_age { - let _ = fs::remove_file(&path).await; - count += 1; - } - } - } - - if count > 0 { - info!(count, "cleaned up expired upload temp files"); - } - Ok(count) - } -} - -/// Compute the BLAKE3 hash of a file. -async fn compute_file_hash(path: &Path) -> Result { - let mut file = fs::File::open(path).await?; - let mut hasher = blake3::Hasher::new(); - let mut buf = vec![0u8; 64 * 1024]; - - loop { - let n = file.read(&mut buf).await?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - } - - Ok(hasher.finalize().to_hex().to_string()) -} - -#[cfg(test)] -mod tests { - use tempfile::tempdir; - - use super::*; - use crate::{model::ContentHash, sync::UploadStatus}; - - #[tokio::test] - async fn test_chunked_upload() { - let dir = tempdir().unwrap(); - let manager = ChunkedUploadManager::new(dir.path().to_path_buf()); - manager.init().await.unwrap(); - - // Create test data - let data = b"Hello, World! This is test data for chunked upload."; - let hash = blake3::hash(data).to_hex().to_string(); - let chunk_size = 20u64; - - let session = UploadSession { - id: Uuid::now_v7(), - device_id: super::super::DeviceId::new(), - target_path: "/test/file.txt".to_string(), - expected_hash: ContentHash::new(hash.clone()), - expected_size: data.len() as u64, - chunk_size, - chunk_count: (data.len() as u64).div_ceil(chunk_size), - status: UploadStatus::InProgress, - created_at: Utc::now(), - expires_at: Utc::now() + chrono::Duration::hours(24), - last_activity: Utc::now(), - }; - - manager.create_temp_file(&session).await.unwrap(); - - // Write chunks - let mut chunks = Vec::new(); - for i in 0..session.chunk_count { - let start = (i * chunk_size) as usize; - let end = ((i + 1) * chunk_size).min(data.len() as u64) as usize; - let chunk_data = &data[start..end]; - - let chunk = manager.write_chunk(&session, i, chunk_data).await.unwrap(); - chunks.push(chunk); - } - - // Finalize - let final_path = manager.finalize(&session, &chunks).await.unwrap(); - assert!(final_path.exists()); - - // Verify content - let content = fs::read(&final_path).await.unwrap(); - assert_eq!(&content[..], data); - } -} diff --git a/crates/pinakes-core/src/sync/conflict.rs b/crates/pinakes-core/src/sync/conflict.rs deleted file mode 100644 index eab7787..0000000 --- a/crates/pinakes-core/src/sync/conflict.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Conflict detection and resolution for sync. - -use super::DeviceSyncState; -use crate::config::ConflictResolution; - -/// Detect if there's a conflict between local and server state. -#[must_use] -pub fn detect_conflict(state: &DeviceSyncState) -> Option { - // If either side has no hash, no conflict possible - let local_hash = state.local_hash.as_ref()?; - let server_hash = state.server_hash.as_ref()?; - - // Same hash = no conflict - if local_hash == server_hash { - return None; - } - - // Both have different hashes = conflict - Some(ConflictInfo { - path: state.path.clone(), - local_hash: local_hash.clone(), - server_hash: server_hash.clone(), - local_mtime: state.local_mtime, - server_mtime: state.server_mtime, - }) -} - -/// Information about a detected conflict. -#[derive(Debug, Clone)] -pub struct ConflictInfo { - pub path: String, - pub local_hash: String, - pub server_hash: String, - pub local_mtime: Option, - pub server_mtime: Option, -} - -/// Result of resolving a conflict. -#[derive(Debug, Clone)] -pub enum ConflictOutcome { - /// Use the server version - UseServer, - /// Use the local version (upload it) - UseLocal, - /// Keep both versions (rename one) - KeepBoth { new_local_path: String }, - /// Requires manual intervention - Manual, -} - -/// Resolve a conflict based on the configured strategy. -#[must_use] -pub fn resolve_conflict( - conflict: &ConflictInfo, - resolution: ConflictResolution, -) -> ConflictOutcome { - match resolution { - ConflictResolution::ServerWins => ConflictOutcome::UseServer, - ConflictResolution::ClientWins => ConflictOutcome::UseLocal, - ConflictResolution::KeepBoth => { - let new_path = - generate_conflict_path(&conflict.path, &conflict.local_hash); - ConflictOutcome::KeepBoth { - new_local_path: new_path, - } - }, - ConflictResolution::Manual => ConflictOutcome::Manual, - } -} - -/// Generate a new path for the conflicting local file. -/// Format: filename.conflict-<`short_hash>.ext` -fn generate_conflict_path(original_path: &str, local_hash: &str) -> String { - let short_hash = &local_hash[..8.min(local_hash.len())]; - - if let Some((base, ext)) = original_path.rsplit_once('.') { - format!("{base}.conflict-{short_hash}.{ext}") - } else { - format!("{original_path}.conflict-{short_hash}") - } -} - -/// Automatic conflict resolution based on modification times. -/// Useful when `ConflictResolution` is set to a time-based strategy. -#[must_use] -pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { - match (conflict.local_mtime, conflict.server_mtime) { - (Some(local), Some(server)) => { - if local > server { - ConflictOutcome::UseLocal - } else { - ConflictOutcome::UseServer - } - }, - (Some(_), None) => ConflictOutcome::UseLocal, - (None, Some(_)) => ConflictOutcome::UseServer, - (None, None) => ConflictOutcome::UseServer, // Default to server - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::sync::FileSyncStatus; - - #[test] - fn test_generate_conflict_path() { - assert_eq!( - generate_conflict_path("/path/to/file.txt", "abc12345"), - "/path/to/file.conflict-abc12345.txt" - ); - - assert_eq!( - generate_conflict_path("/path/to/file", "abc12345"), - "/path/to/file.conflict-abc12345" - ); - } - - #[test] - fn test_detect_conflict() { - let state_no_conflict = DeviceSyncState { - device_id: super::super::DeviceId::new(), - path: "/test".to_string(), - local_hash: Some("abc".to_string()), - server_hash: Some("abc".to_string()), - local_mtime: None, - server_mtime: None, - sync_status: FileSyncStatus::Synced, - last_synced_at: None, - conflict_info_json: None, - }; - assert!(detect_conflict(&state_no_conflict).is_none()); - - let state_conflict = DeviceSyncState { - device_id: super::super::DeviceId::new(), - path: "/test".to_string(), - local_hash: Some("abc".to_string()), - server_hash: Some("def".to_string()), - local_mtime: None, - server_mtime: None, - sync_status: FileSyncStatus::Conflict, - last_synced_at: None, - conflict_info_json: None, - }; - assert!(detect_conflict(&state_conflict).is_some()); - } -} diff --git a/crates/pinakes-core/src/sync/mod.rs b/crates/pinakes-core/src/sync/mod.rs index 77181f1..b19f5d7 100644 --- a/crates/pinakes-core/src/sync/mod.rs +++ b/crates/pinakes-core/src/sync/mod.rs @@ -2,13 +2,10 @@ //! //! Provides device registration, change tracking, and conflict resolution //! for syncing media libraries across multiple devices. +//! +//! Pure domain types and non-storage logic live in `pinakes-sync`. +//! Protocol functions that need `DynStorageBackend` stay in this module. -mod chunked; -mod conflict; -mod models; mod protocol; -pub use chunked::*; -pub use conflict::*; -pub use models::*; pub use protocol::*; diff --git a/crates/pinakes-core/src/sync/models.rs b/crates/pinakes-core/src/sync/models.rs deleted file mode 100644 index 5814c20..0000000 --- a/crates/pinakes-core/src/sync/models.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! Sync domain models. - -use std::fmt; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - config::ConflictResolution, - model::{ContentHash, MediaId}, - users::UserId, -}; - -/// Unique identifier for a sync device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct DeviceId(pub Uuid); - -impl DeviceId { - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl Default for DeviceId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for DeviceId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Type of sync device. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, -)] -#[serde(rename_all = "lowercase")] -pub enum DeviceType { - Desktop, - Mobile, - Tablet, - Server, - #[default] - Other, -} - -impl fmt::Display for DeviceType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Desktop => write!(f, "desktop"), - Self::Mobile => write!(f, "mobile"), - Self::Tablet => write!(f, "tablet"), - Self::Server => write!(f, "server"), - Self::Other => write!(f, "other"), - } - } -} - -impl std::str::FromStr for DeviceType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "desktop" => Ok(Self::Desktop), - "mobile" => Ok(Self::Mobile), - "tablet" => Ok(Self::Tablet), - "server" => Ok(Self::Server), - "other" => Ok(Self::Other), - _ => Err(format!("unknown device type: {s}")), - } - } -} - -/// A registered sync device. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncDevice { - pub id: DeviceId, - pub user_id: UserId, - pub name: String, - pub device_type: DeviceType, - pub client_version: String, - pub os_info: Option, - pub last_sync_at: Option>, - pub last_seen_at: DateTime, - pub sync_cursor: Option, - pub enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl SyncDevice { - #[must_use] - pub fn new( - user_id: UserId, - name: String, - device_type: DeviceType, - client_version: String, - ) -> Self { - let now = Utc::now(); - Self { - id: DeviceId::new(), - user_id, - name, - device_type, - client_version, - os_info: None, - last_sync_at: None, - last_seen_at: now, - sync_cursor: None, - enabled: true, - created_at: now, - updated_at: now, - } - } -} - -/// Type of change recorded in the sync log. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SyncChangeType { - Created, - Modified, - Deleted, - Moved, - MetadataUpdated, -} - -impl fmt::Display for SyncChangeType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Created => write!(f, "created"), - Self::Modified => write!(f, "modified"), - Self::Deleted => write!(f, "deleted"), - Self::Moved => write!(f, "moved"), - Self::MetadataUpdated => write!(f, "metadata_updated"), - } - } -} - -impl std::str::FromStr for SyncChangeType { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "created" => Ok(Self::Created), - "modified" => Ok(Self::Modified), - "deleted" => Ok(Self::Deleted), - "moved" => Ok(Self::Moved), - "metadata_updated" => Ok(Self::MetadataUpdated), - _ => Err(format!("unknown sync change type: {s}")), - } - } -} - -/// An entry in the sync log tracking a change. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncLogEntry { - pub id: Uuid, - pub sequence: i64, - pub change_type: SyncChangeType, - pub media_id: Option, - pub path: String, - pub content_hash: Option, - pub file_size: Option, - pub metadata_json: Option, - pub changed_by_device: Option, - pub timestamp: DateTime, -} - -impl SyncLogEntry { - #[must_use] - pub fn new( - change_type: SyncChangeType, - path: String, - media_id: Option, - content_hash: Option, - ) -> Self { - Self { - id: Uuid::now_v7(), - sequence: 0, // Will be assigned by database - change_type, - media_id, - path, - content_hash, - file_size: None, - metadata_json: None, - changed_by_device: None, - timestamp: Utc::now(), - } - } -} - -/// Sync status for a file on a device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FileSyncStatus { - Synced, - PendingUpload, - PendingDownload, - Conflict, - Deleted, -} - -impl fmt::Display for FileSyncStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Synced => write!(f, "synced"), - Self::PendingUpload => write!(f, "pending_upload"), - Self::PendingDownload => write!(f, "pending_download"), - Self::Conflict => write!(f, "conflict"), - Self::Deleted => write!(f, "deleted"), - } - } -} - -impl std::str::FromStr for FileSyncStatus { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "synced" => Ok(Self::Synced), - "pending_upload" => Ok(Self::PendingUpload), - "pending_download" => Ok(Self::PendingDownload), - "conflict" => Ok(Self::Conflict), - "deleted" => Ok(Self::Deleted), - _ => Err(format!("unknown file sync status: {s}")), - } - } -} - -/// Sync state for a specific file on a specific device. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceSyncState { - pub device_id: DeviceId, - pub path: String, - pub local_hash: Option, - pub server_hash: Option, - pub local_mtime: Option, - pub server_mtime: Option, - pub sync_status: FileSyncStatus, - pub last_synced_at: Option>, - pub conflict_info_json: Option, -} - -/// A sync conflict that needs resolution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SyncConflict { - pub id: Uuid, - pub device_id: DeviceId, - pub path: String, - pub local_hash: String, - pub local_mtime: i64, - pub server_hash: String, - pub server_mtime: i64, - pub detected_at: DateTime, - pub resolved_at: Option>, - pub resolution: Option, -} - -impl SyncConflict { - #[must_use] - pub fn new( - device_id: DeviceId, - path: String, - local_hash: String, - local_mtime: i64, - server_hash: String, - server_mtime: i64, - ) -> Self { - Self { - id: Uuid::now_v7(), - device_id, - path, - local_hash, - local_mtime, - server_hash, - server_mtime, - detected_at: Utc::now(), - resolved_at: None, - resolution: None, - } - } -} - -/// Status of an upload session. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum UploadStatus { - Pending, - InProgress, - Completed, - Failed, - Expired, - Cancelled, -} - -impl fmt::Display for UploadStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Pending => write!(f, "pending"), - Self::InProgress => write!(f, "in_progress"), - Self::Completed => write!(f, "completed"), - Self::Failed => write!(f, "failed"), - Self::Expired => write!(f, "expired"), - Self::Cancelled => write!(f, "cancelled"), - } - } -} - -impl std::str::FromStr for UploadStatus { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "pending" => Ok(Self::Pending), - "in_progress" => Ok(Self::InProgress), - "completed" => Ok(Self::Completed), - "failed" => Ok(Self::Failed), - "expired" => Ok(Self::Expired), - "cancelled" => Ok(Self::Cancelled), - _ => Err(format!("unknown upload status: {s}")), - } - } -} - -/// A chunked upload session. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UploadSession { - pub id: Uuid, - pub device_id: DeviceId, - pub target_path: String, - pub expected_hash: ContentHash, - pub expected_size: u64, - pub chunk_size: u64, - pub chunk_count: u64, - pub status: UploadStatus, - pub created_at: DateTime, - pub expires_at: DateTime, - pub last_activity: DateTime, -} - -impl UploadSession { - #[must_use] - pub fn new( - device_id: DeviceId, - target_path: String, - expected_hash: ContentHash, - expected_size: u64, - chunk_size: u64, - timeout_hours: u64, - ) -> Self { - let now = Utc::now(); - let chunk_count = expected_size.div_ceil(chunk_size); - Self { - id: Uuid::now_v7(), - device_id, - target_path, - expected_hash, - expected_size, - chunk_size, - chunk_count, - status: UploadStatus::Pending, - created_at: now, - expires_at: now + chrono::Duration::hours(timeout_hours as i64), - last_activity: now, - } - } -} - -/// Information about an uploaded chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkInfo { - pub upload_id: Uuid, - pub chunk_index: u64, - pub offset: u64, - pub size: u64, - pub hash: String, - pub received_at: DateTime, -} diff --git a/crates/pinakes-core/src/sync/protocol.rs b/crates/pinakes-core/src/sync/protocol.rs index 5c0c486..5e796b2 100644 --- a/crates/pinakes-core/src/sync/protocol.rs +++ b/crates/pinakes-core/src/sync/protocol.rs @@ -3,16 +3,16 @@ //! Handles the bidirectional sync protocol between clients and server. use chrono::Utc; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::{ +use pinakes_sync::{ DeviceId, DeviceSyncState, FileSyncStatus, SyncChangeType, SyncLogEntry, }; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + use crate::{ error::Result, model::{ContentHash, MediaId}, diff --git a/crates/pinakes-core/src/upload.rs b/crates/pinakes-core/src/upload.rs index 837b34d..ad2d162 100644 --- a/crates/pinakes-core/src/upload.rs +++ b/crates/pinakes-core/src/upload.rs @@ -13,7 +13,6 @@ use crate::{ error::{PinakesError, Result}, managed_storage::ManagedStorageService, media_type::MediaType, - metadata, model::{MediaId, MediaItem, StorageMode, UploadResult}, storage::DynStorageBackend, }; @@ -58,7 +57,8 @@ pub async fn process_upload( let blob_path = managed.path(&content_hash); // Extract metadata - let extracted = metadata::extract_metadata(&blob_path, &media_type).ok(); + let extracted = + pinakes_metadata::extract_metadata(&blob_path, &media_type).ok(); // Create or get blob record let mime = mime_type.map_or_else(|| media_type.mime_type(), String::from); diff --git a/crates/pinakes-core/src/users.rs b/crates/pinakes-core/src/users.rs index 030bd46..159b972 100644 --- a/crates/pinakes-core/src/users.rs +++ b/crates/pinakes-core/src/users.rs @@ -3,42 +3,12 @@ use chrono::{DateTime, Utc}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; -use uuid::Uuid; - use crate::{ config::UserRole, error::{PinakesError, Result}, }; -/// User ID -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct UserId(pub Uuid); - -impl UserId { - /// Creates a new user ID. - #[must_use] - pub fn new() -> Self { - Self(Uuid::now_v7()) - } -} - -impl Default for UserId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for UserId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for UserId { - fn from(id: Uuid) -> Self { - Self(id) - } -} +pub use pinakes_types::model::UserId; /// User account with profile information #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/pinakes-core/tests/book_metadata.rs b/crates/pinakes-core/tests/book_metadata.rs index 2223441..81e156c 100644 --- a/crates/pinakes-core/tests/book_metadata.rs +++ b/crates/pinakes-core/tests/book_metadata.rs @@ -1,12 +1,12 @@ use pinakes_core::{ books::{extract_isbn_from_text, normalize_isbn, parse_author_file_as}, - enrichment::{ - books::BookEnricher, - googlebooks::GoogleBooksClient, - openlibrary::OpenLibraryClient, - }, thumbnail::{CoverSize, extract_epub_cover, generate_book_covers}, }; +use pinakes_enrichment::{ + books::BookEnricher, + googlebooks::GoogleBooksClient, + openlibrary::OpenLibraryClient, +}; #[test] fn test_isbn_normalization() { diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index 9033f9c..78b6f1a 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -841,10 +841,10 @@ async fn test_external_metadata() { let item = make_test_media("enrich1"); storage.insert_media(&item).await.unwrap(); - let meta = pinakes_core::enrichment::ExternalMetadata { + let meta = pinakes_enrichment::ExternalMetadata { id: uuid::Uuid::now_v7(), media_id: item.id, - source: pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz, + source: pinakes_enrichment::EnrichmentSourceType::MusicBrainz, external_id: Some("mb-123".to_string()), metadata_json: r#"{"title":"Test"}"#.to_string(), confidence: 0.85, @@ -857,7 +857,7 @@ async fn test_external_metadata() { assert_eq!(metas.len(), 1); assert_eq!( metas[0].source, - pinakes_core::enrichment::EnrichmentSourceType::MusicBrainz + pinakes_enrichment::EnrichmentSourceType::MusicBrainz ); assert_eq!(metas[0].external_id.as_deref(), Some("mb-123")); assert!((metas[0].confidence - 0.85).abs() < 0.01); diff --git a/crates/pinakes-core/tests/plugin_integration.rs b/crates/pinakes-core/tests/plugin_integration.rs index c48a250..5130e33 100644 --- a/crates/pinakes-core/tests/plugin_integration.rs +++ b/crates/pinakes-core/tests/plugin_integration.rs @@ -9,10 +9,9 @@ #![allow(clippy::print_stderr, reason = "Fine for tests")] use std::{path::Path, sync::Arc}; -use pinakes_core::{ - config::PluginTimeoutConfig, - plugin::{PluginManager, PluginManagerConfig, PluginPipeline}, -}; +use pinakes_core::plugin::PluginPipeline; +use pinakes_plugin::{PluginManager, PluginManagerConfig}; +use pinakes_types::config::PluginTimeoutConfig; use tempfile::TempDir; /// Path to the compiled test plugin fixture. diff --git a/crates/pinakes-migrations/Cargo.toml b/crates/pinakes-migrations/Cargo.toml index 42284da..5e65ed9 100644 --- a/crates/pinakes-migrations/Cargo.toml +++ b/crates/pinakes-migrations/Cargo.toml @@ -3,6 +3,7 @@ name = "pinakes-migrations" edition.workspace = true version.workspace = true license.workspace = true +publish = false [dependencies] rusqlite = { workspace = true } diff --git a/crates/pinakes-migrations/src/lib.rs b/crates/pinakes-migrations/src/lib.rs index 8092426..a1fc8e5 100644 --- a/crates/pinakes-migrations/src/lib.rs +++ b/crates/pinakes-migrations/src/lib.rs @@ -22,17 +22,29 @@ pub fn sqlite_migrations() -> Migrations<'static> { M::up(include_str!( "../migrations/sqlite/V9__fix_indexes_and_constraints.sql" )), - M::up(include_str!("../migrations/sqlite/V10__incremental_scan.sql")), + M::up(include_str!( + "../migrations/sqlite/V10__incremental_scan.sql" + )), M::up(include_str!( "../migrations/sqlite/V11__session_persistence.sql" )), - M::up(include_str!("../migrations/sqlite/V12__book_management.sql")), + M::up(include_str!( + "../migrations/sqlite/V12__book_management.sql" + )), M::up(include_str!("../migrations/sqlite/V13__photo_metadata.sql")), - M::up(include_str!("../migrations/sqlite/V14__perceptual_hash.sql")), - M::up(include_str!("../migrations/sqlite/V15__managed_storage.sql")), + M::up(include_str!( + "../migrations/sqlite/V14__perceptual_hash.sql" + )), + M::up(include_str!( + "../migrations/sqlite/V15__managed_storage.sql" + )), M::up(include_str!("../migrations/sqlite/V16__sync_system.sql")), - M::up(include_str!("../migrations/sqlite/V17__enhanced_sharing.sql")), - M::up(include_str!("../migrations/sqlite/V18__file_management.sql")), + M::up(include_str!( + "../migrations/sqlite/V17__enhanced_sharing.sql" + )), + M::up(include_str!( + "../migrations/sqlite/V18__file_management.sql" + )), M::up(include_str!("../migrations/sqlite/V19__markdown_links.sql")), ]) }