From 935b15980c99712efbf97d8b2387bba9e063696b Mon Sep 17 00:00:00 2001 From: Creidsu Date: Sun, 1 Feb 2026 23:38:06 +0000 Subject: [PATCH] depoly inteligente --- .../__pycache__/deployer.cpython-310.pyc | Bin 7668 -> 14270 bytes .../__pycache__/downloader.cpython-310.pyc | Bin 6211 -> 8704 bytes .../__pycache__/encoder.cpython-310.pyc | Bin 10616 -> 14795 bytes .../__pycache__/renamer.cpython-310.pyc | Bin 16204 -> 16208 bytes app/modules/deployer.py | 417 +++++++------ app/modules/downloader.py | 281 ++++++--- app/modules/encoder.py | 558 ++++++++++++------ app/modules/renamer.py | 2 +- data/presets.json | 1 + data/status.json | 1 - 10 files changed, 807 insertions(+), 453 deletions(-) create mode 100644 data/presets.json delete mode 100644 data/status.json diff --git a/app/modules/__pycache__/deployer.cpython-310.pyc b/app/modules/__pycache__/deployer.cpython-310.pyc index 92c9c6fa2fc5b1862448490699454f01b9862d05..ce00e76f14da671f7ffb795cadedfae02fc37e6e 100644 GIT binary patch literal 14270 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 V0Br{U8M*;c)2}I?Rz98E@xLyBSn>b> literal 7668 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 Oly2Q97nJu)&-_0#e9IUB literal 6211 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 U>BL7r+X8U1+`@FRzFfWczbUf%nE(I) diff --git a/app/modules/__pycache__/encoder.cpython-310.pyc b/app/modules/__pycache__/encoder.cpython-310.pyc index 7b70c4a47c029171eecda1d4f65e6d7ecd0a9472..125dbff744c9a993a6cbc4812d33922b3016de5f 100644 GIT binary patch literal 14795 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 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