From d77f17df2759718563e4947adab555f1f703561c Mon Sep 17 00:00:00 2001 From: Creidsu Date: Mon, 9 Feb 2026 01:35:47 +0000 Subject: [PATCH] adicionado git ignore e algumas melhorias --- .gitignore | 10 + app/__pycache__/database.cpython-311.pyc | Bin 3960 -> 0 bytes app/core/__pycache__/__init__.cpython-311.pyc | Bin 134 -> 0 bytes app/core/__pycache__/bot.cpython-311.pyc | Bin 10549 -> 0 bytes .../__pycache__/ffmpeg_engine.cpython-311.pyc | Bin 7584 -> 0 bytes app/core/__pycache__/renamer.cpython-311.pyc | Bin 9025 -> 0 bytes app/core/__pycache__/state.cpython-311.pyc | Bin 2413 -> 0 bytes app/core/__pycache__/watcher.cpython-311.pyc | Bin 15355 -> 0 bytes app/data/clei.db | Bin 28672 -> 0 bytes app/data/cleiflow.db | Bin 24576 -> 0 bytes projeto_completo.txt | 1493 ----------------- 11 files changed, 10 insertions(+), 1493 deletions(-) create mode 100644 .gitignore delete mode 100644 app/__pycache__/database.cpython-311.pyc delete mode 100644 app/core/__pycache__/__init__.cpython-311.pyc delete mode 100644 app/core/__pycache__/bot.cpython-311.pyc delete mode 100644 app/core/__pycache__/ffmpeg_engine.cpython-311.pyc delete mode 100644 app/core/__pycache__/renamer.cpython-311.pyc delete mode 100644 app/core/__pycache__/state.cpython-311.pyc delete mode 100644 app/core/__pycache__/watcher.cpython-311.pyc delete mode 100644 app/data/clei.db delete mode 100644 app/data/cleiflow.db delete mode 100644 projeto_completo.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ac0cda --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +app/data/cleiflow.db +projeto_completo.txt +app/__pycache__/database.cpython-311.pyc +app/core/__pycache__/__init__.cpython-311.pyc +app/core/__pycache__/bot.cpython-311.pyc +app/core/__pycache__/ffmpeg_engine.cpython-311.pyc +app/core/__pycache__/renamer.cpython-311.pyc +app/core/__pycache__/state.cpython-311.pyc +app/core/__pycache__/watcher.cpython-311.pyc +app/data/clei.db diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc deleted file mode 100644 index c084278b135fe217c1365240b998c0a3bcc34ce8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3960 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 A{{R30 diff --git a/app/core/__pycache__/bot.cpython-311.pyc b/app/core/__pycache__/bot.cpython-311.pyc deleted file mode 100644 index e2bc932b091f1fd6842c28be5936e33f603c9efb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10549 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 diff --git a/app/core/__pycache__/ffmpeg_engine.cpython-311.pyc b/app/core/__pycache__/ffmpeg_engine.cpython-311.pyc deleted file mode 100644 index 3039a788f59a65a14deaa389ccf6b996575df17f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7584 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 diff --git a/app/core/__pycache__/renamer.cpython-311.pyc b/app/core/__pycache__/renamer.cpython-311.pyc deleted file mode 100644 index cf06fd8adb9454b90219edef208ce2d401c6a06c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9025 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 diff --git a/app/data/cleiflow.db b/app/data/cleiflow.db deleted file mode 100644 index 55ded2934e3607844e7b6daba66c2cde5b80b80e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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`) diff --git a/projeto_completo.txt b/projeto_completo.txt deleted file mode 100644 index 9ea2c0e..0000000 --- a/projeto_completo.txt +++ /dev/null @@ -1,1493 +0,0 @@ -================================================================================ -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 - -