depoly inteligente

This commit is contained in:
2026-02-01 23:38:06 +00:00
parent 3ebe723edb
commit 935b15980c
10 changed files with 807 additions and 453 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()
return EncoderInterface()

View File

@@ -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()

1
data/presets.json Normal file
View File

@@ -0,0 +1 @@
{"Filmes": {"src": "/downloads/finalizados/Filmes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Filmes"}}

View File

@@ -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"}