diff --git a/app/main.py b/app/main.py index f31a496..e46459e 100755 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,5 @@ from nicegui import ui, app -from modules import file_manager, renamer, encoder, downloader, deployer +from modules import file_manager, renamer, encoder, downloader, deployer, automator # --- CONFIGURAÇÃO DE ARQUIVOS ESTÁTICOS --- app.add_static_files('/files', '/downloads') @@ -18,6 +18,8 @@ with ui.tabs().classes('w-full sticky top-0 z-10 bg-white shadow-sm') as tabs: t_encode = ui.tab('Encoder', icon='movie') t_down = ui.tab('Downloader', icon='download') t_deploy = ui.tab('Mover Final', icon='publish') + # NOVA ABA AQUI + t_auto = ui.tab('Automação', icon='auto_mode') # --- PAINÉIS DE CONTEÚDO --- with ui.tab_panels(tabs, value=t_files).classes('w-full p-0 pb-12'): # pb-12 dá espaço para o footer não cobrir o conteúdo @@ -36,6 +38,9 @@ with ui.tab_panels(tabs, value=t_files).classes('w-full p-0 pb-12'): # pb-12 dá with ui.tab_panel(t_deploy): deployer.create_ui() +# NOVO PAINEL AQUI + with ui.tab_panel(t_auto): + automator.create_ui() # --- RODAPÉ (FOOTER) --- # Fixo na parte inferior, mesma cor do header, texto centralizado diff --git a/app/modules/__pycache__/automator.cpython-310.pyc b/app/modules/__pycache__/automator.cpython-310.pyc new file mode 100644 index 0000000..fdf5207 Binary files /dev/null and b/app/modules/__pycache__/automator.cpython-310.pyc differ diff --git a/app/modules/automator.py b/app/modules/automator.py new file mode 100644 index 0000000..bc5f0ab --- /dev/null +++ b/app/modules/automator.py @@ -0,0 +1,459 @@ +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() \ No newline at end of file diff --git a/data/automation.json b/data/automation.json new file mode 100644 index 0000000..8d2e9d6 --- /dev/null +++ b/data/automation.json @@ -0,0 +1,12 @@ +{ + "telegram_token": "8543089985:AAFgDMOg9VlikAsPTi35FcF395W2uAtSzuY", + "telegram_chat_id": "8392926121", + "monitor_folder": "/downloads/completos", + "destinations": { + "Filmes": "/media/Jellyfin/onedrive/Jellyfin/Filmes", + "S\u00e9ries": "/media/Jellyfin/onedrive/Jellyfin/Series", + "Animes": "/media/Jellyfin/onedrive/Jellyfin/Animes", + "Desenhos": "/media/Jellyfin/onedrive/Jellyfin/Desenhos" + }, + "tmdb_api_key_view": "12856f632876dc743b6f6775f4e5bd7d" +} \ No newline at end of file diff --git a/data/history.log b/data/history.log index 03b4897..7951228 100644 --- a/data/history.log +++ b/data/history.log @@ -50,3 +50,9 @@ Traceback (most recent call last): OSError: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv' [2026-02-04 22:50:37] ✅ Movido: Se Esse Amor Desaparecesse Hoje (2025).mkv +[2026-02-05 13:23:06] ✅ Movido: Episódio 17.mp4 +[2026-02-05 13:23:09] ✅ Movido: Episódio 03.mp4 +[2026-02-05 13:23:11] ✅ Movido: Episódio 06.mp4 +[2026-02-05 13:23:13] ✅ Movido: Episódio 07.mp4 +[2026-02-05 13:23:13] ⚠️ Pendência: Episódio 14.mp4 +[2026-02-05 13:23:27] 🔄 Substituído: Episódio 14.mp4