From 95a9bcd24d1a6d0647fa36cb19a860f1a43a4b78 Mon Sep 17 00:00:00 2001 From: Creidsu Date: Sun, 8 Feb 2026 23:07:50 +0000 Subject: [PATCH] inicio --- Dockerfile | 19 + README.md | 7 + app/__pycache__/database.cpython-311.pyc | Bin 0 -> 3960 bytes app/core/__init__.py | 0 app/core/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 134 bytes app/core/__pycache__/bot.cpython-311.pyc | Bin 0 -> 10549 bytes .../__pycache__/ffmpeg_engine.cpython-311.pyc | Bin 0 -> 6665 bytes app/core/__pycache__/renamer.cpython-311.pyc | Bin 0 -> 8264 bytes app/core/__pycache__/state.cpython-311.pyc | Bin 0 -> 2147 bytes app/core/__pycache__/watcher.cpython-311.pyc | Bin 0 -> 13937 bytes app/core/bot.py | 166 +++++++++ app/core/ffmpeg_engine.py | 121 +++++++ app/core/flow.py | 15 + app/core/renamer.py | 155 +++++++++ app/core/state.py | 49 +++ app/core/watcher.py | 243 +++++++++++++ app/data/clei.db | Bin 0 -> 28672 bytes app/data/cleiflow.db | Bin 0 -> 24576 bytes app/database.py | 58 ++++ app/main.py | 59 ++++ app/ui/__init__.py | 0 app/ui/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 132 bytes app/ui/__pycache__/dashboard.cpython-311.pyc | Bin 0 -> 11048 bytes app/ui/__pycache__/layout.cpython-311.pyc | Bin 0 -> 2362 bytes .../__pycache__/manual_tools.cpython-311.pyc | Bin 0 -> 184 bytes app/ui/__pycache__/settings.cpython-311.pyc | Bin 0 -> 34890 bytes app/ui/dashboard.py | 138 ++++++++ app/ui/layout.py | 19 + app/ui/manual_tools.py | 2 + app/ui/settings.py | 326 ++++++++++++++++++ docker-compose.yml | 31 ++ requirements.txt | 7 + 32 files changed, 1415 insertions(+) create mode 100755 Dockerfile create mode 100755 README.md create mode 100644 app/__pycache__/database.cpython-311.pyc create mode 100755 app/core/__init__.py create mode 100644 app/core/__pycache__/__init__.cpython-311.pyc create mode 100644 app/core/__pycache__/bot.cpython-311.pyc create mode 100644 app/core/__pycache__/ffmpeg_engine.cpython-311.pyc create mode 100644 app/core/__pycache__/renamer.cpython-311.pyc create mode 100644 app/core/__pycache__/state.cpython-311.pyc create mode 100644 app/core/__pycache__/watcher.cpython-311.pyc create mode 100755 app/core/bot.py create mode 100755 app/core/ffmpeg_engine.py create mode 100755 app/core/flow.py create mode 100755 app/core/renamer.py create mode 100644 app/core/state.py create mode 100644 app/core/watcher.py create mode 100644 app/data/clei.db create mode 100644 app/data/cleiflow.db create mode 100755 app/database.py create mode 100755 app/main.py create mode 100755 app/ui/__init__.py create mode 100644 app/ui/__pycache__/__init__.cpython-311.pyc create mode 100644 app/ui/__pycache__/dashboard.cpython-311.pyc create mode 100644 app/ui/__pycache__/layout.cpython-311.pyc create mode 100644 app/ui/__pycache__/manual_tools.cpython-311.pyc create mode 100644 app/ui/__pycache__/settings.cpython-311.pyc create mode 100755 app/ui/dashboard.py create mode 100755 app/ui/layout.py create mode 100755 app/ui/manual_tools.py create mode 100755 app/ui/settings.py create mode 100755 docker-compose.yml create mode 100755 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..78c99e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +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"] diff --git a/README.md b/README.md new file mode 100755 index 0000000..14966ff --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Clei-Flow + +**Clei-Flow** é um gerenciador de mídia Open Source focado em automação, padronização e compatibilidade de hardware. + +## Estrutura +* **Core:** Núcleos independentes para Renomear, Converter (FFmpeg) e Notificar (Telegram). +* **UI:** Interface NiceGUI para configuração dinâmica. diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c084278b135fe217c1365240b998c0a3bcc34ce8 GIT binary patch literal 3960 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 A{{R30 literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/bot.cpython-311.pyc b/app/core/__pycache__/bot.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e2bc932b091f1fd6842c28be5936e33f603c9efb GIT binary patch literal 10549 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 literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/ffmpeg_engine.cpython-311.pyc b/app/core/__pycache__/ffmpeg_engine.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..869343ef7f12d81d1318a9dbd1ff5503a075ff85 GIT binary patch literal 6665 zcmcH;TWlN0@s7_Ud6bT#UKV9bvaDE?O}+e34lr?m0ii`d@>LaX0mEONy(1}6 z65F^mxWLsU;h+FKWT^RElQxsydx%LL#CVbh#8r0QX>{J+lUoeAvfl* zYpg;0RCGKX6^26vGK3~smKlCe{sh@ADou+GG7ZIink<&n^jA>+cCAhYi*%y9OU4j- zdXftT_)ZoC2!gw!k1S6wO-&YQOSQ6Vjm)I2P)4TG@kHzw^`GB7{CZ+1;QjfN?BA9? zXdV`#6N#zjxFCXLn+;Jk^Kv*DYw_Df!mQ>P7v$jR^07>Vfn4)z_B~ysr2zslH33bEVQdmtLi4$Faw(XYOTO+o!NcRrV+@z^WPgbc81` z5mjOq3^eFw@k=OxwbRIi%*cYQU~Bd?JEWVzNb5l35A~F{SF8G0SAl z`Lt#%X>%#wMXHseskd}RUo*c&U8VhwVa+}^209oOQl>ysvrZ?%f}C;(;$k=|2ghRK z6fbL*NhubEMrk5;H7LoVz)wjj)~FgNDQiL=3mo=aHK&vq1$rStk~B6nA%w03V+lE) zkTn|;LNO$WHNdE5jl_5)`7NTG)EKgF z^gOCLyj*j5sZOamq1K$39eV7o`?xPNrg+;_ZyRRYp5_?eyiER{K7A^YjWySI!cP6c zZaTr5ODw<&eYyoip>V52*J&zS)HZY$)5aqL(c6lB$h1~2qfzrkDz8z?ux87vjX?83>X`uGMqG*u3JoI=bO8`e`E8#&+<*+d&TXKc|Ba&6$S zvD}=9MJ}C^rPQX#WdM{0ko!3(Rs0)D+sbmV^*UhovLh-Hw|V93`9vP$f)vZoT7^8D zY0T5n?PX`JT0g_)v74-dc7V^aehgSjM_KCL^_Mh^Dqhp2df7a0^pBggUEUt6f7~pn z2Gx#lvWT+0*8#u9yJrgyrA=1UQ@BcGP&2N?XwOwbOo(`U-}9TO2yWpdB-gG&4xOlbUg+omt)I zE_UY&s3}a9ddy|;no&zB-i#}47nwBVNlLPy7V;Gb%Sx{j?MypS>l(kGQlFVNTS3|J zyV4Fa0=1zR@}=Ed*q#Hmr@%{?am@WJ?La&7`@60jRX9uiTb!ll$)YQ)Yp7Uz4h^xW z<3DQ14H~LQyFo*V|C5GTT|;bML+}+}EGG-}ziMbtx@J56Ic0ANQj7SFionqP|f?vd8gax ztSojK#5GfrhsndKYQx9tk0yJ1_fA|*#ECzxnWv?s_ySPXtkI+p4QZB80`bo*fhj(o zvUc|+`?{ei7Qddd1VVkulsPaRhXssBVxxQ{IGx}lVflK>6^O)QSAzV603_+2lpUf0 zL4vTr90*No_DFd2TJORAgoHTujNThs@6Zxw2*Q8rNe5)NsBv)M-r;8GGz8mg$3!8D z{jMTSe`uQXfI>`~lLQB$pnN?pXjVRf!m)3tZ%mr0vsjJyU!XbsmevLVQFTfI#KccV3n%#TYN6XB!d34UDA%!w#UnY#NlYZ#P#EoBAC@_m|_=R;5) zC1p#`k=`pm5HUf(vj;vz7kpp0t_5D~hnAm4h++@nmw^yW4$Bci+y_;0KV<9WCmw=^ z=M^0B!V7|{eFN)|d2PwFbnJf9!?VjrF5@GY=cgevKo^`Y3aQU;dFBXA>AOkhQ#PHm ze(@#31V>aQMCJaI5g|MjiCs;QaB%hWPxZ%#lNSd2nl-xF&xzy~)f}g;g@m{s(2x*D zb0xz_h=IDnZZw;4O@>{jnL$|)MT%pZ&9GX{r5jU_C`^Kvp*YyMW`-z7t0>4R-)Dn& z2Oy!pQi$Y)g1|Qxj`}N&Ak`U;LKv$@pqk5wP>l*5*O$jZi6!jR%JnyG&>)G zwFyXb8?5B*Nwe{xSRyKm2gw*ZLX0Yp=mORXk+x=&bTo+^c_bWOm-3NuiQR9Aj7$0m zchc`+lak07-Z(IG`pY9YFr>7eR$EWw?Wc1r2X7;E&Rh4$+qUd&%T6lZ9@X13%j7ES zFmwE~>)8LI(sW#HI*zv;U#YCiRO5;r5Kl7{>z(I6p2DtHfE-)-!P)oE;(9;c^D?eG ztFY%(_8ex<6*}rBunr0vR@pFS!}*SkB{PZZ{0iHlvK^T10J_`v&0d(ju;Q-L$-7&z zyFfnU%${1;Ps5 z^5`&)nwNVcC1LynB`Jv2d?A@g>Z7S1-nYI-dJJ+8XT6XQkuAPNhY*`zw z+pVzeD%+0Pb||bE(#Y7d^j$Zu>r~h-mF>c87ZB#^GA*|QxNaBAr? zWB?D6rSb)ZeCV3DJkh`RFt9KK5J8*1$vv#z`(3?V#^Bz%S> zrn6@ry@LJ^}DwN@5KA4k+9~l{<*JgE_9S_!Y)Qb`AEPGeBX^tIT=KoX;5@ z^G6k?NoATa)AUc+Vij`^T-B3p{kr{&_Jw_S0(S#Y1yGn?mFdMy?@DFWtW&>Q{PeKj zPF{ke{FD$3YR+JADuxp9HW<{{pzg^SJtBDv)L*>Bouu?O$@FWHTMuNb)h*;!TWhFnOJ`gWe_j( zqr4;-Z|dL+i9)CTPYmNW(pfBJ`x$f0Sn_~43Ur8fA*CRLr)iqbQ65}?9Ob&9|K+Hi cxD;|!BQAwii$v4F{nq*4_4a?1>DAZvFD(@gMF0Q* literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/renamer.cpython-311.pyc b/app/core/__pycache__/renamer.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78b4429c15a8cecb33a70af3053a7107292f3a70 GIT binary patch literal 8264 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 literal 0 HcmV?d00001 diff --git a/app/core/__pycache__/watcher.cpython-311.pyc b/app/core/__pycache__/watcher.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdbaefeeee6f885183810eff70ec4c02fcab5347 GIT binary patch literal 13937 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 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 \ No newline at end of file diff --git a/app/core/ffmpeg_engine.py b/app/core/ffmpeg_engine.py new file mode 100755 index 0000000..b1395b8 --- /dev/null +++ b/app/core/ffmpeg_engine.py @@ -0,0 +1,121 @@ +import subprocess +import json +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("⚠️ Nenhum perfil FFmpeg ativo!") + + 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 + + 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") + p = self.profile + metadata = self.get_file_info(input_file) + if not metadata: raise Exception("Metadados inválidos") + + cmd = ['ffmpeg', '-y'] + + # --- HARDWARE INIT --- + # VAAPI (Intel Linux/Docker) - O Jeito Correto + 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' + + elif 'qsv' in p.video_codec: + cmd.extend(['-hwaccel', 'qsv', '-i', input_file]) + video_filters = None + + 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 + + # --- 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]) + + # 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 + + # Configs de Encoder + 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' + 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 [] + 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: + cmd.extend(['-map', f'0:{s["index"]}']) + cmd.extend([f'-c:a:{acount}', 'aac', f'-b:a:{acount}', '192k']) + acount += 1 + if acount == 0: cmd.extend(['-map', '0:a:0', '-c:a', 'aac']) + + # --- LEGENDAS --- + lallowed = p.subtitle_langs.split(',') if p.subtitle_langs else [] + 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: + cmd.extend(['-map', f'0:{s["index"]}']) + cmd.extend([f'-c:s:{scount}', 'copy']) + scount += 1 + + cmd.extend(['-metadata', 'title=', '-metadata', 'comment=CleiFlow']) + 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/flow.py b/app/core/flow.py new file mode 100755 index 0000000..ce3de75 --- /dev/null +++ b/app/core/flow.py @@ -0,0 +1,15 @@ +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 diff --git a/app/core/renamer.py b/app/core/renamer.py new file mode 100755 index 0000000..f0d8c61 --- /dev/null +++ b/app/core/renamer.py @@ -0,0 +1,155 @@ +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 identify_file(self, filepath): + filename = os.path.basename(filepath) + try: + guess = guessit(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 not results: return {'status': 'NOT_FOUND', 'msg': 'Nenhum resultado TMDb'} + + # --- CORREÇÃO DE SEGURANÇA (O erro 'str object' estava aqui) --- + # Se o TMDb retornou um dicionário (paginado), pegamos a lista dentro dele. + if isinstance(results, dict) and 'results' in results: + results = results['results'] + # Se retornou um objeto que tem atributo 'results', usamos ele + elif hasattr(results, 'results'): + results = results.results + + # Se ainda assim for uma lista de strings (chaves), aborta + if results and isinstance(results, list) and len(results) > 0 and isinstance(results[0], str): + # Isso acontece se iterou sobre chaves de um dict sem querer + return {'status': 'NOT_FOUND', 'msg': 'Formato de resposta inválido'} + # ------------------------------------------------------------- + + candidates = [] + for res in results: + # Proteção extra: se o item for string, pula + if isinstance(res, str): continue + + # Obtém atributos de forma segura (funciona para dict ou objeto) + 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 + + # Score e Comparação + 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 válidos'} + + 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: + if (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) + + 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: + return f"{clean_title} ({year}).mkv" + else: + 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}" + file_suffix = f"S{int(season):02d}E{int(episode):02d}" + + return os.path.join(clean_title, season_folder, f"{clean_title} {file_suffix}.mkv") \ No newline at end of file diff --git a/app/core/state.py b/app/core/state.py new file mode 100644 index 0000000..0409e05 --- /dev/null +++ b/app/core/state.py @@ -0,0 +1,49 @@ +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() \ No newline at end of file diff --git a/app/core/watcher.py b/app/core/watcher.py new file mode 100644 index 0000000..70cb327 --- /dev/null +++ b/app/core/watcher.py @@ -0,0 +1,243 @@ +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() + + # 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): + 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 + + # Ignora se já terminou nesta sessão + 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)) + target_info = None + is_semi_auto = AppConfig.get_val('semi_auto', 'false') == 'true' + + if is_semi_auto: + result['status'] = 'AMBIGUOUS' + if 'match' in result: result['candidates'] = [result['match']] + + 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': + 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: + state.update_task(fname, 'error', 0, label="Não Identificado") + state.log(f"❌ Falha TMDb: {result.get('msg', 'Desconhecido')}") + 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 & CAMINHO + try: + # Recupera dados completos para montar o nome + details = self.renamer.get_details(target_info['tmdb_id'], target_info['type']) + + full_details = { + 'title': getattr(details, 'title', getattr(details, 'name', 'Unknown')), + '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] + + # 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']}") + + 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 + ) + + 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') + 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) + + 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 FINAL + 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) + + shutil.move(str(temp_output), str(final_full_path)) + + if AppConfig.get_val('deploy_mode', 'move') == 'move': + os.remove(str(filepath)) + + await self.bot.send_notification(f"🎬 Organizado: `{full_details['title']}`") + state.update_task(fname, 'done', 100, label=f"{full_details['title']}") + state.current_file = "" + + # Limpeza pasta vazia + 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): + """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/data/clei.db b/app/data/clei.db new file mode 100644 index 0000000000000000000000000000000000000000..b5f200cf6decfea2fa14ca1dbf4bbc80a9b9e971 GIT binary patch literal 28672 zcmeI(&2QUe90zbab`vt%JuMwXQ5EJ!n$obeOYFS%z?QWsRO@S+4l03yV?Rkv6WcgW zStl+9F8l#pkx)Rf&>S)8z=4@;KrH9d0VqJfCK}feJ!Pa-kE2v|&3|-DuLPskZ1y zNnoX1DppF;{?_{Leo0#2S}naTMMs)+J4L1KttZN((n8c5FB<=Nb0QFm#rRJTT(#C5 zonT}re}A@0@%BdPg5eB3%DzIS^hT*HZEaVit^JJ+X{WrtSu7t&cS;8dsW+o^w^&|z zvshk`Gnx3XC96F)bZS*KOQ%|NY$MyDZR(D%*PQy(UxN*#B79(gAyIO+=gS$AoMuTFn|KDC7S9KcHt=g)4I9`zbe$@`ENA%=F%hCTpT5~9q(W@$3 z%2l?9E9;x3;a-Tp>tlvj2ZpL`YnE9z8s50WjBp|1u-a?e+xNpFPr)mVpI+tzp{s2C zqb}mG!7$+)WgM3OMaS;cV|TgQ?67@vzB=yxHlHA`bL1QH1!8Cmo{m`QCn5*P<5+@ zo>vNTLC&V+)GeOP&(81r^{#b9&3q=UB=dzrKC@gbt~FLSw;P4KP2;H8-l-T$W=&gD z3YoX$PSM@F-#PFO;wQ)%N6yHP~KA%=+=A;yoy7sP4dTCb!ZSrD!Wi~KM!&<`B=W`uZfSlEpgc<28jndQiLK zOW=Y41Rwwb2tWV=5P$##AOHafJRbsgL_Zg~o++-ffA=sCt@e!;)eZHA-p}^$2}x5O z*Rsrnr08-|&Mqe9#bid3AMA&&LwuDIov>2tWV=5P$##AOHafKmY>&cY#SE!Vf|h;w2%>_o5BLi^3e=4=DJ8 zLO9rq40z}NKJpnyeqeuaK>z{}fB*y_009U<00Izz00bcLYz0cbAa`><%wL}kuAA%~ zeQCF2Xh(Y|rY0?vAAD&Wx)nc^v+3%w%3ksJUPD}Y_RMSALL?l zVSaA*g&kFQ9-LXy!phEmylK?#vEHNoSbOjM-$x#D**+jfhX4d1009U<00Izz d00bZa0SG|g9}|cOH~Fyv1mCm}3y#DEeh1#;BqRU; literal 0 HcmV?d00001 diff --git a/app/data/cleiflow.db b/app/data/cleiflow.db new file mode 100644 index 0000000000000000000000000000000000000000..55ded2934e3607844e7b6daba66c2cde5b80b80e GIT binary patch literal 24576 zcmeI)&2Ae-902fH?>cFs#M3q{PLM*j4pm~w`Lg5KMgn!4I$)8nx=v{%s%5e}cBa|g zS$4-s(*r2L6Yu~W=qn(ONQet3gv5yhPtXH5&dl1e6Ng%GKuQt+k-eU;o%Q_2KJ0(( zeQ`5QP~aoj+@@ClPJTc`D*CruLH)09Lk>=wHshv4yhhl6=diga zm~iOd^<$w)EWr-tWG>D){h=l~CzTV0c{Y7&5kVU~I5wlHcM9=S$(_y0{hhKL;$HcY zp6ax=x~BB4&9kbAw`G?;`A8j1PEIPHkF{2-vrp$$>s_Nx^}n(_f&>jJOrMDT{(|xh z*4j4@=IqXE`~5x$>Blh%IMo3pzAtyGvqm&%x1hdMu-hE;v0k= zT*L3+ar_WR;yIKfi3m|v-{ zDCFzQG{0vT>^H7xXzrGv4s8U)HLZOjOlB8zrEDp?vXouAGboQQr*+yc_yP5b`Q_YV zu~aJN-!hD~#=VWLM(Lr$4vcWS#&Y>JYb{sGe~@h&;=!}#?lArp4dYdWSMg)a@pJqo z{tdstpW+|!x0krYaA*(!0T2KI5C8!X009sH0T2KI5O~!DCe(uR(qII>rp_omafk#~ zr; z00@8p2!H?xfB*=900@8p2!Oz=D)6>4gbKGNlNrm&SHQpRLH-9Xjd# d?8>rvNaPI%qF)X)-K5Wh4D}kBC%z}Me*#Q}1V8`) literal 0 HcmV?d00001 diff --git a/app/database.py b/app/database.py new file mode 100755 index 0000000..e80f61c --- /dev/null +++ b/app/database.py @@ -0,0 +1,58 @@ +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() \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..4c14e7a --- /dev/null +++ b/app/main.py @@ -0,0 +1,59 @@ +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) \ No newline at end of file diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/app/ui/__pycache__/__init__.cpython-311.pyc b/app/ui/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec37aaf444eaca3a6ff29c6688eeb27215e0001f GIT binary patch literal 132 zcmZ3^%ge<81Sf1-GePuY5CH>>P{wCAAY(d13PUi1CZpd-+- yX6nbsXXa&=#K-FuRQ}?y$<0qG%}KQ@Vg;%LnN-XVBt9@RGBSQ(fDuK^KrsMX<{B~p literal 0 HcmV?d00001 diff --git a/app/ui/__pycache__/dashboard.cpython-311.pyc b/app/ui/__pycache__/dashboard.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5429511e78bde80c60aa8b169ed803061856de12 GIT binary patch literal 11048 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 literal 0 HcmV?d00001 diff --git a/app/ui/__pycache__/manual_tools.cpython-311.pyc b/app/ui/__pycache__/manual_tools.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..969cd4e0cdde7c41130e0d2fcf13f57707eb4296 GIT binary patch literal 184 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 literal 0 HcmV?d00001 diff --git a/app/ui/dashboard.py b/app/ui/dashboard.py new file mode 100755 index 0000000..427d259 --- /dev/null +++ b/app/ui/dashboard.py @@ -0,0 +1,138 @@ +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 \ No newline at end of file diff --git a/app/ui/layout.py b/app/ui/layout.py new file mode 100755 index 0000000..078767e --- /dev/null +++ b/app/ui/layout.py @@ -0,0 +1,19 @@ +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') \ No newline at end of file diff --git a/app/ui/manual_tools.py b/app/ui/manual_tools.py new file mode 100755 index 0000000..182bb33 --- /dev/null +++ b/app/ui/manual_tools.py @@ -0,0 +1,2 @@ +from nicegui import ui +# Abas antigas (Renomeador Manual, Encoder Manual) viram ferramentas aqui diff --git a/app/ui/settings.py b/app/ui/settings.py new file mode 100755 index 0000000..0911c05 --- /dev/null +++ b/app/ui/settings.py @@ -0,0 +1,326 @@ +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') \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..a3317be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..360055d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +nicegui +requests +guessit +tmdbv3api +peewee +python-telegram-bot +httpx \ No newline at end of file