diff --git a/app/modules/__pycache__/deployer.cpython-310.pyc b/app/modules/__pycache__/deployer.cpython-310.pyc index 92c9c6f..ce00e76 100644 Binary files a/app/modules/__pycache__/deployer.cpython-310.pyc and b/app/modules/__pycache__/deployer.cpython-310.pyc differ diff --git a/app/modules/__pycache__/downloader.cpython-310.pyc b/app/modules/__pycache__/downloader.cpython-310.pyc index 608d4cf..488bae2 100644 Binary files a/app/modules/__pycache__/downloader.cpython-310.pyc and b/app/modules/__pycache__/downloader.cpython-310.pyc differ diff --git a/app/modules/__pycache__/encoder.cpython-310.pyc b/app/modules/__pycache__/encoder.cpython-310.pyc index 7b70c4a..125dbff 100644 Binary files a/app/modules/__pycache__/encoder.cpython-310.pyc and b/app/modules/__pycache__/encoder.cpython-310.pyc differ diff --git a/app/modules/__pycache__/renamer.cpython-310.pyc b/app/modules/__pycache__/renamer.cpython-310.pyc index c3d4121..e9c5eae 100644 Binary files a/app/modules/__pycache__/renamer.cpython-310.pyc and b/app/modules/__pycache__/renamer.cpython-310.pyc differ diff --git a/app/modules/deployer.py b/app/modules/deployer.py index 7a103e4..444c482 100644 --- a/app/modules/deployer.py +++ b/app/modules/deployer.py @@ -1,123 +1,162 @@ -from nicegui import ui +from nicegui import ui, run import os import shutil -import datetime +import json +import asyncio -# Configurações de Raiz +# --- CONFIGURAÇÕES DE DIRETÓRIOS --- SRC_ROOT = "/downloads" DST_ROOT = "/media" +CONFIG_PATH = "/app/data/presets.json" class DeployManager: def __init__(self): self.src_path = SRC_ROOT self.dst_path = DST_ROOT - self.selected_items = [] # Lista de caminhos selecionados + self.selected_items = [] self.container = None + self.presets = self.load_presets() + self.pendencies = [] # {'name':, 'src':, 'dst':} + self.logs = [] - # --- NAVEGAÇÃO --- - def navigate_src(self, path): - if os.path.exists(path) and os.path.isdir(path): - self.src_path = path - # Nota: Não limpamos a seleção ao navegar para permitir selecionar coisas de pastas diferentes se quiser - # self.selected_items = [] - self.refresh() + # --- 1. PERSISTÊNCIA (JSON) --- + def load_presets(self): + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, 'r') as f: + return json.load(f) + except: return {} + return {} - def navigate_dst(self, path): - if os.path.exists(path) and os.path.isdir(path): - self.dst_path = path - self.refresh() - - def refresh(self): - if self.container: - self.container.clear() - with self.container: - self.render_layout() - - # --- LÓGICA DE SELEÇÃO --- - def toggle_selection(self, path): - if path in self.selected_items: - self.selected_items.remove(path) - else: - self.selected_items.append(path) - # Recarrega para mostrar o checkbox marcado/desmarcado e a cor de fundo + def save_preset(self, name): + if not name: return + self.presets[name] = {'src': self.src_path, 'dst': self.dst_path} + with open(CONFIG_PATH, 'w') as f: + json.dump(self.presets, f) + ui.notify(f'Preset "{name}" salvo!') self.refresh() - # --- AÇÃO DE MOVER --- - def execute_move(self): - if not self.selected_items: - ui.notify('Selecione itens na esquerda para mover.', type='warning') + def delete_preset(self, name): + if name in self.presets: + del self.presets[name] + with open(CONFIG_PATH, 'w') as f: + json.dump(self.presets, f) + self.refresh() + + # --- 2. DIÁLOGO DE CONFIRMAÇÃO DO PRESET (NOVO) --- + def confirm_preset_execution(self, name, paths): + """Abre janela para confirmar antes de rodar o Smart Deploy""" + src = paths['src'] + dst = paths['dst'] + + # Verificação básica antes de abrir o diálogo + if not os.path.exists(src): + ui.notify(f'Erro: Pasta de origem não existe: {src}', type='negative') return - if self.src_path == self.dst_path: - ui.notify('Origem e Destino são iguais!', type='warning') - return + # Conta itens para mostrar no aviso + try: + items = [f for f in os.listdir(src) if not f.startswith('.')] # Ignora ocultos + count = len(items) + except: count = 0 - count = 0 - errors = 0 - with ui.dialog() as dialog, ui.card(): - ui.label('Confirmar Movimentação Definitiva').classes('text-lg font-bold') - ui.label(f'Destino: {self.dst_path}') - ui.label(f'Itens selecionados: {len(self.selected_items)}') + ui.label(f'Executar: {name}?').classes('text-xl font-bold text-blue-900') - # Lista itens no dialog para conferência - with ui.scroll_area().classes('h-32 w-full border p-2 bg-gray-50'): - for item in self.selected_items: - ui.label(os.path.basename(item)).classes('text-xs') + ui.label('Isso moverá TODOS os arquivos de:').classes('text-xs text-gray-500 mt-2') + ui.label(src).classes('font-mono text-sm bg-gray-100 p-1 rounded w-full break-all') + + ui.label('Para:').classes('text-xs text-gray-500 mt-2') + ui.label(dst).classes('font-mono text-sm bg-gray-100 p-1 rounded w-full break-all') - def confirm(): - nonlocal count, errors - dialog.close() - ui.notify('Iniciando movimentação...', type='info') - - for item_path in self.selected_items: - if not os.path.exists(item_path): continue # Já foi movido ou deletado - - item_name = os.path.basename(item_path) - target = os.path.join(self.dst_path, item_name) - - try: - if os.path.exists(target): - ui.notify(f'Erro: {item_name} já existe no destino!', type='negative') - errors += 1 - continue - - shutil.move(item_path, target) - # Tenta ajustar permissões após mover para garantir que o Jellyfin leia - try: - if os.path.isdir(target): - os.system(f'chmod -R 777 "{target}"') - else: - os.chmod(target, 0o777) - except: pass - - count += 1 - except Exception as e: - ui.notify(f'Erro ao mover {item_name}: {e}', type='negative') - errors += 1 - - if count > 0: - ui.notify(f'{count} itens movidos com sucesso!', type='positive') - - self.selected_items = [] # Limpa seleção após sucesso - self.refresh() + if count > 0: + ui.label(f'{count} itens encontrados prontos para mover.').classes('font-bold text-green-700 mt-2') + else: + ui.label('Atenção: A pasta de origem parece vazia.').classes('font-bold text-orange-600 mt-2') - with ui.row().classes('w-full justify-end'): - ui.button('Cancelar', on_click=dialog.close).props('flat') - ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox') + with ui.row().classes('w-full justify-end mt-4'): + ui.button('Cancelar', on_click=dialog.close).props('flat text-color=grey') + # Botão que realmente executa a ação + ui.button('CONFIRMAR MOVIMENTAÇÃO', + on_click=lambda: [dialog.close(), self.move_process_from_preset(paths)])\ + .props('color=green icon=check') dialog.open() - # --- RENDERIZADORES AUXILIARES --- + # --- 3. MOVIMENTAÇÃO E PENDÊNCIAS --- + async def move_process(self, items_to_move, target_folder): + """Move arquivos em background e detecta conflitos""" + for item_path in items_to_move: + if not os.path.exists(item_path): continue + + name = os.path.basename(item_path) + destination = os.path.join(target_folder, name) + + if os.path.exists(destination): + self.add_log(f"⚠️ Pendência: {name}", "warning") + self.pendencies.append({'name': name, 'src': item_path, 'dst': destination}) + self.refresh() + continue + + try: + await run.cpu_bound(shutil.move, item_path, destination) + self.apply_permissions(destination) + self.add_log(f"✅ Movido: {name}", "positive") + except Exception as e: + self.add_log(f"❌ Erro em {name}: {e}", "negative") + + self.selected_items = [] + self.refresh() + + async def move_process_from_preset(self, paths): + """Executa a movimentação após confirmação""" + src, dst = paths['src'], paths['dst'] + if os.path.exists(src): + items = [os.path.join(src, f) for f in os.listdir(src)] + await self.move_process(items, dst) + else: ui.notify('Origem do preset não encontrada!', type='negative') + + def apply_permissions(self, path): + try: + if os.path.isdir(path): os.system(f'chmod -R 777 "{path}"') + else: os.chmod(path, 0o777) + except: pass + + # --- 4. AÇÕES EM MASSA PARA PENDÊNCIAS --- + async def handle_all_pendencies(self, action): + temp_list = list(self.pendencies) + for i in range(len(temp_list)): + await self.handle_pendency(0, action, refresh=False) + self.refresh() + + async def handle_pendency(self, index, action, refresh=True): + if index >= len(self.pendencies): return + item = self.pendencies.pop(index) + + if action == 'replace': + try: + if os.path.isdir(item['dst']): + await run.cpu_bound(shutil.rmtree, item['dst']) + else: + await run.cpu_bound(os.remove, item['dst']) + + await run.cpu_bound(shutil.move, item['src'], item['dst']) + self.apply_permissions(item['dst']) + self.add_log(f"🔄 Substituído: {item['name']}") + except Exception as e: + self.add_log(f"❌ Erro ao substituir {item['name']}: {e}", "negative") + + if refresh: self.refresh() + + # --- 5. NAVEGAÇÃO (BREADCRUMBS) --- def render_breadcrumbs(self, current_path, root_dir, nav_callback): - with ui.row().classes('items-center gap-1 bg-gray-100 p-1 rounded w-full'): + with ui.row().classes('items-center gap-1 bg-gray-100 p-1 rounded w-full mb-2'): ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm') rel = os.path.relpath(current_path, root_dir) if rel != '.': acc = root_dir - parts = rel.split(os.sep) - for part in parts: + for part in rel.split(os.sep): ui.label('/') acc = os.path.join(acc, part) ui.button(part, on_click=lambda p=acc: nav_callback(p)).props('flat dense no-caps size=sm') @@ -125,98 +164,130 @@ class DeployManager: if current_path != root_dir: ui.space() parent = os.path.dirname(current_path) - ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm') + ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm color=primary') + def navigate_src(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.src_path = path + self.refresh() + + def navigate_dst(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.dst_path = path + self.refresh() + + # --- 6. INTERFACE PRINCIPAL --- + def add_log(self, message, type="info"): + self.logs.insert(0, message) + if len(self.logs) > 30: self.logs.pop() + + def refresh(self): + if self.container: + self.container.clear() + with self.container: + self.render_layout() + + def render_layout(self): + # TOPBAR: PRESETS + with ui.row().classes('w-full bg-blue-50 p-3 rounded-lg items-center shadow-sm'): + ui.icon('bolt', color='blue').classes('text-2xl') + ui.label('SMART DEPLOYS:').classes('font-bold text-blue-900 mr-4') + for name, paths in self.presets.items(): + with ui.button_group().props('rounded'): + # AQUI MUDOU: Chama o diálogo de confirmação em vez de mover direto + ui.button(name, on_click=lambda n=name, p=paths: self.confirm_preset_execution(n, p)).props('color=blue-6') + ui.button(on_click=lambda n=name: self.delete_preset(n)).props('icon=delete color=red-4') + + ui.button('Salvar Favorito', on_click=self.prompt_save_preset).props('flat icon=add_circle color=green-7').classes('ml-auto') + + # CONTEÚDO: NAVEGADORES + with ui.row().classes('w-full gap-6 mt-4'): + # ORIGEM + with ui.column().classes('flex-grow w-1/2'): + ui.label('📂 ORIGEM (Downloads)').classes('text-lg font-bold text-blue-700') + self.render_breadcrumbs(self.src_path, SRC_ROOT, self.navigate_src) + self.render_file_list(self.src_path, is_source=True) + + # DESTINO + with ui.column().classes('flex-grow w-1/2'): + ui.label('🎯 DESTINO (Mídia)').classes('text-lg font-bold text-green-700') + self.render_breadcrumbs(self.dst_path, DST_ROOT, self.navigate_dst) + self.render_file_list(self.dst_path, is_source=False) + + # SEÇÃO INFERIOR: LOGS E PENDÊNCIAS + with ui.row().classes('w-full gap-6 mt-6'): + # PAINEL DE PENDÊNCIAS + with ui.card().classes('flex-grow h-64 bg-orange-50 border-orange-200 shadow-none'): + with ui.row().classes('w-full items-center border-b pb-2'): + ui.label(f'⚠️ Pendências ({len(self.pendencies)})').classes('font-bold text-orange-900 text-lg') + if self.pendencies: + ui.button('SUBSTITUIR TODOS', on_click=lambda: self.handle_all_pendencies('replace')).props('color=green-8 size=sm icon=done_all') + ui.button('IGNORAR TODOS', on_click=lambda: self.handle_all_pendencies('ignore')).props('color=grey-7 size=sm icon=clear_all') + + with ui.scroll_area().classes('w-full h-full'): + for i, p in enumerate(self.pendencies): + with ui.row().classes('w-full items-center p-2 border-b bg-white rounded mb-1'): + ui.label(p['name']).classes('flex-grow text-xs font-medium') + ui.button(icon='swap_horiz', on_click=lambda idx=i: self.handle_pendency(idx, 'replace')).props('flat dense color=green').tooltip('Substituir') + ui.button(icon='close', on_click=lambda idx=i: self.handle_pendency(idx, 'ignore')).props('flat dense color=red').tooltip('Manter Original') + + # PAINEL DE LOGS + with ui.card().classes('flex-grow h-64 bg-slate-900 text-slate-200 shadow-none'): + ui.label('📜 Log de Atividades').classes('font-bold border-b border-slate-700 w-full pb-2') + with ui.scroll_area().classes('w-full h-full'): + for log in self.logs: + ui.label(f"> {log}").classes('text-[10px] font-mono leading-tight') + + # BOTÃO GLOBAL + ui.button('INICIAR MOVIMENTAÇÃO DOS SELECIONADOS', on_click=lambda: self.move_process(self.selected_items, self.dst_path))\ + .classes('w-full py-6 mt-4 text-xl font-black shadow-lg')\ + .props('color=green-7 icon=forward')\ + .bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0) + + # --- AUXILIARES (Listas, Checkbox, etc) --- def render_file_list(self, path, is_source): try: entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())) - except: - ui.label('Erro ao ler pasta').classes('text-red') - return - - with ui.scroll_area().classes('h-96 border rounded bg-white'): - if not entries: - ui.label('Pasta Vazia').classes('p-4 text-gray-400 italic') - - for entry in entries: - is_dir = entry.is_dir() - icon = 'folder' if is_dir else 'description' - if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4')): icon = 'movie' - color = 'amber' if is_dir else 'grey' - - # Verifica se está selecionado - is_selected = entry.path in self.selected_items - bg_color = 'bg-blue-100' if is_selected else 'hover:bg-gray-50' - - # Linha do Arquivo/Pasta - with ui.row().classes(f'w-full items-center p-1 cursor-pointer border-b {bg_color}') as row: + with ui.scroll_area().classes('h-[400px] border-2 rounded-lg bg-white w-full shadow-inner'): + if not entries: + ui.label('Pasta vazia').classes('p-4 text-gray-400 italic') + for entry in entries: + is_selected = entry.path in self.selected_items + bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100" - # Lógica de Clique na Linha (Texto) - if is_source: - if is_dir: - # Se for pasta na origem: Clique entra na pasta - row.on('click', lambda p=entry.path: self.navigate_src(p)) - else: - # Se for arquivo na origem: Clique seleciona - row.on('click', lambda p=entry.path: self.toggle_selection(p)) - else: - # No destino: Clique sempre navega (se for pasta) - if is_dir: - row.on('click', lambda p=entry.path: self.navigate_dst(p)) + with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r: + if is_source: + ui.checkbox(value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense') + + icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description' + ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400') + + lbl = ui.label(entry.name).classes('text-sm flex-grow truncate select-none') + if entry.is_dir(): + r.on('click', lambda p=entry.path: self.navigate_src(p) if is_source else self.navigate_dst(p)) + elif is_source: + r.on('click', lambda p=entry.path: self.toggle_selection(p)) + except Exception: + ui.label('Erro ao acessar diretório.').classes('text-red-500 p-4 font-bold') - # COLUNA 1: Checkbox (Apenas na Origem) - if is_source: - # O checkbox permite selecionar pastas sem entrar nelas - # stop_propagation impede que o clique no checkbox acione o clique da linha (entrar na pasta) - ui.checkbox('', value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense').on('click', lambda e: e.stop_propagation()) - - # COLUNA 2: Ícone - ui.icon(icon, color=color).classes('mx-2') + def prompt_save_preset(self): + with ui.dialog() as d, ui.card().classes('p-6'): + ui.label('Criar Novo Smart Deploy').classes('text-lg font-bold') + ui.label(f'Origem: {self.src_path}').classes('text-xs text-gray-500') + ui.label(f'Destino: {self.dst_path}').classes('text-xs text-gray-500') + name_input = ui.input('Nome do Atalho (ex: Filmes, 4K, Séries)') + with ui.row().classes('w-full justify-end mt-4'): + ui.button('Cancelar', on_click=d.close).props('flat') + ui.button('SALVAR', on_click=lambda: [self.save_preset(name_input.value), d.close()]).props('color=green') + d.open() - # COLUNA 3: Nome - ui.label(entry.name).classes('text-sm truncate flex-grow select-none') + def toggle_selection(self, path): + if path in self.selected_items: self.selected_items.remove(path) + else: self.selected_items.append(path) + self.refresh() - # --- LAYOUT PRINCIPAL --- - def render_layout(self): - with ui.row().classes('w-full h-full gap-4'): - - # ESQUERDA (ORIGEM) - with ui.column().classes('w-1/2 h-full'): - ui.label('📂 Origem (Downloads)').classes('text-lg font-bold text-blue-600') - self.render_breadcrumbs(self.src_path, SRC_ROOT, self.navigate_src) - - # Contador - if self.selected_items: - ui.label(f'{len(self.selected_items)} itens selecionados').classes('text-sm font-bold text-blue-800') - else: - ui.label('Selecione arquivos ou pastas').classes('text-xs text-gray-400') - - self.render_file_list(self.src_path, is_source=True) - - # DIREITA (DESTINO) - with ui.column().classes('w-1/2 h-full'): - ui.label('🏁 Destino (Mídia Final)').classes('text-lg font-bold text-green-600') - self.render_breadcrumbs(self.dst_path, DST_ROOT, self.navigate_dst) - - # Espaçador visual - ui.label('Navegue até a pasta de destino').classes('text-xs text-gray-400') - - self.render_file_list(self.dst_path, is_source=False) - - # Botão de Ação Principal - with ui.row().classes('w-full justify-end mt-4'): - ui.button('Mover Selecionados >>>', on_click=self.execute_move)\ - .props('icon=arrow_forward color=green')\ - .bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0) - -# --- INICIALIZADOR --- def create_ui(): + os.makedirs("/app/data", exist_ok=True) dm = DeployManager() - # Garante pastas - for d in [SRC_ROOT, DST_ROOT]: - if not os.path.exists(d): - try: os.makedirs(d) - except: pass - - dm.container = ui.column().classes('w-full h-full p-4') + dm.container = ui.column().classes('w-full h-full p-4 max-w-7xl mx-auto') dm.refresh() \ No newline at end of file diff --git a/app/modules/downloader.py b/app/modules/downloader.py index 249dd94..43318bc 100644 --- a/app/modules/downloader.py +++ b/app/modules/downloader.py @@ -1,32 +1,19 @@ -from nicegui import ui +from nicegui import ui, run import os import threading -import json import time import yt_dlp # --- CONFIGURAÇÕES --- -DOWNLOAD_DIR = "/downloads/Youtube" -STATUS_FILE = "/app/data/dl_status.json" - -# --- UTILITÁRIOS --- -def save_status(data): - try: - with open(STATUS_FILE, 'w') as f: json.dump(data, f) - except: pass - -def load_status(): - if not os.path.exists(STATUS_FILE): return None - try: - with open(STATUS_FILE, 'r') as f: return json.load(f) - except: return None +DOWNLOAD_DIR = "/downloads/ytdlp" # --- WORKER (BACKEND) --- class DownloadWorker(threading.Thread): - def __init__(self, url, format_type): + def __init__(self, url, format_type, status_callback): super().__init__() self.url = url self.format_type = format_type + self.callback = status_callback # Função para atualizar o estado na Interface self.daemon = True self.stop_requested = False @@ -42,33 +29,38 @@ class DownloadWorker(threading.Thread): speed = d.get('speed', 0) or 0 speed_str = f"{speed / 1024 / 1024:.2f} MiB/s" filename = os.path.basename(d.get('filename', 'Baixando...')) + eta = d.get('_eta_str', '?') - save_status({ + # Atualiza estado em memória via callback + self.callback({ "running": True, "file": filename, "progress": pct, - "log": f"Baixando: {speed_str} | {d.get('_eta_str', '?')} restantes", - "stop_requested": False + "log": f"Baixando: {speed_str} | ETA: {eta}", + "status": "downloading" }) elif d['status'] == 'finished': - save_status({ + self.callback({ "running": True, "file": "Processando...", "progress": 99, - "log": "Convertendo/Juntando arquivos...", - "stop_requested": False + "log": "Convertendo/Juntando arquivos (ffmpeg)...", + "status": "processing" }) def run(self): - if not os.path.exists(DOWNLOAD_DIR): os.makedirs(DOWNLOAD_DIR, exist_ok=True) + if not os.path.exists(DOWNLOAD_DIR): + os.makedirs(DOWNLOAD_DIR, exist_ok=True) ydl_opts = { 'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s', 'progress_hooks': [self.progress_hook], 'nocheckcertificate': True, - 'ignoreerrors': True, - 'ffmpeg_location': '/usr/bin/ffmpeg' + 'ignoreerrors': False, # Mudado para False para pegarmos os erros reais + 'ffmpeg_location': '/usr/bin/ffmpeg', + 'writethumbnail': True, # Garante metadados no arquivo final + 'addmetadata': True, } if self.format_type == 'best': @@ -76,111 +68,214 @@ class DownloadWorker(threading.Thread): ydl_opts['merge_output_format'] = 'mkv' elif self.format_type == 'audio': ydl_opts['format'] = 'bestaudio/best' - ydl_opts['postprocessors'] = [{'key': 'FFmpegExtractAudio','preferredcodec': 'mp3','preferredquality': '192'}] + ydl_opts['postprocessors'] = [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192' + }] elif self.format_type == '1080p': ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]' ydl_opts['merge_output_format'] = 'mkv' try: - save_status({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando..."}) + self.callback({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando...", "status": "starting"}) + with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([self.url]) - save_status({"running": False, "file": "Concluído!", "progress": 100, "log": "Sucesso."}) + + self.callback({"running": False, "file": "Concluído!", "progress": 100, "log": "Download finalizado com sucesso.", "status": "success"}) except Exception as e: - msg = "Cancelado." if "Cancelado" in str(e) else str(e) - save_status({"running": False, "file": "Parado", "progress": 0, "log": msg}) + msg = str(e) + if "Cancelado" in msg: + log_msg = "Download cancelado pelo usuário." + else: + log_msg = f"Erro: {msg}" + + self.callback({"running": False, "file": "Erro/Parado", "progress": 0, "log": log_msg, "status": "error"}) + +# --- FUNÇÃO AUXILIAR DE METADADOS (IO BOUND) --- +def fetch_meta(url): + try: + ydl_opts = {'quiet': True, 'nocheckcertificate': True, 'ignoreerrors': True} + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + return ydl.extract_info(url, download=False) + except: + return None # --- INTERFACE (FRONTEND) --- class DownloaderInterface: def __init__(self): self.container = None self.timer = None - self.btn_download = None - self.card_status = None + self.worker = None - # Elementos dinâmicos - self.lbl_file = None - self.progress = None - self.lbl_log = None - self.btn_stop = None + # Estado Local (Em memória) + self.state = { + "running": False, + "file": "Aguardando...", + "progress": 0, + "log": "---", + "status": "idle" + } - def start_download(self, url, fmt): - if not url: - ui.notify('Cole uma URL!', type='warning') - return + # Elementos UI + self.url_input = None + self.fmt_select = None + self.btn_check = None + self.btn_download = None + self.btn_stop = None + self.btn_reset = None - if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE) - t = DownloadWorker(url, fmt) - t.start() - ui.notify('Iniciando...') - self.render_update() + self.preview_card = None + self.preview_img = None + self.preview_title = None + + self.status_card = None + self.lbl_file = None + self.progress_bar = None + self.lbl_log = None + + def update_state(self, new_data): + """Callback chamada pelo Worker (thread) para atualizar o dict de estado.""" + self.state.update(new_data) + + async def check_url(self): + url = self.url_input.value + if not url: + ui.notify('Insira uma URL primeiro!', type='warning') + return + + self.btn_check.props('loading') + self.lbl_log.text = "Buscando informações do vídeo..." + + # Roda em thread separada para não travar a UI + info = await run.io_bound(fetch_meta, url) + + self.btn_check.props(remove='loading') + + if info and 'title' in info: + self.preview_card.visible = True + self.preview_title.text = info.get('title', 'Sem título') + self.preview_img.set_source(info.get('thumbnail', '')) + self.btn_download.enable() + self.status_card.visible = True + self.lbl_log.text = "Vídeo encontrado. Pronto para baixar." + else: + ui.notify('Não foi possível obter dados do vídeo. Verifique o link.', type='negative') + self.lbl_log.text = "Erro ao buscar metadados." + + def start_download(self): + url = self.url_input.value + fmt = self.fmt_select.value + + # Reset visual + self.state['progress'] = 0 + self.btn_download.disable() + self.btn_check.disable() + self.url_input.disable() + self.btn_reset.visible = False + + # Inicia Worker + self.worker = DownloadWorker(url, fmt, self.update_state) + self.worker.start() + + ui.notify('Download iniciado!') def stop_download(self): - data = load_status() - if data: - data['stop_requested'] = True - save_status(data) - ui.notify('Parando...') + if self.worker and self.worker.is_alive(): + self.worker.stop_requested = True + self.worker.join(timeout=1.0) + ui.notify('Solicitação de cancelamento enviada.') + + def reset_ui(self): + """Reseta a interface para um novo download""" + self.url_input.value = '' + self.url_input.enable() + self.btn_check.enable() + self.btn_download.disable() + self.preview_card.visible = False + self.status_card.visible = False + self.btn_reset.visible = False + self.lbl_log.text = '---' + self.state = {"running": False, "file": "Aguardando...", "progress": 0, "log": "---", "status": "idle"} + + def ui_update_loop(self): + """Timer que atualiza os elementos visuais com base no self.state""" + # Sincroniza dados da memória com os componentes + self.lbl_file.text = f"Arquivo: {self.state.get('file')}" + self.progress_bar.value = self.state.get('progress', 0) / 100 + self.lbl_log.text = self.state.get('log') + + status = self.state.get('status') + is_running = self.state.get('running', False) + + # Controle de visibilidade do botão Cancelar + if self.btn_stop: + self.btn_stop.visible = is_running + + # Tratamento de finalização/erro para mostrar botão de "Novo" + if status in ['success', 'error'] and not is_running: + self.btn_reset.visible = True + if status == 'error': + self.lbl_log.classes('text-red-500', remove='text-gray-500') + else: + self.lbl_log.classes('text-green-600', remove='text-gray-500') + else: + self.lbl_log.classes('text-gray-500', remove='text-red-500 text-green-600') def render(self): - ui.label('📺 YouTube Downloader').classes('text-xl font-bold mb-2') + ui.label('📺 YouTube Downloader (Docker)').classes('text-xl font-bold mb-2') - # --- INPUT --- + # --- ÁREA DE INPUT --- with ui.card().classes('w-full p-4 mb-4'): - url_input = ui.input('URL do Vídeo').classes('w-full').props('clearable placeholder="https://youtube.com/..."') - - with ui.row().classes('items-center mt-2'): - fmt_select = ui.select( + with ui.row().classes('w-full items-center gap-2'): + self.url_input = ui.input('URL do Vídeo').classes('flex-grow').props('clearable placeholder="https://..."') + self.btn_check = ui.button('Verificar', on_click=self.check_url).props('icon=search color=secondary') + + with ui.row().classes('items-center mt-2 gap-4'): + self.fmt_select = ui.select( {'best': 'Melhor Qualidade (MKV)', '1080p': 'Limitado a 1080p (MKV)', 'audio': 'Apenas Áudio (MP3)'}, value='best', label='Formato' ).classes('w-64') - self.btn_download = ui.button('Baixar', on_click=lambda: self.start_download(url_input.value, fmt_select.value))\ - .props('icon=download color=primary') + self.btn_download = ui.button('Baixar Agora', on_click=self.start_download)\ + .props('icon=download color=primary').classes('w-40') + self.btn_download.disable() # Começa desabilitado até verificar - # --- MONITORAMENTO --- - # CORREÇÃO AQUI: Criamos o card primeiro, depois definimos visibilidade - self.card_status = ui.card().classes('w-full p-4') - self.card_status.visible = False # Esconde inicialmente + # --- PREVIEW (Melhoria 7) --- + self.preview_card = ui.card().classes('w-full p-2 mb-4 bg-gray-100 flex-row gap-4 items-center') + self.preview_card.visible = False + with self.preview_card: + self.preview_img = ui.image().classes('w-32 h-24 rounded object-cover') + with ui.column(): + ui.label('Vídeo Detectado:').classes('text-xs text-gray-600 uppercase font-bold') + self.preview_title = ui.label('').classes('font-bold text-md leading-tight') + + # --- STATUS E MONITORAMENTO --- + self.status_card = ui.card().classes('w-full p-4') + self.status_card.visible = False - with self.card_status: - ui.label('Progresso').classes('font-bold') + with self.status_card: + with ui.row().classes('w-full justify-between items-center'): + ui.label('Status do Processo').classes('font-bold') + self.btn_reset = ui.button('Baixar Outro', on_click=self.reset_ui)\ + .props('icon=refresh flat color=primary').classes('text-sm') + self.btn_reset.visible = False + self.lbl_file = ui.label('Aguardando...') - self.progress = ui.linear_progress(value=0).classes('w-full') + self.progress_bar = ui.linear_progress(value=0).classes('w-full my-2') self.lbl_log = ui.label('---').classes('text-sm text-gray-500 font-mono') with ui.row().classes('w-full justify-end mt-2'): - self.btn_stop = ui.button('🛑 Cancelar', on_click=self.stop_download).props('color=red flat') + self.btn_stop = ui.button('🛑 Cancelar Download', on_click=self.stop_download).props('color=red flat') - self.timer = ui.timer(1.0, self.render_update) - - def render_update(self): - data = load_status() - - if not data: - if self.card_status: self.card_status.visible = False - if self.btn_download: self.btn_download.enable() - return - - # Atualiza UI - is_running = data.get('running', False) - - if self.btn_download: - if is_running: self.btn_download.disable() - else: self.btn_download.enable() - - if self.card_status: self.card_status.visible = True - - if self.lbl_file: self.lbl_file.text = f"Arquivo: {data.get('file', '?')}" - if self.progress: self.progress.value = data.get('progress', 0) / 100 - if self.lbl_log: self.lbl_log.text = data.get('log', '') - - if self.btn_stop: self.btn_stop.visible = is_running + # Timer para atualizar UI a partir do estado em memória + self.timer = ui.timer(0.5, self.ui_update_loop) # --- INICIALIZADOR --- def create_ui(): dl = DownloaderInterface() - dl.container = ui.column().classes('w-full h-full p-4 gap-4') + dl.container = ui.column().classes('w-full h-full p-4 max-w-4xl mx-auto') with dl.container: dl.render() \ No newline at end of file diff --git a/app/modules/encoder.py b/app/modules/encoder.py index cf869a2..6e39e13 100755 --- a/app/modules/encoder.py +++ b/app/modules/encoder.py @@ -1,200 +1,376 @@ -from nicegui import ui, app +from nicegui import ui import os import threading import time import subprocess import json import re +import math # <--- ADICIONADO AQUI +from collections import deque +from datetime import datetime + +# ============================================================================== +# --- SEÇÃO 1: CONFIGURAÇÕES GLOBAIS E CONSTANTES --- +# ============================================================================== ROOT_DIR = "/downloads" OUTPUT_BASE = "/downloads/finalizados" -STATUS_FILE = "/app/data/status.json" -# --- BACKEND: PREPARAÇÃO DE DRIVERS --- +# Caminhos dos drivers Intel problemáticos (NÃO ALTERAR) +BAD_DRIVERS = [ + "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so", + "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1" +] + +# VARIÁVEIS DE ESTADO (MEMÓRIA RAM) +CURRENT_STATUS = { + "running": False, + "stop_requested": False, + "file": "", + "pct_file": 0.0, + "pct_total": 0.0, + "current_index": 0, + "total_files": 0, + "log": "Aguardando...", + "speed": "N/A" +} + +# Histórico dos últimos 50 processamentos +HISTORY_LOG = deque(maxlen=50) + + +# ============================================================================== +# --- SEÇÃO 2: UTILITÁRIOS (Backend) --- +# ============================================================================== + def prepare_driver_environment(): + """Configura o ambiente para usar o driver i965 e remove os problemáticos.""" os.environ["LIBVA_DRIVER_NAME"] = "i965" - drivers_ruins = ["/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so", "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"] - for driver in drivers_ruins: + for driver in BAD_DRIVERS: if os.path.exists(driver): - try: os.remove(driver) - except: pass + try: + os.remove(driver) + except Exception as e: + print(f"Erro ao remover driver: {e}") -# --- BACKEND: UTILS FFMPEG --- def get_video_duration(filepath): - cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filepath] - try: return float(subprocess.check_output(cmd).decode().strip()) - except: return None + """Usa ffprobe para descobrir a duração total do vídeo em segundos.""" + cmd = [ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", filepath + ] + try: + output = subprocess.check_output(cmd).decode().strip() + return float(output) + except: + return 1.0 def parse_time_to_seconds(time_str): - h, m, s = time_str.split(':') - return int(h) * 3600 + int(m) * 60 + float(s) - -def get_streams_map(filepath): - cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath] + """Converte o timecode do FFmpeg (HH:MM:SS.ms) para segundos float.""" try: - res = subprocess.run(cmd, capture_output=True, text=True, env=os.environ) - data = json.loads(res.stdout) - except: return ["-map", "0"] - - map_args = ["-map", "0:v"] - audio_found = False - for s in data.get('streams', []): - if s['codec_type'] == 'audio': - lang = s.get('tags', {}).get('language', 'und').lower() - if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']: - map_args.extend(["-map", f"0:{s['index']}"]) - audio_found = True - if not audio_found: map_args.extend(["-map", "0:a"]) - - for s in data.get('streams', []): - if s['codec_type'] == 'subtitle': - lang = s.get('tags', {}).get('language', 'und').lower() - if lang in ['por', 'pt', 'pob', 'pt-br']: - map_args.extend(["-map", f"0:{s['index']}"]) - return map_args + parts = time_str.split(':') + h = int(parts[0]) + m = int(parts[1]) + s = float(parts[2]) + return h * 3600 + m * 60 + s + except: + return 0.0 + +def format_size(size_bytes): + """Formata bytes para leitura humana (MB, GB).""" + if size_bytes == 0: + return "0B" + + # --- CORREÇÃO AQUI (Math em vez de os.path) --- + size_name = ("B", "KB", "MB", "GB", "TB") + try: + i = int(math.log(size_bytes, 1024) // 1) + p = math.pow(1024, i) + s = round(size_bytes / p, 2) + return f"{s} {size_name[i]}" + except: + return f"{size_bytes} B" + +def clean_metadata_title(title): + """Limpa o título das faixas de áudio/legenda usando Regex.""" + if not title: + return "" + + # Lista de termos para remover (Case Insensitive) + junk_terms = [ + r'\b5\.1\b', r'\b7\.1\b', r'\b2\.0\b', + r'\baac\b', r'\bac3\b', r'\beac3\b', r'\batmos\b', r'\bdts\b', r'\btruehd\b', + r'\bh264\b', r'\bx264\b', r'\bx265\b', r'\bhevc\b', r'\b1080p\b', r'\b720p\b', r'\b4k\b', + r'\bbludv\b', r'\bcomandotorrents\b', r'\brarbg\b', r'\bwww\..+\.com\b', + r'\bcópia\b', r'\boriginal\b' + ] + + clean_title = title + for pattern in junk_terms: + clean_title = re.sub(pattern, '', clean_title, flags=re.IGNORECASE) + + clean_title = re.sub(r'\s+', ' ', clean_title).strip() + return clean_title.strip('-.|[]()').strip() + + +# ============================================================================== +# --- SEÇÃO 3: LÓGICA DO FFMPEG --- +# ============================================================================== + +def build_ffmpeg_command(input_file, output_file): + """Constrói o comando FFmpeg inteligente.""" + + cmd_probe = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", input_file] + try: + res = subprocess.run(cmd_probe, capture_output=True, text=True, env=os.environ) + data = json.loads(res.stdout) + except: + return [ + "ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128", + "-hwaccel_output_format", "vaapi", "-i", input_file, "-map", "0", + "-c:v", "h264_vaapi", "-qp", "25", "-c:a", "copy", "-c:s", "copy", output_file + ] + + input_streams = data.get('streams', []) + map_args = ["-map", "0:v:0"] + metadata_args = [] + + found_pt_audio = False + + # ÁUDIO + audio_idx = 0 + for stream in input_streams: + if stream['codec_type'] == 'audio': + tags = stream.get('tags', {}) + lang = tags.get('language', 'und').lower() + title = tags.get('title', '') + + if lang in ['por', 'pt', 'pob', 'pt-br', 'eng', 'en', 'jpn', 'ja', 'und']: + map_args.extend(["-map", f"0:{stream['index']}"]) + + new_title = clean_metadata_title(title) + if not new_title: + if lang in ['por', 'pt', 'pob', 'pt-br']: new_title = "Português" + elif lang in ['eng', 'en']: new_title = "Inglês" + elif lang in ['jpn', 'ja']: new_title = "Japonês" + + metadata_args.extend([f"-metadata:s:a:{audio_idx}", f"title={new_title}"]) + + if lang in ['por', 'pt', 'pob', 'pt-br'] and not found_pt_audio: + metadata_args.extend([f"-disposition:a:{audio_idx}", "default"]) + found_pt_audio = True + else: + metadata_args.extend([f"-disposition:a:{audio_idx}", "0"]) + + audio_idx += 1 + + if audio_idx == 0: + map_args.extend(["-map", "0:a"]) + + # LEGENDAS + sub_idx = 0 + for stream in input_streams: + if stream['codec_type'] == 'subtitle': + tags = stream.get('tags', {}) + lang = tags.get('language', 'und').lower() + title = tags.get('title', '') + is_forced = 'forced' in stream.get('disposition', {}) + + if lang in ['por', 'pt', 'pob', 'pt-br']: + map_args.extend(["-map", f"0:{stream['index']}"]) + + new_title = clean_metadata_title(title) + metadata_args.extend([f"-metadata:s:s:{sub_idx}", f"title={new_title}"]) + + if is_forced or "forç" in (title or "").lower(): + metadata_args.extend([f"-disposition:s:{sub_idx}", "forced"]) + else: + metadata_args.extend([f"-disposition:s:{sub_idx}", "0"]) + + sub_idx += 1 + + cmd = [ + "ffmpeg", "-y", + "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128", + "-hwaccel_output_format", "vaapi", + "-i", input_file + ] + cmd += map_args + cmd += [ + "-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0", + "-c:a", "copy", "-c:s", "copy" + ] + cmd += metadata_args + cmd.append(output_file) + + return cmd + + +# ============================================================================== +# --- SEÇÃO 4: WORKER THREAD --- +# ============================================================================== -# --- BACKEND: WORKER THREAD --- class EncoderWorker(threading.Thread): - def __init__(self, input_folder): + def __init__(self, input_folder, delete_original=False): super().__init__() self.input_folder = input_folder + self.delete_original = delete_original self.daemon = True def run(self): + global CURRENT_STATUS, HISTORY_LOG prepare_driver_environment() - files = [] + CURRENT_STATUS["running"] = True + CURRENT_STATUS["stop_requested"] = False + CURRENT_STATUS["log"] = "Escaneando arquivos..." + + files_to_process = [] for r, d, f in os.walk(self.input_folder): if "finalizados" in r or "temp" in r: continue for file in f: if file.lower().endswith(('.mkv', '.mp4', '.avi')): - files.append(os.path.join(r, file)) + files_to_process.append(os.path.join(r, file)) - total_files = len(files) - stop_signal = False + CURRENT_STATUS["total_files"] = len(files_to_process) - for i, fpath in enumerate(files): - # Verifica Parada antes de começar o próximo - if os.path.exists(STATUS_FILE): - with open(STATUS_FILE, 'r') as f: - if json.load(f).get('stop_requested'): - stop_signal = True - break - + for i, fpath in enumerate(files_to_process): + if CURRENT_STATUS["stop_requested"]: + break + fname = os.path.basename(fpath) - # Status Inicial - status = { - "running": True, - "stop_requested": False, - "file": fname, - "pct_file": 0, - "pct_total": int((i / total_files) * 100), - "current_index": i + 1, - "total_files": total_files, - "log": "Iniciando..." - } - with open(STATUS_FILE, 'w') as f: json.dump(status, f) - - rel = os.path.relpath(fpath, self.input_folder) - out = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel) - os.makedirs(os.path.dirname(out), exist_ok=True) - - map_args = get_streams_map(fpath) - cmd = [ - "ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128", - "-hwaccel_output_format", "vaapi", "-i", fpath - ] - cmd += map_args - cmd += [ - "-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0", - "-c:a", "copy", "-c:s", "copy", out - ] + CURRENT_STATUS["file"] = fname + CURRENT_STATUS["current_index"] = i + 1 + CURRENT_STATUS["pct_file"] = 0 + CURRENT_STATUS["pct_total"] = int((i / len(files_to_process)) * 100) + + rel = os.path.relpath(fpath, self.input_folder) + out_file = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel) + out_file = os.path.splitext(out_file)[0] + ".mkv" + os.makedirs(os.path.dirname(out_file), exist_ok=True) + + size_before = os.path.getsize(fpath) + cmd = build_ffmpeg_command(fpath, out_file) + total_sec = get_video_duration(fpath) - total_sec = get_video_duration(fpath) or 1 - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ) for line in proc.stdout: - # Verifica Parada DURANTE a conversão - if "time=" in line: # Checa a cada atualização de tempo - if os.path.exists(STATUS_FILE): - with open(STATUS_FILE, 'r') as f: - if json.load(f).get('stop_requested'): - proc.terminate() # Mata o FFmpeg - stop_signal = True - break - + if CURRENT_STATUS["stop_requested"]: + proc.terminate() + break + + if "time=" in line: match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line) if match: sec = parse_time_to_seconds(match.group(1)) pct = min(int((sec/total_sec)*100), 100) - status["pct_file"] = pct - speed = re.search(r"speed=\s*(\S+)", line) - if speed: status["log"] = f"Velocidade: {speed.group(1)}" - with open(STATUS_FILE, 'w') as f: json.dump(status, f) - + CURRENT_STATUS["pct_file"] = pct + + speed_match = re.search(r"speed=\s*(\S+)", line) + if speed_match: + CURRENT_STATUS["speed"] = speed_match.group(1) + CURRENT_STATUS["log"] = f"Vel: {CURRENT_STATUS['speed']}" + proc.wait() - if stop_signal: - # Limpa arquivo incompleto se foi cancelado - if os.path.exists(out): os.remove(out) - break + final_status = "Erro" + + if CURRENT_STATUS["stop_requested"]: + if os.path.exists(out_file): os.remove(out_file) + final_status = "Cancelado" + + elif proc.returncode == 0: + final_status = "✅ Sucesso" + size_after = os.path.getsize(out_file) if os.path.exists(out_file) else 0 + diff = size_after - size_before + + HISTORY_LOG.appendleft({ + "time": datetime.now().strftime("%H:%M:%S"), + "file": fname, + "status": final_status, + "orig_size": format_size(size_before), + "final_size": format_size(size_after), + "diff": ("+" if diff > 0 else "") + format_size(diff) + }) + + if self.delete_original: + try: + os.remove(fpath) + CURRENT_STATUS["log"] = "Original excluído com sucesso." + except: pass + else: + HISTORY_LOG.appendleft({ + "time": datetime.now().strftime("%H:%M:%S"), + "file": fname, + "status": "❌ Falha", + "orig_size": format_size(size_before), + "final_size": "-", + "diff": "-" + }) - # Status Final - final_msg = "Cancelado pelo usuário 🛑" if stop_signal else "Finalizado ✅" - with open(STATUS_FILE, 'w') as f: - json.dump({"running": False, "file": final_msg, "pct_file": 0 if stop_signal else 100, "pct_total": 100, "log": final_msg}, f) + CURRENT_STATUS["running"] = False + CURRENT_STATUS["log"] = "Parado" if CURRENT_STATUS["stop_requested"] else "Finalizado" + CURRENT_STATUS["pct_file"] = 100 + CURRENT_STATUS["pct_total"] = 100 + + +# ============================================================================== +# --- SEÇÃO 5: FRONTEND --- +# ============================================================================== -# --- FRONTEND: UI --- class EncoderInterface: def __init__(self): self.path = ROOT_DIR - self.container = None - self.view_mode = 'explorer' self.timer = None + self.delete_switch = None + self.main_container = None + + if CURRENT_STATUS["running"]: + self.view_mode = 'monitor' + else: + self.view_mode = 'explorer' + + self.main_container = ui.column().classes('w-full h-full gap-4') + self.refresh_ui() + + def refresh_ui(self): + self.main_container.clear() + with self.main_container: + if self.view_mode == 'explorer': + self.render_breadcrumbs() + self.render_options() + self.render_folder_list() + self.render_history_btn() + elif self.view_mode == 'monitor': + self.render_monitor() + elif self.view_mode == 'history': + self.render_history_table() def navigate(self, path): if os.path.exists(path) and os.path.isdir(path): self.path = path - self.refresh() + self.refresh_ui() else: ui.notify('Erro ao acessar pasta', type='negative') - def refresh(self): - if self.container: - self.container.clear() - with self.container: - if self.view_mode == 'explorer': - self.render_breadcrumbs() - self.render_folder_list() - else: - self.render_monitor() - def start_encoding(self): - if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE) - t = EncoderWorker(self.path) + should_delete = self.delete_switch.value if self.delete_switch else False + + CURRENT_STATUS["pct_file"] = 0 + CURRENT_STATUS["pct_total"] = 0 + + t = EncoderWorker(self.path, delete_original=should_delete) t.start() - ui.notify('Iniciado!', type='positive') + + ui.notify('Iniciando Conversão...', type='positive') self.view_mode = 'monitor' - self.refresh() + self.refresh_ui() def stop_encoding(self): - # Escreve o sinal de parada no arquivo JSON - if os.path.exists(STATUS_FILE): - try: - with open(STATUS_FILE, 'r+') as f: - data = json.load(f) - data['stop_requested'] = True - f.seek(0) - json.dump(data, f) - f.truncate() - ui.notify('Parando processo... aguarde.', type='warning') - except: pass - - def back_to_explorer(self): - self.view_mode = 'explorer' - self.refresh() + CURRENT_STATUS["stop_requested"] = True + ui.notify('Solicitando parada...', type='warning') def render_breadcrumbs(self): with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'): @@ -207,81 +383,93 @@ class EncoderInterface: ui.icon('chevron_right', color='grey') acc = os.path.join(acc, part) ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary') - ui.space() - ui.button("🚀 Converter Esta Pasta", on_click=self.start_encoding).props('push color=primary') + + def render_options(self): + with ui.card().classes('w-full mt-2 p-2 bg-blue-50'): + with ui.row().classes('items-center w-full justify-between'): + self.delete_switch = ui.switch('Excluir original ao finalizar com sucesso?').props('color=red') + ui.button("🚀 Iniciar Conversão", on_click=self.start_encoding).props('push color=primary') def render_folder_list(self): try: entries = sorted([e for e in os.scandir(self.path) if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower()) except: return + with ui.column().classes('w-full gap-1 mt-2'): if self.path != ROOT_DIR: - with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-blue-50 hover:bg-blue-100 cursor-pointer rounded'): + with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-gray-200 hover:bg-gray-300 cursor-pointer rounded'): with ui.item_section().props('avatar'): ui.icon('arrow_upward', color='grey') - with ui.item_section(): ui.item_label('Voltar / Subir Nível') + with ui.item_section(): ui.item_label('.. (Subir nível)') + + if not entries: + ui.label("Nenhuma subpasta encontrada aqui.").classes('text-grey italic p-4') + for entry in entries: - with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-gray-100 cursor-pointer rounded'): + with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-blue-50 cursor-pointer rounded border-b border-gray-100'): with ui.item_section().props('avatar'): ui.icon('folder', color='amber') with ui.item_section(): ui.item_label(entry.name).classes('font-medium') + def render_history_btn(self): + ui.separator().classes('mt-4') + ui.button('Ver Histórico (Últimos 50)', on_click=lambda: self.set_view('history')).props('outline w-full') + + def set_view(self, mode): + self.view_mode = mode + self.refresh_ui() + + def render_history_table(self): + ui.label('Histórico de Conversões').classes('text-xl font-bold mb-4') + + columns = [ + {'name': 'time', 'label': 'Hora', 'field': 'time', 'align': 'left'}, + {'name': 'file', 'label': 'Arquivo', 'field': 'file', 'align': 'left'}, + {'name': 'status', 'label': 'Status', 'field': 'status', 'align': 'center'}, + {'name': 'orig', 'label': 'Tam. Orig.', 'field': 'orig_size'}, + {'name': 'final', 'label': 'Tam. Final', 'field': 'final_size'}, + {'name': 'diff', 'label': 'Diferença', 'field': 'diff'}, + ] + + rows = list(HISTORY_LOG) + ui.table(columns=columns, rows=rows, row_key='file').classes('w-full') + + ui.button('Voltar', on_click=lambda: self.set_view('explorer')).props('outline mt-4') + def render_monitor(self): ui.label('Monitor de Conversão').classes('text-xl font-bold mb-4') lbl_file = ui.label('Inicializando...') progress_file = ui.linear_progress(value=0).classes('w-full') - lbl_status = ui.label('---') + lbl_log = ui.label('---').classes('text-caption text-grey') ui.separator().classes('my-4') lbl_total = ui.label('Total: 0/0') progress_total = ui.linear_progress(value=0).classes('w-full') - # Botões de Controle - row_btns = ui.row().classes('mt-4 gap-2') - - # Botão de Parar (Só aparece se estiver rodando) - btn_stop = ui.button('🛑 Parar Processo', on_click=self.stop_encoding).props('color=red') - # Botão Voltar (Só aparece se acabou) - btn_back = ui.button('Voltar para Pastas', on_click=self.back_to_explorer).props('outline') - btn_back.set_visibility(False) + row_btns = ui.row().classes('mt-6 gap-4') + with row_btns: + btn_stop = ui.button('🛑 Parar Tudo', on_click=self.stop_encoding).props('color=red') + btn_back = ui.button('Voltar / Novo', on_click=lambda: self.set_view('explorer')).props('outline') + btn_back.set_visibility(False) - def update_loop(): - if not os.path.exists(STATUS_FILE): return - try: - with open(STATUS_FILE, 'r') as f: data = json.load(f) - - is_running = data.get('running', False) - - lbl_file.text = f"Arquivo: {data.get('file', '?')}" - val_file = data.get('pct_file', 0) / 100 - progress_file.value = val_file - lbl_status.text = f"Status: {int(val_file*100)}% | {data.get('log', '')}" - - if 'total_files' in data: - curr = data.get('current_index', 0) - tot = data.get('total_files', 0) - lbl_total.text = f"Fila: {curr} de {tot} arquivos" - val_total = data.get('pct_total', 0) / 100 - progress_total.value = val_total - - # Controle de Visibilidade dos Botões - if is_running: - btn_stop.set_visibility(True) - btn_back.set_visibility(False) - else: - btn_stop.set_visibility(False) - btn_back.set_visibility(True) - - except: pass + def update_monitor(): + if not CURRENT_STATUS["running"] and CURRENT_STATUS["pct_total"] >= 100: + btn_stop.set_visibility(False) + btn_back.set_visibility(True) + lbl_file.text = "Todos os processos finalizados." + + lbl_file.text = f"Arquivo: {CURRENT_STATUS['file']}" + progress_file.value = CURRENT_STATUS['pct_file'] / 100 + lbl_log.text = f"{int(CURRENT_STATUS['pct_file'])}% | {CURRENT_STATUS['log']}" + + lbl_total.text = f"Fila: {CURRENT_STATUS['current_index']} de {CURRENT_STATUS['total_files']}" + progress_total.value = CURRENT_STATUS['pct_total'] / 100 - self.timer = ui.timer(1.0, update_loop) + self.timer = ui.timer(0.5, update_monitor) + +# ============================================================================== +# --- SEÇÃO 6: EXPORTAÇÃO PARA O MAIN.PY --- +# ============================================================================== def create_ui(): - enc = EncoderInterface() - if os.path.exists(STATUS_FILE): - try: - with open(STATUS_FILE, 'r') as f: - if json.load(f).get('running'): enc.view_mode = 'monitor' - except: pass - enc.container = ui.column().classes('w-full h-full p-4 gap-4') - enc.refresh() \ No newline at end of file + return EncoderInterface() \ No newline at end of file diff --git a/app/modules/renamer.py b/app/modules/renamer.py index 9e93e0c..95ac084 100755 --- a/app/modules/renamer.py +++ b/app/modules/renamer.py @@ -337,7 +337,7 @@ def create_ui(): # Header with ui.row().classes('w-full bg-indigo-900 text-white items-center p-3 shadow-md'): ui.icon('smart_display', size='md') - ui.label('Media Organizer v2').classes('text-lg font-bold ml-2') + ui.label('Renomeador Inteligente').classes('text-lg font-bold ml-2') ui.label('(Filmes • Séries • Animes • Desenhos)').classes('text-xs text-gray-300 ml-1 mt-1') ui.space() diff --git a/data/presets.json b/data/presets.json new file mode 100644 index 0000000..7ea5b5e --- /dev/null +++ b/data/presets.json @@ -0,0 +1 @@ +{"Filmes": {"src": "/downloads/finalizados/Filmes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Filmes"}} \ No newline at end of file diff --git a/data/status.json b/data/status.json deleted file mode 100644 index 8fa1483..0000000 --- a/data/status.json +++ /dev/null @@ -1 +0,0 @@ -{"running": true, "stop_requested": false, "file": "Press\u00e1gio (2009).mkv", "pct_file": 84, "pct_total": 0, "current_index": 1, "total_files": 2, "log": "Velocidade: 9.61x"} \ No newline at end of file