from nicegui import ui, run import os import json import threading import time import asyncio import requests import shutil import subprocess import re from datetime import datetime from unittest.mock import patch, MagicMock # Importando seus módulos from modules import renamer, encoder, file_manager CONFIG_FILE = '/app/data/automation.json' RENAMER_CONFIG = '/app/config.json' class AutomationManager: def __init__(self): self.is_running = False self.logs = [] self.config = self.load_config() # Controle de Estado e Pausa self.resolution_event = threading.Event() self.items_to_resolve = [] self.waiting_for_user = False self.organizer_instance = None # Para reusar lógica de path # Estado Visual self.total_progress = 0.0 self.tree_data = [] self.container = None self.dialog_resolver = None # Referência para o pop-up # --- CONFIGURAÇÃO --- def load_config(self): default = { "telegram_token": "", "telegram_chat_id": "", "monitor_folder": "/downloads", "destinations": { "Filmes": "/media/Filmes", "Séries": "/media/Series", "Animes": "/media/Animes", "Desenhos": "/media/Desenhos" } } tmdb_key = "" if os.path.exists(RENAMER_CONFIG): try: with open(RENAMER_CONFIG, 'r') as f: tmdb_key = json.load(f).get('tmdb_api_key', '') except: pass if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, 'r') as f: data = json.load(f) if not data.get('tmdb_api_key_view'): data['tmdb_api_key_view'] = tmdb_key return data except: pass default['tmdb_api_key_view'] = tmdb_key return default def save_config(self): with open(CONFIG_FILE, 'w') as f: json.dump(self.config, f, indent=4) ui.notify('Configurações salvas!', type='positive') async def pick_folder(self, key_category=None, target_key=None, start_path='/'): path = start_path if os.path.exists(start_path) else '/' with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col'): ui.label(f'Selecionar: {target_key}').classes('font-bold') lbl = ui.label(path).classes('text-xs bg-gray-100 p-2 border rounded break-all') scroll = ui.scroll_area().classes('flex-grow border rounded p-1 mt-2 bg-white') async def load(p): nonlocal path path = p lbl.text = path scroll.clear() try: with scroll: if p != '/': ui.button('..', on_click=lambda: load(os.path.dirname(p))).props('flat dense icon=arrow_upward align=left w-full') for d in sorted([x for x in os.scandir(p) if x.is_dir()], key=lambda e: e.name.lower()): ui.button(d.name, on_click=lambda x=d.path: load(x)).props('flat dense icon=folder align=left w-full color=amber-8') except: pass def confirm(): if key_category == 'destinations': self.config['destinations'][target_key] = path else: self.config[target_key] = path self.save_config() dialog.close() self.refresh_ui() with ui.row().classes('w-full justify-end mt-auto'): ui.button('Cancelar', on_click=dialog.close).props('flat') ui.button('OK', on_click=confirm).props('flat color=green') await load(path) dialog.open() def send_telegram(self, message): token = self.config.get('telegram_token') chat_id = self.config.get('telegram_chat_id') if not token or not chat_id: return try: url = f"https://api.telegram.org/bot{token}/sendMessage" data = {"chat_id": chat_id, "text": message, "parse_mode": "Markdown"} requests.post(url, data=data, timeout=5) except: pass # --- LÓGICA DE RESOLUÇÃO MANUAL (O NOVO RECURSO) --- def open_resolution_dialog(self): """Abre o modal para o usuário resolver ambiguidades""" with ui.dialog() as self.dialog_resolver, ui.card().classes('w-[800px] h-[80vh] flex flex-col'): ui.label('⚠️ Resolução de Conflitos').classes('text-xl font-bold text-orange-600 mb-2') ui.label('O sistema encontrou ambiguidades. Por favor, identifique os arquivos manualmente.').classes('text-sm text-gray-600 mb-4') scroll = ui.scroll_area().classes('flex-grow border rounded p-2 bg-gray-50') with scroll: for item in self.items_to_resolve: with ui.card().classes('w-full mb-3 p-2 border-l-4 border-orange-400'): ui.label(f"Arquivo: {item['original_file']}").classes('font-bold text-sm') # Opções do Dropdown options = {None: 'Ignorar este arquivo'} if item.get('candidates'): for cand in item['candidates']: # Cria label bonito: "Fallout (2024) - Série" label = f"{cand.get('name') or cand.get('title')} ({cand.get('first_air_date', '')[:4] if 'first_air_date' in cand else cand.get('release_date', '')[:4]}) - {cand.get('media_type')}" # Gambiarra para armazenar dict como value (usando index ou json, mas aqui vamos usar o ID do candidato como chave se possível, ou index) # Para simplificar no NiceGUI select, vamos usar index options[self.items_to_resolve.index(item), item['candidates'].index(cand)] = label # Função de callback ao selecionar def on_select(e, it=item): if e.value is None: it['status'] = 'SKIPPED' it['selected_match'] = None else: _, cand_idx = e.value it['selected_match'] = it['candidates'][cand_idx] it['status'] = 'OK' # Recalcula o caminho usando a lógica do renamer if self.organizer_instance: self.organizer_instance.calculate_path(it) ui.select(options, label='Selecione o correto:', on_change=on_select).classes('w-full') def confirm_resolution(): self.waiting_for_user = False self.resolution_event.set() # Libera a Thread self.dialog_resolver.close() ui.notify('Resoluções aplicadas. Continuando...', type='positive') ui.button('CONFIRMAR E CONTINUAR', on_click=confirm_resolution).classes('w-full bg-green-600 text-white mt-auto') self.dialog_resolver.open() # --- WORKER THREAD --- def worker_thread(self): self.is_running = True self.total_progress = 0 self.logs.append(f"[{datetime.now().strftime('%H:%M')}] Iniciando Pipeline...") self.send_telegram("🚀 *PyMedia*: Iniciando análise...") self.tree_data = [ {'id': 'step_scan', 'label': 'Identificando Conteúdo', 'status': 'running', 'progress': 0}, {'id': 'step_resolve', 'label': 'Verificação de Pendências', 'status': 'pending'}, # Novo passo {'id': 'step_process', 'label': 'Execução (Encode & Deploy)', 'status': 'pending', 'children': []} ] try: monitor_path = self.config.get('monitor_folder') if not os.path.exists(monitor_path): raise Exception("Pasta não existe!") # 1. SCAN (MOCK) dummy_ui = MagicMock() with patch('modules.renamer.ui', new=dummy_ui): self.organizer_instance = renamer.MediaOrganizer() # Guarda instancia self.organizer_instance.path = monitor_path loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(self.organizer_instance.analyze_folder()) loop.close() items = self.organizer_instance.preview_data if not items: self.logs.append("Nenhum arquivo encontrado.") self.tree_data[0]['status'] = 'done' self.is_running = False return self.tree_data[0]['status'] = 'done' self.tree_data[1]['status'] = 'running' # 2. IDENTIFICAÇÃO DE PENDÊNCIAS (PAUSA SE NECESSÁRIO) self.items_to_resolve = [i for i in items if i['status'] in ['AMBIGUO', 'CHECK', 'NAO_ENCONTRADO']] if self.items_to_resolve: self.logs.append(f"⚠️ {len(self.items_to_resolve)} arquivos precisam de atenção manual.") self.send_telegram("⚠️ Intervenção necessária: Arquivos ambíguos detectados.") # SINALIZA UI PARA ABRIR POPUP self.resolution_event.clear() self.waiting_for_user = True # A Thread dorme aqui até o botão "Continuar" ser clicado self.resolution_event.wait() # Thread acorda! self.logs.append("✅ Pendências resolvidas pelo usuário.") self.tree_data[1]['status'] = 'done' self.tree_data[2]['status'] = 'running' # 3. FILTRAGEM FINAL E PREPARAÇÃO valid_items = [] for item in items: # Se o usuário ignorou ou ainda está com problema if item.get('status') == 'SKIPPED': self.logs.append(f"Ignorado pelo usuário: {os.path.basename(item['original_file'])}") item['visual_status'] = 'warning' continue if item['status'] != 'OK' or not item['target_path']: self.logs.append(f"⚠️ Ignorado (Ainda ambíguo): {os.path.basename(item['original_file'])}") item['visual_status'] = 'warning' continue # H.265 Check full_path = os.path.join(item['original_root'], item['original_file']) try: cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "default=noprint_wrappers=1:nokey=1", full_path] codec = subprocess.check_output(cmd, timeout=5).decode().strip().lower() if codec in ['hevc', 'h265']: item['visual_status'] = 'skipped' self.logs.append(f"❌ Ignorado (H.265): {item['original_file']}") continue except: pass item['visual_status'] = 'pending' valid_items.append(item) # Atualiza árvore visual completa self.tree_data[2]['children'] = self.build_tree_structure(items) # Mostra todos # 4. EXECUÇÃO total = len(valid_items) if total > 0: self.send_telegram(f"📋 Processando {total} arquivos aprovados.") for i, item in enumerate(valid_items): src = os.path.join(item['original_root'], item['original_file']) category = item.get('category', 'Filmes') base_dest = self.config['destinations'].get(category) if not base_dest: continue rel_path = os.path.relpath(item['target_path'], os.path.join(renamer.DEST_DIR, category)) final_dst = os.path.join(base_dest, rel_path) self.update_node_status(item['id'], 'running') # Encode temp_encode_path = os.path.join('/downloads/temp_automator', os.path.basename(final_dst)) os.makedirs(os.path.dirname(temp_encode_path), exist_ok=True) cmd = encoder.build_ffmpeg_command(src, temp_encode_path) duration = encoder.get_video_duration(src) process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ) success = False for line in process.stdout: if "time=" in line: match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line) if match: sec = encoder.parse_time_to_seconds(match.group(1)) pct = min(int((sec/duration)*100), 100) self.update_node_status(item['id'], 'running', pct) self.total_progress = (i * (100/total)) + ((100 / total) * (pct / 100)) process.wait() if process.returncode == 0: success = True else: self.update_node_status(item['id'], 'error') if success: try: os.makedirs(os.path.dirname(final_dst), exist_ok=True) shutil.move(temp_encode_path, final_dst) for sub in item['subtitles']: sub_name = os.path.splitext(os.path.basename(final_dst))[0] + sub['suffix'] sub_dst = os.path.join(os.path.dirname(final_dst), sub_name) shutil.copy2(sub['src'], sub_dst) self.update_node_status(item['id'], 'done', 100) except: self.update_node_status(item['id'], 'error') self.send_telegram("✅ Processo concluído.") else: self.logs.append("Nada a processar.") self.tree_data[2]['status'] = 'done' self.total_progress = 100 except Exception as e: self.logs.append(f"ERRO: {str(e)}") import traceback print(traceback.format_exc()) self.is_running = False def build_tree_structure(self, items): # (Mesma lógica de antes, apenas garante que usa 'visual_status' se existir) tree = [] categories = {} for item in items: cat = item.get('category') or "Pendentes" if cat not in categories: categories[cat] = {'id': f"cat_{cat}", 'label': cat, 'children': [], 'map': {}} v_status = item.get('visual_status', 'pending') # Se foi ignorado ou ambíguo não resolvido if item.get('status') == 'SKIPPED': v_status = 'warning' added = False if cat in ['Séries', 'Animes', 'Desenhos'] and item.get('target_path'): parts = item['target_path'].split(os.sep) if len(parts) >= 3: show = parts[-3]; sea = parts[-2]; file = parts[-1] if show not in categories[cat]['map']: categories[cat]['map'][show] = {'id': f"s_{show}", 'label': show, 'children': [], 'map': {}} categories[cat]['children'].append(categories[cat]['map'][show]) if sea not in categories[cat]['map'][show]['map']: categories[cat]['map'][show]['map'][sea] = {'id': f"sea_{show}_{sea}", 'label': sea, 'children': []} categories[cat]['map'][show]['children'].append(categories[cat]['map'][show]['map'][sea]) categories[cat]['map'][show]['map'][sea]['children'].append({'id': item['id'], 'label': file, 'status': v_status, 'pct': 0}) added = True if not added: lbl = os.path.basename(item['original_file']) if item.get('status') == 'SKIPPED': lbl += " (Ignorado)" categories[cat]['children'].append({'id': item['id'], 'label': lbl, 'status': v_status, 'pct': 0}) for k in categories: tree.append(categories[k]) return tree def update_node_status(self, fid, status, pct=0): def search(nodes): for node in nodes: if node.get('id') == fid: node['status'] = status; node['pct'] = pct; return True if 'children' in node: if search(node['children']): return True return False if len(self.tree_data) > 2: search(self.tree_data[2].get('children', [])) def start_process(self): if self.is_running: return threading.Thread(target=self.worker_thread, daemon=True).start() # --- UI LOOPS --- def check_for_resolution_request(self): """Verifica se a thread está pedindo ajuda do usuário""" if self.waiting_for_user and (not self.dialog_resolver or not self.dialog_resolver.value): # Se a flag tá ativa e o dialog FECHADO, abre ele self.open_resolution_dialog() def update_tree_ui(self): # Loop Principal da UI if self.log_container: self.log_container.clear() for l in self.logs[-20:]: self.log_container.push(l) if self.btn_start: self.btn_start.set_visibility(not self.is_running) if hasattr(self, 'progress_bar_total'): self.progress_bar_total.value = self.total_progress / 100 self.lbl_pct_total.text = f"{int(self.total_progress)}%" self.tree_container.clear() with self.tree_container: if not self.tree_data: ui.label('Aguardando início...').classes('text-gray-400 italic mt-10') else: self.render_tree_recursive(self.tree_data) # Checa se precisa abrir o popup self.check_for_resolution_request() def render_tree_recursive(self, nodes, depth=0): # (Mesmo código de renderização anterior) for node in nodes: status = node.get('status', '') pct = node.get('pct', 0) icon = 'circle'; color = 'grey'; spin = False if status == 'pending': icon = 'hourglass_empty' elif status == 'running': icon = 'sync'; color = 'blue'; spin = True elif status == 'done': icon = 'check_circle'; color = 'green' elif status == 'error': icon = 'error'; color = 'red' elif status == 'warning': icon = 'warning'; color = 'orange' elif status == 'skipped': icon = 'block'; color = 'red' margin = f"ml-{depth * 6}" bg = "bg-blue-100" if status == 'running' else "" with ui.row().classes(f'w-full items-center gap-2 py-1 px-2 {margin} {bg} rounded'): if spin: ui.spinner(size='xs').classes('mr-1') else: ui.icon(icon, color=color, size='xs').classes('mr-1') type_icon = 'folder' if 'children' in node else 'movie' ui.icon(type_icon, color='amber-8' if type_icon=='folder' else 'slate-500').classes('opacity-70') ui.label(node['label']).classes('text-sm truncate flex-grow') if status == 'running' and 'children' not in node: ui.linear_progress(value=pct/100, show_value=False).classes('w-24 h-3 rounded') if 'children' in node: self.render_tree_recursive(node['children'], depth + 1) def create_ui(self): self.container = ui.column().classes('w-full h-[calc(100vh-100px)]') self.render_layout() def refresh_ui(self): if self.container: self.container.clear(); self.render_layout() def render_layout(self): # (Mesmo layout anterior) with self.container: with ui.row().classes('w-full items-center mb-2'): ui.icon('auto_mode', size='md', color='indigo') ui.label('Painel de Automação').classes('text-2xl font-bold text-indigo-900') with ui.row().classes('w-full gap-4 h-full'): with ui.column().classes('w-full md:w-1/3 gap-2'): with ui.card().classes('w-full bg-gray-50 p-3'): ui.label('Configurações').classes('font-bold') ui.input('Telegram Bot Token').bind_value(self.config, 'telegram_token').props('password dense').classes('w-full') ui.input('Telegram Chat ID').bind_value(self.config, 'telegram_chat_id').classes('w-full mb-2') ui.button('Salvar', on_click=self.save_config).props('flat dense icon=save color=primary w-full') ui.separator().classes('my-2') ui.label('Monitorar:').classes('text-xs font-bold') ui.button(self.config['monitor_folder'], icon='folder', on_click=lambda: self.pick_folder(target_key='monitor_folder', start_path='/downloads')).props('flat dense align=left w-full text-xs bg-white border') ui.label('Destinos:').classes('text-xs font-bold mt-2') for cat in ['Filmes', 'Séries', 'Animes', 'Desenhos']: dest = self.config['destinations'].get(cat, '?') ui.button(f"{cat}: {os.path.basename(dest)}", icon='arrow_forward', on_click=lambda c=cat: self.pick_folder(key_category='destinations', target_key=c, start_path='/media')).props('flat dense align=left w-full text-xs bg-white border') with ui.card().classes('w-full flex-grow bg-slate-900 text-white p-2'): ui.label('Log do Sistema').classes('text-xs font-bold text-gray-400') self.log_container = ui.log().classes('w-full h-full font-mono text-[10px]') with ui.card().classes('w-full md:w-2/3 h-full border-t-4 border-indigo-500 flex flex-col'): with ui.column().classes('w-full border-b pb-4 mb-2'): with ui.row().classes('w-full justify-between items-center'): ui.label('Status da Fila').classes('font-bold text-lg') self.btn_start = ui.button('INICIAR', on_click=self.start_process).props('color=indigo icon=play_arrow') with ui.row().classes('w-full items-center gap-2'): self.progress_bar_total = ui.linear_progress(value=0).classes('flex-grow h-6 rounded-lg') self.lbl_pct_total = ui.label('0%').classes('font-bold min-w-[3rem] text-right') self.tree_container = ui.column().classes('w-full flex-grow overflow-y-auto p-2 bg-gray-50 rounded border') ui.timer(0.5, self.update_tree_ui) manager = AutomationManager() def create_ui(): manager.create_ui()