From 52442d6a6d774c3a54771aab5974327b506cd6da Mon Sep 17 00:00:00 2001 From: drjones Date: Thu, 23 Jul 2026 03:43:41 -0700 Subject: [PATCH] Fix pipeline shutdown hang, delete path safety, organize folder scoping, journal duplication; add PHOTON.app menu-bar launcher - Stop/pause could block the downscale thread for up to 60s and throw an unhandled exception on shutdown; queue puts are now stop-aware - /api/delete_photo(s) now refuse to delete anything outside the configured photo folder - organize_alias used a stale startup-time default folder instead of the folder actually being scanned/tagged - journal_write is append-only, so retagging a photo left stale duplicate lines that inflated category counts and search results; reads now dedupe by path and the journal is compacted once on startup - batch delete now does one journal rewrite for the whole set instead of one per file - add PHOTON.app: a signed, dependency-free Swift menu-bar app that starts server.py in the background (or attaches to an already-running instance), opens the console, and exposes Open/Restart/Stop/Show Log/Quit Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + PHOTON.app/Contents/Info.plist | 28 ++ PHOTON.app/Contents/MacOS/PHOTON | Bin 0 -> 130032 bytes .../Contents/_CodeSignature/CodeResources | 115 +++++++ server.py | 308 +++++++++++------- 5 files changed, 341 insertions(+), 112 deletions(-) create mode 100644 PHOTON.app/Contents/Info.plist create mode 100755 PHOTON.app/Contents/MacOS/PHOTON create mode 100644 PHOTON.app/Contents/_CodeSignature/CodeResources diff --git a/.gitignore b/.gitignore index b55708f..cacf79a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ photon_audits.jsonl photon_phash.json photon_folder.json photon_categories.json +.photon.pid +.photon_server.log .claude/ diff --git a/PHOTON.app/Contents/Info.plist b/PHOTON.app/Contents/Info.plist new file mode 100644 index 0000000..b577361 --- /dev/null +++ b/PHOTON.app/Contents/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + PHOTON + CFBundleIdentifier + local.photon.console + CFBundleName + PHOTON + CFBundleDisplayName + PHOTON + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSUIElement + + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.utilities + + diff --git a/PHOTON.app/Contents/MacOS/PHOTON b/PHOTON.app/Contents/MacOS/PHOTON new file mode 100755 index 0000000000000000000000000000000000000000..ae3f594ad3c4c528b29ec1c009f2dee238efbbd8 GIT binary patch literal 130032 zcmeFadwi6|^*=tln}h`fgSiqA7DNq-kU)TdQg#!-pa>xwE=AkTk}SzWvKw|cK*Z`I zYEi5b#7nK!Xsu12`mt2;T9x3XrL9^n){mcl`q6-wMxhoXYN9~C?=y3r&6CY0vESeK z_5J*Sqk2)qr!A5Al+IsS+A~>}Z|NB)m_`vn;T%qOxk8>zAtnH#`K?4o z;+No3d5ETVMSck)9KnIOT)|M?>R==)n%_0+6~Fuhm4#>{KP&Tt4&~3~@~>W7-pw?c zUt^u(XG>D~iKg~Ne!b+A{AxU*s%mdA)T_NMn-#xDEs6utj7C@*5tpmn?*kwG7cF1y z4T|4t&OkIbT`$rDLVp{*YeLgqq54`+z_TWr-y!DrYc8eJJ(w5>$QKL+yuM0~i{=*& zEBRhxqa)fVpRsRki= z7K-Tp<%{&M&t21--*Z1!{7QzZ`iVCFqGgS=*W(KX>U)omv+h#-tjvVyXdXuX+}`qP zcQ8l-M(fYU{PMXF(fzkq+FzwIo8I=klKJWVL3ID}vHijJs$n_N^5yJO?X|IdMECcL z=x>eJQ(aM05zVid`Q>UW70|Z;iXsL#7ff z-!|rFJG%jjt{60M`nqSLc#V@R905rmYGBj z@{+A)BIGPLiAPZIxu8j$m@5QLlf?-18;W=ely?~5-7+@zKgG2s@wXUB!x3jgp!MC9 zx?o^RwRiQDiu&pb(DWOHKyuu3&($ff`DcCn#1+?hzDT`f)KtXT5Kt|CSuofvi7VZG zk#G@WjfRqq_7NXiuNr?;?@9zpQ{$5`lbGVH4~9H7Qx zb{Kn2wcl=#3#j}>`dcoe$hsv$ZSTPVb`qv>9kG{+O`r_*_AcKow}BJ!u^aiA$*TM1 zvykR6odt~tV|iOe5~lh-%g^%$Yu%ypD!n+#-?M&{?Wg?0C1kn=`|nlWTn7ey)_;{J z$hNID6+O7OMvPIFWpg zy;GRyzVnGiGz^&|8W#QYx}DQZXZ`sGvuKFU(}p~0NZX4vH1UMkXYZ`c6uTyl6uVAa z&edvwe`OKcOMpas0Z)Q%wWMohz~4Sc?Ac@vAIcQpw6|JPwV_!mpNq>;+;ZTQ|3HQ+ z`?bX+%NHYl1L}Ood||`eKNQV-T$eVtS~9fNl;3m5p~gi|w6{F*M!Ws3kJ|SXnhu&K z-+9PnKiz&9e3~xY+`JZLuW(%0P;|a%-fy-yqyJ7!G?^RnwupmGmP?z7{}!|3Nh(_g z83kl~%~*CzcUj6mkLrE>=0jrmf3&y$ z_fS2zOSL7t;RxD_Js7k(RU;hFVp50Mkk7is++c-#;%w1iGC7`Xv!rQjQ2wVkx;kc# zvUj$E9w)*jM^UyFeK_qrB_|y1W`&d96_31kF<;F_Rb?HQ_gnr58znhpD4>p z>?}ZCZ-c%L`a6m`s}L{FP3$bon%~)lID+HRrVkl^1rT~G*=sQ!JiU1yY`hS*UI3eS zG=Ee1dQ*$V^^Q&0-qyp(B`;$Pn3_))iiR)1AG27eRoHiq72j!fs;ceZgk zs*m(!EHB5~LdUEkVVyA!^|r#EN#@g=_hOui202C>&uzFHJWP<`^yYoCUOk=qYa7xi zeoCfD70LcFRQ{9mp07FOH$ewx=)wY>BtYgwv^}Z$^j3`jt)_$4(T$7A<~~nX`5x4Wa&_ahM5vPSJ#E?mPPjw;d9u$_Lvl zBhk018uigy$S2NiT6_|5Zs5}9m)bjt&l9j$@_iQpr~0p99Y{G^lN(G4N{;i9o>gmV zwhFPsI^x2HCd;g5;$t0V?`*PUbY#P4i{~#t-MBU5l*}`3V#7kDPjrlEXu2@Jxv|iE z@SosK)d!m7GXs*Zrt$k8__U(UUrU&x zeJNqO_TRE>x<>ZX2472UdCpSW(T2R_dr1dBKwQ&`4b3Tuq`M61bIRb0L~`|*XDrK* zHywF1HK*j68A&JqbdQWn)&5ID<*%r0)7H`stK>IQdwA=z4(f-8(cV_esK{9TZZ(!**5Uog)%pq#hx!LW-V>7y_5P;TxIWU1fL^P$0^!A z;AJMU=XzXZ_e?@)IG$=}y}|Aob( zeTn+Nf`7>>F2LNFs%=4iGzLl6TLH-)3IFdj)%R*{&D83VhQ(p(Q-B%b%ptOgjgkB* zS_pV2mpy_$K8o}Ypl>_o73y0r(pxbWDkY?T%+Tsl2gzV;gYL7@@2T0T_D-9%Wsb0h z=d{8H??RkF+?{|Ot{2gNCiJ5j{tRoHR|WhT=0WIZ7t5YW>zMQU_bD^s7s!`Uxz|yy zzqOCN0zC8`}CLw@1yXDOwtMK8(1(3bBXuwFGS+jJRyka_CX$ zvhl~4qFe_0#H3Xq-%FVHXuflR@3>S)=brOSu;&czJ*3fC{Q~){gY+?!E`1O2odCXD zUbgS7`fStT8_b366UDas|A=)|N=oaqPg&ZYJ(<$_Ox0)honj08sIbo(`yXPxJxP>~ zeOjzH4Hczx6U66NrijvPz?R{nG!=F>I6*XAVG5UAoMz8ow@)m+;e4^aZI~#%{rZc3 z_Rzjb$F>09Wx9CX0zkC0#F{8RfA~ySDRh$Reb2sg&#x|RzIBW^XD;e0OGF*0Yu{kh zku3I5-Z1K=dYaM3i6;A#(RFPb(p}d$;8fSgC~vImD%3@?{~9uo?5)CCMW&&Pnnt1W=&5Fl3w1@PW3Le~fB9t7D z0UG6?aZB}2M<5?~AMB~=`m)2HSW++An5wPgQFXQFd_Wwucc!4tzeA8~#$VoXsBG~Q?X8}-+U=9@9P!Va4{fP=qW#F5Z?v0Q zPPfw>vl?|(AbgGbs-&M#&kHm+QokLAFPSU-Z-$l#zR>Xj(K@05IzB-DrWs|`nn119 zN6Wma+Bb+VMV|0m_Ri0M--EJhT|oKLwN=PRY5zdlok%nCxRQB%g0#JsksXh`NGJUh9M&zCsGEZHL zJnp^ro!c-^J;d|Wt*K(quC&%?_h6p7G`;niZ3E0x+ta$|De_Bw%~OvX=Bc}Rp2|Re zntSwl>Q>BCzq@c&vv+Lod8!KYC*`Giit1@bJNufa^tx!CqPl3FGS;QfQ^(Hko~Qbf zo#rW>AI($XC+8_6KbohShIP+V{mO^AN#{rN6!D{Z%E(Wjr-%>s2KLA?5j9U~hIwik z&r`%BdY;l{i8W8@a?m_Qa?m_wl!N9eDnAo})`~P{PYqUf`d_d;viVOWOxOMj*e2(& zw2QiU4%4=->bMeqY90LjI{5n=Ohb-sL0jeAR!Hmq`IsN(VV)?&d{KaT!+~}G@8ujv za(p1?D^>4%vffnfO|I{G2~)HgGS4W;mMZ7HdytRDW5xqzhYuUlwj%$M81vnoi0^mK zvRDq%nw!?)G!I1PF8C#yyE@KM&!#b+dk+}*>GZW~Hs+!xJdf+o+n=MnENhu&e9rH? z?ACK4W&e({jh1sd(lM9q?|xSAr|gJG+1F5(^fVe}w{_2hG0W=nAhowu)|u83HGlRf zr|(J5!rlTsh?6f!X!na-bwj_!f|2oxmuXS|U&OEHg$C^@)Jq%kp zep z7I=>OzDcCMP@dELtYt)pTqFF^-kFR%G~e6`NONNvAgvdw$ab)oDH83?TTz#KuA})n zQ@aIuXn%v|2@7y~J~IKXI$z{9VNW>Sl&tt;4bqOX_ajZt_w#Q*WP1~Pne+eBUN-!n za?N29rw`3To&^Y0@3zRgBaQujI_ku}Abda7_YdSN!v4l!Ip^G3`b|vd3dISJdnQ``<@4OVQ;q)`hOVySl^SwF}*|QLr)*09sfYM z8+CwB1MSz)Gn4EOF`o9gcaJOkUV}jYn}(=8mN$@>bdilTapcuGA|X7d>E@r5SWC^# zO`h8UE!clKb!hJ|rZ-c+i$UR%TTrIyS9Q%zznb1$cKhumV&0{R)Yuy-#~#^I zqN%t;AA`fe>kEuOHTJNN6d8M&+7hJQJzPBPh7QKr#y4P3`T&C5CwPaSbx1$fHyxcO zll^%$Zi+kh&#-qs_m}n1fA{_%;YT1B`M>>1^!!eHTx#!7`j)bk$ev#|>TNn)+iWeC z`+g^^2QO%7+B%c={SL@|Kj<%;Y?b!?Ea|5Rx4IK)-|xW0eJKr1znalZ z*nDAY3HI5tG#}z6FE7Rx*5f$KD95%OBV{~B!ryC{`Ayfo)W=sNoz{D#%jf039?g%b z+H;oTj+1D|2M8acFJA^<^4CXD$5HeH?fsmDZW|F#W8xDDQ?xfE%+NlP5PL*`#=Sg! zz1TD;*sS*VvZNnrw5;qHfp%nKjevSeNJr!+L8OxHybV6OO`Z!}jh|HQ&%nP#`cRZL zmZLJqkVdk-B4LKs0@!3()gg6>H3ZgsopVjrW8?6Qy?221!%tDR-+BF2aj+A1OTLEm zl!W{wV}J9SK8G8hCrv0vzWBS$qx#-fYGSyA{3-3Xe%sk~Z#Y5B6#r@O+yY({mxXb- zD@lBA>gpPhhZ*a%kB!v%Lt~w}|3;k;kHy~jtma0G`QQcMLH)4@>qc5{UI&`S z%SJ%jm-|Jsajax^{1`IPGiLNysfJET)*VQfV+FkF*^|b}N!S^Ug%831OYoz<@7^EH zLVBk5pNJ>lpD63k)W!fRpQPTKk)5k~mG;inzTZIhXo6mi@1;_Bzqb&)Y5$zsslS7| zbVTfZYxs%X`uel&Y|;4(_yk%nO_S@Tlw%KL{oaVVl;XykQjS3{rAM$A^mnDV-2&^A zHsGsKC(Toz0VkWUBEIn3v^Kd0aiqJhp`!DHLeW6ulHL=(gfi+mH%0p!@{qjmQu(3S zdpC)^wzKV>7^k}@8pi29?4eP4<9;&jiNBewe8iu*F3OWiyihOkeFZrA2s7%Ww2wf? z7a2>n%bJOY+h&BoU)zv@bxvz9+bIjZ4h>sqWu!Ml3VS) zfrs43r~RwwcL}=w(j?C`$y0wX@DTWf*Vc91wRU>PPk_gg)o2q(xG!}1#$#PZ>jNq) z@Xkr>7}Br>a_e?N?fV&Y8MBSw@7>1D;G?&(8u-86M#-~h8`pr3-o{ek-?@z}pWa3) zYoj*W6MMBWX8V57yM2Y=p|>v&_`lsg$+KtsCWDXOzR|$HbNg66y?s=+&-P(1kM%y7 z#=Dqr@BH>`vFm9bvnKE%n-j^kV`wg?aeNBt`q=ye_%^e>6Z6aN%-=XV2V1Z|X0qO9 zoki;$^$bAik$AlKCx2EpoZf{AjdVyn|6$e+~J%2zh&_Ct;T@p!v^@*C|f z=O1r3J#qUX#~n|!KfL&@_J@z3Zoh)diy^ljYQ0r!Z~fyRu`hb+&^*M`d@0)onO*~} z-c6-zZR-)|R&lca zW%kZTQHI)MCtWwDHkd5xIYdKzs#V||0@iusE^cVEm}w5(O=~_pzjkJmtzymBI!K&G zo*mZDvuMq?d!i|=q3uEw)|;Xs03OsYUuBT)Q?)xmt2I%&_6l&ZWAzwn2l)$n&wCTM ze;d+BChD&W;M4+ddE!d?dyF=f9pKzGcgD6`N8iE1kx+| zZnt$yLPPo%LGL4W3x~aPG}5TA_TfB{U7jU6+Wu3>Mz%_AG~t;O&mR%HqZ&pdVT;5PL`t%u6LX($3SgGpLu+ zaG*`5QQy&+A)OJguaLeB^*n7hH#DNof3e&hNT>01G9tGcKeJiJHpEL^m=YUCLKjuw zk@0g!C-Ha}X|%Sy2D%uHb5&uB{mF3_$CIUqr!`mWuyBdVcIr?uXtj4i&sX%Ek%6^U zmR65C)EFMAy@foqo_a$&gocE_RC zVNbM+ia)hCF8Zu}ANjfAjqRqg)9oXWhh)(0V;JaIGR!c@upN0whFc|6GQ1=44DBXB zDMPc8VIycM!;qgGYW-)l43D;N`Q**^Bk;BV8rD;eW1wTnfm7p=KJm&rv$RSHl^lB{ zj`MjErfXHmPxey*n#O^$yL8rHy0#MW*{~Vwmc)ikwx9I>P_h>xjru(4OG3YcI! zuR|_skFKwMEH{l8+6VgpJepGNA-)aC-GXMs5qlAP(X@l5Solz-oxajXUJrExZNkA8k3SyWu$}2bJ{q5?+STaOe_|h;>chL{-->;A-@g}mN$%yRx}GL~LS?tX2k5@Z%4-B0 z-g}deroE?$;6wKCpU6x8y05+bzYG(p`nzQvM$b)Dmz+CcJM@e%*DLUca$RCk>k=A= zHk=m?Pkyt#<)6pf9iw*~+Lub>@XPiokU{B``X^1hh-KR?WrO}%#@_&v|6hgvAlZ%c z9Bf0%8S5Miy+b2gBD?(x?;MFo)LE98bDh3VpzI;L;nOd>?j=3PY_CVPcV>c^Q3U%D z3+{kite70A1klIKk2eyK(6mmc7Hm2XJZel6=s)s~`MHu(mVHBu%#h{_yo ze>JMi9b9G}$`GH~lE&FE?9oJ@f4CI=)Zcth&uAU6N9wBv?kn|vCBt|ovETlpvfEL% z5$}_u_H}l|TGQzJI#g~W%8~DldM6XJ+@)9x-GFr>>|+P-Y3|VXG#^e<{fWNVq3>zZ zUM9UuKZ^N-`knCKpgx*|)IKJ?2UBTS&+ZVdX_56T*=?r2p52nJ?+NX&%Cn1^9acK` zAf@i<^EL15{#*8x>>KmN$*z0Jo?_WZ zCfi6h+DA4Z``k9VVH`ih)FYnyyvQQXv6hKh=+^_*gLA%n-)|Wm>HE3hMRKUUxlHXE z(B${2zsL?=MEfFkfIY^>#MpLlGulIT(2VrH_vSLS;ouv~4o;vRvV$?Q{*l^Dz^W*p zr`y1H-J3J+!BCsd2T$tbwN~Yi^!=96XZ7_g)IC4=ARo;|QS(Dg`_SL3%DxrZ4XdG> zjTSM3<_OXy%>iU@cfc>w`oavky5|6OmWbv%vhypDkIE_kug6m&mj;nDftIG8_Cm*w%(NB*=IU3Xb`Dy(<_GNfi{Sf>#zsHX7#y&v9NPfq?3wyEK zV(F9DF*wI5*G_ihbIoKrH-_hl(YxFCrM9%28oz8mg*|KiEESzg`dao|s`ewuOa6W{ zU@TvdhVew>>U^YA`8K>q`5*8S@FD6P{Alfc(C^4+>d}}dGBtXpR6ZjkGEY>ae3m{> z{Pxm+!&hWPd_^93(7RscE3&kuplMv)3`oA>w~)i=EACEEzNIW`&bamq3vd)Odq990&LovPK=THz zX^rC{9d%GYICvjJ_Z`D99`tkTgZr=*fw4#DD1{i?V~pWUu;fYaielOruN~DoT-}5D zd`N6xMLu;t^iN|pYQH98*Pp z{~Pm#nFra-^r*bG{p7uf^U~Q4{oa-AL%hRyXl-_f#IZjg!LgT3cuTt+kIj^B`pAK` zsAyP<_L7a$`40KM1#6|A`;KfM;61jgn|A~UZ^E0w8-3Y>_iV{q?L~II%KQy7KVX#S zX(i7kQSunaCfSPKPEl3Wk&V21Tg%o~b&%ak8Zr^BkKHjCOR?Ymn6=hHwAX9Ws9p#3 zLg#j|hTl>Y{=a9Y*~UjTZy}Z4W<|EJLx_JJVb?i$mZ$fXf8u+7bhe4c_b}8WpV47!Cr|(DnJqZGxeezgThD_Ws7L299_9XO z5>Hc`sazN8FMH6wGuHbHs^@n|$9$%}C(q2KX;#!jcA0(Z`om)rac2PU+{X?U>)n7o zb!yViMqV1ck~e|u}zL~FR@lHYF2D#AIRw*b$^J%|$~+?jYqtp5ex|BW?IUbh<1 zniVd=_b5`wCYdiZ$%vw={@N!ru7!wsTgaXwC)wUr;rvd@&0K1&*tu% zcQ>hQ*9geQ&MXcwGpyw-C&lj_vZwG9|KGZPiY&hhm|(`esI?>7tMD$RP4&198#GRa0MMDHmb#dx9jsjb+59EPz#cdf<%(%h`h zcVnLcHcw-N&VU>Dg2-+^!26ACjLU_`Hyw6~!gj0a*82y4dHvzJv+T#pAv^KELCkBP zczpBWiD?gI;eO?MFY5XfI$Jp6*89gftlBeH<=gRICg%ISa!c#8DW{4L=OJu0wLSYv z67B^Zx66A$LZocRyWT&?ddG7!_*VVhzVqlWM04ADg5H<@S(s9`-+(oEto+oT59K{G z+PkB3_H^&eg#A_%)=3FC+nT67hO%YYYoq526KrOJ)EV>#{_Y#euVnE4Y#Pd^;@o7W zHWPVh-uz15UCz{w%Vz-W;R5c(9;FR>qP}TG-A7R8BZ#+mc0K(k_!!vZiQrmCr+WzI zLhvs}nz3)bk-Svj{2BR;J_nz&IhNNFzQ0u7-;>Y1Cq&u#wAX;MVRIXfpXj=G3(1VW zc#io#2uS{o`rF!h!(pq*bnve6re?A$8|{5mIeOe7?t0aH@RUChXOfd}HhB=p5KhR{hgic+DKQ|9-mHMfb>P4r#m4+`R2&`%W+4IU{>*8znmPk^h51qI3D6>oLbeelpe$83!M;kY9nUui$+9G|W$Kqr8e2o%ttubY3)H z(f&fpnSVUfhI{g39jlIc<@u5BJO@x8<-z^f{JrMPW38z3p-WdC3j<~j5)FTlb60nM z8u!1Kaccbuzq#v8&|kuKTT>j(A7J0$XQ0VO^;~t}*H=NFPtZ5nBevadbqvwkQbgy~@M)L9{vTN9==>Dvt4=RI z?6QnHhBKh0r(i#=IA`=JZ26RB2*!?jZlv{lJ=!Jha}b@AH8s$Aou;i<%RM)eG3{*e zH2D&GcY}B2yH=RQOk?>Kh@)r3EZ8B{H|}&-X@GPFn^wyX3GlyzCG$1J#yqV5){_esl;m*hBtHhi#MG`G?>7!cQL7Bk4svc@@@ zzYp7S4JtZT0DJr7;=<-xgYkYpEo*zmM04|}zp*zDooGQD=|0e4*ekt9N!Ko98Me`W zJkCBrzsJUX)m2&mIX(d2D%?vlVf}#TloEQszZJIF3VqU@2CSz(Z@u@StRv73rBVLP zoZgCb>dS2OWt$LB?-SJabnQ;mr}tA1>Y%IddEcsSCr`rjeCjW6B z`n()IkL05>xg^Ib$UuFPVRJNpi}y`kNkzw2!zcV~sOa35k+D71ZEhavwKUT@i}bjb z^a$CiBpj*X{wD4$Vc!QjEP@@4HI0(~kNU7#_F=mA1DSU;<{w%wD*yNO)kU!R%bUMl z2mg05W(f9wH=sYuapu1jws!=+NN*4PSFiSv91EB?$%FS}GwwYPXBfC0)VKFUwS(5W zr_heSvJdF)mr;BsV>H&|N+)|c5B0aPU+LZ$zPlv#_960g^GJ{IzzA49=?Gf<(Gx*#vM0DZ< zX{pkt@*U`>H0^&7Pw#TDHkCFKy}lwnd~x;|JY%Wve$d=}E7~IPy$w^My>t99d-GUf zK4uez-?XAGoRKdfzj8nL34HQco)1KwHpG)3{bEo-2l>%(lB3!DWmiTK>0x2-d#tTv z!zDw(gVu&}-rw)&{4day-|86O<1VY@v9vc2sblqS;{d(e7=r$d@XK*@zOM7z(vzPR zZL7jFyFfpZtj4*9=9p}xoAHg4aV^=~NlrRnLU#k606&y{vdY~3ofDmhzBfSMLI@!r zt#9-*OtdfXlcb*KPIn>SHtbVQM0=z6eQEE@I49X4PZ{K;^y_3?<4s>FyE>A1@)jjP75~|IpX%o*8-1Uu z-?E1LRNM5jTXQ3KvHC5Gdq4U;uKOcp^ZG2S?9U7Qdo1QYM+57A5ZtLuKBF%-m)QLMgx2Vof*!P=ZMw4 zt2?lUR_BPj?_EjWxOX)UeWBmGdJ6oE_pWeGaF-ut>E4wbC%AVd{X!r2t{RZP4mL)6 z8pnV){W!#XvHE@?zAJ@z?5&&_hj%2jX3T`#G+wshOjRs9J_(;pwoJA~dzQaO`8k*q zXwUMuz{$4Mdvx08r85KSohf}ArUQG;#9O^bp>&m|_APNIMvk#j+9B|vxO*i`(cZ*e zuxD1}(3$%abk6z?#1o%E$WQg&1)A)=2Ls zh#5`iiG!nX?@IDNXz#2=JiY&;w$NP{x|7w~>ps{>Jj5!QcFf1;L||1N$V16 zD?+|6K0tTEHvhBxPT1whOM0==or&*wCyaC(>rU8<&;h+ip>^Ez63^7~kr%vA==Z@E z;%viUoO!dku{VdiVZT8>;)vM$d?8qI?@ z1Ckw#03^F8qIa=$H|+cR-LQ1+N8m~Cq|{l8OnJVL{Qnx}af;>bfUR^&oStjaH9v6T znN9k69q0c*)AL!F>pTgXY>)UJ<8sFV(O-z8{rHjERZ?Ds7f2lIcI4^4AC{)=0zY{_ zEQ{8pDcXNaUYXish^PBu@DB%s@(<`kbuaVR$fNrQ+2$Vi!!opmNQZxr_cFWhhtU~A zi$->3ydQQ8^3och+ehf{YtpqpAdSXI2KoTsqIw$s0($}D@r|DDdt!^usP{>vC;d#t z3sa4oq~9iQE(OfESHvI;%sr`a5|~Y!vh%&(j>R z@;$NrxSL610^{ZYpR?FaYmxsruG)AI>9p5FI(7Mb1wry-u6dwwWkn&yyjq_#@J6seyk+&k0nh0&NIzuSFRY?P#*!d)?H&&^zJ zE8vY-yU9LcpZ*KT&HnsA^t#00&kqQkOUgz0|3Z*{n)-4}?{i5QJQ16xb8j^M$gf_E za?t&OSm%;{9#y7_%Zx!8viDR;kJ6l=>0X-bJFHzM;(ZR?OLJpSnDX`a&Twb~o}G~A zEb!3B=?EL-TOdI>q>o{SqhrguC&g4 ztRqPVn$Kt+9}AppN3A1iO+f2NFdaYC{Oqx2~)I0z#_Gtjp6_FHRxz95BYAvTJu@8);vIK%`dpF zYNU~D_+XCoEk)$#9O}J1$wTtexTHDGi@a)o4tx1XBUwHK+(>IptYxyWo+SS>8ZaAt z==(>9k-iz_s9$@pHPhu<^L*r?_Fo{OzScChpY9{$8I^s+ACRx8kG19x(7#wC%C#o; zBM!Wi-o4hu+(&E80lq(wjDK$B~!TU{;!| zzvEhybQ^1}IS%nO@6mqb7>Uy!VwUy*aPY^x)%*Jcsm#kNzdu0t(y`ZwI5k&fYB)3i zo=S)Jp)aUEvF`(zq8$gM{@(;hwpS$U=()!H(4_Y}*=t5*jrk_?D3>rpb4#e!m;Z?} zk#T_a$&U_Mga7iE8q<{V%!Sk;5&294h?#QTh=1A>j)~`A%L(iOs zb!IDkEXD`z-6cW>*v$@W!(|QDvj2^BrejoOo%v_fsr*5vHXeNSb>!s>u*i>7x-fufA^wpeH^r6Pmsn8jn6v} zPiwljs4Y=8WR&sfC+d8cTvtY&$)t5P)q%g~0exj^Pf9pi+bAJ?9Py5^CjBmJsl_O3 z+>6}Ry+_z{E^@0o3i>`D?Fnu{-b=CH8rg@${`Gb#6Zv>$x3mwr4myhDK^eT$K|AnW zS3avuc~&D&tg~Kpri^4tciU%e!`>y{J0JM^2}kEAz_I^#o0ZR4mf^dOa?X{0FR5WL z;6&4)2HZDXPw%|wj#Br1prh>%qmO6|2;32xDdkc3N3gjL+0rx{$BhA`dL#AmeHg68 z_E3LsKsx!sEI_@_M*w%?o$xDWA;0lR^CI;_ctE(_mQOEtH0>^{(D=A zquB<35WQb(>_f_f@zyI(_NnU*kDVnxUx+<*>OU*i&;D<^GJFU%2zTHe#X7*-0ku__YT7dINr=0l&q7-)6vf8t_-}o=lhXkO6azh}UY8t{(|_&o;x_Z#pB4frDl{4oQr8St+R_%{api~%P<4>Lk#$E1AdMH zzrcWBWWXmI@Fxs)J!!x{HRSosfVUg)uMGG%2K8BJ*5xbZxWR0FAk92GC=xk1Ek~qVZZgiF+lo(0n*8S&x zXAY1)dVqA>0O=D3NY5T1ebNBwIRm8U4UnEcK>B3^q}vBbpEp2y(E#a-21qX+AiZ>e z^pyjoUo${@*#PMk1Eg0CkX}7NdhGz|p#jo2^_`A$Ibl2lh@4x6cx;dm`M>Mxs=(g} z{w46s{@B%32K=YM?Qe8-T?70lz_$Tk349CTI4oESybgHTTU}knz`ej90RA}eGdTE> z{Fkn-cee?#6!E)&)AU63;+&+&zDyaVzjWj3)q3fizYh58jBf%ypYagzS&Y{L$9os~s|G%Y z@hae-;qVy!s&B!~dB#^F{vF0kf#ZFP{1pR#nej!ypJcoU_+yOE1Aaf_cHnm~ei`r^ z8P5m4f$==xYZ=c0j(36bHwkzN=GoM^HW6JQhKan?B25636F ze-=i$+XN+2v9$<;5kd@$iCUjN` z=W2k}*Sc120`yPKUE*|A@Lxl81wA$H+A92w$3j^7FHvW`(|I{^FZ5K_yM)tSZ>~kk z<-#7JWK1m zAzSu3kEb@sENp=~pAQ@-${aHN0-H1BueCX)3JlDF9=&%v$J>I)Q|qaSPQO!6XTCkt zSv1u;TZOm6cVP&e@K+sGKG{B7sLG>DhYqmePcGUj@RuvS)j?Z!4SLmf^p>4AXO(}W zUKjo<K?Pz1GN|%$FpSR^8>Z&jb7tq;b7#mzpxybG8q!d)%4P@Of}T`&wxxMG zH)OLyCJ=DflV~I@$M4326~k+R!{gmFa8p$--o}J zJie!%dXgFAiW{EcaK_ z5F?eaemI-EdkoI5;1xlU+gDNT30~=`pDjwyn^YnyJnrghf4L|%l%G^GsZaz^!X5Mo zw;T~7=m}lr2?p_JmPSpcF5k4tRRPX{y7G{b%?fXI^#ZTY8?0L7uJe^w z!BwDv#x~8Bt?~w;vrYA)bSc`7*-*GEDrh}IvZ3!QR?weEuXUHB-A3(sE9QC1VdC)V z)Tq_f9$MUZP)h!Zb+ytda0jAGsg{*^D!uqi?12UTK#8ZuAMzM&DM)RU!?veVp_dX* zxd(rjxp$cuIR~m?o@ax%+*9gb=kfI_y{s1Y-m~|5G^9!z4T-P3K~DyLEN>QgtDy{| z%Jf)joxd)$Y+>x=VDIQsPfcxXewb9F^4DTc2=&e!i4M-*i29wL5G<=QIGgORn2Hp; zeV*zlUUhIz^Sl*~@^VjYsIb~!SAoClEj(_33=!A05_r8C$7=dnAvuJmB&8iroQsjR&^b zQyIwBYc%y_Qsk}ZRXIk7x6&6|uw}lg7$ubD_0Z`&fB8C^RC`3y5_%cjL#WyV!J=%9 zydeI9xu=3=$Zq{?fLEcB+JjM#yyP^LGMD+>F|%VmTUTA-s0;aP;K|D2D(e^d0le3G z;6mrGuk&tjSEHYLYEP9?Ua!PMBVl%xJGk5%^g^5!USEZOW2^%GHd65{^#p3XKIwT3 z-4T`MScCptuwdr=vGSXWd{`5ry)>c z>^4wJ3>%Tr(mkO8=mvdOT3tu;Sg|`)C05sk@Ng)~tKGq%z*Izk3_e)u*XPFB0)`Ha zk=Zyf@IzIzX}*%3<_=*|iSizF2Tl2?NM{b@24aSW{c=p%r0d3ajv}uB)8q z36=-EwUmGfbd9^NI&_uWhq*BTvO8%Fs+_+Gu0O~X3yM*!HDE6}l6ry&M| zGXxpwv036veAV?>)wrdbDD}^$wvtgGuExK?L#qJRIxjZhg7E-X0bA;p8FBPr%#Y!q z>S~Aqo`Zc13jKAy5G^gP@>jU4MX2=RgKOH*1Wn>MpvW`?+6#@^zJl3TLQIl-yjjQrEla@fd7~RYtBC7e&Xhr;a_r6J1?h zg}@tc?CSbEQXdCSdw({BbOgd_yBYWS1?{=v+c+Zo!LBaCs{vC1p97@$lN_J_kcz(v zkm6qir2L{$#V-O(26+?17k{DXie?2L__cx`{zk#P-zs?79t9s|IO!QhUjwK*&d^)3 zUuq*m^{r!FT?Y^bVKZx?j6o>6-~qmkE&fIHyw zM^N9}!JZK9vYN3sngG5D_}i33C!CG25{)kdLE)P9_y@l-`EU0>CXCw3&@d1Z0 zr50eDcY#Q-UM_}QgKg9b?4H&Z2~$&%5N$;wvB)XR#ZHm9>l$Ht;~HUp`x?9>_<^uY z`k^rA{ZN>$`=Jo0fa5M_0&K~GVp0)gAM`U_i(^!q#+Z`N8Y^f6D-)skWpoaH$7N1L zl$~{M@`I+IoA0vhO1LNS-lY2m{nUE@;GYeFq9!1D5km5-s!SQM+%wVm8$}Y*S!tp(7}7aaqDL~!VmOK+ohhdDa~b0ABT5>_ zcqOzk9LI1x!}A!@xmGHFKEn$bUdS+;p}NhV9KML@iy2O0NZ$@2KJwB$p!$SCa+uEA zQhYAMsSKwv%wss6A-)leUve1Vhm=rV9#0PA7_p>rj99`;8LG3?$>BLne~;l@hIWPy zh6M}@8O~!kpWyKt)$ zcp1~n8LnWslHvCmu3~sKLmY#~FFE`JhByW-X&1vXhHi$d8J07wU`Y1^h|d~^l?%4Arsqie)}!%~V3ilI1{5784lSwkm#G z2tdBDgge;Z;QKq07UGhS(kPQ3$nSnI0r1f!!P3Iw*^3rBOXrhG9X}}*Y46Sv)sBij zVsc0^T~^n@Av@x=#Niy_d++8 z%6#&B@aypzk8U0QU%*Zb(2hrCJJ3IJz}UMbit67zeY7`|+j&`!_9lzs0ra}rw^P=h%lI~kC9ewbdz|zUWy3?-iwNr}v+3q4}Q-u7NN){XFo@h_LHd)ewEKPh5@W!2#KUu7l_X%zC zk5O3>vzya@(MS4ooc^;N#_~Cl@_N0-dxey9;!P@lAwzE;^bU^Kp%#_@m8kSnOpm-- z@xPSeT84Kre43%ox0UHZx2SSD)aAN_<7*f;GJKPvo=@lZEyw@=3vKe=cmkSq-JPmk zZ*NoZ2>0i^KUVZ5ocDA>SsIYXU~FN)sC z^bHJkTCe{xj@RK-w!gC)RC}J__Fcj9zsl*)GW;Jde}>`zaK2r4sPYdnY+?8@!!fLv zSq!gaSjKP*!>1X(z;F=PJA&bb48O00mE|6U%~b6V!0+UT+8qe zoc~Lv=Wzb7SkAMVPGh=+;UUid-(3H5Y-cC9-k);5;cQofIDQq=GnxLJ>%WofIgisv zar%0uuVUKH^edeIL5BM{{ceWuv0XmK`PXs(U(R;7l3|G98!YcXxZGC^ALsHNoNpZS z`HI`SjnlJr`;zlka>O6$en|H}%J104O_(^W=u#VXtKp~Sy=2iMKd6EEN`5LKSyV}A zH{|Sz?z)obWFe;qeCJ|6&s);1ftJ_ug)OX?8Y1t>+jUaPF$h#976~6CTwoE2*H}c-HiY8{8xlm)yhM?7Bf>Wbmn8urTsBA~J&tge6?qVTk8l!U z{9uvvBEo_pBI!wlD~5`sw-833C6ZPlyoOLVOeD1;oER>W9ynVh{RshgE0gjORwLY= zEC#)Ra1`MT!UZG5pm_*o2-l7f)>jbDAe5dXth*4NL&!`K)|(I#QiXL5LI~kDghqr< z5mM8T7vc8^M-k?w3u_6&D+s3$)@BIneuVT)VZ9dNMTF2uVZ8&P72y=Z2sa`89^rzq zV(5(suOJM!iJ`L*HXuBT@F~K3;}ADq44rVE82Ti_4<~>h!pR9@(B$*Qpwjb&^+v#* z=L_LlQgB6~t8kIS>2ytSEpfUkyun&`sJzOxzRpwU5iVCjwZD9wtK45(A0YysYL7ce ziHn_ueqVXO6Y@Ai?(%gqFQQ%LRqKMGb*>6`V58SZ5kX3(XxAFIx4LIcWt}@vA-Zc0 zdTQLYRsMj-1RIQlMwuFSHDqxGz1JeD##2+{-vDH>(@|R+)egi}djg@}v9-0; z-g0-y>xWQ1TE5u1u*O{pc2Ticd3<#-BNv7|HN6s`giu|uz#Zt7vcex&2gR26uA!*3 zw0K#`q7qNA*6$;wMoEA)rzaSUC32~^#)GOR1aos1`0IQXvSGP7k{h}&7+Rh?J%l{| zy3j&j$P?J$u69;z2=-rMmTPsuTT$sL^)Fey)>9txZtxU3{kc;YJG=Q7)-ULn$+Wsa zb#89CyS&O%?1#qdd&%J_cT5An z!b|o9+pI5;idzuy*L16{-a*>STkJHJEp%2ch*7^W%ktj!Po0Wk=-U9pUhJG72>1h~ zehKP>IrDQ|wa{c?4snidm{fG`^flgU4^+Ow8>%XHhpHf#v&t1q$0~npJD4uJDn|dy zLNkJ)fY(W%H zPNI0_VW_*iWp_F&9S)_c-i?UehVEA8)rHpNM+QVxMcvs{mpR=%)frn87|)JCz+JB^ zqGzG+st9-erpulF2(O4L>U)Zi51rT4pbH}-KRDIvqpr;D=?q*UIWn9*%TLV-cxtQN zHn@b93Awe+?WB zoJypd>t*H6)xE4#@h7fPQ%3>jgzDZi3e`~L?lb8sw+}O6?3LKhR!5sUe0pIf(VB`^Sko>Yt{rkp#t#cGEOgBw6T_%dM(u2 z@_@HClwa!)$|*{3PDJuZt+OCy?hGlWYND|=wqyv|UEyM-SZ8T)MKPoG!K=y@8k{

