From 5063bbc8c89b4f759672b59f779e09a9627fa07d Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 14 May 2026 04:27:46 +0000 Subject: [PATCH] Add Windows GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mediahive/winmain.py: pywebview launcher with threaded uvicorn backend, - scripts/winbuild.py: build script — downloads ffmpeg, runs PyInstaller, makes zip - mediahive/config.py: TOML config persistence in %APPDATA%/mediahive/ - server.py: POST /api/change-folder switches media root - showreel.py: suppress console windows for ffmpeg subprocesses on Windows - protocol.py: add ChangeFolderRequest struct --- frontend/src/api.ts | 25 ++++ frontend/src/components/Header.vue | 25 ++++ frontend/src/styles/main.css | 22 +++ mediahive/__main__.py | 2 +- mediahive/assets/mediahive.ico | Bin 0 -> 39166 bytes mediahive/config.py | 48 +++++++ mediahive/hivescan/showreel.py | 29 ++-- mediahive/models/protocol.py | 6 + mediahive/server.py | 84 ++++++++++- mediahive/winmain.py | 220 +++++++++++++++++++++++++++++ pyproject.toml | 8 ++ scripts/MediaHive.spec | 98 +++++++++++++ scripts/winbuild.py | 133 +++++++++++++++++ 13 files changed, 687 insertions(+), 13 deletions(-) create mode 100644 mediahive/assets/mediahive.ico create mode 100644 mediahive/config.py create mode 100644 mediahive/winmain.py create mode 100644 scripts/MediaHive.spec create mode 100644 scripts/winbuild.py diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 452837e..bcb4bd8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -87,3 +87,28 @@ export function getCoverUrl(coverPath: string | null): string { return `/api/media${encodedPath}`; } + +/** + * Invoke the native OS folder picker via pywebview, then switch the server's + * media folder in-place and reload the page. Only works inside the packaged + * desktop app. + */ +export async function pickFolderAndRestart(): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const api = (window as any).pywebview?.api; + if (!api) return; + const folder: string | null = await api.pick_folder(); + if (!folder) return; + const res = await fetch('/api/change-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder }), + }); + if (res.ok) { + // Give the server a moment to complete the background folder switch before reloading + setTimeout(() => window.location.reload(), 500); + } else { + const err = await res.json().catch(() => ({ detail: res.statusText })); + alert(`Failed to change folder: ${err.detail || res.statusText}`); + } +} diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 4c10ed4..617b0ea 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -56,6 +56,18 @@ @keydown.escape="handleEscape" /> + +
+ +
@@ -64,6 +76,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from 'vue'; import { useRouter } from 'vue-router'; import { navAttrs } from '../composables/useKeyboardNavigation'; import logoUrl from '../assets/mediahive.webp'; +import { pickFolderAndRestart } from '../api'; const props = defineProps<{ currentView: 'movies' | 'series'; @@ -82,6 +95,18 @@ const router = useRouter(); const searchInputRef = ref(null); const localSearch = ref(props.searchQuery); +// True only when running inside the packaged pywebview desktop app. +// pywebview injects window.pywebview asynchronously, so we listen for the +// 'pywebviewready' event rather than checking at component creation time. +const isDesktopApp = ref(typeof (window as any).pywebview !== 'undefined'); +function _onPywebviewReady() { isDesktopApp.value = true; } +window.addEventListener('pywebviewready', _onPywebviewReady, { once: true }); +onUnmounted(() => window.removeEventListener('pywebviewready', _onPywebviewReady)); + +async function changeFolder() { + await pickFolderAndRestart(); +} + // Check if we're on a detail page const isDetailPage = computed(() => { return props.position === 'after-movie-header' || props.position === 'after-series-hero'; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index bf8fcea..abb88d7 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -152,6 +152,28 @@ html, body { gap: 12px; } +.header-settings { + display: flex; + align-items: center; + margin-left: 8px; +} + +.header-settings-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + transition: color var(--transition-fast); +} + +.header-settings-btn:hover { + color: var(--text-primary); +} + .search-input { background: rgba(20, 20, 20, 0.9); border: 2px solid var(--border-color); diff --git a/mediahive/__main__.py b/mediahive/__main__.py index 78e7e3b..40e5fda 100644 --- a/mediahive/__main__.py +++ b/mediahive/__main__.py @@ -21,7 +21,7 @@ def resolve_media_root(path: str | None = None) -> Path: mediaroot = Path(*rest).resolve() if not mediaroot.exists() or not mediaroot.is_dir(): sys.stderr.write(f"Error: Folder does not exist: {mediaroot}\n") - exit(1) + sys.exit(1) return mediaroot diff --git a/mediahive/assets/mediahive.ico b/mediahive/assets/mediahive.ico new file mode 100644 index 0000000000000000000000000000000000000000..eaceab1a612ca9b04353b11e83e0ea9f12bd0fc9 GIT binary patch literal 39166 zcmZQzU}Rur5D;KsU|>*SXklhx2m`ScAbdkE28LVA3=9Sa5WcD~0|Pf71A~JDgs&*a zz+j-rz|hbD;jfKkU56Vtw>37tp6gMJG9*q}R0;MwFO z9T`gfD{gSA{gGJ2##ATd)8iR@DdTOs%Zdv|Hx`Oc>0rw23|NwyvHWexyU#XfWToYf zO`S9O&D_1=W!FwIO;cE^vhqsztNo$}pPcwuI{)n*i%&myOP|00>u1F5>z^N-t=L>4 zVQU+Eig}UG+mC;5_q}@+lUu4TY&5kd>|Jef@7Js9CzXTZ*8P3YSNWo8l5qT+8$VCC zZoI1(uah4Y+Iv-ZD^pBDLB^bawbf#ErY5&HE&G~qytGH#_CYZA(6|UUNm>isRF^Z+p0X{v`?TbO(*S`SQFKVnQC8F*l|)pL%6r zR=StrdJM~Rwi%ZF2_Ej3FNbyT!ena)Au{os9=` znAbgSh%jEUCdAd%M5FHi*KIbSzwZ6ppYIMF`TDW%=FIK|6Lch0 zy51T%t#pW-sixoe_aHl)-pcOEu4T)f-3yPuzlXmrUQm)>smxdH!;e#gZw%w!4?T zdFi+A{JVRXbnd^e2&&srdiUtuNcglexueP}=Xl!}mgGM_FkQano40ZB z|9a!N_wr`_we>Yy?`eW^p{J{#%Q~loCMI(w{=MP98TpOO56MIzj zU)r;Kzr(CMg@x4nWYY^zywXv7u*QUQLYvSpqk0svUu70m)ynNR0IjsS%3txHozrR~qeE#P%>*qDb z>^sgly5xihPAq)4bA8gw{c*KVJ{(_f6XSR8qHFr|uTp!z6}^qSw%}1x;XCt-M=tfx ztNYn{|8CCzy0<=;=1-T4nQrmp;beWGqnrQVww^BaCa7LaT)g|xA*ZQdybou6-&6Jf z-`?Hw7NPYzTX*ght>p}Qz@*7|&iGjG!6llPtJcTv`!ti&+sx*;#Ov$dy1Tn86B8Hy ziDT&Ksz`jO78b-L$XTy7#8i=(IIhri5_P|M}()RqP~vKN5SCg zpP&5g{`{8D%Hq?pyjvj@J9pi4PDQa8<9TZ%ZKYQV3deYUIcAe{zmVx^YVQ*(PSdQA zkd)N)W78*-2Lh&Ga5BCG{SlefAKMB>51iZXe~N&rS`q;_ll?D^VjF+ z=C~B6@0a-^GTSUyC%*WyjLTU8Ka*|B$uS~MnkL(M4)L&0+>jd*V!CkGqCi(3H_65B z9fiW`k5?V_zIQ6V`hD85rsMkGj_M^TtWCSkyZI3TrGq$}V&)j=f;$VJ^ zE%)^&y{*CbX&iWk} z|NZ)?x^>^ebqig$m2YwPGdH!F6CC_;^5yop+jC}?f0t&kaX#MsOG~}>-}$%t)86a5 zJ8_;b9oUN8IJKV9dtD$l!88Xj|^*Gd&Ma%raog z-&-lL^!MtA7R?&Bm1=!B%{kXu|`Z59P8=0%A8VY!>Z0)U!4sVztz~)gnK0!&;R_etz9D5-5OzHII<{^p+zL%XW*O>vE562cGWIBRvbgm6T&Ey-3^ z6Boi`tE z^iJ1&9Pp$`AyHE7QK59On9hMMFNGf-VO#Ka?ur#|w@<3syO-SAS}VW${Fd58AwFJv z_L80{e9{t}_0Nr;`?*=<@`&ljGN|9 zuRYE9CnHvGncq97(7Td5&MA0E8pyD&ocqEcGixdH(@FXPRv|%F6Uum=980X`Dy(M6 z&Y#)*()ie;t!vpo_}*6FTqdGA7M982rrf3*wZ{=NO_ zETAc7mUDu6`n-7FdA1MQ-Mv>&%khvd$hEJoIc0l2{;}Gxgq0H4*39LN5Sv$DroW2+ zs`ud!73Zc3yL{`B40Tu${rj6_Le3*er6_~y|F8Mq-1>MkKf7Pf*YfhMmG|ma^DK>2 z{`o3&=ZvYd`TY2V3ePrNdd2;6$Aq>|^JgY~NYeAT$-ySFYSrwI7q=y6Sl(T^$g%Iu znT<)e*4)|mR6G2iVfM$l=Z%g%vSaw~?X5UoPgH`eG@2I(%J)k#YUsc**3x z`_gALl(xORy_fOu(%p*|W?QAo|9jEt%YOQ_xyiNb3pZ=)vCDpVY3%-rVbQKly*kZT zRs?D$d7UeH@?5_{H~+4)v}Cfc`0ZbDYb6D5yz6y!PGnrMzvk%i===4bGM-N7mXeaX zc=>(Cw1})Dk8VyjHni5?pY!$YYx$&yG5ZBQFYWc5?=0T&{=Y$2SJ#eZ1s%K>%bv`w z-e&jp^E3DS`QH8O=lj36`PzLseczp5-iw}CMh65a{Ch9E>HVMEu|>z`O;X=Bt?tKi zU6(0#CTnM3JpHQceBXbDW$z8I{`XsZn?QpR)a^3>HyFd2ezr3(2)%(c7>`DA7Q`H# z`ES?ny*u5Nf7%_`E}nc^+-zIQ>FvGz91{dIUHQtiSE@{1^(O7S&cdLLGEN?4lUakd zdQZ!_s&T{tPw zH?yzq?*ET}?|(1f&zN`7Is5FlcX#t*tLxJacmB1vytV23PPeuCYo&h9IdV`>Mtxc&U}^k)}ucD>l_{cnHwu%3^rKRf>n zV{EW;#MO?6RaY+D-2QLdzP$I>`^;r~=44xUI~c4mX^7D~{eDNXn3%4%yjJwiPb)91 z$@Mor_Grt#bt@k#7yr2^bD_%G-Sj}&n}De7z8??IoB#XPn=hAXXY$qM^zMcrqbL!< zk1kR^97npEJdPR%F(*AdR2dPs@70}0U#0gqFLJd{?aAD`*FgUE!hZ|+B;LHcQ)!xH zb#>EEpXB7P3xDm}^lX`1it*vRz`&3vMV8kJGX?eq8gj6vw6z&l1{pHd&G%DjPf?NB za_V&0gLkRgeCHzeS6U};fBW&-dO?S!O3U+BGDltBAyvowT>M$N*`A+km$f<1x4Bcp zviI&^qp+AGG6i)zt2z26A8ot3^v1SAHI?Lww^PkzR^+$ZXCxk4BINOO)s+A*e+hH9 zJA3!YJQWqy?~DI*;c?GT|G45MUWROctE~%U&mI3Z|JL6O=i0a7eWtP;`|8&Pvobrj zI1An@R_#08>>RNrfy=J+e~`}{At$w&Z4B-fua38ROTM{PDssGU{?Qc|1AAnHdvf%a z8_ndIZI~>zdv%R#Rp`;K`tEbP{{QaT`0xJK3;C}Y5_j*~C1q_h=k|-$|AYR{tG#b= z?}EY3rCUW$O)`?=>|WT?+#Sn2!=%*4?(0X_2#ftJnVO2lW(!nztX7!XA}jWR)!Cxd zsBd~)hJ$;$xSF3t@1qEY&I+%hwMQnoa&O#`cHJ zmJSy8cNVL!m+IVgx$gbN?cYP?EqA29{CN26gvyt0-API>J<9$inJ;n;wE1wLW=*6q zyV2W69>MZOToN)bj_=~VyfC)+SFP*@rbLa5l+TA(MA&ods_*{r$@{&++@9oDPYOA1 zZ7KAu-S?ZLwSyPX~Xk;n)t*-v)^_3_BCu zQ!i${cmGZuoO`w3?5eEH?W$F> z+j37&GE_}cnCIf2E)=}1@2L3x9ZXA?PM&!BNl)ONv=2X>U)a2TaOY*7!82x)_A5-& zWE-aOd7SmoJ7d-5Y#`rqzG-%`pg&LYnH3EC{%zfwk^Ae5~)# z(RX$~>wJ6HZ{7QJ_35yE!BcHMuU1!1dT@NkH*w3R$*EH&7_JmPE@rZC*Z20_akp4C z6qQ{A?dFubysi!p5b1ujd46?^cNV{Ic5GZR`DOx*h65A8s;vX&bzC%H-paw^)3@xVgS+tp)pu0x^ci zGPh1Saq>RfEYQ_+Xs0oA#XI->nC9i8i0#_iA+h>QtC{l)1^P^vIiR zNrq&BL%+Q=S^hBgNXs65kif?NE%sP+^{c{{MdEI~T@UWuEY8T@-4NkvxNFz%f?r=G zH8jsI6{&R#E@Vq&Kd57Gk^JBa$IL$rhqz|EZ;@qK{vx`r;pYOA=KJaoHcMoE-`9Rx zHnT@lvn@9=?(dAB_C8kf1rKgLx?-KN*5{*P+T%AfxeJQ;YMQzxDZl7{)O%f;@yCU_ zPfTpT*?qob3ZB)t6e7*AnRUj8zzi;bHZAtb@-U4SVa&~nnaB$vszo6t~ zaOQ>WRJQLnJ5J5Ju8t2kl#ShgK!asH|T!QNik5{4wd>{fl<57QYm_ z;qQ}*g8>Cu9tT_bqY4dXJmd7~9J z<_1cWP4@I`oVe5OQmnOkgFx%L2{WDc&fwILb_)0zx$K0~L4hS<1$Sgjmd*({#xiMT zqR4a?-ebM%+j9S~?RHu0vQdafbtA{UJAc_~+U9Ku%+lao*1{gkHObR%ZMNjx3$y0E z%2H%DvwPJLe4>~ADbpE^x2zVTMw8AwGswFm!4vn=sMSkkW})aO0giTM4<4Rb*&4H! zFh>QbX=JxDT^GuA_jjr|%CvUL#~BIEuWue|@^103GGCVC1m(whOo~$O6_evc zowaZCsOMI#G)>QSrJ=rv z)3n}(^ZgBPoew;3zAtZZ>fBF{1vbXEdJFedUN<^>z#~54z5{#CdH>`OTe&_gGrr=b zIYr=~!m@MoItxs47EC#tF4%Ew!@GIC5_0FH_Dr7PxQau2oPFrj?& z4C{Km3boFIE;6yUe{X!a5VqO!}meuQw0V7OwQr{52)V^=@nL{$I!418mfIQj9O0VicBnzkP52 z^ZGxFB$GX|Sy2c`(z%=L6M~*n?S+`VrrzxM;oua3A^oZu; ziJrWbQH~bRRKiSLnF=0Hu*%Q4ku0e&@kP6_Maz)`0&Ks9J-);{Y8j?1(%sqQeR0_f zFW#d|9@ecDQI_5#wL`JD>YiY$=3$pF?jp}~U0)VIs_$AlS=w!bMbqxnV#&|WNM6w5 zbh8P`aGnz4Hh1c5Mj8KuoLxad-VTln6GN1_R2o^7rJV0qYKwbC2Hw@YJSn(8`%~7w zJz+=Wiw=}1Mmb+{-Id`fs7bq~LYW=WIOpW5K`mJT(%2SFTPz z5#V3cTWh-HjN>Vr76JwtN%Rz8~#M3Go~@bE@0{o%@(&A8B64roN|1*=b+r;H5%gU&N%LtEQp#eaook2 z`{)UqCXUi&CVI?%x?%@gwJ!?=h#CeJUQeIXrt(bbhsC@mX~|{ZRtL=6!clxSvokUM z^UvNXH@=< zJ7wzvuVu4aCpVYrg4ur$MPR_T(!;0Yq7O%uIM-Og3M zUpe<2&+Z)+JLQX?-|71}x#onm-3y(<)!YA@xfCXIL`G@qNIg}U_PXweX`5v2nSL$boMfBnf z?RIDDO5yOMk}}5~kGG!gXsi8kMtODMt_Ni@HZ_diNM6D$vP zwK*qMSgiZg7+Ce^hk(3<*W;D-|8%F_wUwU#_lwb#T>Yb~*PALT$UKo1Qsh%LjhWu( zcOv7KvB;?jMeB=~Ej5sM&UNX{)S2v#Cw-o+*x?&iA~$>6nPbm%iwmDlI$~^pqx;$8 z_lr|X1d|>&op@$sG}ZS*!o+xnBRtA{Az_zpsylDmyEfTW%{W%{}?>>GWBF@zrvn z^82~F#r$3Gojh3hN#GFw@xWGFZBJQd^^b-ZCI+xJ8d)1@6*ftWR@kxczwde?fAPMZ z!uoHlni{p6cjP8@z6`m1vge77{B0RiuhzAlu_`#ES0gArckbbe%y8wtZF3I)cqEbL zKexe0Z8cjkbI!TB#Yaw+O0$KBhSd1q+4oNJSj4)5sI~4)^;a7YvEQ<`NtI;q&srO= zwfFN;S@x{ga(Olz`2W`a-Lzsw$9bK7y<4}JKJGkTp8WY3>y0hBY)y&cFH59;?rGVk zqBHx~43!zl2Mdc7lsvt}ZDzQuyij@{_2cR1_m9?Y?>{KM#jyHUih=vfBMwfzvesTX zd-J8WuC4q1ej~SC;8_Xl?+2?zt;^p0V93As!13md?dzk~L~n1lipr?1%l^IZ`aFw} z?fHe57b?4KG!o+OT=V$BqopiKuC70RG;IG%WZIH1PkR#SJEsCMAKs=L;k&2p-GHm=O?oil5F?C!%~ zO8&36HYqXAPrs@ewzYWqm8jnrzFrIGf491N%|C~`FM`*;wUXd^nGrbQ`?uu=H)bEZ zV>$W519h>4UnW*t&ndjWWVzDhx%I*cZtm_}`u`t^_-%jg1w@&A6Yv;{}{%(CE`rdnb(bJ9YG16tbgeNVNgUq2W1edBS7 z*<}}IZ+B}5t(5&M|Hy7*Irplr@KDK!LLQkvmlp22m2diX{oEV3E^$5NKT%+z!p6o{ zs5!6sXpB#Xn_#Ew1TS9C<7c~DyTupge7$vUlWF$8x6ciCl=xfY*JTnw)(csBoE(d4>c@3*}aUjNs7wx3-64Vl}b zo65E8H#S9oRXDtEo@$2euR|Z>ZEx4U`kKDw+Ov{u>s<2{d3g^X*E4?W6MrXGefk1{ zK{C)<2nX;W*{aOKdkhS+FCc?tSF1ToLa$GKy6^kG@9ZzLT{;4VCOe2tW;K#%mgF&0 zQ7gN%aqgzot0sqpS1r}rx+>f{^kqqsbitdF?VjIduW_3l)MaX( zzoE@Ooxk?orH|r#F?HX!@1A0{HR;FiXKCHv{;oN8)b1^}#;&%YIfl<(pMUkl=lzpv z{`b};FaEu~J!___McBPPhhlfUXp3L4>mPehuXX0PY@wdJtNVYNXET^)33-+Ff7L7b zcee9z?)^L8zeTt1{=P-?*o;X*O`1oi>qWO5)eb+FerDeH{uOI}RUcdR`)i@EZ`0l1 zFD095rPM>c3KNtiZmUcUGP~sd|Id|&+urZlob6tk?ajTX}35_wg8dcv&}#6IX=azs@gyL_c3So zZ>D+Kd5-?)MfHU^xerPt2RRn-X-d@GXK!av2{D@cO@1-j&*eN%{0vWJSSpg8c~c{7?X4#>&&Tq2UqKDT=J{I7MoY2=IASPq3UOcmB*Sd9ncQ&Qzikax$Yg#qS zwQ9tzgPeE{2c}_&3%QB-EwX%d2I9Q`@VnE({J7XJ$vu&^1k~! ztHn2EJ{1=c;b2<*giCI7;Xfz+wl<-Nx?rA2!EM(c-(mLU6;WICNzq$R%|4Ju++*&<7 z>D8K`B}See$5|ycgtiH2c09cCk!9Ymt9~VWs-;4My8`W&N$fj)V8_X&CqumYR%(c^ zzP|S8A6D+$YG>wr)acf!Io7k_ZmFif|9;xUPPN|ueYZQ`+uy(5<=#FraT}i`pZh_jInSrG zckrfc7Q1I%$?BtVr|`Vcn&{YWC$=xVDhn3L#E3Th6q>k#<#R)d4ZGT*0!>~6`J+dl z7T7-cdi~qu*6H6HHTyD6lLa_EQ)F5&xclw?nr#2i*G}!VzTXyfsoyoZ>5{}|=MNIctJBXZ z%Y{emtzvt(uUb*-_^}Dz&kFweu&MViS8R1rn%DX`=Gfo*D&v^z$GfAyHLW!A;4A1( z&M@G0|N2<}|K+z9w`GrS_T7E)Pa3<|X^UmPa~RfT3O^6?gYk8t?xu<#g)Tg-+p(XXg3dwCecm{C)1-&p(`Ve}9YJzIUmp z^WVR9iHQpn8W^T`wCK!v-}gADoN4nj-(&SZZYf>y)pxaesAN8+bxQKZ@~2FFd#XA1 zy-&>0X#Ob7t*FC2@BD)bJ-@Pj-1C1PWBAM2Gc%wm=hWdD84~<|uI5G*d)v;d$`bF} z7JsOvkadMj{1Q(isa}qzTeH{iQ{;bl*S_rUuk(L5BtC3fzw*mOcZpv+E#pB9pp<=>w3^XuCDylP(YbMroPcPH&xupz+1u|WU0!<~Z{zxyRW zT2fT=`{4P7g>|M6(k8r_oM0x)kI#HoIQxy}5I_^?3cwroXaxXP7RPx;TUPxO9r^QCFqu z-mBH?KYITR%PzkAYHN1=RI87l=8ImrRy%)V=304Cv1{x}O?O{wmsVE2%lZAi%zppV z)w{RtJ!y68_TK=NGf7969{L{J!X+Zp{`kPd%Lx+=_Whb_x})~2kINyspqDBZj~^s* z=DAKRR#oRbH?I(wtlUk3mK05SN`&0V=FUF?j_pP(@_T=83 zb!2C5|8X_{Uv|lx{c1&+W(0BV&Ho>)V^#R4{$0-9KL^%Ld#jrN{>EC1$8|bk3LbL| zx;Y#lTsg^@BE+@&`k6y_%g?$W-63@3ap8|Tj%}hB*Z8rn-Jtx!_W1*!>kOAN`4wlY zEnBdp=}}69K#Rip3*8qLcr*h!PwH^wHGULW%E|Gtxv($Ah`mqe-;>kDg|E9bE0R9P zZOM7-Qd!9oYWlQc%7!-r{H}{EE)`yRZ&5ZyKf3O6`1G|AKaZANKW#7d!uq?ty567u znKjckFlD?b3XQ$D_sN5E=4OANsN1(rFa6u(5#n%Rsp^j0hi87;#q61L>=Enbk5BER zdS0hZSCo@K6kx=zo#QC*Z05SWgpUF?>Mh=P_wFeADWnrEI6LcbfZ&mqrK#%^g8Zi) zQx=`6F5i72YeF;=50h8Ww-1aD4{ze$uy!Z&REv$IN&!z1b2@%bOWzrPp!OZ@(yV~b0B&9@pID|@oqr%qJvRPvvg=aKilch#*~ z5n>_mp_0X!v(=ehZ_%0!Jp8rkw);NEhc8&wwfMak)9>#~3JU7Yy}FJubg79lJr9gX z;$dSKIX8cIgGno^xttkWdcd3t1s*pK0Vc6pHUD4^1Ggi5iaC4v#l?0Q{0@$2S))`^ zp8nxVaP`Mio7+JXTXH`i%Kmt^{n~*-%f2n~E@saKBp!HnUYcYy(I+M4`pROvQ(ZUQ ziyPh>A=p@e+!B;YrdYJmh|;h)QyEKk3Q;L>Xgun^IDYqU-HYh*khMc zKPL(@Soj`n(?4uc{flRpe%vMDMY~*ohyP4acB=Ps__K&lle@pbKlswhttg!dM z&V*^A4;>=Xo6a7b^7HDH2M0xk`hq?zba?zi+T)>id}N>X`&nIcCLg&V;C%im$G3OZ z?YXz_yD61Qc2;n$)qShcGu7!}lWbQ~hDPV*^z#aHyYYsiqHFzX)Kd2P+#+2=FYdB zl{<_kG@jiu@1mm1B}eTwfztKz>PAgcM>i^}T;BNLRNL!{4>oP(w;yNRz1P;<&i~^4 zwhe({Y01C8#h$Q|6P(cZwfOn2!iP26&;7Pb3=~{{`R0sy8b|l1x*p_sX47Ejnf#%F zeaAVWLwvLMi!D@L^L_aSM&VllCaX6b72s^$_&?6PIfTLe2UOPK2GWW8PZ_kw3h`FDk!_Jb;>#uAGzI(_+`vN5?@`jqzRf}cS$ zbJhKn=lUGnrhE9;;pT@Ami#zz*laK7%)||Uw)*Ut5SVjHXc}*xAp49?;o}C6n7Jg+ ziiZSwIQ$X_6KT4=i%-$+;$+45*(?_KiYL3+{dFAw_}mM&*24kw{B)?x*Xzg|Dub9V@ynG;;Njprn$mw zUoN#sTxz>;vswC)O0s0#$A^cn_B}rMQ91ZhVZw$~?ynnzX0T3E)PBBS;k;nv#mpNY z?AOL@(d$2|Qxade;pt1U9@*%YgN=>>#w|MY<$LV(=c;>@&j}U_NNZww`JhH>!oyQ#4?&NQ1z-0#dGEiQ`B!(W;V=BBv7S9= z!F}$6*&Qd_zH|z2dHt2;tBS;RFMgwL?Ynn3=tj3Qe2n+Z?_NJB2^KGJwzh1lP-Z3=bzdbskGdI%U!FZ@Sg&6|2?0CT@>j zv)V53-EJ+XHvX&cSK6k&>}1_P(LD1TT8NwnoPy4XI<@zUQ5GhP->dtwk(QDkz|%t?PlMO2L^U zXRl=B#0W15o4sZKBsFdptuMDY1mv$OaYop4aM$X&?u3+v~4;M4k9qj%#eQ`;{&38Xnn5<}uP(1W?rOVZ=nvSP@&Q>`8=8}yD?YJ&TWseTFt9alB(W(P^39pmCbP04xf$}bKZKl zBsgmDc^Cc-|16-q)8{KgYG2^wEAKxG<^&j3Rk`d-i=WJ9AH(%~agTt+p$U6M4=B%< z@{FhmICc4!&BW7-mh9mO3%LENKIVbfe*wGyOZBsS^VueDH@eHe#5}}GJmc}R;57@y zPd#NxWlQ_z_iO#y)y{lrfj?#(e9E}`u5+h*f8VW_jv9TlR_t6RdD@*@v8Ta(vOmMk zL}m6(&HEHgQe?%qZhoHd=Ljc<`{IHc`9senBxDZV=$yQv3sd$fMZV6ew4QPD(XPtk z4=?`LvHUxrde%A0T0_Y3$Q!2U_zK?K{Qb!?rdeJoVRL7dCYo;Qee(UjA@8QZb|>?8 zUoB3S4HsvL%nq@t){D?-i7d{#Xnvvp+`2fO_|0rbpR!84>6YGiK|{(fVwp*p=K=p$ zC8?W2HnjU~wA6UcwaM>NhwS`s3%jmd{PRHgONxfZCO5|@_vL}jRaYD(W^ByXk8fqD zySip&c;QvIvc(F@_gR0M{5MbViv4gQaAn+?ycMoHESCM|oFBiRDbH$cblj87*n6){ zvQoC1t@3K9^wdas_Nc;I^Vq!RO`C*#W`^~KoN_*U=A(xi z8-r-;g{cyr39K)Ff3S7x<*%?jQ0lP#jIVBjjrVosO-pK5%uo2zDE@*WXv&k6qL!bz zESr|Sy8LvzAA6-3ca~T#=W8jM2eW@>+P@A-6%p;^k+SGy&b>IX=;D_3FOr%RCrTXJ z^t3l4!a(|EMgO*GldiZM99c4Zp`+)TiBGMoHknjP$?P=`-Dh&?`tol(#cRw~`K>;< zpm~k45nJqf>X5=&jw7!S-{C@tOWzOc$6{ z^f+$vBx@}ynA;=Qp~A0qeanF-9eK-7{@Ta<^5?Hj$FiS2ImT$WM1I1Od0W_5#x$F~ zjk}y6`PMJiSHS4S(}{+kdD~Vq|BmAL@gVJ&-!X2+gFN%kCj>hOR`H2!-5=36VO6Tp zLN`z2%E=zJ1*wud7pvIlbKfui)OviwJ_BAJ&S%yQCB9+`&OusVV`DqBmiN3`IQhHj ztIum~Z2f-STcI4QQJ-U~!Q^z-JiSdir6j4#U3}mCt0s#&Jq>?cx*Yc{l`qJOL(nVp z)0BB8Nlky&^UkRMIpI{}^XFd*TUhS0Uoa2R&}BQ+?bOh-!1=a_la!{*sS-Zh$NcBI z*10~v)LCx99l%n*K|Q0k<9zRhi#oHfGaTEcqOwKg>bWJ1(j9>_wf@f03pKUtm{W1V z=3~>}$_wHq+h_Ck)EY@iHY||q5mHWB=6UJ7E;JIDPHlj*3Ii-TFa(i z?&*yW-uc&E*s|YW(a9>oV&crI1~s1z>L;XGRtY{+e^MzDcO_|gbfQ_CkR%V^lM?Be zswq~yi+J9A{>yc1-A%FZWxC=f5nD`SHmF2IoimQr;fcko&j}i-~=I6aA@l+I-aO_sF;{SWKb;d)c?spo- zQOPB&&L4UH-JNT`XvLjo9_}AAIVLQgpk%`yR6T`d$MeIU6AbVExiXufrqeT->%-~! z?_F&s9(jKK+_Ag$n>dQ4vp!s#onH9mfvQSyX07+^8#9w4cV2n&B%#DuIHtVE_nmq7 zJdKtfsi210mtEXvHoU67d*niD?Y>7P3{KZLuJKmxTITb9-;L&f^Hip-U2#-5JIi3@ zDxtle?EW^p<=5}~rl;1gYo6sH9zA8{)8i8pBzESUTQ0GT>)P4M6%xS>8RwiY>wio1 z&ldmpV&Bq;8xftam-!yvs673`mmF;;m7FEXD(huDeWQMAPg?Eh?zUO|Bd2b&ave{^ zyrk&+yi090?+Q*fs*p<+F~1!^%jo#_J+ajyW@>QX>|Zljg+4A>GuP0$<*?YKPDlHw2#pu({8SDF#@n-r z>+?R`Z~rS=C)!Fpepk8Pv%3+^A5C9hxIdpSL_W06@!N$-A*)q+3!ADrv zE9YgH&p$8lNa5`K5inm~yR)`NsV^nb-R*_+MYUniN#3Dby_2l6pn_>s!mE zCR~iF4|d)Qt+kB$;!+t@RoQ2tq`m&xipS1t^F38w2CC02iU??~P-zocwJOOYMWycF z=ED_VuhwpuabQj4RR-?^r7|3W4KaG^wa=2iu3dffQs{QxslD^R9DXPK{FYhH4TC*- zC#0U$EEb*2V5V96Z)K+O>-r4|2b=WwemkZ8qGtVReI1==M+B#p7}RDlKJskS^?!e? z@9o?j|L3XhNKD+iPu2IPgL!JN-PMop1KlKCoqpYOP*}zB&g%I7{L2r$7Ao4->Q8iX zJ!NvpOn15Fp}tu04=I=ZzQoHN*)ogEMS0u4?%aETU3|lXwv{{npVl_msI_MLNsacG)g1el{N_k&vNCF#6!lAJ-|WjQ9!Xx; z86CG~WZ64$Ox>>+<23c@Vui4d&FMxJkHYmIT{y@+!(g4v*Uf5KO`TrIL#pd6MvwgZg{mR*Y=iXh9*U!$a z^}Zzg#KYuJaU}awRk5U}!WZuq3f}+wEjXe+UHRRwLp2>9e9k*tH075xz1~?6BR*}? ziAtU@GiG*6f#ZR*7OPrpNo(ktvhdv|XLHAz9e!M`N0)jmQ=RwI)jICxEwVhc8Rz4K6S;pCm#KL{ypLSIlnu_ z?~_dQ663~X^~_v#kn4pQ$%ir$U<@a1meBbE~uU0$aq^hDp-lWM;$&${hX7l)d^ zo~j_n%iw;F6DgNU)=yp^w{gzhJy~~;dKRz$Z`bvzevx>UscC1gtZY}<#(JyOM`yb! zEDcE))r+fmG1vC@zI|_d*IJZ)Ss~x+wo{hV(5WTW_=%C6Upb$GSoec>zr~VIURoJ& zQ?X*n_7$sjR5|@bMB2V&ZIsnj|9I##`$P|`6*J=&2|nG`klQ}tw58GHb1PqqpAps$ zU;jz^S^WQc!^(HxC8clsK3*8H|JtS-{zn)NCtkdybK}FQkDE_d_iwYmR+#X8)AaZ% z#@gE2xWGi808OWr_99&ix10BVey*RCR%`lh_wTARbHol`2#DH#{P0GJzPT@sZ}w%s zSouh4)#_u5cU(#7TF?@BGUb%K{klU@yYp@4{&)&Fl?_kVx?Ye97O>m}yfw+j9KJuxFfVE5~#c|DK+wARH)oq9Me^m5^M z@wC8jp7VRIG`_uk-?h?HQO!^1dZw>_#TK0uAx(Q9{@}ZtM7*cny<@s@+e5*yiywZd zf8`N8tiGYQZC+69rA6H$X;)D} z-HIY-zGXg=%!#)P=FEAgX4$74d;KVHciw~3Ve3zxoo)YIKG1URw8P&ela{%@slO4d zG=B%=S0)J(iEJS#Mv1WQv(G# z51;4qh<;r^)$>^D*DIQjwtX#MwD4n_cye&ITvE=pgZ_^jz1sXMq-Di^6@PlM-~Rr^ zZzme>t~HImpV2tk*t=jQw@c`jywI0k6P4%ry(~SseQjjxvDsmzepxY}6p~GS|4(vH z`o}7}&_0Ko?ZtEXRY@Cv)rWmd*7u)h^)x^I>iOTV&ExM&S#H?xd&|k8#c7M1WS*Y3 z?bGC&o1~9^65Xznem4I{b;y-}%*mo31NmKRwfy^yYEIq0dR~6c?Duo5ew}!D`1iYt zmph*q?0U_k`#-@rd->NbICg0nB?cVP@!LE%eIj1MtxbF(t_54opx47!o`oGz^ zy!+LU-`bkp`Sd;a@%sRN1em*u{G zs4mF((ICIi|9nJxM$}qodvm#}tci)U?rZ4j#oes>sHAh+DKK#2;>vqbFFxi^?^$no z>iN~HGo#j8Ja4Y4NnmXV`?)Rmc2AULr+8dsb*-!G$@w?t{kObd{6#$e&ZD>Yz8ZB+ z@;o!&K7O`!^|G+Au!-kx?|i?#Jmuf>6+6G1UZ^ZBElu_?=S4CwP&~1_l+wS)}Foe zDcE1mzEk{Kev$uNo7@b$fS8z;95e3grGni@C!Bve@np)M%*)GmeXn_uSNz5|_~PxW zo9&-g)yJ1UzMF08`?>c1bw;5U{=WJJwE_fB*je&pJEnt804;)BDfPza4!4Ts(VO=k8zQv)|>NdYwm;b>mH+gVXqw!H zvmIZB&zt{WmUC%UaLuatbSBexzas_iZD)G0J~sQoKT|o&^xChbq1)#EUw`yy=lYbO zm0jZ7b3VpZR!+Zv?N;84UHuhDSbQg@TVHsoy|$)Q(U-5$?f#|jQaXo4oF<$;__?9$ zl-Kfa-=dQLHAQdru(>$n>`nU@rE!j@+WlShO7807^Yil!cz6wTh3CEAFl%{dy8FH4vx0jTWEVY}%D`Y%lJO-k zwmC84M9LM^sl zr5?JiSUUb?KZ?C@@Djy$r=gR&MC_O!w{b}uFw-5JTH!awE z{jink$G7U+`~7y^wBNeun!3$xz52YnR`vJ)?Trhs-YwYgk|5FWy0XA_VdG7M_Rzij z7Q5J@FHb0xonv*sZ}Zo^7i1DnEiv+$XCX8(gXLuj*SyzCNp2QK0eVdx5+1V{4jcTa zK3*0O7WQymOlAANCELQ9AD1uLyt95|ch~=oIcJ*s+U4sa>Qi2Yyxp54c5gD1z_G`* z`z>#l{kW>{?(@1{#P#dK>TmCkep_3b=bYU9@JX(SYWESA!&4{w3ZK8sI^Tprx^~l3 zt51FXm(!yQAC-uoYCl?deedT9@6IM%=B|G5tl_mT<_qZXB!)FVt`#D`Z_r9p)alW^@ zUj4tlQf682UD?-v{8}GB#o$r=<}Zt`=B{0PXSL>@R0JQ1)-4>Ar6od){%+ZvRrcWS_vsGyjj9>{(bHUw88U=Gx`uA0Me6zQP+2 z@V?q^eNolLCzlU&7o2Tcp87z_?9;sdDZD0g&o5fIPx7#q@Q)j|#f8^j?d(viT{1VW z?1RsQ<>!CJwp7>n%J#iJ(BTpg7S_A$nBAJ_?cDpT(&VkIizMv#F$u4iXFL4Cb1Qc| zvlzF6dw+!MVyr`0a|U5swJb#J8l_q%!fpSw?1uiuoOFy(Wj zIK!u&?5wO3zsp$;wp_;*U`n&Vj_|g*UxU-{p(OGTh6TwCoQYqbWHU6nsIUw%kevXbKBh>^nTg)Qgx2q z#wRA1A2`}Y-*5U={PxA$v&jb+ZQN0R*5}NgR2`$0GM8rZJi4e6-|Sp%Br9Y3L3BZ- zPN0m@q4Ec>Ma(U9I4&$QIHPoAk>{SO2P$Z;+(VGCw90`);;8Q-PK0$d%Dm3hwGwt9+I&vzNMPznXe_ZhX+8duQ z<<*~l<{KCN*mbc*B+pp`mlJH_^PX)?em?2^`k8xH9a(fW(M{*e(q9Fyp196g>7RJy zhLX*P0NyOIyA2gbnqO9SNSM5TWYzcb)4BnhZjnV{gzOPG`Xshtg>d! zdXI%0EV4KFaL;?J@cxFzdYNbIb|g3r}0ov7)NwPkrZ&%0r+v(Y>k|J}{&)|vFI zP+Y{^ThPeCqkbw%C2opyrK`v~-fuU2KeKzgv64!eU1I=j%(AvpYH-8Pf5hMiT!gwvL$Sub)CrU`L(Ak3tzQ7+}S7A>(Y{yl0QqvILa`k-u6ievz%qdK4qUq zwk_MA7QBg24|*vwO-Hibzh;q`xvzAm&pv)XJMLiP1v^bHvhlsK93#ub=Q%@7Py!KS#6q>A8z99n;~P*YBd# z`8@tklk4Z>30GfPdRW?Zb!%_@KXvktoBn3W_wQ|9p`m^FiRV%ek%{Y_{xENRr`hh% zbnGD0Z-Xyqf_5{!c=@(9an`>>uHAQ^majEy@00L5_U>rkx^wS)=9T?7le@pm>hr|h z+k4#Wo0}5;Ez>)q6V*GR)Z*@PGvA`p|Jlnr{maX@yn4MY=j*pOH7C!1`8vb!w3E%W zCsWV*$S@Tx+3n2iBv*CCm!XaG{29f$p9@~RV2#N+^3H>3sj^tonxJ z--dr)+~5A_sO)ot#2UkOvjZ9zd5A2NX08$6H?KW^L*awxOY382uGY}kZ-4yj*15UX zXPevkc5c_3v;1!9^SP&2X|K)-`Z~MrS=O)et%Y0NE7sIXy}r+r{PVh4!>(QJXJ^fF z*QhDfyLLS^a_2q$TR*SJyXtPPIlYYc-nqBdci#2sDNAh(n9{K^+{bLk{7;GP;cM8O zjczfB=9q2VY+V8uJMT?xe4S~q#bS*IXP@aD z2c4+4z13xlS1<22u4cJ+Au4VDfzy}0gwHKlcDOX>g(3H>yalUwOY{2rdZcfXiM{Z< zl`lbj(jUu|&n55G;;&y(?#S}2cxh5%+#~z9G+KIuytfE=O>#Z4Ea+JGYx9REUbEkwT7GAtew4}o;17lQub%v> z_p;tue|5{PWsPf|m&Y<_9Q^p>aq!k#w@mpythiVD;-|jboICZ`dH?@1?~^&U>-n{v z;(8{pQkSY$c^b9t@qHsDSai{``TR-gu=8>{nZm|1P0pF67k>E>dBLXkP4edt7yHi~ z???=E>D4I`Irm#tChKQZn8Oykwf9t_qK!{1QO@u;U%YoY-Pk}`;)4Q*p z-9++_hy{$*nsx>m@gSWxvI5`@Q^@a{nJ|x!Nf;I)(1nPk2-vXEL~8{MKqm zVQcEE`ovlHa>b=yfBaK?{$_i>jEU`W(^K8v4{lgm^f?#ltIb{0vhMJ?iwrI|FE10F z`PpVmS;@n#*Uu|IXILHPdsJOMs?%_B*Mp~*Pkb*uXwDl^HR*A#yx9V4gIS(-D~HZZ_?^EV}HQOKcOPiT3Y5U zVtzE0L#Am#$}aZ1n;p*8pXp*ev2gQR!KtF0HJ@k8AOD*@?Q#77x0-M6MD|Y>*YTNU zz_hq6e8M|-yY~mKzu26cF-zC}2-myWXTo)aF8ayYr_Hk{yfS}h{;tix*6mj3UAgMt z_AAT9YP5cL^4`B_l6!c4@Wn5EED9TJ`sB$54Aqaur{J-&8H1I!hOE0_VI+>IdpVe*q&d{0=JY}?x;LgQS)SyZvu>`#p-U-yvKbZ`;3YmD!z|$0{yLOABIn=UqD+ zc`Cu{2XpO?ece18rz8tz>|6fUe#8B$w`a}w-dcM4PwIC4SP^fLy%%bVF0b#ov}ymP zEj!oq9*L<-W^%Y{n3A3GN!mYOPPlll{=v=vqg~uLt;^f|{n0VsIQQz+fl*OC6*>o( za7|pJsNZ#E#SFHfmGkly=6@CFocZGPmq#BC>d9EviCy=VXGDwh{;C97H}ezT;j zuw+)v%+A!Qo|BzpY~AjfbnQ0HaASHdeNJhf&cT0w^EYi16po!?a*ct9`7(=8Q$|yR zdhZ1FBBfI21con?%`NU+HDdQKZ~ZbMq5FnkR`Nktg$M4JC!Ak$BR(R|n6)^)=iswm z_m7X|_xJSb?`wR$ZoOM*sH9M#(3y?{GB@OtUIcgO-QRrpytKErx!cAejLdG&d@nrGE3_x!E(y?k!3@c#ofr#HO&c)XPB^{dyfl9bmK?|WhS zPJZV+>wmr1?>pS>j-Ffm^=r$*jMUVnHJ7*L8h18bXIg2r?e~%0rza%$FA3$bUjO*T zLvD+jMY1P@9u&{tEy}+(vh(3n&L!3Qijz+rR{EMgaqqN%wGD5Vl*@hC5g25}^88$! z)2i!@3wQ6hai`bChVA;ot2$w;z8F4z5)`4K9-wUM$0y0z(=bzVqu47~tK~&HT>G@r zWi*d0K4A1f{Ijm;o@&bmoeY)Ind?M^b~UzN(>eI4)%@Y{{WqFQrSCcXeW~U%$3o=s z@leJS2fprJ_)}0K%H_iKrMybBq+6#70*^KpZnO| zUt(J3tdlE_7CYTDxgFiv&>7OZB%xZ4$GFKurES^gyB0Mo1h?_uDSfU|^QrN+L86mr zjCuRLdwP8g8q36A%#V1vrf%kG{Sd45Tv9c^EY2UA8QiO(E!oEW`+iI6u?IKSoe^(Q zcVF1KQV|!Zb$0xUeflfm`0`he+BH z1=GHH^FC|n_dl3jU&L>>r;4xc!(!bVKZ9(eq^u1q9~Ff3uX~`;DJI0dOi1gb>w`Vb z%K|@g`P{wWCvTlJ&%XSYy;b?T=-^=Myhlqq|L@*jeph{Vzun*Kv&!xlGe7(Emb1a< zPQ#<0G0UFsKFaxd-?wjSYyRF;`@WTVeO8%Ox|_~6Z?6ki?H_G4?Y+6}$wi?Z{&JiK`~2d)TkVyf)gB!@uXm(p=G66($zO_L8XraObKYQH@w zc_6?-UB+*z+7x{^bDw?QV(aF+9r1Da?z_c&-r*y!EcX58=TFGKoB8L({cVrVnoC{0 zbE924Po~i&uqoVjV(8DGrHoU%W!Nk`m)x|OA|mQ&ZjyVw@9ndH;c?%l-Q9O3e%q&S zm7<-O(r@?MehfZu{=diLO1MzdLDL48&sKc4zkhymd9ttc{QC3nZ)`j4^YyF5G)YID zSq;x`G|3##nwqouLk#N+r*m=>zQ=s|ns(vJjN>zn-_9^HY}Bv&sdwd`o%F|+{*YCy z>n^Ly*sPVXdUWAz+@Z4;pZMmOT{6fK&dfPk=gwlXTD{=mAxRO{(*>`tXfCwXIVd~f zv|0$mp;-}Y;`$CB?XGnW`Z;e#h@$ox`Dv5aCP+Wzh%;-NVxQ19$>HA-#UGtoAD^MOk2~Cdz?X^DfYbMWzWq4F?w^f z*?d)%j=xMe+wwSQU4*mUbXUFE?QI-VQc?n)6W%{;3royk_5LgJL%^tfy=S%Gd53dS z|NU0XW_+&A7&2Ypy>h$350<4CKdg2o@NZnFByZdmu|;C7?(`2=CZA7wx#{SP%?VOU zQEV^Y#knkU-SK>}@rD&?N_D>$|NU`Qzx-iMt?)9R-EvEx3TEt*eq`O%s(41w*;_`@ zwMXO8Lcm#;5w z{Ja$SjnVGQ!`2hef~S0be15`3vD=YxbLV~dV`|%$vPkB4-=0nnm9Aj**AK1ud?smR zcCpEwIw-tQ!*PGnqm$|hYZZdGoiW*EyGZMNf_{=ed z!8W{gc3?yp>$iPHLhk+Zjy}4bcXYSvrd$sfmLKzG0?8$px zd_h)z*K!ZxLo*+#Xz|Om1}tJ(y?C?u*X2?LE3Z19ounD@`GJkk-b{^6GkJDdMJ3Ft zJhI4T@gklD{pNl1+S?Xh`0Hi!{Z4Vjwj9Mts;&i}EHl%OKInX9{psGzN$pdfxSN7L5}hKi-j7W4h?0ME30(v$I=yy!LbI zyE)%ed$)+c`I&3r(m-Z`hR6pk8F^Krxoh9v z*!9!Y=F7{aFJ9ybo|CMfCcqN4vW53|U#rI&j{H3z*%nR_S-g2U`EZtX%D!D6zXV$wBtDAJiL;ScRkb~la_i>1`@1r1(#}b?>(2i6 zaOLvv$;bPGCG0oyT)ir);iclTt>l2>ySXj>ri<#&N*(wdbM~UmjTWzqC6k>OE5)1D z+76>HG?jrKzka*hTK5OxXMIJo0vAG`U2Ug6_-=X0dvT(O@+vMX>6_32U+#Wtz zS@ov6R5<4NfgKwkZ>YR{I$~Fn-6YqecXl?b$6ZP0e5986ZdYf_wY2{Id)5f`_WE9V z-0tfjF!i*7UxMHwvj87Ci=IWU2Mg9YUNAXpd+hGy?tN>p zC~{3?vGFwBor3!-9=Wcm{iji6V36&lc}en9W6S@8^BtcsRS8yi&2u$)y1t@i$C}p1 z2N)l+yxR3T_|01>x%SdL4rTG=M~6*HpM17^_d9^s?mN5p!z}R&S1;>4id%Ltck=b7_}F?K1mg`W7TM3u~OR4*V|0@-#=&HK=2qc13H6ZzIF2X{ze(Z5tJz=*t9f zyBPhRe;#J}?AXV(a?btrc1|14Pg$qE;?;I9o)_W9jHfQA zbAWcJD`bcUWChzu|7_ovec+@;$IQSZC-r{Hl^A=8@w3OmcP$GH%~9oS-Sp! z;_N5vL7O_&=5OkcnRDv1a(iS}@b%IkmR()l7q49Dl>hT!x{i$6B7<4wMT;?5|_ylV7HnW0D!N-o42wM{@qhhEJDNX3gIH@lu~{NUe6R&RPj3$C@LH z3g=kgKQQ@9$wJB4yRkNwJd-!A5|`?7YxLe0aaV{*{l>1IJBBM%6cgqi@{NpC*;_U* zrgGn|HUY<-voemJJh|+*fF%p3+QsfmN8ZRsByEb6m=bWI_KSS7kl!WVAe#ljDi430 zNZ~ax`?6rY{^=O@9b((JB+pgcDk1FGd`LvJv_(ZRqq%6R<-S>*37Y(2SD(I^QPbq$ z&ox{3p6ky%>-UORA20e>!oDr7#SzNG}jERPSV&@@9vn+-bwb?hlUY-oAM8 zVrzzumiKPe#mCP*e9{@Lp{I9n;o)|{eqrhGPzL_0@=sz%S=jE>USd)6ouW6#!Slj| zCkwQH`_8%Tu)y|2O8_(HSKDHil%~#><{Ms2nM>|pe&3R}s5(&aVCr|R!}zK`{ga@}7X_{$mWCy#JGs{B)(VJj+hw%-#93{TCnc?}vOeuGoR4-XhSut;DI^@3 zuqx1e-GV^-Kh~`tD|ZWSn{vk4D6lKShUxY>BgUqr;6=3?dmmK`7jEj)SYEcWGuZW~ z#G2|n@mbQh1$vL|JaM_$caG)NL#|TG{pPqF*)-wwvm+*(d-+>yC%t~ozu{p@mdE<; zPYm-v|8iWS*g4CFk;mricjhC9CVyCWgn{LM{GOJ*;!$lC^@jpOjybn=teO{iJ<;g= zh6l=*{&obF85w?2+Qd;jIYn%X!M}$#?mO=Gdbpijzsct9+P>Al8S-oQ$rS$l_o1Q4 zZyw7Eht)yNrhb;Qhd-O6FI=4Fs`hlj;>FC*&zm3Ew21M6@rlRZ z_q)G5@QQ`Gcr%Aos_5$1t?xts)U~Q}&)I(9N@oqL?fihttrPrbuo$s7RUYA6bjFTL zec}X{*1+%AlNhJ1tdit2c-;JY52L?st>iHi4woOf8=6|BrnXM&suS&#n8K?zTVjHa z)80@vYm1P@6*pQFj~e9NVR=*elflc|yWlbJ+`KWjSc|<>OS)f*i`;3+4eHMQL5{w zfLddxiqDg&GaMcq=`8=ex9yMAG}ftKIJr+8_|2o(pZ~mqo7X{pG24Rur+D=QEIe8_ zyTq(b2%8!H{L0RP4M(PQicgd};?1zyrsB@U%ZD#?DV|t%Ib;8g#&zrDwrq(>u|Kdl z$Vlyb;IEwwf7wK~T+n*Q{#xvS;LjIUB~7WRjtLX^(pvOIm`>lwcqSffW~b1ZRIf zbSC!ij$Ld!E){WPGl*)jF)4+vR@{?1v25xt9gdBMriU(^ne$gE*G##8U+D zSd=+!NRkl}(@ax}R$*&q5Mhe$JnyfuWYs_3nVn6Xiwi`~!s=%R;Q9+54p+!ir7uE=-#zC@^S(f>JD z1g8IHJH^A@ckHU`!9QPRTN*cR+tPjMqoSUI>=vWSr3G)Kzm;+QYg~{mTIc<--bm6l zJKI>fW?D<;@A@S(ER3X|iawoFbuMjF%q!6k2b!ES<)Zc`J<3#bX=?kyqg1465j)*} zx#J(Zeg2%EH0B8yc4-_@(LEw5@$&hJ4LwP9%q+c=15P|ieo@R7$@6umqSwq{zKIno zmw%pW|G(}U8=FI$#?nc7;{UThxd>Rhm3$T`R@!`K%|Vgxacj>_c-z+RqQdk@#bBF~ zVe!|6Uf)zr)|I0M8* zru=Hk3VgHp0rPqx$vuBvYrd8k*dC3RW6o=E-F)GAJA?GmWbT63j2ueqcQy2t{xs!Q zxU8@`=vJwaUeole?^~6nD#DCTtG{n%4|h7ibLoPCNWh+eeZh9YKhhd6r3kK3bPkj= z=wE7lW~JLK6Gw05_|{9eOd_YO>r?3x>^{!yVSdK!&HtQN>7gOYhuj>6`G0eS+g*v@ z$$lu5?{n79hJUSr%=Qio{0@5YD2D!>UjdX%qG^6OHuZpA|NjqctOrOU5rIS>WuFl(hJ-iw!Hd0)mLc0 zy8E{S|7uVCsxA0kCHX#mnP4){BUkO|XBilcLguq9RB4&F}Ny`Ig|;6spbz*8T) z{!1A>5AV&JCwETLV@5#i)`A!|3&HLF?(Ws*Img8$U8FP){nSq`?Pv^r`ryxtU0qLE zOSGL_1=q1MDVTEpopSc6LG?G?73)?xxjPHbX1-YF)_PCt%o0hVGYQKso=gfpd_B;1 zkyPm6RfTU}1m^WP-hKY-XvJT-P#vj>=^8Fdj{;=aUtZ?1O4wh@IDw_(gW~TGE1G4r z``-U9y)Y?Xwf&UI#fvvPEsN70U%ehDsxyt@=&oOmlZCb>y?F5=ZP7~Cqe9CVj|VPX z{zUlH1%rr4BbF;CI8*#%7PfXBF8`@ZV~>WZrzTjH~tA1_&28Md>@X?tPc->X`w zX&XeCwB8idn#|eVu&wZ^1EcxnFrA#lD?CXp)N)>g!aPQhF9c95|6E|P^FXl4GXQJU% zmRT>(F1dL3dhez#A;<5NB+cC3hy*PCW99Z@ioVjXZ7g#<7tMBCVN_o5V#*x%yCwx2 zUoyRMn{8XS?a@M(nGcjaLMG4k`^4?iocVRnsh29xBTVLs3F|GYF-%)EzwuP$7yrGd zSg+;BOxdi_72u(fd54WD?R^aMimc-SIXCYoF0H@X(8@UNLcYb8AeC>87nc8+`Qq{N zbMp@W;1s@i@8UV%DK5gDtM5p0GYC3I3HVNXFip~U!r3P~Pj8}U9!R_>;H`~-rUCH z1wR%4E_3ubyx_+HQT9%;t~D_`muWPX7XGj1;C86b50Sp2!s5xluWdu*lP#U%Um2A8 zcK01V-j{gdg4Yxg@y+J)pCo?S?l5>O)OOW(j#ahb_VqisColJLRhynT!*q?R^N+a) z)FaI=+qx}~G+bx6fJ^z2p3!snwwSI1H|uwtRQ%kMtUl4>Uh@gn{P-2MijTb>d#;bo zV)OLaGb{gY{hZXbsSj+vSAUJqX%%*!!`oRHx0|6OFyK1xzP7`Hfg4s{*)8~qk?Y=y zP0328Z@PMAGPK2$$}9Lp4Ih-$DxZtB=%lJ&Ubx{<`B|EH4}Wd=_1>a&4&3oZx`Rt%g&-MFj5q*>+N1;odxtn_XAc zuBK_s6k4-0f$>DzwomA=*--D?Y_A-Z5tmSFMeJAOM16+ z*GnN4hlYD0TzB_s+Wc7Qf1pQ%dzs+V2fUj;EZQ?gR?E&`Z0)M6TH!mz4s<(hX8EG) z8OZ%hpv5hWv-wWxh6^E+xcqATx2&1Yqm}qSG<;$~t;30eHcLU~t=-{r)$^}hr zZRdN-%=)|5uYVB1KQTil{k&nA*KRn6aX|y+FW4<6GEt{R^uz9&}CS)QQ?6z$-3QaCeF49`ETMuU5`t z;h(i|q5j;XJ3qgzc(IUOKwGk5phCf3BhP(M68olU8#$9&Bkj zb#?1%gYKu*Ex(WXRu*jC)v&#nnPujk3BpnwNg82S4{67Wg=jr@7pWF^kb79rU8EId z8Y}yDN9jYRH}|$Rb31EkwJ+Mfe!6+lk~fy`&m>A(l&|7E+|GY^)$VBr&oA4NGRLyq zYHDA%-Qh2rZ#Xy|+A*o&q))=Xgh1m^g(R;tb8npKQa@G0xYM|E{lRasbF69^*S?G^ z*_JM_@LWQmH^+)6_FaL(tD??!6zQz|+?koNBlT8F&+a;tb92|aA6dfWC+M{~Yx)}H zYbhyAp{8QiGZvXS-Iv#Qja z)m#&J4j-5 z%o6z&E88K$%b6u{;~o2*e~eZU@1$4UF+9IqX0HM3nIF6yi&K)hk0?fppL%6J^<477 zN4E{?9#v$loRcAX+{%TMVd>t(yZO4(muxdF*=Y=i*vyMk+G@Rx=@pL*jx5L8+ZfuNP5?S*uyyr?hxS)~w&%L-a zbM5){-{0z<-Vo>8LpQ-+Xo_ zKFMtYbDb*{Hwjz(5Eee>P|dVb*{@ zmNyswSm2|S{B=={mc$AH-dOH`fv+w#KeM>Aul3iF57!trWrQeJA988kH2G5Kd(ZL% zr{f*A?e6{gJI5p9LaV~6B;$LB1B3!S8y1Jj+_WiW-k1>hQo=kc@J>fS^GZjPbzIAv z?=gp8Iuml_v~(y(o{Gs)j$7ZN%b1$g0=Z7`yxSAtQ}lE)cbw(h>l4o>UAz=^j42~w zWkgW_kqh@GKdZBlum9lesII&5QL9)%hCGA%`55b>sqD;=ESH?-Cm;UqS&(z!JgX&x z;^gMLYYQHpynVeS>+36qY1(WvGaj@p>HPS8OJd_2MTZkh{zQI}72wrcv0Jjn%ai-g zLixS|(d?54r01Sn_~NOMz>efkQdJk6wmp-YctHD~Z|$P4rz(ws7bmiZtl;OO7J*Nlbry-%k%cskP^^;p_OzYr-t{W}N3fTl(-*pPJUBiz?zt4^|%t z(g}&*d-&FY@-m(yXId0E9(X-G;ibX0i_6G*McMmDp)VBEzb{<=TWFfjYR7yv*WSDd zTV+eizCBo+{jNz=bRX;WXBsm?k4dyvHJ_dzl4I{uXjAJq?bi&)#k`t3N^LDuoN~K& z>%Y-GvYcHqLh?-eA5Ko$)-y{^v94$n{<7$a*xLX{gRP(RLhb4&ESwl_Zu4=G3`;4G zZb_Q3&1t7H&qTRbb*dB2hHCEjGkWn>=G4oHQ48*`zkf?`Dt9+qXUNXOp}SRHhAv)h zSp80Cj%Brw?A*IM>OQgDv8op{TIuI_CZ(X|+Ydoz;Syo)#J5?h;)gT~`5rUu$=(vU57JQJUMO0_` zk*j4pHu*On@cZ>VcHPqo_owx**!Bu+k^D1pe;2#Z$=OVMZst22b`@B4y`jre!|_O( z!;wQX=jrLhMm6V{bsK+8FZ_S}y>nrzpy{&4Wx>p@QV|nl7KQpgbG~|ewP#FKYq!$Bx^OF<$&sWPASvFlUV0*trdO{)VS{GN7J`@&${mVOvx&*{SlgmT413r55AB%ik6M5M%Cqh!}z8QbsEGPS*n=HcCt#1}-ZRI;! z=g72les4&WqmPJk`=wxhC-rlOJj<;s^ioehwG7##d7gj&l*OJ~Iep4f-v)4S9!@TD z{o;S|#EeaLGv@N!-fmESV|_tTz;~S`56{Xab-of){;P4#Y?6NNIO)}f2h1(+^Bz1+ zwT;`K9{6ERUyNDnt)<*M{T+-Fm8QOBohtR=)Z~>97ck8XU9@VFq=n8yotYo3ZQ}Yi zrFdKRI3}+@#LChaT9Lot*-<-{Pq8dot*ra&uLU=_{8e|Z&~27wRViKYK`iKe^U*G5 z_UOCEO_drSE?F)mGIa*$rOjN-6SbY+u3epzRPeb((5ZH6y((+h=kGy>npE1R=-ms| zp6-@e$+Y_YnujkMtkSqT!p-6)nQSi5+;!l_(OB_6j0;08yb{+sN;Ek0PESqLvYetA zrknp~#e&$EQAVF5ZPmYiUT83ZCBk&3;C_Cgq)Yq)N7an1PdrwdAN)0r%R9S(f78=< z2Xt2H{C)O5RNN?1Treb|)%RHF^8ahCT0VHh{VTuN?SA#I__Qq%tz4bEz0Oa&5a_X6 zEUeGQI>6Xuh0opRys{iQnk%AM_luqT>}>mqt=eXxVN3C|F1bAm?;WZM_1ZdV3h$o! z%x_Ja+}v|n;(sZoeVO{V@lcnj=cWS1R}b18emeeb3w#%{?%nsblXLf%hVb_0%Y z{6B=A%;C~&Iv{rB`;oWds`IDIZaS>dy``o;(WaFxfpI}?`#rwWU)$rwFZ@>w@eFq? z^(ZwvG4qeL*Ilh*p%n!ym!-ZimM}a2%7P*LNDF)4iY*@>y;TxEQPXvRGeGTd>v@Jr zUydE#F7waL!)e0X$9adhbhC4>tl&;nI9htt<#VRZ%S9$W0)Z)>6%3s*nO{$5Z(94> ze#fDfl~w9iliHr|3SS=E{Y#phk)*s^WzHF1#ZtXlCR$9H3txsLzIRx+ zKitFMtA@f`Mb@S9_9}f^Px?}gUUB?Uo2etD*C=#rdm0y8pDc5nQ2&FYs_%S%${$MU zTF2xV_all^F{?FpZn)KnwQV6fcU9t_bBPPCc3oN5IIELW^?g*C^3(R0>#eRn+0^wZ z*U;C7;nAGaR~AV*Pu>1p;;!hj(Bh|0L?p}~31uZ6S~&HCeMPDEv|@!btS`cSb{Uyx z-L7Wk(LDZ<>vZs}IdxGER?~Ez8A)+=tXbInSI0G&yG(mWnbV1ejHsBM7urfbREvLe zH}U+y&X@3`E2x(3btIG38yn5vt0HWsL@bP#m?W#byTsVUPdhyC^11nIcKq#HXu@D1 z+TwdsdBTDT0yf@B5}!PuxU{%8@Ru2>{?d@&u_1JiKuTWtLB82<5>BNsU;L~6=go9^ zr-&P!wYMX$tp3wm{^dvSl4eo;egD1JxVO#_eUx!DIr!{S?mfRQXzvIY+@7D53ho5cye!5Tjx~M>l_FL`krK(ob&-__gJwaZHLC(?pv1i)X_nbeb zRr6j>KP|Ce{+8IIC8rA$miyK1GmzcZqn1;V7w4jSW#6&eH@{3cS7OwDOIWG1tF74ZoV<#4N6|-qY!mw{H!}=W{)pIw?Xu`}xf_O}@m3j4mASJErK~O#9_OYtn|^Pz&bY;yv5F zFD<#1;Kb?M@OQOJ*P-4iYp1=kT5xN{v8fNY8~Vgf9p$oeT_zfOWPyP9{ACB8_HN4LZ)|JxUSVI(CwkcH zn%r@Mz%iZdKby(xWe!Cc6OFPyE#E?S+-P^F@>c_ z^mw$De?DbxIVIHP!&3V_{O)}+hlR7_4_#dB9%#5k=&+XiA(QS07fxN|2uM6u&+xmb z%zyb+{j$SC{A$*PD{Aa7E}GHk{A`Yk|19>nRZ22H zC@f#SflXC4#cH>M%Jw<)+a#BsU3q$<_r-fWQgiksn6?WZtb6;XBTUbO`9_)U?zW#c zQHlz!{8#zIb6QfA+D~25m{(b3o^$-*T=`vcQ=K0be71DaWtQA6Yktp2sNUuyqs)!V z9cO2MTeR<=Mcb5qHxnUum83&YZT56rmdsaQ-Xo*_?7>MV_nSepa`qcNe)F?dLqStZPNnJiNfl0y*|m$kbl7C9{luU=rylA1=l9^Suq`}jn?Tzk3ld&bS*MZWBfOW71T zLoh2*yg4+e@miX6|Nk$Rb1d%lcwP@ZxJWnM$<9S_d%%l>9?>~*Z!5Uf173=J6bQPa zy2Mj)#&Q0_%e{*)mTVNa{~sy4E&qPMe|;IJnEo!uZ$0Of=lUM(atYX+WAyCo?55n= zamU-lzb#mK{n(M$Y6@ch2fSA9Ynt^Svq-KX%)4=p{@q;%6azQfzU11t;c#E1<5F=6 zvsK<4Yoi{X?3S}hQq|;L^oOG{o+W%@O^xSqE~n6!*&22Z8ubbfG74oKBjM`mQRZG3W}iyFeXP0S({ugBTQ$2^2Dc^O zSz$C|8N0rJ!W5ng)zw}X9v?~PTKC&pq~TxtG1+xTzGWyXPB741si^(v;jY(OhI@9& zEce^`p!$An`!3)0k2lZX$!+c@+wHSXcBT(^(agLfYqjqDUZ^_5^08aaoh_pI`zpC= zEx9>YZ7yChch2G4$AfqqBwVCYZtilGW)M7mY>Hj}vIRN$3Okwk4n7H#J#&Jk!o*@r z=?Vh}M|Ho(M2&oRH+CbPC$+2=CQCO2icM=dsn6)|#L?MQ7hzmzeo5liE$ffJvezrm zKmX8petg5y(|N8YuMTdsu1MCo-KQ{9hq*`S&7E2v`>Gt_bMxLhmX=D2>CWqz@$|sA zOMAF?zbIY75~nirM$M;)o!6f+vjj3^Up`cpeMmt=Y_&w{s}fiFtmnoaM|kfT)v`oI zw>`M^PY_gtsNb7RtO})a=B)@#dMw5wqzOL5X7Q$;&d~@TP{Sud3I^Mpn_&0NV z{%5|pMK_ygC^Yhju{apccS#EmIBGF%`HcB2idViw1sph%CZTj_GM}#Hrg^=4vJ0PI z*_{4rW6DR~9p4X|C%@PrVYHIZqH-5M*WDxyuaHN7e&*TyeY1W;;bX;!G#TT(U3{s| z8Jjib^X8rDc;y-5AnBU9`+VJ2w+6}IX4e~7+W4=R#Xft!Tkrjqr3O_~F9f7UtZC{@ z@cjF)Jz_`c!-zSSms`~5-)a6^-T$C?{%&dG=RJ;r8{7BHZG3p~-9k0D9-X7&`-}AB zYTs&|t_|w6@%;G$}D-=KJ6urYU+cyTwye?e=|m`rDxPS;~@Mvnu@+ zvZ7yC2a2d`?Qs5OeE#k6$^Ln+?%TUhn)4jokm$VQM)MZ2ua1uj&Kp=hGFrlaAkU;+ zn?2{cbG+H}+e^4gl&0=?W}IUkLNdZTyye!u(E_VxANeIZ`UWv#fGMc-XJ zx&NVK;orD>m%jdb?VebX%J=+y>7(`g{s!N%`O0SV<>qaJug{DsrZvlieznq!sAJij z=HK9(b#tL^*2#~8tBqyc3jdssHR3AN_g|RuV#4_cEk9~s$z7e>IaSlDgClAA=~kJR zdxrbg9eAQpG$Euojr-&xKEI8f8Ll_}FvSNP*yF?ydRSxP^$80XKVPs(sq54!mRq-^ z7R;_(C)pWT&&+f1Tdw=Z$Ny^>)%&(5-kD*f6J;UO<{EwKYk_0W!|5#6lUAlLo_j1~ z;e~%aR}PxIe_~j2#3TRXCDDCTbW}#NF(o}WY_8oz6`mWGll+)nmoD9r7{z+Y*2+)LzT5uq z<@%$0<>z+x+gC`pg-@L@p-}j!&$@o|kP9&z+vjuSFKJT@>lI;|`HJh(-ljW_fy{4n zHPjL&eCh4j^QDqt!W>? z*^yQ3{Mh9mZ>^l^rVCpwbXLoxpWF7({@-8CH+PHK3Qr%|!)m^0&6E$5c;48Qs(cnO zJXxq?vgFY>bGEHV7r#>CFSF~<(0O)MC+gYyJ;_@ax=*eO&OTqyBWqTqYrK2ihT@jW zr6vY*O&+yXzhi&D>s6nsiCyzMcJmj@yt+@Jd$xRLvHN#GNhj7&Qq?u^$*&b}PP>QncU@JU zYk2VYD~QQLW-PEhkUh_*pw|nXOgcx4*BCtobT}UvJIPJO8Su z=$^@zT{-t$HW=hD64AOX*8A^B*ak;#iIr09mzKI1c_d0OpHQ83YsJe~hQ$rry)||o z@SUQWF^y@W;u0CojcqLb7an_V&t-OtG3mb%Q_hlo{oSFNk7Xt7%{o6OyBj&0&KF#o zdFVkvm3Pw4E(>i(gIl#{PgY7;mrXJLzpeGyAJ^_Pd%~~WxIFt$-cR1`e`hbtwA{b$ zoJwxp(v$mFF=!|zOpA$~yRvtMFXNBf-wsF5>z*?6{*K(2pZd?vt6jY>^>tYK?QOYk zT345=PTtZ#f5XNsB`(|Dns3S;Wb$#`n`e@!;uiM2;8BJ4zaRZ|ZK2!mx16snQ+4aR zHqmRTMP&M=Y3t_X1Z|77uxjOTWt<$ox^RQYDvtY0|AzM^O_=yu|M6{a)+_$}EVVU1 znofzFyt4k$q|+G(ls~P~iVBQAo^9#U-BtLhgsuPY7uKBWmn^4FFP>pvFEZ&P%V{~r z%u`o7R=74qE%IKWav@9V(o7RCFR#C|ZmGUo6?)qDeoy7vT-*PXN;lu1y(QtL=B~+B zvo_zlwR$^e?d|}BSdWI7(zaz+<=8$rYpi$*1f!;@Uh$TCmV&A zux-(J`|4oqrr(?Ig)p;!N?G}5^`0F(#p!$pk3O?|bFY%6x=jAia=YCE<#+dToK)i8 zyEnGSP(o()cbime#Uu|0KRT++-es1*Y)53HM}FfR zPDh=TFFVdFu1;%I+3;*}i+JpO&DVlQgpX!qzC9%DpL^udHK{QHYJFT9q=@JJdl^(8<2DIRywzwV#WtXb8DRVAFqdL_GN zq!wHk=)Qg6^tJp+XN4WEvwgKQxhcOOBI(GBtnJ&%R@&7cv{9bDS--60yIqjs*S7x` zww$!QAD_+eee%ZoXyN~TtO^qCrr8xUCn{vz@~vgqcfGFYuyEw{-?z^E{Jh_$@>cJy zZ(pVF{?&hbZP#wAl4E=;s<&rV-Qb@lx}!#K_Ve0R>b{bFpH`{($z~lqT>r6HXWdT5 z`F~P$kNK@|y7#W}aq1Jc3tGPxWKV2fV&YP-uBYK6B&xxrkb3@ZTJV!gm3=wt@>cuf z8GU|;eqo$ex?$C73-7jq5Y3p)XM{q+`VKYzRsVQoa&^Ijt;QN!z6tM|3>VaH)qSM$ zEJyI`2ay%MW+8KzWJEa5IofwmZpxIYRa?EY3y;f|OQ-2fdh7S~Z}+JLERQ1OC_ixpmvtq?|Cf`}xvecaC|s*|q%} z?p40M8t!{9!oM3lpR~2W?ZsW8h6$5joUoX@q@elz?`UiPwf}>@ z?)X^$=ji%>(>LxefBS8F-pw~#WA}LU?mqHtf|m2K+uMcmJk5okn?6Wed3buG;p6t? zBP+P(SYGZpUbl<2x_ob^eod9=Go2lB`z|-etP_tq^5n;?NS9gt$BuPd6z}4k88a(- zzeX8Pbo%s7eTFH&F4=I(wNI#gX>YM-eY|@(7ccMJ7rw&N6`cgF=O`S1f1oF*=~%0) zi>NjC@zc(iWmAm!PMvZ;SU#!ZTc>Qq?+RJH*iVLKNrGy=>)Iaw*z=b6=8qMElXus3 z7wNGsxoF4frgG>fYgT`_ZPu3J>$cx#ZO)&Yx@_}K{dN=pr zfw|?r3~U@t-t*K;9aqfQ@@xL<_}@Q^k9JP7pKV$HaC+V>ySo*cx5LtpZ(HlOYIpSF zx%QdzjClnDi{d9w_bdJHa&=SY1fK`HUb`oK`Qf$a=d;Q`7Z2MX%=OMY;LNXgr1WuH zamd6?yAvez5^iPeywb90QPL6Nj}2VQ4>@Kjx-*4E849Vos>N7qXA5{R6m_Wc#@O8I z$XLS>wx^?7FxY9W%_m>8|bL5O<$ip`K$J*4S6W~Ofg_nkZz94&sVOL$=*E z2G^xhR>9&48G#Z11ZM2wysT0&<;w02OPy{D?09qV+4)lkO+1SO4tY*+VO`F^lhaxI ze~m-6LG{Vf#B9AyZOr=D&+*6^NA`9=7e}=qn66WBR0Ge;cQtD+*t`>+l*0TcO%*dldz}>x?bSF4omG?P~X38`kqO z|I?4P91ODi{%`mP+9hs!D7<6o?C&*xr^{+=<+?-mfBGc1=ffeZ?QertC%)KnT<@)k zp7*K{$@yniX~xVoYd7F|CH&&SnVW7h))(6ilTUqM=fB&hYMQ)YrtOI@&jfn|?CYHr zTw4z0hAvHhA-Lm+MWblU?Wrav_vXxe`MgAKW537UZjl``JXWclQ_Rk~b7$l3(+8%> zKL2ntt;PLuo9F(Y%h6zU!uhGjOq1VB-zpo; zJTrf`)V~GmciaB_wC?V`ZEJ5&eCPdoi!Arb%ug3M1r+D(p1>hg$yoF*W9Ch}3pXbp zm(GuId6pz~dbiHD!xm`F)O_knhhb%-A&J?Om@DdGTps0a%c7JOR2X$J=HEXp7i34_ws%3#i#$Ny*YzbcFDVsCzMm< z>a>Obuj6Epb=;zGu(Rg!waA+x@3$*ig_+OY-Jl!2&8j-?Uh<1w3q_}j6c%}y#7;BU zOs?C?J|XE)(C+Ws)TEdD&r5usdGyrGA0OJqlU@`E&y3Md|LLJzpsaawu6M4G1sl4BR;_2%t(pwKUFUAc7& z^BYmcJKv`=tZ;VWzRjerR-5;ExlTs>^@qN88~%r`sej12eB;WKD!B_@T(6mYDB)Dp zL8r%Tvm9>4pE&#U;*ATv%I($$t9D87*CZ*5UH4;Jy3R7{f^C8O{RUedtL_R%(f(WS zf*!tO5tt~?=(0AcE=yF9N-M2WV`6+U=&^4RKg_czT{c2`)H z6K62N!@+oGkA2}Imhbbb-x=ob%hcLr`F+A;*L|Tihu`*JuKxc1e*W26rl)sGmHpo* z#!$*}rR%~G_r3dm?b5n_vi|6W9{aB`*856NiisRB#jOfI>neZvsb9+xoG;h;?w;C@GI^mR`V(S(r?h<( zZJ+RFlW3xX`}P`%EmM2qyY}w(e|Ylv>w;fbRC(4*HBDTsJ;5U&O<((!ct@a$`NE*t zp_6V3Y$;!+{jsd#>SDLb)$bxYcWys-sZ{yaT(9N-&4R@KJHA};^z9)ZyLFkf7;fG> z`?y?*;gx`5Z^esg$@+I>E-vnmm{u2m`DRs4ec|TM&lWbjsa-wnxvXH?bj@pT0y?{5 z1#Ja(IE8w!Md^Ke^kt>-hP^h^?%7Fyzq?=J>Qu2UIjh&miG49Nci?$h6=QwoZ_VRQ zqb(+BA1=%19`Dm8Sf{Bd1sZW*@tekR}}NJI=^Lq%MR`OzLsHy^HSrN zzqbkN-&=8fZob>+bI0$#%Ug4Q&&kTn<9dg;oQJ-uTS zwR@9A;V#jaA?DMr`yFJK-z}=VO8LWE4Zrzsj_J?8(K-Fwm1K{leH)Y87H`?nB5WLT z#&Na>i}n4Ly&IOiH0WS>yxZqOhpO*FJ#<6po0Po6gIZ|t6|kCIlC zlYVWVz3lH^(T0PEj=gL4zaJYksYasB?DYr5MN4u^`{b;%BzCPkx;RkHxk%49_m5~v z(&Y+cO|}XX;hl5Z+uF)x>ha&pRF!K zvn#ha|61Ly8P}edI{)7MD5d7ex3|`NzrHqGuzhi`{vzMSnz72|PX&I~Ka?&A*FE>D zv+mH8Cof-}e0b%iUQE_o2NkX!skp|CS4BIb1@GOA4hi+z*Qgc7%N58mb=!_w-J^dF zw}tOq{eIu+8K%`@MwZWQOun!8R=@XCe%7+5_p@L9t5s)sl^}F#mZ^_`pShj&v9r?m zukpNXtT9Wr+x{)``-9i(Gk4~=sXx;>JNqpcQ-=^kq2Q^vPhBFnO?Bbp-12ovf`n#B zsLI6Ge#(y`FDRDDf8Oz^CB(Dwn(vhYFQccQ%!FKzzI!xlX~e#yofCC5eM?J9{~9FD zst+})EPcJZ_0ivZ90wB87H+L`&Mh?g$!k43Hmd#+%e?wun#&jd+v2GhVH4StGw&B? zcW#`^+-WyfP1A15i0hRIe_4?iR>sX(65ix!a`A}y(vl6?#=p&^Oec6QwM@7CeXaG~ zs@?C+R=f2%rKex#o;m0LTF%Y(TfXmoa<~2gi-RodGNa??ZtXlQz0YRNmKXXy>egA` z&spVH$IiWf;l>f4)I;|*Z)^MtKe4U!kyfkQG=ZaE96zV>skl^l>{kzHuc)3k_xOib zg6H09zcUcKw=Jaj>x+$h9@i|d__FBuy?){U`$QQ^3#R$-RfM0`7QXZD+kOL^@0$hh zUYuX~tn&NQ*xg@i?A8ai3f;_lr>IcdoFJTD+45aX={)jLFLfg}T!`OR zGxOo>k0IB-{o7yR`qN${{Oo>@huU)US81-g^ZRYZeXauuiOHW2KF`uQ`}^1g#iiTl z+t*6@+y32B`{Y96j{(_4AosVnv8A>>G=7w)_zyE#Stq(h0j{Is9?C-g|H`UrK_tt~B zoJ=zf_WURpUSZuACRa0>f*F~d7tCQk`(g*@j>&^0JTFTxG`Nn(OXn(9x-c#W} zl}C=Qu6$Cv{GI&HO1%iz3XH99p|_Z|v%|k*k&${WQB>_W$~Y=xH_nm$j-pS|Wp;=S=#Z z7xBGXm!V`s{Pwqr&KH-@x>r*dRvGzbw&Cd=+t)4?^tb=2{rrITZyw+0x6F?Sb1u1@ zDa5qt zecRgu+jixiyIGvhd%LXQ^jY)WtKaXryeILL%9kaZew(a)W34(_%vO1wv&;4G^UM41 zmorN6_~zJcEsQvH{r~RgzvH*QjVb!v9sTE&qVty9)2}Vsy?lM}^$*v7ZhG;(oqzpX z<>F629`_qB|5*N)=l}N~y_`)JdUJ1>J^pi0eBS Path: + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA") or Path.home()) + elif sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + else: + base = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) + return base / "mediahive" + + +def config_path() -> Path: + return config_dir() / "config.toml" + + +def load_config() -> Config: + path = config_path() + if path.exists(): + try: + return msgspec.toml.decode(path.read_bytes(), type=Config) + except Exception: + return Config() + return Config() + + +def save_config(cfg: Config) -> None: + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(msgspec.toml.encode(cfg)) diff --git a/mediahive/hivescan/showreel.py b/mediahive/hivescan/showreel.py index 43d9807..049ef78 100644 --- a/mediahive/hivescan/showreel.py +++ b/mediahive/hivescan/showreel.py @@ -11,6 +11,8 @@ import json import logging import re import shlex +import subprocess +import sys from collections import Counter from pathlib import Path from typing import Optional @@ -19,6 +21,13 @@ from aiopathlib import AsyncPath logger = logging.getLogger("hivescan.showreel") +# Suppress console windows when spawning subprocesses on Windows +async def _subprocess_exec(*args, **kwargs): + """Wrap asyncio.create_subprocess_exec to hide console windows on Windows.""" + if sys.platform == "win32": + kwargs.setdefault("creationflags", subprocess.CREATE_NO_WINDOW) + return await asyncio.create_subprocess_exec(*args, **kwargs) + # Showreel timestamp positions in seconds (5, 10, 15, 20, 25 minutes) SHOWREEL_TIMESTAMPS = [5 * 60, 10 * 60, 15 * 60, 20 * 60, 25 * 60] @@ -142,7 +151,7 @@ async def get_av1_encoder() -> str: # Check for NVIDIA AV1 encoder try: - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( "ffmpeg", "-hide_banner", "-encoders", @@ -152,7 +161,7 @@ async def get_av1_encoder() -> str: stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) if b"av1_nvenc" in stdout: # Verify it actually works (driver support) - test_proc = await asyncio.create_subprocess_exec( + test_proc = await _subprocess_exec( "ffmpeg", "-f", "lavfi", @@ -220,7 +229,7 @@ async def detect_dovi_profile(video_path: str) -> Optional[int]: video_path, ] logger.debug(" $ %s", shlex.join(cmd)) - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -259,7 +268,7 @@ async def detect_dovi_profile(video_path: str) -> Optional[int]: "csv=p=0", video_path, ] - codec_proc = await asyncio.create_subprocess_exec( + codec_proc = await _subprocess_exec( *codec_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -299,7 +308,7 @@ async def is_hdr_video(video_path: str) -> bool: Returns True if the video has HDR metadata (bt2020, SMPTE ST 2084, etc.) """ try: - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( "ffprobe", "-v", "quiet", @@ -368,7 +377,7 @@ async def detect_crop(video_path: str) -> Optional[str]: video_path, ] logger.debug(" $ %s", shlex.join(dim_cmd)) - dim_proc = await asyncio.create_subprocess_exec( + dim_proc = await _subprocess_exec( *dim_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -409,7 +418,7 @@ async def detect_crop(video_path: str) -> Optional[str]: "-", ] logger.debug(" $ %s", shlex.join(cmd)) - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -494,7 +503,7 @@ async def get_video_duration(video_path: str) -> Optional[float]: Get the duration of a video file in seconds using ffprobe. """ try: - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( "ffprobe", "-v", "quiet", @@ -664,7 +673,7 @@ async def generate_showreel_images( logger.debug(" $ %s", shlex.join(cmd)) try: - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -837,7 +846,7 @@ async def generate_episode_reel( logger.debug(" $ %s", shlex.join(cmd)) try: - proc = await asyncio.create_subprocess_exec( + proc = await _subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, diff --git a/mediahive/models/protocol.py b/mediahive/models/protocol.py index 95a23f2..a407c72 100644 --- a/mediahive/models/protocol.py +++ b/mediahive/models/protocol.py @@ -79,6 +79,12 @@ class OpenFolderRequest(msgspec.Struct): folder_path: str = "" +class ChangeFolderRequest(msgspec.Struct): + """POST /api/change-folder body.""" + + folder: str + + # --------------------------------------------------------------------------- # FastAPI response helper # --------------------------------------------------------------------------- diff --git a/mediahive/server.py b/mediahive/server.py index 831dbaf..3ea6543 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -15,16 +15,26 @@ import sys from contextlib import asynccontextmanager from pathlib import Path +# Suppress console windows when spawning subprocesses on Windows +_POPEN_KWARGS: dict = ( + {"creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} +) + import aiofiles import msgspec +import msgspec.structs from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, StreamingResponse from fastapi_vue import Frontend +from mediahive.config import load_config, save_config +from mediahive.hivescan.scanner import start as start_scanner +from mediahive.hivescan.scanner import stop as stop_scanner from mediahive.index_store import IndexStore from mediahive.models.events import ScanEvent, Task, Upsert from mediahive.models.protocol import ( + ChangeFolderRequest, MsgspecResponse, PlayMediaRequest, OpenFolderRequest, @@ -145,6 +155,76 @@ async def health_check(): return {"status": "ok"} +@app.get("/api/config") +async def get_config(): + """Return current server configuration.""" + return {"media_folder": str(MEDIAROOT) if MEDIAROOT else None} + + +@app.post("/api/change-folder") +async def change_folder_endpoint(request: Request): + """Switch the media root folder without restarting the server. + + Validates and persists the new folder, then returns immediately. + The actual in-memory switch runs as a background task so the HTTP + response is not held up by the (potentially slow) scanner teardown. + The client should poll /api/config or reload after a short delay. + """ + body = msgspec.json.decode(await request.body(), type=ChangeFolderRequest) + new_root = Path(body.folder).resolve() + if not new_root.exists() or not new_root.is_dir(): + raise HTTPException(status_code=400, detail=f"Folder does not exist: {new_root}") + + # Persist first — if the background switch crashes, the next launch still uses the new path + cfg = load_config() + save_config(msgspec.structs.replace(cfg, media_folder=str(new_root))) + logger.info("Config saved: media_folder=%s", new_root) + + # Schedule the in-memory switch without blocking this response + asyncio.create_task(_switch_folder(new_root)) + return {"status": "ok"} + + +async def _switch_folder(new_root: Path) -> None: + global MEDIAROOT, store, _consumer_task, _scan_events + + try: + # Cancel scanner tasks immediately — no need to wait 30 s + await stop_scanner() + + # Tear down the old event consumer + if _consumer_task and not _consumer_task.done(): + _consumer_task.cancel() + try: + await _consumer_task + except asyncio.CancelledError: + pass + + # Flush the old index snapshot + if store: + await store.flush_snapshot() + + # Update env and module globals + os.environ["MEDIAHIVE_PATH"] = str(new_root) + MEDIAROOT = new_root + + # Fresh event queue — discard any stale events from the old folder + _scan_events = asyncio.Queue() + + # Re-initialise the index store + snapshot_path = MEDIAROOT / ".mediahive" / "index.json" + store = IndexStore(snapshot_path, media_root=str(MEDIAROOT)) + await store.load_snapshot() + + # Restart consumer and scanner + _consumer_task = asyncio.create_task(_consume_scan_events()) + await start_scanner(_send_event) + + logger.info("Switched media folder to %s", MEDIAROOT) + except Exception: + logger.exception("Error switching media folder to %s", new_root) + + @app.get("/api/index") async def get_index(): """Return the full media index from the in-memory store.""" @@ -250,10 +330,10 @@ async def open_folder(request: Request): if sys.platform == "win32": if target_path.is_file(): # Open parent folder and select the file - subprocess.Popen(["explorer", "/select,", str(target_path)]) + subprocess.Popen(["explorer", "/select,", str(target_path)], **_POPEN_KWARGS) else: # Open the folder directly - subprocess.Popen(["explorer", str(target_path)]) + subprocess.Popen(["explorer", str(target_path)], **_POPEN_KWARGS) elif sys.platform == "darwin": if target_path.is_file(): subprocess.Popen(["open", "-R", str(target_path)]) diff --git a/mediahive/winmain.py b/mediahive/winmain.py new file mode 100644 index 0000000..767e7ce --- /dev/null +++ b/mediahive/winmain.py @@ -0,0 +1,220 @@ +"""GUI launcher for MediaHive using pywebview. + +Run with: python -m mediahive.winmain [media_folder] +Or from PyInstaller: MediaHive.exe [media_folder] +""" + +import argparse +import logging +import os +import sys +import threading +import time +import urllib.request +from pathlib import Path + +import uvicorn +import webview +import msgspec.structs + +from mediahive.__main__ import DEFAULT_PORT, resolve_media_root +from mediahive.config import Config, load_config, save_config + +BACKEND_HOST = "127.0.0.1" +BACKEND_PORT = 8420 +BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}" +HEALTH_TIMEOUT = 2 # seconds + + +def _setup_logging() -> Path: + """Redirect stdout/stderr and configure logging to a log file in %APPDATA%/mediahive/. + + In a PyInstaller --windowed build there is no console, so any print() or + unhandled exception traceback would be lost. This ensures everything ends + up in a persistent log file the user can send for bug reports. + Returns the path to the log file. + """ + from mediahive.config import config_dir + + log_dir = config_dir() + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / "mediahive.log" + + # Rotate: keep previous run as .log.1 + prev = log_path.with_suffix(".log.1") + if log_path.exists(): + if prev.exists(): + prev.unlink() + log_path.rename(prev) + + log_file = open(log_path, "w", encoding="utf-8", buffering=1) # line-buffered + + # Redirect raw stdout/stderr so print() and tracebacks go to the file + sys.stdout = log_file + sys.stderr = log_file + + # force=True removes handlers added by uvicorn/fastapi during import so that + # basicConfig actually takes effect (without it, it's a silent no-op) + logging.basicConfig( + force=True, + handlers=[logging.FileHandler(log_path, encoding="utf-8")], + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + logging.getLogger("mediahive.winmain").info("MediaHive started") + return log_path + +# Minimal branded setup page shown while the native folder dialog is open. +_SETUP_HTML = """ + +

MediaHive

Choose a folder that contains your media…

+""" + + +class JsApi: + """Python methods exposed to the frontend via window.pywebview.api.""" + + def __init__(self) -> None: + self._window: webview.Window | None = None + + def pick_folder(self) -> str | None: + """Open a native OS folder picker and return the chosen path (or None).""" + if not self._window: + return None + result = self._window.create_file_dialog(webview.FOLDER_DIALOG) + return result[0] if result else None + + +def _prepend_meipass_to_path() -> None: + """When frozen, ensure bundled binaries (ffmpeg) are found first on PATH.""" + if getattr(sys, "frozen", False): + meipass = sys._MEIPASS # type: ignore[attr-defined] + os.environ["PATH"] = meipass + os.pathsep + os.environ.get("PATH", "") + + +def _wait_for_backend(timeout: int = HEALTH_TIMEOUT) -> bool: + url = BACKEND_URL + "/api/health" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=2): + return True + except Exception: + time.sleep(0.25) + return False + + +def _icon_path() -> str | None: + """Locate the application icon at runtime (frozen or development).""" + if getattr(sys, "frozen", False): + base = Path(sys._MEIPASS) # type: ignore[attr-defined] + else: + base = Path(__file__).parent + ico = base / "assets" / "mediahive.ico" + return str(ico) if ico.exists() else None + + +def _run_initial_setup() -> str | None: + """Show a setup window, prompt for a folder, then close and return the path. + + Returns the chosen folder path, or None if the user cancelled. + The window is always destroyed before this function returns so winmain() + can continue without a process restart. + """ + chosen: list[str] = [] + window = webview.create_window( + "MediaHive — Setup", + html=_SETUP_HTML, + width=520, + height=300, + resizable=False, + ) + + def on_shown() -> None: + result = window.create_file_dialog(webview.FOLDER_DIALOG) + if result: + chosen.append(result[0]) + window.destroy() + + webview.start(func=on_shown, icon=_icon_path()) + return chosen[0] if chosen else None + + +def winmain() -> None: + parser = argparse.ArgumentParser(description="MediaHive") + parser.add_argument( + "media_folder", + nargs="?", + help="Path to the media folder (default: saved config, MEDIAHIVE_PATH, or cwd)", + ) + args = parser.parse_args() + + _prepend_meipass_to_path() + + # In a frozen (windowed) build there is no console — redirect output to a log file + if getattr(sys, "frozen", False): + _setup_logging() + + # Resolution order: CLI arg → MEDIAHIVE_PATH env → saved config → ask user + folder = args.media_folder or os.environ.get("MEDIAHIVE_PATH") or load_config().media_folder + + if not folder: + folder = _run_initial_setup() + if not folder: + return # user cancelled the folder picker + + mediaroot = resolve_media_root(folder) + os.environ["MEDIAHIVE_PATH"] = mediaroot.as_posix() + + # Persist the resolved path so subsequent launches remember it. + cfg = load_config() + if cfg.media_folder != mediaroot.as_posix(): + save_config(msgspec.structs.replace(cfg, media_folder=mediaroot.as_posix())) + + # Run the FastAPI backend on a background thread so the main thread is + # free for pywebview (Edge WebView2 requires the GUI on the main thread). + config = uvicorn.Config( + "mediahive.server:app", + host=BACKEND_HOST, + port=DEFAULT_PORT, + loop="asyncio", + log_level="warning", + ) + server = uvicorn.Server(config) + backend_thread = threading.Thread( + target=server.run, daemon=True, name="mediahive-backend" + ) + backend_thread.start() + + if not _wait_for_backend(): + server.should_exit = True + raise RuntimeError(f"Backend did not become ready within {HEALTH_TIMEOUT}s") + + api = JsApi() + window = webview.create_window( + title="MediaHive", + url=BACKEND_URL, + fullscreen=True, + js_api=api, + ) + + def on_shown() -> None: + api._window = window + + webview.start(func=on_shown, icon=_icon_path()) + + server.should_exit = True + backend_thread.join(timeout=10) + + +if __name__ == "__main__": + winmain() diff --git a/pyproject.toml b/pyproject.toml index 4a88007..1709e21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "httpx[http2]>=0.28.1", "msgspec>=0.19", "parse-torrent-title>=2.8.1", + "tomli-w>=1.2.0", "uvicorn[standard]>=0.40.0", ] @@ -37,6 +38,13 @@ package = true [tool.uv.sources] parse-torrent-title = { git = "https://github.com/platelminto/parse-torrent-title.git" } +[project.optional-dependencies] +gui = [ + "pywebview>=6.2.1", + "pythonnet>=3.1.0rc0; platform_system == 'Windows' and python_version >= '3.14'", + "pyinstaller>=6.0", +] + [dependency-groups] dev = [ "httpx>=0.28.1", diff --git a/scripts/MediaHive.spec b/scripts/MediaHive.spec new file mode 100644 index 0000000..6a3aa79 --- /dev/null +++ b/scripts/MediaHive.spec @@ -0,0 +1,98 @@ +# MediaHive.spec — PyInstaller build for the Windows GUI application +# +# Build manually (from repo root): +# uv run --no-project --python 3.14 --with ".[gui]" --with pyinstaller ^ +# pyinstaller --noconfirm --clean scripts/MediaHive.spec +# +# Or use the build script (recommended—handles versioning and packaging): +# uv run scripts/build_windows_gui.py + +import mediahive.winmain +import mediahive.server +from pathlib import Path + +block_cipher = None + +_pkg = Path(mediahive.server.__file__).parent +_frontend_build = _pkg / "frontend-build" +_icon = _pkg / "assets" / "mediahive.ico" +_ffmpeg = Path(SPECPATH).parent / "build" / "ffmpeg" / "ffmpeg.exe" + +a = Analysis( + [mediahive.winmain.__file__], + pathex=[], + binaries=[ + # Bundle ffmpeg so showreel generation works without a system install. + # Populated by build_windows_gui.py before PyInstaller runs. + (str(_ffmpeg), "."), + ], + datas=[ + # Bundled Vue frontend served by the FastAPI backend + (str(_frontend_build), "mediahive/frontend-build"), + (str(_icon), "mediahive/assets"), + ], + hiddenimports=[ + # uvicorn dynamic imports + "uvicorn.logging", + "uvicorn.loops", + "uvicorn.loops.auto", + "uvicorn.loops.asyncio", + "uvicorn.protocols", + "uvicorn.protocols.http", + "uvicorn.protocols.http.auto", + "uvicorn.protocols.http.h11_impl", + "uvicorn.protocols.websockets", + "uvicorn.protocols.websockets.auto", + "uvicorn.protocols.websockets.websockets_impl", + "uvicorn.lifespan", + "uvicorn.lifespan.on", + # mediahive & hivescan modules imported at runtime + "mediahive.server", + "mediahive.hivescan.scanner", + "mediahive.hivescan.indexer", + "mediahive.hivescan.scanning", + "mediahive.hivescan.images", + "mediahive.hivescan.showreel", + "mediahive.hivescan.tmdb_client", + # async / ASGI internals + "anyio", + "anyio._backends._asyncio", + "starlette.routing", + # msgspec TOML write backend + "tomli_w", + ], + hookspath=[], + runtime_hooks=[], + excludes=[], + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="MediaHive", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + icon=str(_icon), + # windowed=True hides the console; the backend subprocess inherits this + console=False, + windowed=True, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="MediaHive", +) diff --git a/scripts/winbuild.py b/scripts/winbuild.py new file mode 100644 index 0000000..05b8eaf --- /dev/null +++ b/scripts/winbuild.py @@ -0,0 +1,133 @@ +"""Build the Windows GUI application and package it as a version-numbered ZIP. + +Usage: + uv run scripts/build_windows_gui.py + +This runs in the project environment where dependencies are available via pyproject.toml. + +This script: + 1. Reads the version from pyproject.toml + 2. Runs `uv build` to produce the wheel/sdist + 3. Downloads the latest ffmpeg.exe + 4. Builds MediaHive.exe using PyInstaller + 5. Creates a ZIP file with the version number +""" + +import io +import shutil +import subprocess +import sys +import tomllib +import urllib.request +import zipfile +from pathlib import Path + +# BtbN automated builds always publish a 'latest' tag with this asset. +_FFMPEG_URL = ( + "https://github.com/BtbN/ffmpeg-builds/releases/download/latest" + "/ffmpeg-master-latest-win64-gpl.zip" +) +_FFMPEG_STAGING = Path(__file__).parent.parent / "build" / "ffmpeg" + + +def fetch_ffmpeg() -> Path: + """Download latest ffmpeg.exe from BtbN builds into build/ffmpeg/.""" + dest = _FFMPEG_STAGING / "ffmpeg.exe" + if dest.exists(): + print(f"ffmpeg already staged at {dest}, skipping download.") + return dest + + _FFMPEG_STAGING.mkdir(parents=True, exist_ok=True) + print(f"Downloading ffmpeg from {_FFMPEG_URL} ...") + with urllib.request.urlopen(_FFMPEG_URL) as resp: + data = resp.read() + + print("Extracting ffmpeg.exe ...") + with zipfile.ZipFile(io.BytesIO(data)) as zf: + # The zip contains a top-level folder; ffmpeg.exe is under .../bin/ + ffmpeg_entry = next( + name for name in zf.namelist() + if name.endswith("/bin/ffmpeg.exe") + ) + with zf.open(ffmpeg_entry) as src, open(dest, "wb") as out: + out.write(src.read()) + + print(f"ffmpeg staged at {dest} ({dest.stat().st_size // 1024 // 1024} MB)") + return dest + + +def read_version() -> str: + """Read version from pyproject.toml.""" + pyproject = Path(__file__).parent.parent / "pyproject.toml" + with open(pyproject, "rb") as f: + data = tomllib.load(f) + return data["project"]["version"] + + +def build_wheel() -> None: + """Run uv build to produce the wheel and sdist.""" + repo_root = Path(__file__).parent.parent + cmd = ["uv", "build"] + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=repo_root) + if result.returncode != 0: + raise RuntimeError(f"uv build failed with exit code {result.returncode}") + + +def build_exe() -> None: + """Run PyInstaller to build the executable.""" + repo_root = Path(__file__).parent.parent + spec_file = Path(__file__).parent / "MediaHive.spec" + cmd = [ + sys.executable, "-m", "PyInstaller", + "--noconfirm", "--clean", + "--distpath", str(repo_root / "build"), + "--workpath", str(repo_root / "build" / ".pyinstaller-work"), + str(spec_file), + ] + print(f"Running: {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=repo_root) + if result.returncode != 0: + raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}") + + +def create_zip(version: str) -> Path: + """Create a version-numbered ZIP file of the dist/MediaHive folder.""" + repo_root = Path(__file__).parent.parent + dist_folder = repo_root / "build" / "MediaHive" + + if not dist_folder.exists(): + raise FileNotFoundError(f"Distribution folder not found: {dist_folder}") + + zip_name = f"MediaHive-{version}-win64.zip" + zip_path = repo_root / "build" / zip_name + zip_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"Creating {zip_path}...") + shutil.make_archive( + str(zip_path.with_suffix("")), # removes .zip so make_archive can add it + "zip", + root_dir=str(dist_folder), # zip contents of MediaHive/, not the folder itself + ) + return zip_path + + +def main() -> None: + try: + version = read_version() + print(f"MediaHive version: {version}") + + fetch_ffmpeg() + build_wheel() + build_exe() + zip_path = create_zip(version) + + print(f"✓ Built successfully: {zip_path}") + print(f" Size: {zip_path.stat().st_size / (1024 * 1024):.1f} MB") + except Exception as e: + print(f"✗ Build failed: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()