From c4ad4e1970af071ea7bea37eb3f3ab54491f7cf1 Mon Sep 17 00:00:00 2001 From: vNawar Date: Wed, 27 Aug 2025 00:14:56 +0300 Subject: [PATCH] add grid view with thumbnail preview of videos --- app/main.py | 61 +++++++++++++-- ui/src/app/app.component.html | 112 ++++++++++++++++++---------- ui/src/app/app.component.sass | 30 +++++++- ui/src/app/app.component.ts | 42 +++++++++++ ui/src/app/downloads.service.ts | 1 + ui/src/assets/audio-placeholder.png | Bin 0 -> 9315 bytes 6 files changed, 201 insertions(+), 45 deletions(-) create mode 100644 ui/src/assets/audio-placeholder.png diff --git a/app/main.py b/app/main.py index 20c399f..b705df4 100644 --- a/app/main.py +++ b/app/main.py @@ -5,19 +5,19 @@ import os import sys import asyncio from pathlib import Path -from aiohttp import web -from aiohttp.log import access_logger +from aiohttp import web # type: ignore +from aiohttp.log import access_logger # pyright: ignore[reportMissingImports] import ssl import socket -import socketio +import socketio # type: ignore import logging import json import pathlib import re -from watchfiles import DefaultFilter, Change, awatch +from watchfiles import DefaultFilter, Change, awatch # type: ignore from ytdl import DownloadQueueNotifier, DownloadQueue -from yt_dlp.version import __version__ as yt_dlp_version +from yt_dlp.version import __version__ as yt_dlp_version # type: ignore log = logging.getLogger('main') @@ -345,6 +345,57 @@ if config.URL_PREFIX != '/': def index_redirect_dir(request): return web.HTTPFound(config.URL_PREFIX) +@routes.get(config.URL_PREFIX + 'thumb') +async def thumb(request): + # Query: base=video|audio, file=, folder=, t= + base = request.query.get('base', 'video') + file = request.query.get('file') or '' + folder = request.query.get('folder', '').strip('/') + t = request.query.get('t', '1') + + # Choose base dir + base_dir = config.AUDIO_DOWNLOAD_DIR if base == 'audio' else config.DOWNLOAD_DIR + + # Build absolute path and secure it + # file is a filename relative to the download folder for that item + # folder (if any) is relative to base_dir + rel_parts = [p for p in [folder, file] if p] + abs_path = os.path.realpath(os.path.join(base_dir, *rel_parts)) + real_base = os.path.realpath(base_dir) + if not abs_path.startswith(real_base): + raise web.HTTPBadRequest(text='Invalid path') + if not os.path.exists(abs_path): + raise web.HTTPNotFound() + + # For audio, return 404 so the img error handler hides it (you’ll show a placeholder instead) + audio_exts = ('.mp3', '.m4a', '.opus', '.wav', '.flac') + if abs_path.lower().endswith(audio_exts): + raise web.HTTPNotFound() + + # Run ffmpeg to stdout (single frame, scaled to width 320) + cmd = [ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', + '-ss', t, '-i', abs_path, + '-frames:v', '1', + '-vf', 'scale=320:-1', + '-f', 'mjpeg', 'pipe:1', + ] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + except FileNotFoundError: + raise web.HTTPServiceUnavailable(text='ffmpeg not found') + + data, err = await proc.communicate() + if proc.returncode != 0 or not data: + raise web.HTTPNotFound(text='Could not generate thumbnail') + + resp = web.Response(body=data, content_type='image/jpeg') + # Optional caching + resp.headers['Cache-Control'] = 'public, max-age=86400' + return resp + routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE) routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE) routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube/browser')) diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index a536154..41355d8 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -321,7 +321,17 @@ -
Completed
+
+ Completed +
+ + +
+
@@ -329,49 +339,73 @@
-
- - - - - - - - - - - - - - - - - -
- - VideoFile Size
- - -
- - +
+
+ + + + + + + + + + + + + + + + + +
+ + VideoFile Size
+ + +
+ + +
+ {{ download.value.title }} + + {{download.value.title}} +
{{download.value.msg}}
+
Error: {{download.value.error}}
+
+
+ {{ download.value.size | fileSize }} + +
+ + + + +
+
+
+
+
+
+
+
+ +
+
+ {{ download.value.title }} + {{ download.value.title }}
- {{ download.value.title }} - - {{download.value.title}} -
{{download.value.msg}}
-
Error: {{download.value.error}}
-
-
- {{ download.value.size | fileSize }} - -
+ {{ download.value.size | fileSize }} +
+ -
+
+ + + diff --git a/ui/src/app/app.component.sass b/ui/src/app/app.component.sass index 1a3e6ce..5e65182 100644 --- a/ui/src/app/app.component.sass +++ b/ui/src/app/app.component.sass @@ -29,8 +29,11 @@ button.add-url font-weight: 300 position: relative background: var(--bs-secondary-bg) - padding: 0.5rem 0 + padding: 0.5rem 0.75rem margin-top: 3.5rem + display: flex + align-items: center + justify-content: space-between .metube-section-header:before content: "" @@ -42,6 +45,31 @@ button.add-url border-left: 9999px solid var(--bs-secondary-bg) box-shadow: 9999px 0 0 var(--bs-secondary-bg) +.metube-view-toggle .btn + min-width: 96px + font-weight: 500 + +.metube-view-toggle .btn-outline-secondary + background-color: var(--bs-body-bg) + border-color: var(--bs-border-color) + color: var(--bs-body-color) + +.metube-view-toggle .btn-outline-secondary:hover + background-color: var(--bs-secondary-bg) + +.metube-view-toggle .btn-primary + box-shadow: 0 .25rem .5rem rgba(0,0,0,.08) + +.thumb + width: 80px + object-fit: cover + border-radius: 4px + vertical-align: middle + +.thumb-grid-img + aspect-ratio: 16/9 + object-fit: cover + button:hover text-decoration: none diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 1f9b144..11c004b 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -11,6 +11,7 @@ import { MasterCheckboxComponent } from './master-checkbox.component'; import { Formats, Format, Quality } from './formats'; import { Theme, Themes } from './theme'; import {KeyValue} from "@angular/common"; +import { faList, faTableCells, faGrip } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'app-root', @@ -43,6 +44,10 @@ export class AppComponent implements AfterViewInit { ytDlpVersion: string | null = null; metubeVersion: string | null = null; isAdvancedOpen = false; + viewMode: 'list' | 'grid' = 'list'; + faList = faList; + faTableCells = faTableCells; + faGrid = faGrip; // Download metrics activeDownloads = 0; @@ -504,6 +509,43 @@ export class AppComponent implements AfterViewInit { this.isAdvancedOpen = !this.isAdvancedOpen; } + getThumbnailUrl(dl: Download): string | null { + if (this.isAudioDownload(dl)) { + // show your local placeholder for audio + return 'assets/audio-placeholder.png'; + } + // Build query: base (video/audio), folder (if any), file (the filename) + const params = new URLSearchParams(); + // Decide base by the same logic you use for buildDownloadLink + const isAudio = dl.quality === 'audio' || + (dl.format && ['mp3','m4a','opus','wav','flac'].includes(dl.format)); + params.set('base', isAudio ? 'audio' : 'video'); + if (dl.folder) params.set('folder', dl.folder); + if (dl.filename) params.set('file', dl.filename); + params.set('t', '1'); // second to seek; tweak if you like + return `thumb?${params.toString()}`; + } + + onImgError(ev: Event) { + const img = ev.target as HTMLImageElement | null; + if (img) img.style.display = 'none'; + } + + isAudioDownload(dl: Download): boolean { + const audioExts = ['.mp3','.m4a','.opus','.wav','.flac']; + if (dl.quality === 'audio') return true; + if (dl.format && ['mp3','m4a','opus','wav','flac'].includes(dl.format)) return true; + if (dl.filename) { + const lower = dl.filename.toLowerCase(); + if (audioExts.some(ext => lower.endsWith(ext))) return true; + } + return false; + } + + setView(mode: 'list' | 'grid') { + this.viewMode = mode; localStorage.setItem('completedView', mode); + } + private updateMetrics() { this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cf63a5e..ecb6ca2 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -27,6 +27,7 @@ export interface Download { filename: string; checked?: boolean; deleting?: boolean; + thumbnail?: string; } @Injectable({ diff --git a/ui/src/assets/audio-placeholder.png b/ui/src/assets/audio-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..c012e2416d1b953a94b3a897fda3ff5030586d79 GIT binary patch literal 9315 zcmch7c|6qJ+y9v{c0!UOOcZq!Ekapl?(Ry;b}NxRDrIL98O&(8BSj@!*3cq5Wymrr zYhqe#lVxO=otPQJ%=4LHp6~1VKF|GoJ@@aAXI?KepL5Rpx~}(io$Fkm^Z6VR=B9?C zLNY=C08yh8M=bz=Lzi$Mzz6;O3T#`3eh`==MrQ<|Pq4s+aHuWlbK)EZ!gsR&U`KW( z1wx%$uO73$dfNNa)qwMU7ePQkfch0LcZ|zEZsa|r7 zy;aEA-0XlJjV$$QUVG^FtIE^Y%PjTEgv_?wOUx*%xn*AO%cnk^aNS%aOx$DRM&$8$ zJAn=F-XGs$`$zMWoy|{dEZol$vQo2B`4?Z8;2N}w?*BDyfK4kQIi%SyUO&Oxo%hWK z8$y!rSgulQ4vk(ujQjC5ue=*0Hrz4NF|0KbHC&I``dQw#bcQ^scdUXY{`1f{CC)a+ zHZ93FWrEKN&oK#_< znx^~}Z?}p82|RE*B+2T=kPd|%se+>sNKp1=h!tg55Tnv|02~Eyyt@gLI`lkQ=V>1d z3E8Ne`<+M{iA&bpejkX#1V9YVm}z$C+>s#8=oS7N7t_ZNyg)cWpr6FBdes>>*)aUC z)Fo<-f&km#iAjB2!EzX*kryRxEemrmhdSkZ)IGn!<phIWt4GvRb7T|#04Cs_6 zNxu6LYm9korc7_GiB^HY#t88>OVYqSMc{XiA3?Cf)_--&{;S*P)4#e0|E0u7aZNQG zaEKxS;8^+xO$3zHkSrRk$+DX#w82q4Km!oLhZhhB0s#31@(K=MV28{a5`UBjjSv6` z@Zd5;8-@p9qbQI-LWOz|hL~WWsRnq?6u=04z$bbbCqWuM08%Ff=J$7kWWj zOuW1%bk||*`}qv1bt?gd-8dum-`{<3}APxdUz6Q2xr;kzZFr4Bu2fyB2_q z{1mK7E(cQ+3yyO9vLGP>_nLEpwPUQS=>WMzBk-Bc+A|M{QM== zV z)*u_!A?aMm5Er7ug@~?0oVbuSE<}?H5m|>IxsX~eWG@#Y%;f@K=kl5hQR6~{xLml9 zEG}dh7b3{z!i7BMLUwQ=0$eU!$Sp1e$%XK9xxm)BT;)QxaUpzME?me3E@TTA!pr5t zg`D9+HgO>cE*CE3C>J8mh465>fORgKT!;u40_Sq!LUwQ=0$d1;%Y_Ts#DyTZ5byvA zLjn;5fW8p`z&VMJU}pogAYe!UUBJ-*%3%Z^K*)3T_ zJ(EJJQl<9x#Mzm7DaOM-2T{GIN%Dc&C`Pl~Dw#s;N%I^lf1EOXphy@$pv*)y>OYj# z?gy_Fc%w)6`3xlsI)L2J0pd>TOK&uwXlA$aMDd z;z8elqPSNaK;Qq1`2OPw`L6gKytT=$H!>R}p1I;dQs+pXDDw?kJCJ4LazjEY#9X)k@3I0^lB84|=S z1GWt@^~B=B`&l~r4_8?SVq$-_Wk=Lg%85OgLlxKsJSKsi_fG!PO=ig6OpLaEgvM|T zB_KL_CzZZJ3|6#m{c=DnzL)4<`0UH(n0H?-I(!?Rjo+S(uR0wY5mqrznkSOXE}mXU zp`PDBQrFBc>rp~*CP;PheciZJiciNd#lV8@k=mk?9lLE*d9*~VbQe6d{C!&9T|>J+ z$vvo;5Wh~J?$8FYAql@=kNiPYbJ@nlu+4lg*~HR6=qKVb9b4uiSGp~FQAytZ1=A%F zt|}jyWcfZ!xe}4?Nd0NyHTv0Osj;tl4{L;?d(bh7v=9Dk)jpqnKR^O6#c4THE#IaE zhDyCVgIpWZpLn}HL~AZuzs%)j8>7viRv(mER+)2*bb1BJ(zPSFgv>l%NMc*>udfdF z$;y^AF}{jxAMp0b3sN5VbQD7o8Z@GPtzMmxZ~Ym%O*W;u!$Xxy$5TDGHa?e*bEA@D z<`RxI^_7BM9ZljSBq6j#nApV5=&v)ifXWk zp_Q6@2e+vJ1Rj)C2adgZnYp2Wzh3pqu`0gJq_d9~G1b9zx47?fDK2%a)w{egKZCZ( zm;dAn*z@w$^bA-meV+aiwW>Dov_z8Ez*>7J5F!?=TE8RkIPXh&D=$H9_2oDehd^%& zIlVx}60VLcfVrXT5AT3o%y81{#zlB_(B-VswDyud1Qa*iuH;+ap*+C{)z)DeDpTanrAb)TQX#2Zl80$LYV_tN517%8M1@p`!u@baW3n(p!u|ZE6k+z|-{`vggX*m%fa;W2Irdr)>Ze(egC8 z8^JCvo}zf2(2($B9+(dWf^JO}PSyW3n%l(${l zyqH-3A*V?FxQe4_*H_fHuI*pTP1x)B^AZSGT2d?cp2qk?L-dl8mGr4qjFmpH@r_iz zb}V?ltE;&5i9^f2y(OQ!NEu^k3>V$MWQ33C`}<|gy>fb}_4sx8X|)X(=cU;mocRqn zE3R1I?${aKT+gue5wr)Dn^lVpLXG==d8NvXD1ix(o zzR6qCRELvnVx@5spwq!)Jn=@SZFpVM;q$q5S`8N~J?B5fNVZ;tObFnA@}al+4tP`3 zwEOORwPPvp{$*#`@r+Q>xf#0o?-TIHJNv8?cGEN*^oSdijvR^>k+L)2%pp)n4Q2s@apYCFKU?bSvZwZLJnTiz(ihPqw!$Zl+NM9on_KJA5pG)yE=aU z0eX6>Z24rmm9??8IoJTpNkLkfwqreI_o)>j0LdKI0ye8{^B=J4t_zW;;ORGD;sd4T z5)G`7^23z7X$Dp+@Y9)A(j{j8vtCJX&L&{Co1b9NeWN*HrkQv?ZtiCy*wxWAH_|C+ z9q?+^>_Y>VMsieR>azl`4G-$xNiKnCiGvgcP=L#%`!tz(2P~3aPmxqW+>AS~M>E6D z5lh)0(P>8z+l)$ec1gRYudM_)GSB4r3+(oIE*1xZoRmAjnp^@}40aPe3q!=<_50r! z*zFGW&F-2(o;`gve*UrNiSt}!tl82!l(pLT^J9EPhuW;^V1en8KrIW-s_R9de;{-_KJAm z1Zw9##0;8*C?z>qZv=8OU~WMZt5;aH@?v=Kn&paDq(N$FW*Ue6H@H)F4d(nO3a%<( zST&_i)%rz!Bu?Dj2)s59&hc8>#dz~oLw?Kd;U8adgmoE7p7b$G65plG#ngh?@lnSm zQ>jo3CcQlSdG)v;K9;@`Hj)Ypzh3^Sd6eaI;q`3F4yDvIolAx&j^}sy(7~8)yV)DM zMd>GV;vaA~J|nLt{O!b)gsh=*j{;^I6F+tl*Uq z5>Kr@C4%m(P%rbuo;D>b2v7Gf)!h2fSNLU&)}y=LthrAgyLy}l_ardKZ|4TM8R!~l z#plhWxOq?+FS^y>&_)XnUIv0%qUyEXs`E>ib43C&Tg(P5v%Ag}HYzcbq9^GTS8)V- zBJPXl(z~~Tky&GDs?|>kjH0hhyp>{o4=oJ=p@7xI!5A}QAH40NV-lG|Ju4*L8 z-8GWpJJoGS2}Yd-uj{47G;y*j9k^Zdu6Ox`MmOgyoHg23bl55QY=BMxXKBCM4CeAy z0^c=f)GzRQua>*0yvs0ancJ{r%hH9X^XiTbn{JxrYcL~iV#wI5{b}E>bXLJ`f)3+E zGZu~v_GmEIIz2o1)qCZVJmu3}hg&-{hNs=t6qUe7X~}aEhI%44LnJBca878YkMG-} zhmSCc9PS%o;??%ErokoG&4U%w3I`4r+~l2ktM#a%_Q`~v$4>CkpVjg1NZhyk0Sl4I z0y9l6g{PweefDY*{R}wu{){3x@&%jOz=vX;n+;*zPVzXq1ALS$I9Kggo{-RedX@*T zaQ({Oi7X}fSW>T-AZM%a+eR=qmH#vTPZ$2TvLOcW^hyTvn0nu@lpE6D=stIN(c9*1 zetiE!ky9+p!+=2aH1W|M%U`$3hh@a1h4Axr!;4*W`GpZ0)Q216fc&oWFNnp_f$mQm z^WN~XYX#h?9VYH<)cZ=#Re2=dpJWX-5pR2aao0}XqUs7u&#)qRv`gS-8@ta)z0i<`|l=G8Q5PRG^F??I{UNp^tMA_gI)P|+N$*K)cWXSAp`9n&kps)3x!fy zHq9oeZr%2WF!8FXMKp)>PB^kQXlm;4wNYG0tol=%Q84yPspID5b{+ci!!O6%a<9Up zaLREdQL0h}6`EC_i+BEIOW(>di>MI|d3nonB@Scv$?R>B%a00P^q#1)*oExPsIkp+ zG-#7|zZ)*A(Q<*bXmW5VllCP$Vlu+Tg={5u|3X?{K)J8`PKU6nL1k8cVEbMzS_G9k zsAW`=wB3G&cCyLJ2(dN`1Iz_CjoU(aH+f?9Ui*roEd*KT=*U%GmKQa) z@KgqS#?T=5H*h2v>(>6|)AQZ%^dQ5@@Bq?CPyvoLR8;g{KKUN5g;6mn&ACavJOL_I zP+<*Gmo6$4@%?@Pnyi-{5)*wd=7YKo14N`sw|pU}NvZ*e#>?4@o} z@G)a>X0*)De;?L~VQw*az)NtGZ>>&1HI&UY$d|_8mO}Eb(e|3I^V2tNlUUDpfmgn@ z!?SlJ?R(Ybbv%~B2^;!K$esX(1jZ{;Lij+LW%RplMt#%tL;ZMRIyCW&=NCuZ@BygxbSGv>}h}J2Lu`$73jBm$@buj3#VYRk|8-*V|JNGFT#=R^_-nHNb9n5 z8Qc#0Ol~M|qGxH)9Z#ugwM`aHx7}h#7w^VbgC&S)#+|fpk*G+k%e$3-XLd3gGYl0)ZJ+;V-j1vFU% z-E>H?sj?-M{O(Hu(XD$Xh;rWIJyrw1xx&+FN~xEljZo@FFP7aXC#8yFMxuM5GXi)` zQ`S$|?DEMltXDC{N$-I9h{G&AovvhXvnzajwy}MZ*lKbqy2`JPF_ts?4SI|U(ml?0 z6bViW<2yq}hE>m5v5W^x(H?m+*cR`xB(Ge>uDhfctuS%QZ>V^)#$$H{nLqU7OAdDb zC4@tk7_%#fr$TZ|t7)q%^mssqZV5_g1(cpmgcDh-YsD6%p`5JHoRG_H0#j#40qt?~ z44E=r(G&RB&?RC+gBZbk&uXB~wNm{3F!{d079kE8&fdkW@uwyE5Z%R-OGxEoh9!0Z$@wy*qZcV zX9>gHT&oUQxWsPBg*uN1QWCxdD~+{E2%U|{L)EtGrBdl{QTKE2E|aHwe*MfqSxLtW zF}4Pbxb&2pu0T^_BPAIZ~|e(F7e5S5h35 zGZrGQUpv3sy-tFa_=9|VvbM`^u;r%p#lmB(@%r<&7h#+=4td`%>U?PYt%5FJFN3o+ z=OjYk5VR*Orq@0DZpxf9WGtk_1#RX83P5t94aAf!1)d<7Ra>_948ysH_~>KrxVmf8 z8*DeUdcrwfkc}GA+L+}JC6R9uSClTFW&E~YhZ0U8&=vy@^v3qqX#8o(h5LCoS4}y= zY%ZSrYWz(lmA_Q1eQ6;gw(l&q7G>K(YHz2k%uHn-WL7=f3@dW!H}s&Y0t6mdN4zjS zNZ9r9a_HCGh0$-Hw5MpU&CBK0lp!9EIr$Yibt#r9LPS#{c(CY;KScyq$M(`h@Q&c?U)((jH2Is;T?j;)mF=`C(Q zcl=U#q2jhm4`z&Yy+S2%>`nLXe{gU?hVlH=0Vb1DBW8M!aJ8;FR=(l*s9VUy+7pyw zLGJv1>M*&k{&br^(_?xN zmK?9-4pZ`#ZtRI$Tulne;1Dpsk(Qij_eF5`o7nnNQ8iXJW4{8kqJz*DJGmp^O3>3^ zs*?T=2DFA3D#d--^f=>{Ika=F%}PKBlVjQhenlU8pXihPZ7H^d%5&dQUHyU8CQE^VQDI&|04k7+Whar=`lP` z@6q5y^Qx^nR2x21T=s&j;fGVwkf&o5XLci}Vi}@1#>!{E5A|n|pquDtmikQ?a!>b$ zv`x^)n?=P8@*&VN28F{GrKA1(U=M;oW zd$tU6)%z~=26qEK2s=`fSU;ozRRUR_{rB9O%QS!-Dg?aIVw*F$4Es@<(nCuFdU%Up;t_D-A zdVVJ4gY{H9UPWy);`7$Xm0B#blhMI8>SiT!@8V6D8GIM|WkWj*zZBXWZNCaVz*={s z;;8qfm|j|QkUq3mIS+5T>3|UXxD*bM`xqOjoJt5R7ETg+0TeKw{m?NTjdlfMjew2= zO_Rr^02~cK))MG1uRaA`LK6_r;v^Ax!(76ZQrX2$XFJ$EZs6Mig#bE5v?*YwG`1$v zcp)PvF8@gTLtlMW7}EFP`w(u~F3rdh$}0S0bp~UE13zNMXyCz3Lmoqr##)6LI@>Eq z&wUj}SG$JRQiKuY#hdqOz$sA#I!*ffRDg&=gLv?7`O4`)0!Sr0dmvJOcWA)5PyUZ8 z{??}QdG<*Jnr)b~1lUUn{AZW-C2&tx05ol7PkBNZc#!M@M*|N{aqy_*PpHuO^TE-6 z9Z=R5c0d{2Y6C+8`Tfw~!UISffh}2X55a6P2cWn1`v;P7IRL5XR0X~Jzqe?afHkgZ z7r-Bc5_f-x0B{h)cl$;1@uH{d%9&cQ8d)W$oG_R<);4GVOhRyuBoLF?0^U&5L4O|1 z=hXD$cQA8X-zefS;FBj%)(K!Q^RPLZqX~gIQoy@;O6vr0Q)!*@mIOV!;C)xpL0hYC zjus{1>K+jU8ZOKdz_$wE38O)xGWaOz(RqR}IJ*%x(`d3)56G#u{+N0BQ_rP1?;4^f z#dd!uy@XCOga6xylz2=38GCpeVOuGYJ5~Rq%()@Kn z`n*A>UV?vce3x&3D`y-Y{v8E-a9p5g z?O$0gKY(viZq2qYNOAGbZmv8(pX(~p6K9KIwwv6(B;tU#)Ndy;-(bc){gYLV6BeVx zY%zpU`Hy)mdCi`m