================================================================================ 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