QAr7DTGZ7T1tc*(WSQiLmJ&ad{=z>R|nU0YqJ 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 literal 0 HcmV?d00001 diff --git a/PHOTON.app/Contents/_CodeSignature/CodeResources b/PHOTON.app/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..d5d0fd7 --- /dev/null +++ b/PHOTON.app/Contents/_CodeSignature/CodeResources @@ -0,0 +1,115 @@ + + + + + files + + files2 + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/server.py b/server.py index 0579043..d7368f9 100644 --- a/server.py +++ b/server.py @@ -227,30 +227,55 @@ def resolve_portable_path(path): return cand return path +def read_journal_deduped(): + """Read the journal into {abs_path: latest_record}. journal_write only + ever appends (fast during a tagging run), so a photo retagged more than + once leaves stale earlier lines behind — this keeps just the newest + record per path so counts/search never double up a single photo.""" + latest = {} + if not os.path.exists(JOURNAL): + return latest + with open(JOURNAL, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + path = resolve_portable_path(rec.get("path", "")) + latest[path] = rec + except Exception: + pass + return latest + +def compact_journal(): + """Rewrite the journal keeping only the latest record per path, so + on-disk duplicate lines from past retags don't linger forever.""" + latest = read_journal_deduped() + if not latest: + return 0 + with open(JOURNAL, "r", encoding="utf-8") as f: + line_count = sum(1 for l in f if l.strip()) + removed = line_count - len(latest) + if removed > 0: + with open(JOURNAL, "w", encoding="utf-8") as f: + for rec in latest.values(): + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + return removed + def load_journal(): - n = 0 with S.lock: S.cat_counts = {c: 0 for c in CATEGORIES} S.done_paths = set() - if os.path.exists(JOURNAL): - with open(JOURNAL, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - try: - rec = json.loads(line) - path = resolve_portable_path(rec.get("path", "")) - with S.lock: - S.done_paths.add(path) - cat = rec.get("category") - if cat not in S.cat_counts: - S.cat_counts[cat] = 0 - S.cat_counts[cat] += 1 - n += 1 - except Exception: - pass - return n + latest = read_journal_deduped() + with S.lock: + for path, rec in latest.items(): + S.done_paths.add(path) + cat = rec.get("category") + if cat not in S.cat_counts: + S.cat_counts[cat] = 0 + S.cat_counts[cat] += 1 + return len(latest) def journal_write(rec): with open(JOURNAL, "a", encoding="utf-8") as f: @@ -273,77 +298,117 @@ def trash_or_remove_file(path): log("error", f"Failed to delete file {path}: {e}") return False -def remove_photo_records(path): - abs_path = os.path.abspath(path) +def cleanup_organized_symlinks(abs_paths): + """Remove _organized alias symlinks pointing at any of the given files. + One folder walk covers the whole batch instead of one walk per file.""" + if not abs_paths: + return with S.lock: folder = S.folder org_root = os.path.join(folder, "_organized") - if os.path.exists(org_root): - for root, dirs, files in os.walk(org_root): - dirs[:] = [d for d in dirs if not d.startswith(".")] - for name in files: - p = os.path.join(root, name) - if os.path.islink(p): + if not os.path.exists(org_root): + return + targets = set(abs_paths) + for root, dirs, files in os.walk(org_root): + dirs[:] = [d for d in dirs if not d.startswith(".")] + for name in files: + p = os.path.join(root, name) + if os.path.islink(p): + try: + if os.path.realpath(p) in targets: + os.remove(p) + except Exception: + pass + +def cleanup_thumb_cache(abs_paths): + for abs_path in abs_paths: + try: + h = hashlib.md5(abs_path.encode()).hexdigest() + for sub in ("photon_thumbs", "photon_view"): + t = os.path.join(tempfile.gettempdir(), sub, f"{h}.jpg") + if os.path.exists(t): try: - if os.path.realpath(p) == abs_path: - os.remove(p) + os.remove(t) except Exception: pass + except Exception: + pass - try: - h = hashlib.md5(abs_path.encode()).hexdigest() - t1 = os.path.join(tempfile.gettempdir(), "photon_thumbs", f"{h}.jpg") - t2 = os.path.join(tempfile.gettempdir(), "photon_view", f"{h}.jpg") - for t in (t1, t2): - if os.path.exists(t): - try: os.remove(t) - except Exception: pass - except Exception: - pass - - if os.path.exists(JOURNAL): - lines_to_keep = [] - with open(JOURNAL, "r", encoding="utf-8") as f: - for line in f: - line_str = line.strip() - if not line_str: - continue - try: - rec = json.loads(line_str) - if os.path.abspath(rec.get("path", "")) != abs_path: - lines_to_keep.append(line_str) - except Exception: +def remove_journal_entries(abs_paths): + """Strip every journal line matching any path in abs_paths in a single + read+write pass, instead of rewriting the whole journal once per file.""" + if not os.path.exists(JOURNAL) or not abs_paths: + return + targets = set(abs_paths) + lines_to_keep = [] + with open(JOURNAL, "r", encoding="utf-8") as f: + for line in f: + line_str = line.strip() + if not line_str: + continue + try: + rec = json.loads(line_str) + if os.path.abspath(rec.get("path", "")) not in targets: lines_to_keep.append(line_str) - with open(JOURNAL, "w", encoding="utf-8") as f: - for l in lines_to_keep: - f.write(l + "\n") + except Exception: + lines_to_keep.append(line_str) + with open(JOURNAL, "w", encoding="utf-8") as f: + for l in lines_to_keep: + f.write(l + "\n") +def remove_photo_records_batch(paths): + """Purge journal/cache/in-memory records for a whole batch of files at + once. Used by both single and bulk delete so a duplicate-cleanup of + hundreds of files does one journal pass instead of hundreds.""" + abs_paths = [os.path.abspath(p) for p in paths] + if not abs_paths: + return + cleanup_organized_symlinks(abs_paths) + cleanup_thumb_cache(abs_paths) + remove_journal_entries(abs_paths) + + abs_set = set(abs_paths) + removed_failed = None with S.lock: - if abs_path in S.done_paths: - S.done_paths.remove(abs_path) - if abs_path in S.files: - S.files.remove(abs_path) - if abs_path in S.video_files: - S.video_files.remove(abs_path) - if abs_path in S.failed_paths: - S.failed_paths.remove(abs_path) - write_failures() - + S.done_paths -= abs_set + S.files = [p for p in S.files if p not in abs_set] + S.video_files = [p for p in S.video_files if p not in abs_set] + removed_failed = S.failed_paths & abs_set + if removed_failed: + S.failed_paths -= removed_failed + if removed_failed: + write_failures() + global SEARCH_CACHE SEARCH_CACHE = {"mtime": None, "records": []} load_journal() push_stats() -def delete_photo_file_and_record(path): +def remove_photo_records(path): + remove_photo_records_batch([path]) + +def delete_photo_file_and_record(path, skip_records=False): + """skip_records=True defers journal/cache cleanup to a single batched + call afterwards — used by bulk delete so N files cost one journal pass, + not N. The single-delete endpoint uses the default (clean up right away).""" if not path: return False, "path is required" abs_path = os.path.abspath(path) + with S.lock: + folder = os.path.abspath(S.folder) if S.folder else None + try: + inside_folder = bool(folder) and os.path.commonpath([folder, abs_path]) == folder + except ValueError: + inside_folder = False + if not inside_folder: + return False, f"refusing to delete outside the configured photo folder: {abs_path}" deleted_disk = False if os.path.exists(abs_path): deleted_disk = trash_or_remove_file(abs_path) else: deleted_disk = True - remove_photo_records(abs_path) + if not skip_records: + remove_photo_records(abs_path) return deleted_disk, None SEARCH_CACHE = {"mtime": None, "records": []} @@ -358,32 +423,26 @@ def load_search_index(): if SEARCH_CACHE["mtime"] == mtime: return SEARCH_CACHE["records"] records = [] - with open(JOURNAL, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue + for path, rec in read_journal_deduped().items(): + try: + rec["path"] = path + name = os.path.basename(path) + rec["name"] = name + rec["_ext"] = os.path.splitext(name)[1].lower() + rec["_hay"] = " ".join([ + path.lower(), (rec.get("desc") or "").lower(), + (rec.get("category") or "").lower(), name.lower() + ]) try: - rec = json.loads(line) - path = resolve_portable_path(rec.get("path", "")) - rec["path"] = path - name = os.path.basename(path) - rec["name"] = name - rec["_ext"] = os.path.splitext(name)[1].lower() - rec["_hay"] = " ".join([ - path.lower(), (rec.get("desc") or "").lower(), - (rec.get("category") or "").lower(), name.lower() - ]) - try: - st = os.stat(path) - rec["_mtime"] = st.st_mtime - rec["sizeBytes"] = st.st_size - except OSError: - rec["_mtime"] = rec.get("ts", 0) - rec["sizeBytes"] = None - records.append(rec) - except Exception: - pass + st = os.stat(path) + rec["_mtime"] = st.st_mtime + rec["sizeBytes"] = st.st_size + except OSError: + rec["_mtime"] = rec.get("ts", 0) + rec["sizeBytes"] = None + records.append(rec) + except Exception: + pass SEARCH_CACHE = {"mtime": mtime, "records": records} return records @@ -987,6 +1046,8 @@ def process_loop(settings): ocr_model = settings.get("ocrModel") or "glm-ocr:latest" organize = bool(settings.get("organize", False)) integrity = bool(settings.get("integrity", False)) + with S.lock: + organize_folder = S.folder or DEFAULT_FOLDER photo_prompt = build_prompt(length_key, "photo") shot_prompt = build_prompt(length_key, "screenshot") @@ -1034,49 +1095,66 @@ def process_loop(settings): stop_pipeline = threading.Event() consec_fail = 0 + def should_halt(): + return S.stop_evt.is_set() or stop_pipeline.is_set() + + def put_until_halt(q, item): + """Blocking put that keeps re-checking stop flags instead of hanging on + a full queue for up to 60s when the consumer has already exited.""" + while not should_halt(): + try: + q.put(item, timeout=0.5) + return True + except queue.Full: + continue + return False + # Stage 1: Downscaler Thread def downscale_worker(): for i, path in enumerate(pending): - if S.stop_evt.is_set() or stop_pipeline.is_set(): + if should_halt(): break while not S.pause_evt.is_set(): - if S.stop_evt.is_set() or stop_pipeline.is_set(): + if should_halt(): break time.sleep(0.2) - if S.stop_evt.is_set() or stop_pipeline.is_set(): + if should_halt(): break try: t0 = time.time() img_bytes = downscale(path, max_px, tmpdir) b64 = base64.b64encode(img_bytes).decode() - downscale_queue.put((i, path, img_bytes, b64, t0), timeout=60) + if not put_until_halt(downscale_queue, (i, path, img_bytes, b64, t0)): + break except Exception as e: log("error", f"Downscaling failed for {os.path.basename(path)} :: {e}") - downscale_queue.put((i, path, None, str(e), time.time()), timeout=60) - downscale_queue.put(None) + if not put_until_halt(downscale_queue, (i, path, None, str(e), time.time())): + break + put_until_halt(downscale_queue, None) # Stage 2: Inference Thread def inference_worker(): while True: - if S.stop_evt.is_set() or stop_pipeline.is_set(): + if should_halt(): break while not S.pause_evt.is_set(): - if S.stop_evt.is_set() or stop_pipeline.is_set(): + if should_halt(): break time.sleep(0.2) - if S.stop_evt.is_set() or stop_pipeline.is_set(): + if should_halt(): break try: item = downscale_queue.get(timeout=1.0) except queue.Empty: continue if item is None: - write_queue.put(None) + put_until_halt(write_queue, None) break i, path, img_bytes, b64_or_err, t0 = item name = os.path.basename(path) if img_bytes is None: - write_queue.put((i, path, None, None, None, f"Downscale error: {b64_or_err}", t0)) + if not put_until_halt(write_queue, (i, path, None, None, None, f"Downscale error: {b64_or_err}", t0)): + break continue with S.lock: S.current = {"path": path, "name": name, "idx": idx_offset + i + 1, "total": total} @@ -1116,9 +1194,9 @@ def process_loop(settings): desc = f"{desc} | text: {words[:300]}" except Exception as e: pass - write_queue.put((i, path, desc, category, route, None, t0)) + put_until_halt(write_queue, (i, path, desc, category, route, None, t0)) except Exception as e: - write_queue.put((i, path, None, None, None, f"Inference error: {e}", t0)) + put_until_halt(write_queue, (i, path, None, None, None, f"Inference error: {e}", t0)) t_downscale = threading.Thread(target=downscale_worker, daemon=True) t_inference = threading.Thread(target=inference_worker, daemon=True) @@ -1162,7 +1240,7 @@ def process_loop(settings): else: write_metadata(path, desc, category, keep_backup, preserve_date) if organize: - organize_alias(settings.get("folder") or DEFAULT_FOLDER, path, category) + organize_alias(organize_folder, path, category) dt = time.time() - t0 with S.lock: @@ -2120,16 +2198,19 @@ class Handler(BaseHTTPRequestHandler): paths = body.get("paths") or [] if not paths: self._json({"error": "paths array is required"}, 400); return - deleted_count = 0 + deleted_paths = [] failed = [] for p in paths: - success, err = delete_photo_file_and_record(p) + success, err = delete_photo_file_and_record(p, skip_records=True) if success: - deleted_count += 1 + deleted_paths.append(p) else: failed.append({"path": p, "error": err}) - log("ok", f"Batch deleted {deleted_count} photos from disk" + (f" ({len(failed)} failed)" if failed else "")) - self._json({"ok": True, "deleted": deleted_count, "failed": failed}) + # One batched journal/cache pass for the whole set, instead of + # one full journal rewrite per file. + remove_photo_records_batch(deleted_paths) + log("ok", f"Batch deleted {len(deleted_paths)} photos from disk" + (f" ({len(failed)} failed)" if failed else "")) + self._json({"ok": True, "deleted": len(deleted_paths), "failed": failed}) elif self.path == "/api/dedupe/build": with S.lock: @@ -2182,6 +2263,9 @@ class Handler(BaseHTTPRequestHandler): self.send_response(404); self.end_headers() def main(): + removed = compact_journal() + if removed: + print(f"journal compacted: removed {removed} duplicate/stale entries from past retags") n = load_journal() load_failures() load_phash_cache()