From 3fe63c0bc666ccc3d40e26944ce9c1059c3f80ed Mon Sep 17 00:00:00 2001 From: Creidsu Date: Mon, 9 Feb 2026 00:37:57 +0000 Subject: [PATCH] corrigido o progresso na inteface e o encoder --- .../__pycache__/ffmpeg_engine.cpython-311.pyc | Bin 6665 -> 7584 bytes app/core/__pycache__/state.cpython-311.pyc | Bin 2147 -> 2413 bytes app/core/__pycache__/watcher.cpython-311.pyc | Bin 14221 -> 15355 bytes app/core/ffmpeg_engine.py | 104 +- app/core/state.py | 26 +- app/core/watcher.py | 157 +- app/ui/__pycache__/dashboard.cpython-311.pyc | Bin 11048 -> 12968 bytes app/ui/dashboard.py | 64 +- projeto_completo.txt | 1493 +++++++++++++++++ 9 files changed, 1684 insertions(+), 160 deletions(-) create mode 100644 projeto_completo.txt diff --git a/app/core/__pycache__/ffmpeg_engine.cpython-311.pyc b/app/core/__pycache__/ffmpeg_engine.cpython-311.pyc index 869343ef7f12d81d1318a9dbd1ff5503a075ff85..3039a788f59a65a14deaa389ccf6b996575df17f 100644 GIT binary patch delta 3675 zcmb_fZ%kX)6@Sn6v){9yjqL~i10gnr0tO6}KLt!6aRUj-lr$NEDrgOT@q2(%W1D9i z63dK8>r@&=E6J@C+ODhD^$%6mDU&vdeb_|RFa1zW@1Z9JJthwt z`VUJJ`k~UU&pY?rbMC$8oZq|e-s_io@`JYbtX4AtB~bTLbjEqZ<~ef)TDJ&+5XcxY zRjkP=ve4E|>7drd^z()(1Bq?=7&UL4GLnRjI8F$L7YKoRS4R*Zz*8(!reDP`ki#Kp z{rR>0-!@rKOOR)7UOf7d4E!rW}-3wv19W|an{eLql<|;|3r$93kkm<&hVL7 zI@ON8AYV1oFhyz(-OwF-{2Y0gbzDCoyS}fm<0?BYmuXi|GUv!$0(10lT@CqHRHa83 z(uEX@0W}cPW6OrLwb;V^lBp~UOwRB+@rLeQ5>kMtSV-bLq`GYQp+O+VOVSd#L|oRN zCzi;78ExuMJF~V?NlHK-Nhxtr4EX~zq7ChCjTyZh5tHd?A}$$VI*A1qpisZz0Hcv& zih`yeoj3F}J3va^1a_6!sqm~f!GlT_{d}qck4fLxWn!G zkJ_nlH~Np^L#q$W0iA?_l4{UzseZB=eL+2AW+=yJW#IjMKa{5bHS15~w zbPS!Qy%gwYb?6UFfHDbmQ3^0v_b|TkuwZ^@1`qn$TGL>8i+rT7Utqt{7bVylqhJ)Q zMGq#MP$AfJB>I$T)jI?yxc#S7$RpZ27%fV|9D~0ap-E^iNdcjyB(=_#fGY)0kVK9V+Oh!6GIRYV$o|s1@#X*( zwzetw=sOqM3kdANxi12&(6NU=ZH0f?ma{_sFaDqMe-7}j&#{pI;fM2QI&-!{{v5hz zt*R^LX1l(cK2XjX_Lz#YoVR0Ky~MXzVPVl68r3_{9A&c!es6gkv0Q;2`<@WGauwL! zqf0nP<(=i>2T3B`v^SQxR9TjVZlR~RsL_vYEi1jJGafLVDQ?5@kAIQyM-uUw=xj#f zg+zPC3C6Irkb!;KAB``5{PS28)S7W-25;C|UGR!VE%JOadY=x?E%A|v7|T|arD;K2 zj7G#P*8xI@AVoVQF)oPG$P-=tSr_iLf(J||GU;R{Jw20<=J~XyUq~$m3{o?AmIA=; z8-kJ0qDIekJ=rr2HjRoeit&hMh-3slO9#_)lE@1wjqHJNqR@MuPiCpk&|;_)`XrK9 zq!ySZs}C;D+$Y9gQUnn6b#F2@v%xkbzpIpW#o zWim(RF|vS0@p}Mcq2x)d?tDmlLw7wePP|XNuhXbhT8bt$DwbFhC1~r~J2f(frtM<` zlQ2UHhjx{aJrhORSXg(w>3+@qs`sbfH+`@96mN^_ZP_zR_>|DlVaIV4wsW;o4>aG` zNds6*rc!v5^ZO;d)}%r7p`)9825s209gr3u329{4U+|$8FiIUzVTkxpCr&xUOUJRs z5WEQD7#ebTzf=BQ`{rMWPU^+M^aFb|y51SAE+OEWlnb8lB2qOh?l(vFPc@zD~$l^e0E?$t)9`7t_4Jr}?b0FlZ=) z_mkZ>UoYHBj-DC``8CoXsFLtTD^+9WLrJHXGF(bT<7m~{dR*!Qt!68`osRMG*_5VB zq%=d4PtQR>$yhWkUQTQDMLs3Q`FT+@^2sDD`Ug&bJG$#^Tfv)yW?hU5VqzL@Kxt7* zX;wal*NIS?*ve;wXkr>xkjT%cV5UeS6HiNFJko%DYZeG)x-_WR*z24sbk;19n8?Sc z3mK>EFw$QvD9#Pk9fWr3Gq{)FI|p|?C(~o^49LObO8uBxKPK0Xp-(v9%J3UkP z8&-Y8vUm7ig?qhHwjG4~h>`HttXl6`oNN3m^RlHL2y|Ba%O|g$TsP;B%kDOX4XSKV zW`jFy1y;+RfZTdSc83-ADV2RnW}n(Kblz@~-64e?RM|nfK)Wnq;~EQ-TkBq;+>wSw|kzJ&x|WQ=hU8aU{X92s%K)=y5pIYJ(Kydjq_?l zA4rO4QuRy%Vh-o3amR6RmoPF13T!pJ!&Tnuz1h3&$~zUVLFF3maLwCX^G5aU356R} zxxqVJc$*6=+|fFfJGwfu!})g!gY`guWaE_FFaWeUu{AFbjRT?Ixf)jP6*H0#wB?Th z1KJSf-qFoTU_e`opRCA}Klo$?s^=ndjiA^?)h^0*amVgjue)JcGk$r`;#fO-ZE$sP zeQ0%X4}5s7m=tzQWyfT841jh&L&CRt`w6xCsN8*GOS;o_#=YHjMm~E^>6%cxCcvn; zCRNwuss&wed!|bOK<*R<0N8f{pp-wrr-%F-Hd^J!dVw~pwp!(ZQ$Ub^ANtzu=Klqu zd0&~m<7(V?HNGRgy>zFkZ@Z~))1owus7)h^YgBcOZt<#XY}K+uTMDb?dvaYLOj*~5 ziR)MB0hJz*>4CC+P@#P)?UQNWXS5ZKdi>-Ln(_48@qJlp!0dUR;?e delta 3077 zcmb7GTWk~A8J@8{zKqBA*pt{!Y$pyO1e-XCAuJ)m0hSQ%u+_FuqLygzOp?itFBx9~ zuA}hKR$i2fuxBOQUAF6P+ilQ-lvVqH`cTnsAGUp{j?qY1mbOBDtNKt^+e#=;J!hP- z1kzP|{LgzLRKnhY$#Xl!?h^Pfn6( zu9I~M{iL46YkJv`peCu?lRnb;Y5oeiYX`FW>wA@dJiHY=FD7SmiC|h(rX@KzF_B1% zGeJHpk5rjal^KPb)X5YO3RG6@Fh7imjk)UV~MJz>fM)xsE5TBr@*--K5 zMPo89SYHD1V~{8#SOsilc*Tl9R%3!~JF8Acuz!tJ8%A#1J{E+k1@918@S3I5;(SYo z<5Ze9F2VU~{3~QKfNe+@ShaM34%(?BEg(SmIk_w@>PAC>8o?vl4bn8n{o$2#wVF9w zwz2R~t%7^TfD1@@5Q+)hx3n9Q8NmjANbhmH>cX?3_J$Ry9TeKYIvsMhZ;RZ-LyCR` zY~Bo2X@7B6v4Eq_mJY$UbqkW@46$eu{NE>v1|a}`YYjL$7A-~dIPu-WB3+~_G&XAy zIwj&`RKriSI${q^!EPZabZr$ZT8k#dTtqxiWDG(V?v-&|T9ZTAQKW@#Alnbdb`~w~ z6Cdb=9%0wUsQ1<5QM3rV3+Ui3T9&_u#vL2m=s{@Sz34=SJ|efKLlP+G=e5)-@O<`l z6<~z$^9uN!HlbtDUbLYa!j)jQOUvX$Ue{qDC2F~X^Nc2YY%1VCrvs5r6 z%3@qfC3zv0YeBMeVwM-sBoma9`OkhTOK4a(PEX_8t)PosuTy!RPfGs``D2x_(| z*nq0*@;q`S6^qy)O?PYrR?>BH6o|>q~4n1XGMfbMhm7$ zS`jm17U{?5>n2&cdTsA(Ll{GuUMKs&4bBaYxQho;I9Vj27|3I-JiUN``p zE&Iqh5NI9wR>-h!i6pcg$?^rGW)@RkM`{H&o=POdWHxq87NrR}wUEOXdgH_QwRh98 zbK@hyIvEVHiUlp#Eyu6L#dH?!vl0M5YHb7eTf^bHeKV9PnNQB3ZRXQyv}Fo@yC@8{ zGt8x=WXJ&m?!GB}!0NWVB#5ag^v27IN~Uh&W#n2E>NY+nNU5nzRuTCGMvA9$$*l4k zwjt%ZS>9B%BA;KWWi+)6P8|H+eRv7`$ovNRzd_U-+;S0`PyFH_j7(I!PpI7|VBkcJ zVVBRA$QtK)!u34ndMa~OE~;|TlDX#aK=Y9guEWsbYUdHP^9XD^^3>rew?KPGN#8IN z47b9+n}F7CL^Z~7>-5dj&>Mn#PC?J0IgDQ=3b}w{=6?CVrW&9A6z;YloggRVJn~G04Pf{$NwRrA*$n zL0b=^P3&R1GO;?o7KWZ!l^Ii+G02Q<=p#Qi7RUT@0Gb^1dShf2h~7@tQ|`JOfsS58 z4Tf;Gf(PFHXWZJNvllvhYbV3m!kY!qq)o_gn&1_tjZpzGL5*SnD?uAaxPo=Uvx3ahR#bcG+eN~W6k zBJ^IYoLs%E?mU1-RqsXBd$DAzIb9`V&Dqf)D2rcXrK2_0d53z3TA40it+HJz+x3L) zdCa1nUF)f`!zw$BRJisAp||_aA?F5ivxM@>?@O zso%D)7@yUw&XsT694(EO^&isj(Xjhild9fv)jJNo;|(LRZ%EzyI^<5Tpd;1&gbO|9 zLaW_Xu3zQ)A=m%84xKXhCFs6X*}ZyD-LW5ys_sjw`W`I?!{S5B4} z%ZqE&LoXaWiU=ike(8pJko9%BOYOfpUfm;H=h3*IunJyO1Cl>Md92wnO#S< zHHhcTe1)XA2Jr&#cwwSwezB*JNpYi2X$61uhq|}Mv~E6>NC`Pv98<0$9ekl?3ZOO6 Z31WerhchIJ9Bj}3sjdI(zeqLj{{j9zNk0Gp diff --git a/app/core/__pycache__/state.cpython-311.pyc b/app/core/__pycache__/state.cpython-311.pyc index 6263016228d084b6ada608a8bbdbdc129905a715..d95f53e69619515691e86838df9cdf8ae1726c80 100644 GIT binary patch delta 1072 zcmZuvOK1~87~aV~n@3F_RMMt(Ox0#BiS@0aRf<-{LQyITy_B^(jiH` z6iFJa1~wrc+Sdr1lph?Tb%0|kz-Cim0m+4L7uhSZrD?!KW&pZamK;<@rnD%tHSr{p zS-aFND$i6lD0Qn^FEGj?dnk1T$}&tbHR!t(zD6N-PwH$BbB`Gr_CLM8^a0Eu-e~NN zv}-!OWjfT(Quw0`zO=Ej@p;R>Zdlkf@D+oSaRVoNQU`l_^exhO&LX4#V*=%jUARf8 z`}(nsO&j;q$x`m7{ZE8xj9rHiGlQ=Z%O1;_hDk7tu^Kl?rZV_M`sh?GtEW1oVCD*0 zzoLFYb=gx~D0#{Cw9Wf?k-x6z$$lzNwv6k<`rHh)I5!P1E?I_Kq^)4mHV$zP?HoD) zhIEmJVTP9lRj7{b5h zObeWF-viths!<`_eE-N|W;wJM#%gP4CEi}yov6gxDzT2`*!~B}YDn2rUljmm&dyz& zpIQmyA5r#EzMSakn~~;%^C#YE@%y7ITB?#v)g0GWw7sh=t>_Au;uvD{fo3+ToDSFX z><9o9%w8%O9cCYuSPMU;OB<5q!u89{0%}*hfm=H{?BUP_@Km6_!lP_JO$>(QT~%QV zT17cg6}BMQCLgK_e?taw#&)2M?74cNwVS6|j}NP#T5sMqOBOjr)9jbp-FFZHc{_aN XzVEe-8%U^Yn9*i~bHUp(?|#VN85s~K delta 820 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 delta 4263 zcmZ`6ZEzb$^-iZx`=pa4S>Gq0EZegD5r4#q6~~To6T3-V=fh4DCoyqlpW?`|CHHiy z8*^$XrC?~<7&d^R$&5N3T0=9`owQ7b>3~Bi6aIkCa}bTFiwk3DX9`h(PWc!H-pP?e zOgZhneY@}N``Uf)R{xxSHfDHPuP?&jdB^spNN4qHh8qMPM`^-Vlv86aW>$HDT0^&q zCMOJ;idm(kb!R-~a_`DG%VhHEwV?9LIIc!-sagq*K%g#@g*=Y2>Ll?55!JG4M5`-1 zwJ+fq_6oe~&r?AE3~?0u2Jr^pi4M_vM(ALjf9vOGZBkTF>H*9hq=chZ@nBt zta=7NfZkRAyx9wU@n(?pKKwq5t!ecabZOHuQ9CGU2h-ZYoRZS{GL-qk`5Z>-J93I* z^sdH>uYFb9j<2E)?USmH)R@_cZfF@r8FlS<+TY^%_G>>aDpjdFWzkpAdz4dc*VW#^ z>&7Zo#^TN?(Qk|) z(AHJ5P16LTav&;MQd_J81y8~I7dM5jmE@ExmQpaul<8ziFAAnq z1-j!bJ-dZ%xZ1cWhGxFEuo~Q`D&TCBVBW-O5iHyzf{`W8DrGM21b?u=jbZ$ud$?dp z1vc#XhQ-i61RIkqO z_ir!Mvs?cifK<(fy4y5~{@@O{u5&ilArPV^&V^?&gPF5cQ0eM=QGBEGw6Abk7#9satczPF025LG~*5Eu+?f6 z$ov^|3eNGy0y)(r?}ekDt-reC{s@D4&JFV@yaPR1Zqv8H+QQO&A{+P(+rZAe54aQk z+wL~v7$4qX#m2X8xOdW_*ErX60bR6M5ydzHJK6U8wX^Ma3zBz}g2POU0ivxhh)%W0 z4wQC4VwP@X6ZSmD?z%#t*HmuwG2`%evY*;a|KHtUyUCiMf(za8R-oSU%95#jj1?Hy zk)-AD65Rrwua2*luUEq!7{6e)*23o%OVUr!EH-c#o5rWH$BEcA{2azm)@4OuoBon^m3*hd6Es{iwo^htVG@7*jxzRmXXd#pnAl$Dxi&XU~kAk4%O;s)yLm zG$#XXoBD{+07lDS%nuI9Vzpf(F^&g|W{SHxF2?lqOiqQzJ{Up2skGN6{IZqbO5MJG zkr_yg#KFHrBnlUi5cg;z@>q-sKOTwm;8nPdK_6Gv82*7j!flgfv$AY-psF3;fu5=Q zGkV(JGBApj16ZM9N=+q4nj{%hNiMaBnlplak0cyJgb}zMR=}8 zUaIE8ld;Fb*&;5?C%CA*7?IW0IFGhfH$Cj(J2|ioQhq$pY zKOBjkj%CS6d^jGSj0}eod@Ng>fIHRj=$Tk#G@R9r!k&-CxN}(z8|Fiii8%UM_278} zALBz4!)$^J@sU_GON>sk*&^A8;gNIlAyMZ2DU!oJyncl6Gg*8xi^sFnXoBM)K%f%# zl#OSJsZl;losL97;_1Z1#PC#zKf`@Zwwj!bMI(HS8|Gp$o@;`sxCa1wFJMEtfCl|7 zP_H>5&mPgDx*E?Oxa&z;$9!eVvCx&?c6ccP;BM3Qi`srk+n?6rxM=O?A*j zZM~$ePiyPpj%Ko=i#7KB8LM-)c*Vv@w(14f;+SaLCE0c@RV~~0&XTLewj8F=_2QYb zn%SPYph&tily$x;O)=1}S1sAzY)J3epQ>7Li{;zVM>RWJ7ZvHYlS|f{nxB-V4-ZOR z$6+Wpa!4eHBywmSS=F28_DXs>JE9)Kb=`2ctf}|zfSat-o3btV-)Ozwy3})X zpSbm?wDqXyI3_ucW$d1e-IwZ-?6o2R@Gnd*hQzAYxq}&}J5{#0OZ0Xv^^44dqI2)u z?iGtevQ(zR3&*b$B7FEIOMBYVo^ye1IS=}Lqn}u%%xJOk5j57c^}HN`{b{mSBzq;Y z7Z3<`nJ*QTe66AWP%g<32lWekw|(zoI1KNaJ~JeM%sBO9qdn&10p#fkpqBayuN3_AI}

e$4@F8X-1oe{*G0{+*IU7DV9pj+VsGwLz!U2N&KKjMBlqRc+^IT*K@!L-* zKE_ab`?UT|FdFDTgpT_WI?_6*_&bKa*BXSsmcMVU)WM3W2`Ikv$P{d}z9m;4H2_9_ zKE*NeswdE3&{y)IOnMu@-#&}M4mY=>F9*xEt(k3Cw3jK-9+2z-(Oe^$Yi6|>vnz*b zb?abucgAd$%#~@fA@w*M9D6^GejS8Ar|4h7W))17`BC$ZN7PTqs@_ENJL=9GRrLJ9 z90uKHP+?a2a@Yo_(WnABYy+4as@jE?90uKHu>Fui)r~LkIV_KSa>pu5R82XoIS=mx zoP43@(kH_^`T4*8A8KCJ Fe*kFoqci{j diff --git a/app/core/ffmpeg_engine.py b/app/core/ffmpeg_engine.py index b1395b8..09ba015 100755 --- a/app/core/ffmpeg_engine.py +++ b/app/core/ffmpeg_engine.py @@ -1,5 +1,6 @@ import subprocess import json +import os from database import FFmpegProfile from core.state import state @@ -11,111 +12,108 @@ class FFmpegEngine: self.profile = FFmpegProfile.get_or_none(FFmpegProfile.is_active == True) if not self.profile: - state.log("⚠️ Nenhum perfil FFmpeg ativo!") + state.log("⚠️ AVISO: Nenhum perfil FFmpeg ativo! Usando defaults.") def get_file_info(self, filepath): cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath] try: output = subprocess.check_output(cmd).decode('utf-8') return json.loads(output) - except: return None + except Exception as e: + state.log(f"Erro FFprobe: {e}") + return None def get_duration(self, filepath): try: return float(self.get_file_info(filepath)['format']['duration']) except: return 0 def build_command(self, input_file, output_file): - if not self.profile: raise Exception("Perfil não selecionado") + if not self.profile: raise Exception("Perfil não configurado.") p = self.profile metadata = self.get_file_info(input_file) - if not metadata: raise Exception("Metadados inválidos") + if not metadata: raise Exception("Arquivo inválido.") - cmd = ['ffmpeg', '-y'] + cmd = ['ffmpeg', '-y'] - # --- HARDWARE INIT --- - # VAAPI (Intel Linux/Docker) - O Jeito Correto + # --- CONFIGURAÇÃO DE HARDWARE OTIMIZADA --- + video_filters = [] + if 'vaapi' in p.video_codec: - cmd.extend(['-init_hw_device', 'vaapi=va:/dev/dri/renderD128']) - cmd.extend(['-hwaccel', 'vaapi', '-hwaccel_output_format', 'vaapi', '-hwaccel_device', 'va']) - cmd.extend(['-i', input_file]) - # Filtro essencial para VAAPI: garante formato NV12 na GPU - # Mas como usamos hwaccel_output_format vaapi, o filtro pode ser simplificado ou scale_vaapi - # Vamos usar o padrão seguro que funciona em Haswell: - video_filters = 'format=nv12,hwupload' + # TENTATIVA OTIMIZADA: HWAccel habilitado na entrada + # Isso reduz a CPU drasticamente se o decode for suportado + cmd.extend(['-hwaccel', 'vaapi']) + cmd.extend(['-hwaccel_device', '/dev/dri/renderD128']) + cmd.extend(['-hwaccel_output_format', 'vaapi']) + + # Não precisamos de 'hwupload' se o output format já é vaapi + # Mas vamos deixar o filtro scale_vaapi caso precise redimensionar (opcional) + # video_filters.append('scale_vaapi=format=nv12') elif 'qsv' in p.video_codec: - cmd.extend(['-hwaccel', 'qsv', '-i', input_file]) - video_filters = None + cmd.extend(['-hwaccel', 'qsv', '-c:v', 'h264_qsv']) elif 'nvenc' in p.video_codec: - cmd.extend(['-hwaccel', 'cuda', '-i', input_file]) - video_filters = None - - else: - # CPU - cmd.extend(['-i', input_file]) - video_filters = None + cmd.extend(['-hwaccel', 'cuda']) + + # Input e Threads (Limita CPU se cair pra software decode) + cmd.extend(['-threads', '4', '-i', input_file]) # --- VÍDEO --- - cmd.extend(['-map', '0:v:0']) - + cmd.extend(['-map', '0:v:0']) + if p.video_codec == 'copy': cmd.extend(['-c:v', 'copy']) else: cmd.extend(['-c:v', p.video_codec]) - # Se tem filtro de hardware (VAAPI precisa subir pra GPU se hwaccel falhar no decode) - if 'vaapi' in p.video_codec: - # Se usarmos -hwaccel vaapi, o stream ja esta na GPU. - # Mas as vezes precisamos garantir o filtro scale_vaapi se fosse redimensionar. - # Para manter simples e funcional no Haswell: - # cmd.extend(['-vf', 'format=nv12,hwupload']) <--- Se nao usar hwaccel - # Com hwaccel, nao precisa do hwupload, mas precisa garantir compatibilidade - pass + # Filtros + if video_filters: + cmd.extend(['-vf', ",".join(video_filters)]) - # Configs de Encoder + # Qualidade if 'vaapi' in p.video_codec: - # VAAPI usa QP, não CRF padrão - # Se der erro, troque '-qp' por '-rc_mode CQP -global_quality' + # -qp é o modo Qualidade Constante do VAAPI + # Se der erro, remova -qp e use -b:v 5M cmd.extend(['-qp', str(p.crf)]) - elif 'qsv' in p.video_codec: - cmd.extend(['-global_quality', str(p.crf), '-look_ahead', '1']) - cmd.extend(['-preset', p.preset]) - elif 'nvenc' in p.video_codec: cmd.extend(['-cq', str(p.crf), '-preset', p.preset]) elif 'libx264' in p.video_codec: cmd.extend(['-crf', str(p.crf), '-preset', p.preset]) - # --- ÁUDIO --- - allowed = p.audio_langs.split(',') if p.audio_langs else [] + # --- ÁUDIO (AAC Stereo) --- + allowed_langs = [l.strip().lower() for l in (p.audio_langs or "").split(',')] audio_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio'] + acount = 0 for s in audio_streams: - l = s.get('tags', {}).get('language', 'und') - if not allowed or l in allowed or 'und' in allowed: + lang = s.get('tags', {}).get('language', 'und').lower() + if not allowed_langs or lang in allowed_langs or 'und' in allowed_langs: cmd.extend(['-map', f'0:{s["index"]}']) - cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k']) + # AAC é leve e compatível + cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k', f'-ac:{acount}', '2']) acount += 1 - if acount == 0: cmd.extend(['-map', '0:a:0', '-c:a', 'aac']) + + if acount == 0 and audio_streams: + cmd.extend(['-map', '0:a:0', '-c:a', 'aac', '-b:a', '192k']) - # --- LEGENDAS --- - lallowed = p.subtitle_langs.split(',') if p.subtitle_langs else [] + # --- LEGENDAS (Copy) --- + sub_allowed = [l.strip().lower() for l in (p.subtitle_langs or "").split(',')] sub_streams = [s for s in metadata['streams'] if s['codec_type'] == 'subtitle'] + scount = 0 for s in sub_streams: - l = s.get('tags', {}).get('language', 'und') - if not lallowed or l in lallowed or 'und' in lallowed: + lang = s.get('tags', {}).get('language', 'und').lower() + if not sub_allowed or lang in sub_allowed or 'und' in sub_allowed: cmd.extend(['-map', f'0:{s["index"]}']) cmd.extend([f'-c:s:{scount}', 'copy']) scount += 1 - cmd.extend(['-metadata', 'title=', '-metadata', 'comment=CleiFlow']) + # Metadados + clean_title = os.path.splitext(os.path.basename(output_file))[0] + cmd.extend(['-metadata', f'title={clean_title}']) cmd.append(output_file) - # LOG DO COMANDO PARA DEBUG state.log(f"🛠️ CMD: {' '.join(cmd)}") - return cmd \ No newline at end of file diff --git a/app/core/state.py b/app/core/state.py index 0409e05..6f9a6ef 100644 --- a/app/core/state.py +++ b/app/core/state.py @@ -8,35 +8,43 @@ class AppState: # --- Referência ao Watcher --- self.watcher = None - # --- Lista de Tarefas (Visualização tipo Árvore/Lista) --- + # --- Lista de Tarefas --- self.tasks = OrderedDict() - # --- Variáveis de Estado (Compatibilidade) --- + # --- Variáveis de Estado --- self.current_file = "" - self.progress = 0.0 - self.status_text = "Aguardando..." def log(self, message): """Adiciona log e printa no console""" print(message) self.logs.append(message) - def update_task(self, filename, status, progress=0, label=None): - """Atualiza o status de um arquivo na interface""" + def update_task(self, filename, status, progress=0, label=None, file_progress=0, speed=''): + """ + Atualiza o status de um arquivo. + progress: Progresso Global da Tarefa (0-100) + file_progress: Progresso da Conversão do Arquivo (0-100) + speed: Velocidade de conversão (ex: 8.5x) + """ # Se não existe, cria if filename not in self.tasks: self.tasks[filename] = { 'status': 'pending', 'progress': 0, + 'file_progress': 0, + 'speed': '', 'label': label or filename } - # Limita a 20 itens para não travar a tela + # Limita a 20 itens if len(self.tasks) > 20: - self.tasks.popitem(last=False) # Remove o mais antigo + self.tasks.popitem(last=False) # Atualiza dados self.tasks[filename]['status'] = status self.tasks[filename]['progress'] = progress + self.tasks[filename]['file_progress'] = file_progress + self.tasks[filename]['speed'] = speed + if label: self.tasks[filename]['label'] = label @@ -44,6 +52,4 @@ class AppState: return list(self.logs) # --- INSTÂNCIA GLOBAL --- -# Ao ser importado, isso roda uma vez e cria o objeto. -# Todo mundo que fizer 'from core.state import state' vai pegar essa mesma instância. state = AppState() \ No newline at end of file diff --git a/app/core/watcher.py b/app/core/watcher.py index f19483d..d1849f8 100644 --- a/app/core/watcher.py +++ b/app/core/watcher.py @@ -15,40 +15,29 @@ class DirectoryWatcher: def __init__(self, bot: TelegramManager): self.bot = bot self.renamer = RenamerCore() - - # Inicia pausado (True só quando ativado no Dashboard) self.is_running = False - self.temp_dir = Path('/app/temp') self.temp_dir.mkdir(parents=True, exist_ok=True) self.current_watch_path = None - - # Controle de Processo self.current_process = None self.pending_future = None self.abort_flag = False - state.watcher = self async def start(self): - """Inicia o loop do serviço""" state.log("🟡 Watcher Service: Pronto. Aguardando ativação no Dashboard...") - while True: if self.is_running: try: config_path = AppConfig.get_val('monitor_path', '/downloads') watch_dir = Path(config_path) - if str(watch_dir) != str(self.current_watch_path): state.log(f"📁 Monitorando: {watch_dir}") self.current_watch_path = watch_dir - if watch_dir.exists(): await self.scan_folder(watch_dir) except Exception as e: state.log(f"❌ Erro Watcher Loop: {e}") - await asyncio.sleep(5) def abort_current_task(self): @@ -67,23 +56,16 @@ class DirectoryWatcher: if state.current_file: state.update_task(state.current_file, 'error', label=f"{state.current_file} (Cancelado)") return - if not self.is_running: return - if file_path.is_file() and file_path.suffix.lower() in VIDEO_EXTENSIONS: if file_path.name.startswith('.') or 'processing' in file_path.name: continue - - # Ignora se já terminou nesta sessão - if file_path.name in state.tasks and state.tasks[file_path.name]['status'] == 'done': - continue - + if file_path.name in state.tasks and state.tasks[file_path.name]['status'] == 'done': continue try: s1 = file_path.stat().st_size await asyncio.sleep(1) s2 = file_path.stat().st_size if s1 != s2: continue except: continue - await self.process_pipeline(file_path) if self.abort_flag: return @@ -92,23 +74,28 @@ class DirectoryWatcher: self.abort_flag = False state.current_file = fname - state.update_task(fname, 'running', 0, label=f"Identificando: {fname}...") + # Etapa 1: Identificação (Global 0-15%) + state.update_task(fname, 'running', progress=5, label=f"Identificando: {fname}...") state.log(f"🔄 Iniciando: {fname}") - # 1. IDENTIFICAÇÃO result = self.renamer.identify_file(str(filepath)) - target_info = None + is_semi_auto = AppConfig.get_val('semi_auto', 'false') == 'true' - - if is_semi_auto: + if is_semi_auto and result['status'] == 'MATCH': result['status'] = 'AMBIGUOUS' - if 'match' in result: result['candidates'] = [result['match']] + result['candidates'] = [result['match']] + + target_info = None if result['status'] == 'MATCH': target_info = {'tmdb_id': result['match']['tmdb_id'], 'type': result['match']['type']} - state.update_task(fname, 'running', 10, label=f"ID: {result['match']['title']}") + state.update_task(fname, 'running', progress=10, label=f"ID: {result['match']['title']}") elif result['status'] == 'AMBIGUOUS': + if 'candidates' not in result or not result['candidates']: + state.update_task(fname, 'error', 0, label="Erro: Ambíguo sem candidatos") + return + state.update_task(fname, 'warning', 10, label="Aguardando Telegram...") self.pending_future = asyncio.ensure_future( self.bot.ask_user_choice(fname, result['candidates']) @@ -123,67 +110,104 @@ class DirectoryWatcher: if not user_choice or self.abort_flag: state.update_task(fname, 'skipped', 0, label="Ignorado/Cancelado") return - target_info = user_choice else: + msg = result.get('msg', 'Desconhecido') state.update_task(fname, 'error', 0, label="Não Identificado") - state.log(f"❌ Falha TMDb: {result.get('msg', 'Desconhecido')}") + state.log(f"❌ Falha Identificação: {msg}") return if self.abort_flag: return - # 2. CATEGORIA + # Etapa 2: Categoria category = self.find_category(target_info['type']) if not category: state.update_task(fname, 'error', 0, label="Sem Categoria") return - # 3. CONVERSÃO & CAMINHO + # Etapa 3: Conversão try: - # Recupera dados completos para montar o nome details = self.renamer.get_details(target_info['tmdb_id'], target_info['type']) + r_title = getattr(details, 'title', getattr(details, 'name', 'Unknown')) or 'Unknown' - full_details = { - 'title': getattr(details, 'title', getattr(details, 'name', 'Unknown')), - 'year': '0000', - 'type': target_info['type'] - } + full_details = {'title': r_title, 'year': '0000', 'type': target_info['type']} d_date = getattr(details, 'release_date', getattr(details, 'first_air_date', '0000')) - if d_date: full_details['year'] = d_date[:4] + if d_date: full_details['year'] = str(d_date)[:4] - # Gera caminho inteligente guessed_data = result.get('guessed', {}) relative_path = self.renamer.build_path(category, full_details, guessed_data) temp_filename = os.path.basename(relative_path) temp_output = self.temp_dir / temp_filename - state.update_task(fname, 'running', 15, label=f"Convertendo: {full_details['title']}") + state.update_task(fname, 'running', progress=15, label=f"Convertendo: {full_details['title']}") engine = FFmpegEngine() - total_duration = engine.get_duration(str(filepath)) + + # Tenta pegar duração total em Segundos + total_duration_sec = engine.get_duration(str(filepath)) + # Converte para microsegundos para bater com o FFmpeg + total_duration_us = total_duration_sec * 1000000 + cmd = engine.build_command(str(filepath), str(temp_output)) + # --- MODIFICAÇÃO: Injeta flag para saída legível por máquina (stdout) --- + # Isso garante que teremos dados de progresso confiáveis + cmd.insert(1, '-progress') + cmd.insert(2, 'pipe:1') + + # Redireciona stdout para pegarmos os dados. Stderr fica para erros/warnings. self.current_process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) + # --- LOOP DE PROGRESSO (MÉTODO KEY=VALUE) --- + current_speed_str = "" + while True: if self.abort_flag: self.current_process.kill() break - line_bytes = await self.current_process.stderr.readline() + + # Lê stdout (progresso) + line_bytes = await self.current_process.stdout.readline() if not line_bytes: break - line = line_bytes.decode('utf-8', errors='ignore') - time_match = re.search(r'time=(\d{2}):(\d{2}):(\d{2})', line) - if time_match and total_duration > 0: - h, m, s = map(int, time_match.groups()) - current_seconds = h*3600 + m*60 + s - pct = 15 + ((current_seconds / total_duration) * 80) - state.update_task(fname, 'running', pct) + line = line_bytes.decode('utf-8', errors='ignore').strip() + + # O formato é chave=valor + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # 1. Tempo decorrido (em microsegundos) + if key == 'out_time_us': + try: + current_us = int(value) + if total_duration_us > 0: + file_pct = (current_us / total_duration_us) * 100 + if file_pct > 100: file_pct = 100 + if file_pct < 0: file_pct = 0 # As vezes vem negativo no inicio + + # Global: 15% a 99% + global_pct = 15 + (file_pct * 0.84) + + state.update_task( + fname, 'running', + progress=global_pct, + file_progress=file_pct, + speed=current_speed_str, + label=f"Processando: {full_details['title']}" + ) + except: pass + + # 2. Velocidade + elif key == 'speed': + current_speed_str = value await self.current_process.wait() + # ----------------------------------- if self.abort_flag: state.update_task(fname, 'error', 0, label="Abortado") @@ -191,36 +215,32 @@ class DirectoryWatcher: return if self.current_process.returncode != 0: + # Se falhar, lemos o stderr para saber o motivo + err_log = await self.current_process.stderr.read() + state.log(f"❌ Erro FFmpeg: {err_log.decode('utf-8')[-200:]}") state.update_task(fname, 'error', 0, label="Erro FFmpeg") return self.current_process = None -# 4. DEPLOY SEGURO - state.update_task(fname, 'running', 98, label="Organizando...") - - # Monta caminho completo + # Etapa 4: Deploy + state.update_task(fname, 'running', progress=99, label="Organizando...") final_full_path = Path(category.target_path) / relative_path - - # Garante que a PASTA existe (Merge seguro) - # Se a pasta já existe, o mkdir(exist_ok=True) não faz nada (não apaga, não mexe) final_full_path.parent.mkdir(parents=True, exist_ok=True) - # Tratamento de ARQUIVO duplicado if final_full_path.exists(): - # Se o arquivo já existe, apagamos ele para substituir pelo novo (Upgrade) - state.log(f"⚠️ Substituindo arquivo existente: {final_full_path.name}") - os.remove(str(final_full_path)) + try: os.remove(str(final_full_path)) + except: pass - # Move APENAS o arquivo shutil.move(str(temp_output), str(final_full_path)) if AppConfig.get_val('deploy_mode', 'move') == 'move': - os.remove(str(filepath)) + try: os.remove(str(filepath)) + except: pass - await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`") - state.update_task(fname, 'done', 100, label=f"{full_details['title']}") + await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`\n📂 {category.name}") + state.update_task(fname, 'done', 100, label=f"{full_details['title']}", file_progress=100, speed="Finalizado") state.current_file = "" - # Limpeza pasta vazia + if AppConfig.get_val('cleanup_empty_folders', 'true') == 'true': try: parent = filepath.parent @@ -234,20 +254,11 @@ class DirectoryWatcher: state.update_task(fname, 'error', 0, label=f"Erro: {e}") def find_category(self, media_type): - """Encontra a categoria correta baseada nas keywords""" - # Define keywords esperadas keywords = ['movie', 'film', 'filme'] if media_type == 'movie' else ['tv', 'serie', 'série'] - all_cats = list(Category.select()) for cat in all_cats: if not cat.match_keywords: continue - - # --- AQUI ESTAVA O ERRO POTENCIAL --- - # O .strip() precisa dos parênteses antes do .lower() cat_keys = [k.strip().lower() for k in cat.match_keywords.split(',')] - if any(k in cat_keys for k in keywords): return cat - - # Fallback (primeira categoria que existir) return all_cats[0] if all_cats else None \ No newline at end of file diff --git a/app/ui/__pycache__/dashboard.cpython-311.pyc b/app/ui/__pycache__/dashboard.cpython-311.pyc index 5429511e78bde80c60aa8b169ed803061856de12..1ec105704303a4685276f24729995f83d8bcd645 100644 GIT binary patch delta 3416 zcmb7GYfKy26`rv@#x@2AY~vRWGhoLa!T{!xK*ELvLc%5`JhFj2Xj8*XST@EcV`oFW zcAfo4q^e4D(+%0pO00xZR;3kD`ba9ZimF!GDl2uy8p+5l+elSZ?XQ|Ht0wJ_?Y%Pu z42f3lXy%)9&$;(I-#vHkwHJmKxZ)od6&WyiJ)*Bpgxq(F|6wv0!loHMU$*Hn>__-b z>}ny{v7;R?1pPbytk!(n0BTFB!IKJ`g4ZuyUx|W*4tDh1QA1+&b#n#o1yg1>_`A6l-vw6HWHCq#h3R&YKqpT{_RZy#B z1P?S7cpaFs_;3o$THNaTk5n>A0DNvaP@B-O+VdL36g4D>YdHT#f?ylY!`WdNY<2#x z!Wv&GO!F#XO?kqa(M<4-3FCY6N~qVwC=3Iyo9$Y-nCr5wdtWLf!TZ1!vkfO?2`zA1 zpMr90t>K#H`$iT!j?t}P$$F%`=-fnzJ3GZk#(6Fp<#@dxWWdf`I0<1g4t7FAK$lLh-PwhoRNu`0F*6z zm1YZUPEEt&ZsbSBos5JdbP4#6%~4f{*1BLU6pTi>XjaE_?{It+t~((M%Bsh?7-+CJ z9wOk^F%`|KC&Dq>m?h4IgOglV!v^6;P>m=I`( z4)6oJS8IeONrGS550w8+i(${MV9PpNM(0lJ+@h{R(p3n$il-(EFgs?|PhC}D$zeaS zU*D-(#SmUSI@5ivY1!(S_22l;%%Nq6d-iR~LC+jnwz+OjU!R^cEtQD2M#Wz_ z1ZTVGY?qwv8Ry}&^YEuy(b*?C`vhm-S6U2qbKG*>c4ZtjX-AE~bc>EdlH*Vgb6HUu zgir0x+qyaHEz@mN#$K1U*9rA!MEi(j9}(;$E9=2jUUW#b4@>r8!9JYRV&#={r_!!k z(N+7Ds+uqR*gkL1P=PcR5E^<#>X<|w%VBDF99M9Kten>^*hDfQk%0`^nkHL?ecu(y zphN}*G6=&G*>K zOEm!34x~ zCo|sWw6|HuZ@WwR`RfdhlPvm z^+}#R_fI|?doUsloe?}EqGv?%jAT4)8g}6lq9-YNlCWsrgQ#c_VTHO?gQ#fU+bgcyjKVuRBqAV zE&01M{@%2|S2#8$`Y%ZS3xfZ`^6vT^rlR4eR#Us!vvdM2NT#MMUDJhh;2lT@(}{FI zgf4{o&15KL3hhqhTFXT;Gf``*s#($n#tyI$(41F&q60c+YJ~{eJiEK@;(ZvW~WamV!s6 z@I~qWSlqeuXiMSa8Y65xZqXcVGd*roBfA}k_Geftv_EU@EE+Hr{z=2HhV#Fkt;K(jLDmSb~m|UbZ1KuLht46jTM>Z|n zHAbjVY|#v~6^d2%FeYy-EG~cuC{8EJ<0eWf}9|1w?ApRb>L!E?w zM!%+hSAQ1%DA8sf{oTtJz#qM{?*v0U`m2}KjPnyLe*~I*KZ0Wj>cDEHli-g-8Rdm{5QP5rLqxB m4*NggtA=}HbQ_K*z+AN(zXE<+{RVYHg}ZautC6=sLH`A7uF+ut delta 2008 zcmb7FZA?>F7{2G;-hRu%$1=?%{=t89TQ;p((l$4#VPRRaA6u4emWYY___1?ti=b>vc6#rV z=e+NEpZA>iyf>YWPVcS$Sg+R-XzV-s-mrt6seWWI;<*vMk25quybnGgPHSLIjUQ;> z?wao?@||wDuGXCvXcouGBc%|ODOymal!7{?5;Q4wk`%PZNbHQ$LRHxp+ls8Zid7@% zk+m8{7*Iq_N*(heZ*9e^5^9mR4tb5p!y`}qGc^WzOsRk}#vrr#nc0lYmXtE4MOLN+5ZPtjW*I3lTvyPk{EEO`a) zMg#o3z6I`88k~TLnV(xGmlQ8e(n%#+%3r6DGzELiW@s~N;2qk4Q-emhKpA1$#E?ma ztd><8;9o{R{IjmsiVm_=t!vMz>z1|-9F=$-+-B0~l9bSrs3HhKePb>B&C~*aHMWu> z2hT7Z<-9`5367Zz&`h6!HSBr~d8S7o!i38Wcd|kF8T<2qk5Ab7p^@lGZ+z?oKP*N^ zC%lnpOpM0)!-M1A4Rhqgs0+*~CWaHygkFrE6upN=jz_(n?d>jdj*g6tjK$;I(76)^ zS>-U6!IA9=Csf#Ytr$NZiwug=)ObXUdk~Aa zV(5m4xE2MzC3u$WCQm8gx15=Nq9ke<_!D>W4aDq@k9sqqqNB6m=*&AdOODNXTbE?( zDiN(30W8RPIyBjPZsS9Sz37~JckzIt&*g*D zZ{;{&p7TkZugGmFa9d{mc`hVzp`858TkV%yuR5lqvyPnApSSuYs~-_`oMz-6#^DDK>T#8ar~nJ^9A4)EF)iB!}fN&ht$Y@0&HwhVPi~y`AI3c|I)h z;UeE(;QMm}6M0^gcrnL|XkkWubNemolbttqPB-9Rp6`1t!eqF*jXxg_ZYxiX6@s}t)>2C{B^jGeHE9g;uZ4A5|WHk4x1CCwYRrh^5G`Zic z2$F^@NgLKjUWatE9!0RdD%(zBz5!sq5%UL> zBBath(1MWa_Jx+mB-jB@Iro7u{NA|(jKOBtexQX(*B4+f^t;#8?7(0DE)1_>@W7bc zs+yz6<749!@T|KT1mTQ3;5dSv5ex!`D25>nVGM^c?184XCas7l`j;doW^TXI7NYw) uXk4U!!6SgBy(Qv*z>`h4e{A0hfCzaX2TsCn->$}95^yErpD}Jmd-)qXy(Y*2 diff --git a/app/ui/dashboard.py b/app/ui/dashboard.py index 427d259..81555c2 100755 --- a/app/ui/dashboard.py +++ b/app/ui/dashboard.py @@ -45,33 +45,32 @@ def show(): log_content = ui.label().style('white-space: pre-wrap; font-family: monospace;') with log_container: log_content.move(log_container) - # --- COLUNA DA DIREITA: LISTA DE TAREFAS (Igual ao antigo) --- + # --- COLUNA DA DIREITA: LISTA DE TAREFAS --- with ui.card().classes('w-full h-[80vh] bg-gray-50 flex flex-col p-0'): # Cabeçalho da Lista with ui.row().classes('w-full p-4 bg-white border-b items-center justify-between'): ui.label('📋 Fila de Processamento').classes('text-lg font-bold text-gray-700') lbl_status_top = ui.label('Ocioso').classes('text-sm text-gray-400') - # Container da Lista (Onde a mágica acontece) + # Container da Lista tasks_container = ui.column().classes('w-full p-2 gap-2 overflow-y-auto flex-grow') - # --- RENDERIZADOR DA LISTA --- + # --- RENDERIZADOR DA LISTA (Com Duas Barras) --- def render_tasks(): tasks_container.clear() - # Se não tiver tarefas if not state.tasks: with tasks_container: ui.label('Nenhuma atividade recente.').classes('text-gray-400 italic p-4') return - # Itera sobre as tarefas (reversed para mais recentes no topo) for fname, data in reversed(state.tasks.items()): status = data['status'] - pct = data['progress'] + global_pct = data['progress'] + file_pct = data.get('file_progress', 0) + speed = data.get('speed', '') label = data['label'] - # Estilo baseado no status (Igual ao seu código antigo) icon = 'circle'; color = 'grey'; spin = False bg_color = 'bg-white' @@ -91,35 +90,52 @@ def show(): icon = 'block'; color = 'red' with tasks_container: - with ui.card().classes(f'w-full p-2 {bg_color} flex-row items-center gap-3'): + with ui.card().classes(f'w-full p-3 {bg_color} flex-row items-start gap-3'): # Ícone - if spin: ui.spinner(size='sm').classes('text-blue-500') - else: ui.icon(icon, color=color, size='sm') + if spin: ui.spinner(size='sm').classes('text-blue-500 mt-1') + else: ui.icon(icon, color=color, size='sm').classes('mt-1') # Conteúdo - with ui.column().classes('flex-grow gap-0'): - ui.label(label).classes('font-bold text-sm text-gray-800 truncate') - ui.label(fname).classes('text-xs text-gray-500 truncate') + with ui.column().classes('flex-grow gap-1 w-full'): + # Título e Nome Arquivo + ui.label(label).classes('font-bold text-sm text-gray-800 break-all') + ui.label(fname).classes('text-xs text-gray-500 break-all mb-1') - # Barra de Progresso (Só aparece se estiver rodando) + # Se estiver rodando, mostra barras if status == 'running': - with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.linear_progress(value=pct/100, show_value=False).classes('h-2 rounded flex-grow') - ui.label(f"{int(pct)}%").classes('text-xs font-bold text-blue-600') + # Barra 1: Global + with ui.row().classes('w-full items-center gap-2'): + ui.label('Total').classes('text-xs font-bold text-gray-400 w-12') + ui.linear_progress(value=global_pct/100, show_value=False).classes('h-2 rounded flex-grow').props('color=blue-4') + ui.label(f"{int(global_pct)}%").classes('text-xs font-bold text-blue-400 w-8 text-right') + + # Barra 2: Conversão de Arquivo + # Mostra se tivermos algum progresso de arquivo OU velocidade detectada + if file_pct > 0 or (speed and speed != "0x"): + with ui.row().classes('w-full items-center gap-2'): + ui.label('File').classes('text-xs font-bold text-gray-400 w-12') + + # Barra Verde + ui.linear_progress(value=file_pct/100, show_value=False).classes('h-3 rounded flex-grow').props('color=green') + + # Porcentagem e Velocidade + with ui.row().classes('items-center gap-1'): + ui.label(f"{int(file_pct)}%").classes('text-xs font-bold text-green-600') + if speed: + # Badge de Velocidade + ui.label(speed).classes('text-xs bg-green-100 text-green-800 px-1 rounded font-mono') # --- LOOP DE ATUALIZAÇÃO --- def update_ui(): - # 1. Logs + # Logs logs = state.get_logs() log_content.set_text("\n".join(logs[-30:])) log_container.scroll_to(percent=1.0) - # 2. Re-renderiza a lista de tarefas - # Nota: O NiceGUI é eficiente, mas para listas muito grandes seria melhor atualizar in-place. - # Como limitamos a 20 itens no state, limpar e redesenhar é rápido e seguro. + # Tarefas render_tasks() - # 3. Controles Globais + # Status Global if state.watcher and state.watcher.is_running: lbl_status_top.text = "Serviço Rodando" lbl_status_top.classes(replace='text-green-500') @@ -129,10 +145,10 @@ def show(): lbl_status_top.classes(replace='text-red-400') switch_run.value = False - # 4. Botão Cancelar + # Botão Cancelar if state.current_file: btn_cancel.classes(remove='hidden') else: btn_cancel.classes(add='hidden') - ui.timer(1.0, update_ui) # Atualiza a cada 1 segundo \ No newline at end of file + ui.timer(1.0, update_ui) \ No newline at end of file diff --git a/projeto_completo.txt b/projeto_completo.txt new file mode 100644 index 0000000..9ea2c0e --- /dev/null +++ b/projeto_completo.txt @@ -0,0 +1,1493 @@ +================================================================================ +ARQUIVO: ./app/core/bot.py +================================================================================ +import asyncio +import httpx # Usamos para testar a rede antes +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ApplicationBuilder, ContextTypes, CallbackQueryHandler, CommandHandler +from database import AppConfig +import logging + +# Configuração de Log +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("telegram").setLevel(logging.INFO) + +class TelegramManager: + def __init__(self): + # NÃO carregamos o token aqui. Carregamos na hora de iniciar. + self.app = None + self.active_requests = {} + self.is_connected = False + + async def check_internet(self): + """Testa se o container tem internet antes de tentar o Telegram""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + await client.get("https://www.google.com") + return True + except: + return False + + async def start(self): + """Inicia o Bot""" + # 1. Pega o Token Fresquinho do Banco + token = AppConfig.get_val('telegram_token') + chat_id = AppConfig.get_val('telegram_chat_id') + + if not token: + print("🟡 Bot: Token não configurado. Aguardando você salvar no Painel...") + return + + # 2. Teste de Rede Prévio + print("🤖 Bot: Verificando conectividade...") + if not await self.check_internet(): + print("❌ Bot: ERRO DE REDE! O container não consegue acessar a internet.") + print(" -> Verifique DNS ou Firewall.") + return + + print(f"🤖 Bot: Conectando com token termina em ...{token[-5:]}") + + try: + # 3. Constroi a Aplicação + self.app = ApplicationBuilder().token(token).build() + + # Handlers + self.app.add_handler(CommandHandler("start", self.cmd_start)) + self.app.add_handler(CommandHandler("id", self.cmd_id)) + self.app.add_handler(CallbackQueryHandler(self.handle_selection)) + + # Inicializa + await self.app.initialize() + await self.app.start() + + # Inicia Polling (Limpa mensagens velhas acumuladas para não travar) + await self.app.updater.start_polling(drop_pending_updates=True) + + self.is_connected = True + print("✅ Bot Online e Rodando!") + + # Tenta mandar um oi se tiver chat_id + if chat_id: + try: + await self.app.bot.send_message(chat_id=chat_id, text="🚀 Clei-Flow: Conexão restabelecida!") + except Exception as e: + print(f"⚠️ Bot online, mas falhou ao enviar msg (Chat ID errado?): {e}") + + except Exception as e: + print(f"❌ Falha Crítica no Bot: {e}") + self.is_connected = False + + async def stop(self): + if self.app: + try: + await self.app.updater.stop() + await self.app.stop() + await self.app.shutdown() + self.is_connected = False + except: pass + + # --- COMANDOS --- + async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + chat_id = update.effective_chat.id + await update.message.reply_text(f"Olá! Configurado.\nSeu Chat ID é: `{chat_id}`", parse_mode='Markdown') + # Opcional: Salvar o Chat ID automaticamente se o usuário mandar /start + # AppConfig.set_val('telegram_chat_id', str(chat_id)) + + async def cmd_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + await update.message.reply_text(f"`{update.effective_chat.id}`", parse_mode='Markdown') + + # --- INTERAÇÃO (Renomeação) --- + async def ask_user_choice(self, filename, candidates): + chat_id = AppConfig.get_val('telegram_chat_id') # Pega sempre o atual + if not chat_id or not self.is_connected: + print("❌ Bot não pode perguntar (Sem Chat ID ou Desconectado)") + return None + + request_id = f"req_{filename}" + keyboard = [] + for cand in candidates: + # Texto do botão + text = f"{cand['title']} ({cand['year']})" + # Dados (ID|Tipo) + callback_data = f"{request_id}|{cand['tmdb_id']}|{cand['type']}" + keyboard.append([InlineKeyboardButton(text, callback_data=callback_data)]) + + keyboard.append([InlineKeyboardButton("🚫 Ignorar", callback_data=f"{request_id}|IGNORE|NONE")]) + reply_markup = InlineKeyboardMarkup(keyboard) + + try: + await self.app.bot.send_message( + chat_id=chat_id, + text=f"🤔 Clei-Flow Precisa de Ajuda:\nArquivo: {filename}", + reply_markup=reply_markup, + parse_mode='HTML' + ) + except Exception as e: + print(f"Erro ao enviar pergunta: {e}") + return None + + loop = asyncio.get_running_loop() + future = loop.create_future() + self.active_requests[request_id] = future + + try: + # Espera 12 horas + result = await asyncio.wait_for(future, timeout=43200) + return result + except asyncio.TimeoutError: + if request_id in self.active_requests: del self.active_requests[request_id] + return None + + async def handle_selection(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + + data = query.data.split('|') + if len(data) < 3: return + + req_id = data[0] + tmdb_id = data[1] + media_type = data[2] + + if req_id in self.active_requests: + future = self.active_requests[req_id] + if tmdb_id == 'IGNORE': + await query.edit_message_text(text=f"🚫 Ignorado.") + future.set_result(None) + else: + await query.edit_message_text(text=f"✅ Processando...") + future.set_result({'tmdb_id': int(tmdb_id), 'type': media_type}) + del self.active_requests[req_id] + else: + await query.edit_message_text(text="⚠️ Solicitação expirada.") + + async def send_notification(self, message): + chat_id = AppConfig.get_val('telegram_chat_id') + if self.app and chat_id and self.is_connected: + try: + await self.app.bot.send_message(chat_id=chat_id, text=message) + except: pass + + +================================================================================ +ARQUIVO: ./app/core/ffmpeg_engine.py +================================================================================ +import subprocess +import json +import os +from database import FFmpegProfile +from core.state import state + +class FFmpegEngine: + def __init__(self, profile_id=None): + if profile_id: + self.profile = FFmpegProfile.get_by_id(profile_id) + else: + self.profile = FFmpegProfile.get_or_none(FFmpegProfile.is_active == True) + + if not self.profile: + state.log("⚠️ AVISO: Nenhum perfil FFmpeg ativo! Usando defaults.") + + def get_file_info(self, filepath): + cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath] + try: + output = subprocess.check_output(cmd).decode('utf-8') + return json.loads(output) + except Exception as e: + state.log(f"Erro FFprobe: {e}") + return None + + def get_duration(self, filepath): + try: return float(self.get_file_info(filepath)['format']['duration']) + except: return 0 + + def build_command(self, input_file, output_file): + if not self.profile: raise Exception("Perfil não configurado.") + p = self.profile + metadata = self.get_file_info(input_file) + if not metadata: raise Exception("Arquivo inválido.") + + cmd = ['ffmpeg', '-y'] + + # --- CONFIGURAÇÃO DE HARDWARE OTIMIZADA --- + video_filters = [] + + if 'vaapi' in p.video_codec: + # TENTATIVA OTIMIZADA: HWAccel habilitado na entrada + # Isso reduz a CPU drasticamente se o decode for suportado + cmd.extend(['-hwaccel', 'vaapi']) + cmd.extend(['-hwaccel_device', '/dev/dri/renderD128']) + cmd.extend(['-hwaccel_output_format', 'vaapi']) + + # Não precisamos de 'hwupload' se o output format já é vaapi + # Mas vamos deixar o filtro scale_vaapi caso precise redimensionar (opcional) + # video_filters.append('scale_vaapi=format=nv12') + + elif 'qsv' in p.video_codec: + cmd.extend(['-hwaccel', 'qsv', '-c:v', 'h264_qsv']) + + elif 'nvenc' in p.video_codec: + cmd.extend(['-hwaccel', 'cuda']) + + # Input e Threads (Limita CPU se cair pra software decode) + cmd.extend(['-threads', '4', '-i', input_file]) + + # --- VÍDEO --- + cmd.extend(['-map', '0:v:0']) + + if p.video_codec == 'copy': + cmd.extend(['-c:v', 'copy']) + else: + cmd.extend(['-c:v', p.video_codec]) + + # Filtros + if video_filters: + cmd.extend(['-vf', ",".join(video_filters)]) + + # Qualidade + if 'vaapi' in p.video_codec: + # -qp é o modo Qualidade Constante do VAAPI + # Se der erro, remova -qp e use -b:v 5M + cmd.extend(['-qp', str(p.crf)]) + + elif 'nvenc' in p.video_codec: + cmd.extend(['-cq', str(p.crf), '-preset', p.preset]) + + elif 'libx264' in p.video_codec: + cmd.extend(['-crf', str(p.crf), '-preset', p.preset]) + + # --- ÁUDIO (AAC Stereo) --- + allowed_langs = [l.strip().lower() for l in (p.audio_langs or "").split(',')] + audio_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio'] + + acount = 0 + for s in audio_streams: + lang = s.get('tags', {}).get('language', 'und').lower() + if not allowed_langs or lang in allowed_langs or 'und' in allowed_langs: + cmd.extend(['-map', f'0:{s["index"]}']) + # AAC é leve e compatível + cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k', f'-ac:{acount}', '2']) + acount += 1 + + if acount == 0 and audio_streams: + cmd.extend(['-map', '0:a:0', '-c:a', 'aac', '-b:a', '192k']) + + # --- LEGENDAS (Copy) --- + sub_allowed = [l.strip().lower() for l in (p.subtitle_langs or "").split(',')] + sub_streams = [s for s in metadata['streams'] if s['codec_type'] == 'subtitle'] + + scount = 0 + for s in sub_streams: + lang = s.get('tags', {}).get('language', 'und').lower() + if not sub_allowed or lang in sub_allowed or 'und' in sub_allowed: + cmd.extend(['-map', f'0:{s["index"]}']) + cmd.extend([f'-c:s:{scount}', 'copy']) + scount += 1 + + # Metadados + clean_title = os.path.splitext(os.path.basename(output_file))[0] + cmd.extend(['-metadata', f'title={clean_title}']) + cmd.append(output_file) + + state.log(f"🛠️ CMD: {' '.join(cmd)}") + return cmd + + +================================================================================ +ARQUIVO: ./app/core/flow.py +================================================================================ +from .renamer import RenamerCore +from .ffmpeg_engine import FFmpegCore +from .bot import BotCore + +class CleiFlow: + """ + Gerencia o ciclo de vida do arquivo. + Modos: Manual, Híbrido, Automático. + """ + def start_pipeline(self, file_path): + # 1. Identificar + # 2. Se ambíguo -> Chamar Bot.ask_for_decision() -> Pausar Thread + # 3. Converter (FFmpegCore) + # 4. Mover + pass + + + +================================================================================ +ARQUIVO: ./app/core/__init__.py +================================================================================ + + + +================================================================================ +ARQUIVO: ./app/core/renamer.py +================================================================================ +import os +import re +from guessit import guessit +from tmdbv3api import TMDb, Movie, TV, Search +from database import AppConfig +from difflib import SequenceMatcher + +class RenamerCore: + def __init__(self): + self.api_key = AppConfig.get_val('tmdb_api_key') + self.lang = AppConfig.get_val('tmdb_language', 'pt-BR') + self.min_confidence = int(AppConfig.get_val('min_confidence', '90')) / 100.0 + + self.tmdb = TMDb() + if self.api_key: + self.tmdb.api_key = self.api_key + self.tmdb.language = self.lang + + self.movie_api = Movie() + self.tv_api = TV() + self.search_api = Search() + + def clean_filename(self, filename): + name, ext = os.path.splitext(filename) + patterns = [ + r'(?i)(www\.[a-z0-9-]+\.[a-z]{2,})', + r'(?i)(rede\s?canais)', + r'(?i)(comando\s?torrents?)', + r'(?i)(bludv)', + r'(?i)(\bassistir\b)', + r'(?i)(\bbaixar\b)', + r'(?i)(\bdownload\b)', + r'(?i)(\bfilme\s?completo\b)', + r'(?i)(\bpt-br\b)', + ] + clean_name = name + for pat in patterns: + clean_name = re.sub(pat, '', clean_name) + + clean_name = re.sub(r'\s+-\s+', ' ', clean_name) + clean_name = re.sub(r'\s+', ' ', clean_name).strip() + return clean_name + ext + + def identify_file(self, filepath): + # ... (código identify_file igual ao anterior) ... + # Vou resumir aqui para não ficar gigante, mantenha o identify_file + # que passamos na última resposta (com o fix do 'results' e 'str'). + filename = os.path.basename(filepath) + cleaned_filename = self.clean_filename(filename) + try: guess = guessit(cleaned_filename) + except Exception as e: return {'status': 'ERROR', 'msg': str(e)} + + title = guess.get('title') + if not title: return {'status': 'NOT_FOUND', 'msg': 'Sem título'} + if not self.api_key: return {'status': 'ERROR', 'msg': 'Sem API Key'} + + try: + media_type = guess.get('type', 'movie') + if media_type == 'episode': results = self.search_api.tv_shows(term=title) + else: + results = self.search_api.movies(term=title) + if not results: results = self.search_api.tv_shows(term=title) + except: return {'status': 'NOT_FOUND', 'msg': 'Erro TMDb'} + + if isinstance(results, dict) and 'results' in results: results = results['results'] + elif hasattr(results, 'results'): results = results.results + + if results and isinstance(results, list) and len(results) > 0 and isinstance(results[0], str): + return {'status': 'NOT_FOUND', 'msg': 'Formato inválido'} + + candidates = [] + for res in results: + if isinstance(res, str): continue + if isinstance(res, dict): + r_id = res.get('id'); r_title = res.get('title') or res.get('name') + r_date = res.get('release_date') or res.get('first_air_date') + r_overview = res.get('overview', '') + else: + r_id = getattr(res, 'id', None) + r_title = getattr(res, 'title', getattr(res, 'name', '')) + r_date = getattr(res, 'release_date', getattr(res, 'first_air_date', '')) + r_overview = getattr(res, 'overview', '') + + if not r_title or not r_id: continue + r_year = int(str(r_date)[:4]) if r_date else 0 + + t1 = str(title).lower(); t2 = str(r_title).lower() + base_score = SequenceMatcher(None, t1, t2).ratio() + if t1 in t2 or t2 in t1: base_score = max(base_score, 0.85) + + g_year = guess.get('year') + if g_year and r_year: + if g_year == r_year: base_score += 0.15 + elif abs(g_year - r_year) <= 1: base_score += 0.05 + + final_score = min(base_score, 1.0) + candidates.append({ + 'tmdb_id': r_id, 'title': r_title, 'year': r_year, + 'type': 'movie' if hasattr(res, 'title') or (isinstance(res, dict) and 'title' in res) else 'tv', + 'overview': str(r_overview)[:100], 'score': final_score + }) + + if not candidates: return {'status': 'NOT_FOUND', 'msg': 'Sem candidatos'} + candidates.sort(key=lambda x: x['score'], reverse=True) + best = candidates[0] + if len(candidates) == 1 and best['score'] > 0.6: return {'status': 'MATCH', 'match': best, 'guessed': guess} + + is_clear_winner = False + if len(candidates) > 1 and (best['score'] - candidates[1]['score']) > 0.15: is_clear_winner = True + + if best['score'] >= self.min_confidence or is_clear_winner: + return {'status': 'MATCH', 'match': best, 'guessed': guess} + return {'status': 'AMBIGUOUS', 'candidates': candidates[:5], 'guessed': guess} + + def get_details(self, tmdb_id, media_type): + if media_type == 'movie': return self.movie_api.details(tmdb_id) + return self.tv_api.details(tmdb_id) + + # --- AQUI ESTÁ A MUDANÇA --- + def build_path(self, category_obj, media_info, guessed_info): + clean_title = re.sub(r'[\\/*?:"<>|]', "", media_info['title']).strip() + year = str(media_info['year']) + + forced_type = category_obj.content_type + actual_type = media_info['type'] + + is_series = False + if forced_type == 'series': is_series = True + elif forced_type == 'movie': is_series = False + else: is_series = (actual_type == 'tv') + + if not is_series: + # Filme: "Matrix (1999).mkv" + return f"{clean_title} ({year}).mkv" + else: + # Série + season = guessed_info.get('season') + episode = guessed_info.get('episode') + + if isinstance(season, list): season = season[0] + if isinstance(episode, list): episode = episode[0] + + if not season: season = 1 + if not episode: episode = 1 + + season_folder = f"Temporada {int(season):02d}" + + # MUDANÇA: Nome do arquivo simplificado + # De: "Nome Serie S01E01.mkv" + # Para: "Episódio 01.mkv" + filename = f"Episódio {int(episode):02d}.mkv" + + # Caminho relativo: "Nome Série/Temporada 01/Episódio 01.mkv" + return os.path.join(clean_title, season_folder, filename) + + +================================================================================ +ARQUIVO: ./app/core/state.py +================================================================================ +from collections import deque, OrderedDict + +class AppState: + def __init__(self): + # --- Logs do Sistema --- + self.logs = deque(maxlen=1000) + + # --- Referência ao Watcher --- + self.watcher = None + + # --- Lista de Tarefas (Visualização tipo Árvore/Lista) --- + self.tasks = OrderedDict() + + # --- Variáveis de Estado (Compatibilidade) --- + self.current_file = "" + self.progress = 0.0 + self.status_text = "Aguardando..." + + def log(self, message): + """Adiciona log e printa no console""" + print(message) + self.logs.append(message) + + def update_task(self, filename, status, progress=0, label=None): + """Atualiza o status de um arquivo na interface""" + # Se não existe, cria + if filename not in self.tasks: + self.tasks[filename] = { + 'status': 'pending', + 'progress': 0, + 'label': label or filename + } + # Limita a 20 itens para não travar a tela + if len(self.tasks) > 20: + self.tasks.popitem(last=False) # Remove o mais antigo + + # Atualiza dados + self.tasks[filename]['status'] = status + self.tasks[filename]['progress'] = progress + if label: + self.tasks[filename]['label'] = label + + def get_logs(self): + return list(self.logs) + +# --- INSTÂNCIA GLOBAL --- +# Ao ser importado, isso roda uma vez e cria o objeto. +# Todo mundo que fizer 'from core.state import state' vai pegar essa mesma instância. +state = AppState() + + +================================================================================ +ARQUIVO: ./app/core/watcher.py +================================================================================ +import asyncio +import os +import shutil +import re +from pathlib import Path +from database import AppConfig, Category +from core.renamer import RenamerCore +from core.ffmpeg_engine import FFmpegEngine +from core.bot import TelegramManager +from core.state import state + +VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.wmv'} + +class DirectoryWatcher: + def __init__(self, bot: TelegramManager): + self.bot = bot + self.renamer = RenamerCore() + self.is_running = False + self.temp_dir = Path('/app/temp') + self.temp_dir.mkdir(parents=True, exist_ok=True) + self.current_watch_path = None + self.current_process = None + self.pending_future = None + self.abort_flag = False + state.watcher = self + + async def start(self): + state.log("🟡 Watcher Service: Pronto. Aguardando ativação no Dashboard...") + while True: + if self.is_running: + try: + config_path = AppConfig.get_val('monitor_path', '/downloads') + watch_dir = Path(config_path) + if str(watch_dir) != str(self.current_watch_path): + state.log(f"📁 Monitorando: {watch_dir}") + self.current_watch_path = watch_dir + if watch_dir.exists(): + await self.scan_folder(watch_dir) + except Exception as e: + state.log(f"❌ Erro Watcher Loop: {e}") + await asyncio.sleep(5) + + def abort_current_task(self): + state.log("🛑 Solicitando Cancelamento...") + self.abort_flag = True + if self.current_process: + try: self.current_process.kill() + except: pass + if self.pending_future and not self.pending_future.done(): + self.pending_future.cancel() + + async def scan_folder(self, input_dir: Path): + for file_path in input_dir.glob('**/*'): + if self.abort_flag: + self.abort_flag = False + if state.current_file: + state.update_task(state.current_file, 'error', label=f"{state.current_file} (Cancelado)") + return + if not self.is_running: return + if file_path.is_file() and file_path.suffix.lower() in VIDEO_EXTENSIONS: + if file_path.name.startswith('.') or 'processing' in file_path.name: continue + if file_path.name in state.tasks and state.tasks[file_path.name]['status'] == 'done': continue + try: + s1 = file_path.stat().st_size + await asyncio.sleep(1) + s2 = file_path.stat().st_size + if s1 != s2: continue + except: continue + await self.process_pipeline(file_path) + if self.abort_flag: return + + async def process_pipeline(self, filepath: Path): + fname = filepath.name + self.abort_flag = False + state.current_file = fname + + state.update_task(fname, 'running', 0, label=f"Identificando: {fname}...") + state.log(f"🔄 Iniciando: {fname}") + + # 1. IDENTIFICAÇÃO + result = self.renamer.identify_file(str(filepath)) + + is_semi_auto = AppConfig.get_val('semi_auto', 'false') == 'true' + if is_semi_auto and result['status'] == 'MATCH': + result['status'] = 'AMBIGUOUS' + result['candidates'] = [result['match']] + + target_info = None + + if result['status'] == 'MATCH': + target_info = {'tmdb_id': result['match']['tmdb_id'], 'type': result['match']['type']} + state.update_task(fname, 'running', 10, label=f"ID: {result['match']['title']}") + + elif result['status'] == 'AMBIGUOUS': + if 'candidates' not in result or not result['candidates']: + state.update_task(fname, 'error', 0, label="Erro: Ambíguo sem candidatos") + return + + state.update_task(fname, 'warning', 10, label="Aguardando Telegram...") + self.pending_future = asyncio.ensure_future( + self.bot.ask_user_choice(fname, result['candidates']) + ) + try: + user_choice = await self.pending_future + except asyncio.CancelledError: + state.update_task(fname, 'error', 0, label="Cancelado Manualmente") + return + + self.pending_future = None + if not user_choice or self.abort_flag: + state.update_task(fname, 'skipped', 0, label="Ignorado/Cancelado") + return + target_info = user_choice + else: + msg = result.get('msg', 'Desconhecido') + state.update_task(fname, 'error', 0, label="Não Identificado") + state.log(f"❌ Falha Identificação: {msg}") + return + + if self.abort_flag: return + + # 2. CATEGORIA + category = self.find_category(target_info['type']) + if not category: + state.update_task(fname, 'error', 0, label="Sem Categoria") + return + + # 3. CONVERSÃO + try: + details = self.renamer.get_details(target_info['tmdb_id'], target_info['type']) + r_title = getattr(details, 'title', getattr(details, 'name', 'Unknown')) or 'Unknown' + + full_details = {'title': r_title, 'year': '0000', 'type': target_info['type']} + d_date = getattr(details, 'release_date', getattr(details, 'first_air_date', '0000')) + if d_date: full_details['year'] = str(d_date)[:4] + + guessed_data = result.get('guessed', {}) + relative_path = self.renamer.build_path(category, full_details, guessed_data) + + temp_filename = os.path.basename(relative_path) + temp_output = self.temp_dir / temp_filename + + state.update_task(fname, 'running', 15, label=f"Convertendo: {full_details['title']}") + + engine = FFmpegEngine() + total_duration = engine.get_duration(str(filepath)) + cmd = engine.build_command(str(filepath), str(temp_output)) + + self.current_process = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + + # --- LOOP DE PROGRESSO CORRIGIDO --- + current_speed = "" + while True: + if self.abort_flag: + self.current_process.kill() + break + + line_bytes = await self.current_process.stderr.readline() + if not line_bytes: break + + line = line_bytes.decode('utf-8', errors='ignore').strip() + + # 1. Captura Velocidade (Ex: speed=4.5x) + # Regex pega numeros e ponto antes do 'x' + sp_match = re.search(r'speed=\s*([\d\.]+)x', line) + if sp_match: + current_speed = f" ({sp_match.group(1)}x)" + + # 2. Captura Tempo (Ex: time=00:12:30.45) + # Regex pega H:M:S independente de milissegundos + time_match = re.search(r'time=(\d+):(\d+):(\d+)', line) + + if time_match and total_duration > 0: + h, m, s = map(int, time_match.groups()) + current_seconds = h*3600 + m*60 + s + + # Calcula porcentagem (15% é o início, vai até 98%) + percent = current_seconds / total_duration + ui_pct = 15 + (percent * 83) + + if ui_pct > 99: ui_pct = 99 + + # Atualiza dashboard com velocidade + label_text = f"Convertendo{current_speed}: {full_details['title']}" + state.update_task(fname, 'running', ui_pct, label=label_text) + + await self.current_process.wait() + # ----------------------------------- + + if self.abort_flag: + state.update_task(fname, 'error', 0, label="Abortado") + if temp_output.exists(): os.remove(str(temp_output)) + return + + if self.current_process.returncode != 0: + state.update_task(fname, 'error', 0, label="Erro FFmpeg") + return + self.current_process = None + + # 4. DEPLOY + state.update_task(fname, 'running', 98, label="Organizando...") + final_full_path = Path(category.target_path) / relative_path + final_full_path.parent.mkdir(parents=True, exist_ok=True) + + if final_full_path.exists(): + try: os.remove(str(final_full_path)) + except: pass + + shutil.move(str(temp_output), str(final_full_path)) + + if AppConfig.get_val('deploy_mode', 'move') == 'move': + try: os.remove(str(filepath)) + except: pass + + await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`") + state.update_task(fname, 'done', 100, label=f"{full_details['title']}") + state.current_file = "" + + if AppConfig.get_val('cleanup_empty_folders', 'true') == 'true': + try: + parent = filepath.parent + monitor_root = Path(AppConfig.get_val('monitor_path', '/downloads')) + if parent != monitor_root and not any(parent.iterdir()): + parent.rmdir() + except: pass + + except Exception as e: + state.log(f"Erro Pipeline: {e}") + state.update_task(fname, 'error', 0, label=f"Erro: {e}") + + def find_category(self, media_type): + keywords = ['movie', 'film', 'filme'] if media_type == 'movie' else ['tv', 'serie', 'série'] + all_cats = list(Category.select()) + for cat in all_cats: + if not cat.match_keywords: continue + cat_keys = [k.strip().lower() for k in cat.match_keywords.split(',')] + if any(k in cat_keys for k in keywords): + return cat + return all_cats[0] if all_cats else None + + +================================================================================ +ARQUIVO: ./app/database.py +================================================================================ +from peewee import * +from pathlib import Path + +# Garante pasta de dados +data_dir = Path('/app/data') +data_dir.mkdir(parents=True, exist_ok=True) + +db = SqliteDatabase(str(data_dir / 'cleiflow.db')) + +class BaseModel(Model): + class Meta: + database = db + +class AppConfig(BaseModel): + key = CharField(unique=True) + value = TextField() + + @classmethod + def get_val(cls, key, default=''): + try: + return cls.get(cls.key == key).value + except: + return default + + @classmethod + def set_val(cls, key, value): + cls.replace(key=key, value=value).execute() + +class Category(BaseModel): + name = CharField(unique=True) + target_path = CharField() + match_keywords = CharField(null=True) # Ex: movie, film + # NOVO CAMPO: Tipo de conteúdo (movie, series, mixed) + content_type = CharField(default='mixed') + +class FFmpegProfile(BaseModel): + name = CharField() + video_codec = CharField(default='h264_vaapi') + preset = CharField(default='medium') + crf = IntegerField(default=23) + audio_langs = CharField(default='por,eng,jpn') + subtitle_langs = CharField(default='por') + is_active = BooleanField(default=False) + +def init_db(): + db.connect() + db.create_tables([AppConfig, Category, FFmpegProfile], safe=True) + + # Migração segura para adicionar coluna se não existir + try: + db.execute_sql('ALTER TABLE category ADD COLUMN content_type VARCHAR DEFAULT "mixed"') + except: pass # Já existe + + # Perfil padrão se não existir + if FFmpegProfile.select().count() == 0: + FFmpegProfile.create(name="Padrão VAAPI (Intel)", video_codec="h264_vaapi", is_active=True) + + db.close() + + +================================================================================ +ARQUIVO: ./app/main.py +================================================================================ +import asyncio +from nicegui import ui, app +from ui import layout, dashboard, settings, manual_tools +from database import init_db +from core.bot import TelegramManager +from core.watcher import DirectoryWatcher +from core.state import state + +# Inicializa Banco +init_db() + +# Instâncias Globais +bot = TelegramManager() +watcher = DirectoryWatcher(bot) + +# --- LIFECYCLE --- +async def startup(): + # 1. Inicia o Watcher (Ele começa pausado, safe) + asyncio.create_task(watcher.start()) + + # 2. Inicia o Bot com atraso e em background + # Isso evita que a falha de conexão trave a UI + asyncio.create_task(delayed_bot_start()) + +async def delayed_bot_start(): + print("⏳ Aguardando rede estabilizar (5s)...") + await asyncio.sleep(5) + await bot.start() + +async def shutdown(): + await bot.stop() + +app.on_startup(startup) +app.on_shutdown(shutdown) + +# --- ROTAS --- +@ui.page('/') +def index_page(): + ui.colors(primary='#5898d4', secondary='#263238') + ui.page_title('Clei-Flow') + layout.create_interface() + dashboard.show() + +@ui.page('/settings') +def settings_page(): + ui.colors(primary='#5898d4', secondary='#263238') + ui.page_title('Configurações') + layout.create_interface() + settings.show() + +@ui.page('/explorer') +def explorer_page(): + ui.colors(primary='#5898d4', secondary='#263238') + ui.page_title('Explorador') + layout.create_interface() + manual_tools.show() + +if __name__ in {"__main__", "__mp_main__"}: + ui.run(title='Clei-Flow', port=8080, storage_secret='clei-secret', reload=True) + + +================================================================================ +ARQUIVO: ./app/ui/dashboard.py +================================================================================ +from nicegui import ui +from database import AppConfig +from core.state import state + +def show(): + semi_auto_initial = AppConfig.get_val('semi_auto', 'false') == 'true' + + with ui.grid(columns=2).classes('w-full gap-4'): + + # --- COLUNA DA ESQUERDA: CONTROLES --- + with ui.column().classes('w-full gap-4'): + + # Painel de Controle + with ui.card().classes('w-full p-4 border-l-4 border-indigo-500'): + ui.label('🎛️ Painel de Controle').classes('text-xl font-bold mb-4 text-gray-700') + + # Switches + is_running = state.watcher.is_running if state.watcher else False + + def toggle_watcher(e): + if state.watcher: + state.watcher.is_running = e.value + state.log(f"Comando: {'INICIAR' if e.value else 'PAUSAR'}") + + switch_run = ui.switch('Monitoramento Ativo', value=is_running, on_change=toggle_watcher).props('color=green size=lg') + + def toggle_semi_auto(e): + AppConfig.set_val('semi_auto', str(e.value).lower()) + state.log(f"⚠️ Semi-Auto: {e.value}") + + switch_auto = ui.switch('Modo Semi-Automático', value=semi_auto_initial, on_change=toggle_semi_auto).props('color=amber size=lg') + + # Botão Cancelar Global + def cancel_task(): + if state.watcher: + state.watcher.abort_current_task() + ui.notify('Cancelando...', type='warning') + + btn_cancel = ui.button('CANCELAR ATUAL', on_click=cancel_task, icon='cancel').props('color=red').classes('w-full mt-4 hidden') + + # Terminal de Logs + with ui.card().classes('w-full h-64 bg-black text-green-400 font-mono text-xs p-2 overflow-hidden flex flex-col'): + ui.label('>_ Logs').classes('text-gray-500 mb-1 border-b border-gray-800 w-full') + log_container = ui.scroll_area().classes('flex-grow w-full') + log_content = ui.label().style('white-space: pre-wrap; font-family: monospace;') + with log_container: log_content.move(log_container) + + # --- COLUNA DA DIREITA: LISTA DE TAREFAS (Igual ao antigo) --- + with ui.card().classes('w-full h-[80vh] bg-gray-50 flex flex-col p-0'): + # Cabeçalho da Lista + with ui.row().classes('w-full p-4 bg-white border-b items-center justify-between'): + ui.label('📋 Fila de Processamento').classes('text-lg font-bold text-gray-700') + lbl_status_top = ui.label('Ocioso').classes('text-sm text-gray-400') + + # Container da Lista (Onde a mágica acontece) + tasks_container = ui.column().classes('w-full p-2 gap-2 overflow-y-auto flex-grow') + + # --- RENDERIZADOR DA LISTA --- + def render_tasks(): + tasks_container.clear() + + # Se não tiver tarefas + if not state.tasks: + with tasks_container: + ui.label('Nenhuma atividade recente.').classes('text-gray-400 italic p-4') + return + + # Itera sobre as tarefas (reversed para mais recentes no topo) + for fname, data in reversed(state.tasks.items()): + status = data['status'] + pct = data['progress'] + label = data['label'] + + # Estilo baseado no status (Igual ao seu código antigo) + icon = 'circle'; color = 'grey'; spin = False + bg_color = 'bg-white' + + if status == 'pending': + icon = 'hourglass_empty'; color = 'grey' + elif status == 'running': + icon = 'sync'; color = 'blue'; spin = True + bg_color = 'bg-blue-50 border-blue-200 border' + elif status == 'warning': + icon = 'warning'; color = 'orange' + bg_color = 'bg-orange-50 border-orange-200 border' + elif status == 'done': + icon = 'check_circle'; color = 'green' + elif status == 'error': + icon = 'error'; color = 'red' + elif status == 'skipped': + icon = 'block'; color = 'red' + + with tasks_container: + with ui.card().classes(f'w-full p-2 {bg_color} flex-row items-center gap-3'): + # Ícone + if spin: ui.spinner(size='sm').classes('text-blue-500') + else: ui.icon(icon, color=color, size='sm') + + # Conteúdo + with ui.column().classes('flex-grow gap-0'): + ui.label(label).classes('font-bold text-sm text-gray-800 truncate') + ui.label(fname).classes('text-xs text-gray-500 truncate') + + # Barra de Progresso (Só aparece se estiver rodando) + if status == 'running': + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.linear_progress(value=pct/100, show_value=False).classes('h-2 rounded flex-grow') + ui.label(f"{int(pct)}%").classes('text-xs font-bold text-blue-600') + + # --- LOOP DE ATUALIZAÇÃO --- + def update_ui(): + # 1. Logs + logs = state.get_logs() + log_content.set_text("\n".join(logs[-30:])) + log_container.scroll_to(percent=1.0) + + # 2. Re-renderiza a lista de tarefas + # Nota: O NiceGUI é eficiente, mas para listas muito grandes seria melhor atualizar in-place. + # Como limitamos a 20 itens no state, limpar e redesenhar é rápido e seguro. + render_tasks() + + # 3. Controles Globais + if state.watcher and state.watcher.is_running: + lbl_status_top.text = "Serviço Rodando" + lbl_status_top.classes(replace='text-green-500') + switch_run.value = True + else: + lbl_status_top.text = "Serviço Pausado" + lbl_status_top.classes(replace='text-red-400') + switch_run.value = False + + # 4. Botão Cancelar + if state.current_file: + btn_cancel.classes(remove='hidden') + else: + btn_cancel.classes(add='hidden') + + ui.timer(1.0, update_ui) # Atualiza a cada 1 segundo + + +================================================================================ +ARQUIVO: ./app/ui/__init__.py +================================================================================ + + + +================================================================================ +ARQUIVO: ./app/ui/layout.py +================================================================================ +from nicegui import ui + +def create_interface(): + # Cabeçalho Azul + with ui.header().classes('bg-blue-900 text-white'): + ui.button(on_click=lambda: left_drawer.toggle(), icon='menu').props('flat color=white') + ui.label('Clei-Flow').classes('text-xl font-bold') + + # Menu Lateral + with ui.left_drawer().classes('bg-gray-100').props('width=200') as left_drawer: + ui.label('MENU').classes('text-gray-500 text-xs font-bold mb-2 px-4 pt-4') + + # Helper para criar links + def nav_link(text, target, icon_name): + ui.link(text, target).classes('text-gray-700 hover:text-blue-600 block px-4 py-2 font-medium').props(f'icon={icon_name}') + + nav_link('Dashboard', '/', 'dashboard') + nav_link('Explorador', '/explorer', 'folder') + nav_link('Configurações', '/settings', 'settings') + + +================================================================================ +ARQUIVO: ./app/ui/manual_tools.py +================================================================================ +from nicegui import ui +# Abas antigas (Renomeador Manual, Encoder Manual) viram ferramentas aqui + + + +================================================================================ +ARQUIVO: ./app/ui/settings.py +================================================================================ +from nicegui import ui +import os +from database import Category, FFmpegProfile, AppConfig + +# Lista de idiomas (ISO 639-2) +ISO_LANGS = { + 'por': 'Português (por)', 'eng': 'Inglês (eng)', 'jpn': 'Japonês (jpn)', + 'spa': 'Espanhol (spa)', 'fra': 'Francês (fra)', 'ger': 'Alemão (ger)', + 'ita': 'Italiano (ita)', 'rus': 'Russo (rus)', 'und': 'Indefinido (und)' +} + +# --- COMPONENTE: SELETOR DE PASTAS (Restrito ao /media) --- +async def pick_folder_dialog(start_path='/media'): + """Abre um modal para escolher pastas, restrito a /media""" + ALLOWED_ROOT = '/media' + + # Garante que começa dentro do permitido + if not start_path or not start_path.startswith(ALLOWED_ROOT): + start_path = ALLOWED_ROOT + + result = {'path': None} + + with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col'): + ui.label('Selecionar Pasta (/media)').classes('font-bold text-lg mb-2') + path_label = ui.label(start_path).classes('text-xs bg-gray-100 p-2 border rounded w-full break-all font-mono') + + scroll = ui.scroll_area().classes('flex-grow border rounded p-1 mt-2 bg-white') + + async def load_dir(path): + # Segurança extra + if not path.startswith(ALLOWED_ROOT): path = ALLOWED_ROOT + + path_label.text = path + scroll.clear() + + try: + # Botão Voltar (Só aparece se NÃO estiver na raiz permitida) + if path != ALLOWED_ROOT: + parent = os.path.dirname(path) + # Garante que o parent não suba além do permitido + if not parent.startswith(ALLOWED_ROOT): parent = ALLOWED_ROOT + + with scroll: + ui.button('.. (Voltar)', on_click=lambda: load_dir(parent)).props('flat dense icon=arrow_upward align=left w-full') + + # Lista Pastas + with scroll: + # Tenta listar. Se diretório não existir (ex: nome novo), mostra vazio + if os.path.exists(path): + for entry in sorted([e for e in os.scandir(path) if e.is_dir()], key=lambda x: x.name.lower()): + ui.button(entry.name, on_click=lambda p=entry.path: load_dir(p)).props('flat dense icon=folder align=left w-full color=amber-8') + else: + ui.label('Pasta não criada ainda.').classes('text-gray-400 italic p-2') + + except Exception as e: + with scroll: ui.label(f'Erro: {e}').classes('text-red text-xs') + + def select_this(): + result['path'] = path_label.text + dialog.close() + + with ui.row().classes('w-full justify-between mt-auto pt-2'): + ui.button('Cancelar', on_click=dialog.close).props('flat color=grey') + ui.button('Selecionar Esta', on_click=select_this).props('flat icon=check color=green') + + await load_dir(start_path) + await dialog + return result['path'] + +# --- TELA PRINCIPAL --- +def show(): + with ui.column().classes('w-full p-6'): + ui.label('Configurações').classes('text-3xl font-light text-gray-800 mb-4') + + with ui.tabs().classes('w-full') as tabs: + tab_ident = ui.tab('Identificação', icon='search') + tab_cats = ui.tab('Categorias', icon='category') + tab_deploy = ui.tab('Deploy & Caminhos', icon='move_to_inbox') + tab_ffmpeg = ui.tab('Motor (FFmpeg)', icon='movie') + tab_telegram = ui.tab('Telegram', icon='send') + + with ui.tab_panels(tabs, value=tab_ident).classes('w-full bg-gray-50 p-4 rounded border'): + + # --- ABA 1: IDENTIFICAÇÃO --- + with ui.tab_panel(tab_ident): + with ui.card().classes('w-full max-w-2xl mx-auto p-6'): + ui.label('🔍 Configuração do Identificador').classes('text-2xl font-bold mb-4 text-indigo-600') + tmdb_key = AppConfig.get_val('tmdb_api_key', '') + lang = AppConfig.get_val('tmdb_language', 'pt-BR') + confidence = AppConfig.get_val('min_confidence', '90') + + ui.label('API Key do TMDb').classes('font-bold text-sm') + key_input = ui.input(placeholder='Ex: 8a9b...', value=tmdb_key).props('password').classes('w-full mb-4') + ui.markdown('[Clique aqui para pegar sua API Key](https://www.themoviedb.org/settings/api)').classes('text-xs text-blue-500 mb-6') + + with ui.grid(columns=2).classes('w-full gap-4'): + lang_input = ui.input('Idioma', value=lang, placeholder='pt-BR') + conf_input = ui.number('Confiança Auto (%)', value=int(confidence), min=50, max=100) + + def save_ident(): + AppConfig.set_val('tmdb_api_key', key_input.value) + AppConfig.set_val('tmdb_language', lang_input.value) + AppConfig.set_val('min_confidence', str(int(conf_input.value))) + ui.notify('Salvo!', type='positive') + + ui.button('Salvar', on_click=save_ident).props('icon=save color=indigo').classes('w-full mt-6') + +# --- ABA 2: CATEGORIAS (ATUALIZADA) --- + with ui.tab_panel(tab_cats): + ui.label('Bibliotecas e Tipos').classes('text-xl text-gray-700 mb-2') + + cats_container = ui.column().classes('w-full gap-2') + + def load_cats(): + cats_container.clear() + cats = list(Category.select()) + if not cats: ui.label('Nenhuma categoria.').classes('text-gray-400') + + for cat in cats: + # Define ícone e cor baseados no tipo + icon = 'movie' if cat.content_type == 'movie' else 'tv' + if cat.content_type == 'mixed': icon = 'shuffle' + + color_cls = 'bg-blue-50 border-blue-200' + if cat.content_type == 'series': color_cls = 'bg-green-50 border-green-200' + if cat.content_type == 'mixed': color_cls = 'bg-purple-50 border-purple-200' + + with cats_container, ui.card().classes(f'w-full flex-row items-center justify-between p-3 border {color_cls}'): + with ui.row().classes('items-center gap-4'): + ui.icon(icon).classes('text-gray-600') + with ui.column().classes('gap-0'): + ui.label(cat.name).classes('font-bold text-lg') + # Mostra o tipo visualmente + type_map = {'movie': 'Só Filmes', 'series': 'Só Séries', 'mixed': 'Misto (Filmes e Séries)'} + ui.label(f"Tipo: {type_map.get(cat.content_type, 'Misto')} | Tags: {cat.match_keywords}").classes('text-xs text-gray-500') + + ui.button(icon='delete', color='red', on_click=lambda c=cat: delete_cat(c)).props('flat dense') + + def add_cat(): + if not name_input.value: return + try: + Category.create( + name=name_input.value, + target_path=f"/media/{name_input.value}", + match_keywords=keywords_input.value, + content_type=type_select.value # Salva o tipo escolhido + ) + name_input.value = ''; keywords_input.value = '' + ui.notify('Categoria criada!', type='positive') + load_cats() + except Exception as e: ui.notify(f'Erro: {e}', type='negative') + + def delete_cat(cat): + cat.delete_instance() + load_cats() + + # Formulário de Criação + with ui.card().classes('w-full mb-4 bg-gray-100 p-4'): + ui.label('Nova Biblioteca').classes('font-bold text-gray-700') + with ui.row().classes('w-full items-start gap-2'): + name_input = ui.input('Nome (ex: Animes)').classes('w-1/4') + keywords_input = ui.input('Tags (ex: anime, animation)').classes('w-1/4') + + # NOVO SELETOR DE TIPO + type_select = ui.select({ + 'mixed': 'Misto (Filmes e Séries)', + 'movie': 'Apenas Filmes', + 'series': 'Apenas Séries' + }, value='mixed', label='Tipo de Conteúdo').classes('w-1/4') + + ui.button('Adicionar', on_click=add_cat).props('icon=add color=green').classes('mt-2') + + load_cats() + # --- ABA 3: DEPLOY & CAMINHOS --- + with ui.tab_panel(tab_deploy): + + # Helper para o Picker funcionar com categorias ou solto + async def open_picker(input_element): + # Seletor de pastas (inicia onde estiver escrito no input ou na raiz) + start = input_element.value if input_element.value else '/' + selected = await pick_folder_dialog(start) + if selected: input_element.value = selected + + # --- 1. CONFIGURAÇÃO DA ORIGEM (MONITORAMENTO) --- + with ui.card().classes('w-full mb-6 border-l-4 border-amber-500 bg-amber-50'): + ui.label('📡 Origem dos Arquivos').classes('text-lg font-bold mb-2 text-amber-900') + ui.label('Qual pasta o Clei-Flow deve vigiar?').classes('text-xs text-amber-700 mb-2') + + monitor_path = AppConfig.get_val('monitor_path', '/downloads') + + with ui.row().classes('w-full items-center gap-2'): + mon_input = ui.input('Pasta Monitorada (Container)', value=monitor_path).classes('flex-grow font-mono bg-white rounded px-2') + # Botão de Pasta (reutilizando o dialog, mas permitindo sair do /media se quiser, ou ajustamos o dialog depois) + # Nota: O pick_folder_dialog atual trava em /media. Se sua pasta de downloads for fora, + # precisaremos liberar o picker. Por enquanto assumimos que o usuário digita ou está montado. + ui.button(icon='folder', on_click=lambda i=mon_input: open_picker(i)).props('flat dense color=amber-9') + + def save_monitor(): + if not mon_input.value.startswith('/'): + ui.notify('Caminho deve ser absoluto (/...)', type='warning'); return + AppConfig.set_val('monitor_path', mon_input.value) + ui.notify('Pasta de monitoramento salva! (Reinicie se necessário)', type='positive') + + ui.button('Salvar Origem', on_click=save_monitor).classes('mt-2 bg-amber-600 text-white') + + ui.separator() + + # --- 2. REGRAS GLOBAIS DE DEPLOY --- + with ui.card().classes('w-full mb-6 border-l-4 border-indigo-500'): + ui.label('⚙️ Regras de Destino (Deploy)').classes('font-bold mb-2') + deploy_mode = AppConfig.get_val('deploy_mode', 'move') + conflict_mode = AppConfig.get_val('conflict_mode', 'skip') + cleanup = AppConfig.get_val('cleanup_empty_folders', 'true') + + with ui.grid(columns=2).classes('w-full gap-4'): + mode_select = ui.select({'move': 'Mover (Recortar)', 'copy': 'Copiar'}, value=deploy_mode, label='Modo') + conflict_select = ui.select({'skip': 'Ignorar', 'overwrite': 'Sobrescrever', 'rename': 'Renomear Auto'}, value=conflict_mode, label='Conflito') + + cleanup_switch = ui.switch('Limpar pastas vazias na origem', value=(cleanup == 'true')).classes('mt-2') + + def save_global_deploy(): + AppConfig.set_val('deploy_mode', mode_select.value) + AppConfig.set_val('conflict_mode', conflict_select.value) + AppConfig.set_val('cleanup_empty_folders', str(cleanup_switch.value).lower()) + ui.notify('Regras salvas!', type='positive') + ui.button('Salvar Regras', on_click=save_global_deploy).classes('mt-2') + + ui.separator() + + # --- 3. MAPEAMENTO DE DESTINOS --- + ui.label('📂 Mapeamento de Destinos (/media)').classes('text-xl text-gray-700 mt-4') + paths_container = ui.column().classes('w-full gap-2') + + def save_cat_path(cat, new_path): + if not new_path.startswith('/media'): + ui.notify('O caminho deve começar com /media', type='warning'); return + cat.target_path = new_path + cat.save() + ui.notify(f'Caminho de "{cat.name}" salvo!') + + def load_deploy_paths(): + paths_container.clear() + cats = list(Category.select()) + if not cats: return + for cat in cats: + with paths_container, ui.card().classes('w-full p-4 flex-row items-center gap-4'): + with ui.column().classes('min-w-[150px]'): + ui.label(cat.name).classes('font-bold') + ui.icon('arrow_forward', color='gray') + with ui.row().classes('flex-grow items-center gap-2'): + path_input = ui.input(value=cat.target_path).classes('flex-grow font-mono') + ui.button(icon='folder', on_click=lambda i=path_input: open_picker(i)).props('flat dense color=amber-8') + ui.button(icon='save', on_click=lambda c=cat, p=path_input: save_cat_path(c, p.value)).props('flat round color=green') + + load_deploy_paths() + ui.button('Recarregar', on_click=load_deploy_paths, icon='refresh').props('flat dense').classes('mt-2 self-center') + + # --- ABA 4: FFMPEG (Com VAAPI) --- + with ui.tab_panel(tab_ffmpeg): + ui.label('Perfis de Conversão').classes('text-xl text-gray-700') + + # 1. Carrega dados + profiles_query = list(FFmpegProfile.select()) + profiles_dict = {p.id: p.name for p in profiles_query} + + # 2. Ativo + active_profile = next((p for p in profiles_query if p.is_active), None) + initial_val = active_profile.id if active_profile else None + + # 3. Função + def set_active_profile(e): + if not e.value: return + FFmpegProfile.update(is_active=False).execute() + FFmpegProfile.update(is_active=True).where(FFmpegProfile.id == int(e.value)).execute() + ui.notify(f'Perfil "{profiles_dict[e.value]}" ativado!', type='positive') + + # 4. Select + select_profile = ui.select(profiles_dict, value=initial_val, label='Perfil Ativo', on_change=set_active_profile).classes('w-64 mb-6') + + ui.separator() + + # Editor + for p in profiles_query: + with ui.expansion(f"{p.name}", icon='tune').classes('w-full bg-white mb-2 border rounded'): + with ui.column().classes('p-4 w-full'): + with ui.grid(columns=2).classes('w-full gap-4'): + c_name = ui.input('Nome', value=p.name) + c_codec = ui.select({ + 'h264_vaapi': 'Intel VAAPI (Linux/Docker)', + 'h264_qsv': 'Intel QuickSync', + 'h264_nvenc': 'Nvidia NVENC', + 'libx264': 'CPU', + 'copy': 'Copy' + }, value=p.video_codec, label='Codec') + c_preset = ui.select(['fast', 'medium', 'slow'], value=p.preset, label='Preset') + c_crf = ui.number('CRF/QP', value=p.crf, min=0, max=51) + + audios = p.audio_langs.split(',') if p.audio_langs else [] + subs = p.subtitle_langs.split(',') if p.subtitle_langs else [] + c_audio = ui.select(ISO_LANGS, value=audios, multiple=True, label='Áudios').props('use-chips') + c_sub = ui.select(ISO_LANGS, value=subs, multiple=True, label='Legendas').props('use-chips') + + def save_profile(prof=p, n=c_name, c=c_codec, pr=c_preset, cr=c_crf, a=c_audio, s=c_sub): + prof.name = n.value; prof.video_codec = c.value; prof.preset = pr.value + prof.crf = int(cr.value); prof.audio_langs = ",".join(a.value); prof.subtitle_langs = ",".join(s.value) + prof.save() + profiles_dict[prof.id] = prof.name + select_profile.options = profiles_dict + select_profile.update() + ui.notify('Salvo!') + + ui.button('Salvar', on_click=save_profile).classes('mt-2') + + # --- ABA 5: TELEGRAM --- + with ui.tab_panel(tab_telegram): + with ui.card().classes('w-full max-w-lg mx-auto p-6'): + ui.label('🤖 Integração Telegram').classes('text-2xl font-bold mb-4 text-blue-600') + t_token = AppConfig.get_val('telegram_token', '') + t_chat = AppConfig.get_val('telegram_chat_id', '') + token_input = ui.input('Bot Token', value=t_token).props('password').classes('w-full mb-2') + chat_input = ui.input('Chat ID', value=t_chat).classes('w-full mb-6') + def save_telegram(): + AppConfig.set_val('telegram_token', token_input.value) + AppConfig.set_val('telegram_chat_id', chat_input.value) + ui.notify('Salvo!', type='positive') + ui.button('Salvar', on_click=save_telegram).props('icon=save color=blue').classes('w-full') + + +================================================================================ +ARQUIVO: ./docker-compose.yml +================================================================================ +version: '3.8' + +services: + clei-flow: + build: . + container_name: clei-flow + restart: unless-stopped + ports: + - "8087:8080" + # Mapeamento do Hardware Intel (Para o FFmpeg Core) + devices: + - /dev/dri:/dev/dri + + volumes: + # --- AMBIENTE DE DESENVOLVIMENTO (HOT RELOAD) --- + # Mapeia o código do seu Ubuntu direto para o container + - /home/creidsu/clei-flow/app:/app/app + + # Persistência (Banco de Dados e Configs) + - /home/creidsu/clei-flow/app/data:/app/data + + # --- SUAS MÍDIAS (EDITE AQUI CONFORME SEU PC) --- + # Exemplo: - /home/creidsu/downloads:/downloads + - /media/qbit/download:/downloads + - /media:/media + + environment: + - PUID=1000 # ID do usuário linux (geralmente 1000) + - PGID=1000 + - TZ=America/Sao_Paulo + - PYTHONUNBUFFERED=1 # Garante que os logs apareçam na hora no terminal + + +================================================================================ +ARQUIVO: ./Dockerfile +================================================================================ +FROM python:3.11-slim + +# Instala FFmpeg e dependências de sistema +RUN apt-get update && apt-get install -y \ + ffmpeg \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app /app/app + +# Porta do NiceGUI +EXPOSE 8080 + +CMD ["python", "app/main.py"] + + + +================================================================================ +ARQUIVO: ./requirements.txt +================================================================================ +nicegui +requests +guessit +tmdbv3api +peewee +python-telegram-bot +httpx + +