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 os
import shutil import shutil
import datetime import json
import asyncio
# Configurações de Raiz # --- CONFIGURAÇÕES DE DIRETÓRIOS ---
SRC_ROOT = "/downloads" SRC_ROOT = "/downloads"
DST_ROOT = "/media" DST_ROOT = "/media"
CONFIG_PATH = "/app/data/presets.json"
class DeployManager: class DeployManager:
def __init__(self): def __init__(self):
self.src_path = SRC_ROOT self.src_path = SRC_ROOT
self.dst_path = DST_ROOT self.dst_path = DST_ROOT
self.selected_items = [] # Lista de caminhos selecionados self.selected_items = []
self.container = None self.container = None
self.presets = self.load_presets()
self.pendencies = [] # {'name':, 'src':, 'dst':}
self.logs = []
# --- NAVEGAÇÃO --- # --- 1. PERSISTÊNCIA (JSON) ---
def navigate_src(self, path): def load_presets(self):
if os.path.exists(path) and os.path.isdir(path): if os.path.exists(CONFIG_PATH):
self.src_path = path try:
# Nota: Não limpamos a seleção ao navegar para permitir selecionar coisas de pastas diferentes se quiser with open(CONFIG_PATH, 'r') as f:
# self.selected_items = [] return json.load(f)
self.refresh() except: return {}
return {}
def navigate_dst(self, path): def save_preset(self, name):
if os.path.exists(path) and os.path.isdir(path): if not name: return
self.dst_path = path self.presets[name] = {'src': self.src_path, 'dst': self.dst_path}
self.refresh() with open(CONFIG_PATH, 'w') as f:
json.dump(self.presets, f)
def refresh(self): ui.notify(f'Preset "{name}" salvo!')
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
self.refresh() self.refresh()
# --- AÇÃO DE MOVER --- def delete_preset(self, name):
def execute_move(self): if name in self.presets:
if not self.selected_items: del self.presets[name]
ui.notify('Selecione itens na esquerda para mover.', type='warning') 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 return
if self.src_path == self.dst_path: # Conta itens para mostrar no aviso
ui.notify('Origem e Destino são iguais!', type='warning') try:
return 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(): with ui.dialog() as dialog, ui.card():
ui.label('Confirmar Movimentação Definitiva').classes('text-lg font-bold') ui.label(f'Executar: {name}?').classes('text-xl font-bold text-blue-900')
ui.label(f'Destino: {self.dst_path}')
ui.label(f'Itens selecionados: {len(self.selected_items)}')
# Lista itens no dialog para conferência ui.label('Isso moverá TODOS os arquivos de:').classes('text-xs text-gray-500 mt-2')
with ui.scroll_area().classes('h-32 w-full border p-2 bg-gray-50'): ui.label(src).classes('font-mono text-sm bg-gray-100 p-1 rounded w-full break-all')
for item in self.selected_items:
ui.label(os.path.basename(item)).classes('text-xs') 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(): if count > 0:
nonlocal count, errors ui.label(f'{count} itens encontrados prontos para mover.').classes('font-bold text-green-700 mt-2')
dialog.close() else:
ui.notify('Iniciando movimentação...', type='info') ui.label('Atenção: A pasta de origem parece vazia.').classes('font-bold text-orange-600 mt-2')
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()
with ui.row().classes('w-full justify-end'): with ui.row().classes('w-full justify-end mt-4'):
ui.button('Cancelar', on_click=dialog.close).props('flat') ui.button('Cancelar', on_click=dialog.close).props('flat text-color=grey')
ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox') # 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() 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): 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') ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm')
rel = os.path.relpath(current_path, root_dir) rel = os.path.relpath(current_path, root_dir)
if rel != '.': if rel != '.':
acc = root_dir acc = root_dir
parts = rel.split(os.sep) for part in rel.split(os.sep):
for part in parts:
ui.label('/') ui.label('/')
acc = os.path.join(acc, part) acc = os.path.join(acc, part)
ui.button(part, on_click=lambda p=acc: nav_callback(p)).props('flat dense no-caps size=sm') 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: if current_path != root_dir:
ui.space() ui.space()
parent = os.path.dirname(current_path) 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): def render_file_list(self, path, is_source):
try: try:
entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())) entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower()))
except: with ui.scroll_area().classes('h-[400px] border-2 rounded-lg bg-white w-full shadow-inner'):
ui.label('Erro ao ler pasta').classes('text-red') if not entries:
return ui.label('Pasta vazia').classes('p-4 text-gray-400 italic')
for entry in entries:
with ui.scroll_area().classes('h-96 border rounded bg-white'): is_selected = entry.path in self.selected_items
if not entries: bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100"
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:
# Lógica de Clique na Linha (Texto) with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r:
if is_source: if is_source:
if is_dir: ui.checkbox(value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense')
# Se for pasta na origem: Clique entra na pasta
row.on('click', lambda p=entry.path: self.navigate_src(p)) icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description'
else: ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400')
# Se for arquivo na origem: Clique seleciona
row.on('click', lambda p=entry.path: self.toggle_selection(p)) lbl = ui.label(entry.name).classes('text-sm flex-grow truncate select-none')
else: if entry.is_dir():
# No destino: Clique sempre navega (se for pasta) r.on('click', lambda p=entry.path: self.navigate_src(p) if is_source else self.navigate_dst(p))
if is_dir: elif is_source:
row.on('click', lambda p=entry.path: self.navigate_dst(p)) 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) def prompt_save_preset(self):
if is_source: with ui.dialog() as d, ui.card().classes('p-6'):
# O checkbox permite selecionar pastas sem entrar nelas ui.label('Criar Novo Smart Deploy').classes('text-lg font-bold')
# stop_propagation impede que o clique no checkbox acione o clique da linha (entrar na pasta) ui.label(f'Origem: {self.src_path}').classes('text-xs text-gray-500')
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()) 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)')
# COLUNA 2: Ícone with ui.row().classes('w-full justify-end mt-4'):
ui.icon(icon, color=color).classes('mx-2') 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 def toggle_selection(self, path):
ui.label(entry.name).classes('text-sm truncate flex-grow select-none') 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(): def create_ui():
os.makedirs("/app/data", exist_ok=True)
dm = DeployManager() dm = DeployManager()
# Garante pastas dm.container = ui.column().classes('w-full h-full p-4 max-w-7xl mx-auto')
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.refresh() dm.refresh()

View File

@@ -1,32 +1,19 @@
from nicegui import ui from nicegui import ui, run
import os import os
import threading import threading
import json
import time import time
import yt_dlp import yt_dlp
# --- CONFIGURAÇÕES --- # --- CONFIGURAÇÕES ---
DOWNLOAD_DIR = "/downloads/Youtube" DOWNLOAD_DIR = "/downloads/ytdlp"
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
# --- WORKER (BACKEND) --- # --- WORKER (BACKEND) ---
class DownloadWorker(threading.Thread): class DownloadWorker(threading.Thread):
def __init__(self, url, format_type): def __init__(self, url, format_type, status_callback):
super().__init__() super().__init__()
self.url = url self.url = url
self.format_type = format_type self.format_type = format_type
self.callback = status_callback # Função para atualizar o estado na Interface
self.daemon = True self.daemon = True
self.stop_requested = False self.stop_requested = False
@@ -42,33 +29,38 @@ class DownloadWorker(threading.Thread):
speed = d.get('speed', 0) or 0 speed = d.get('speed', 0) or 0
speed_str = f"{speed / 1024 / 1024:.2f} MiB/s" speed_str = f"{speed / 1024 / 1024:.2f} MiB/s"
filename = os.path.basename(d.get('filename', 'Baixando...')) 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, "running": True,
"file": filename, "file": filename,
"progress": pct, "progress": pct,
"log": f"Baixando: {speed_str} | {d.get('_eta_str', '?')} restantes", "log": f"Baixando: {speed_str} | ETA: {eta}",
"stop_requested": False "status": "downloading"
}) })
elif d['status'] == 'finished': elif d['status'] == 'finished':
save_status({ self.callback({
"running": True, "running": True,
"file": "Processando...", "file": "Processando...",
"progress": 99, "progress": 99,
"log": "Convertendo/Juntando arquivos...", "log": "Convertendo/Juntando arquivos (ffmpeg)...",
"stop_requested": False "status": "processing"
}) })
def run(self): 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 = { ydl_opts = {
'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s', 'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s',
'progress_hooks': [self.progress_hook], 'progress_hooks': [self.progress_hook],
'nocheckcertificate': True, 'nocheckcertificate': True,
'ignoreerrors': True, 'ignoreerrors': False, # Mudado para False para pegarmos os erros reais
'ffmpeg_location': '/usr/bin/ffmpeg' 'ffmpeg_location': '/usr/bin/ffmpeg',
'writethumbnail': True, # Garante metadados no arquivo final
'addmetadata': True,
} }
if self.format_type == 'best': if self.format_type == 'best':
@@ -76,111 +68,214 @@ class DownloadWorker(threading.Thread):
ydl_opts['merge_output_format'] = 'mkv' ydl_opts['merge_output_format'] = 'mkv'
elif self.format_type == 'audio': elif self.format_type == 'audio':
ydl_opts['format'] = 'bestaudio/best' 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': elif self.format_type == '1080p':
ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]' ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]'
ydl_opts['merge_output_format'] = 'mkv' ydl_opts['merge_output_format'] = 'mkv'
try: 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: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.url]) 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: except Exception as e:
msg = "Cancelado." if "Cancelado" in str(e) else str(e) msg = str(e)
save_status({"running": False, "file": "Parado", "progress": 0, "log": msg}) 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) --- # --- INTERFACE (FRONTEND) ---
class DownloaderInterface: class DownloaderInterface:
def __init__(self): def __init__(self):
self.container = None self.container = None
self.timer = None self.timer = None
self.btn_download = None self.worker = None
self.card_status = None
# Elementos dinâmicos # Estado Local (Em memória)
self.lbl_file = None self.state = {
self.progress = None "running": False,
self.lbl_log = None "file": "Aguardando...",
self.btn_stop = None "progress": 0,
"log": "---",
"status": "idle"
}
def start_download(self, url, fmt): # Elementos UI
if not url: self.url_input = None
ui.notify('Cole uma URL!', type='warning') self.fmt_select = None
return 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) self.preview_card = None
t = DownloadWorker(url, fmt) self.preview_img = None
t.start() self.preview_title = None
ui.notify('Iniciando...')
self.render_update() 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): def stop_download(self):
data = load_status() if self.worker and self.worker.is_alive():
if data: self.worker.stop_requested = True
data['stop_requested'] = True self.worker.join(timeout=1.0)
save_status(data) ui.notify('Solicitação de cancelamento enviada.')
ui.notify('Parando...')
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): 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'): 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('w-full items-center gap-2'):
self.url_input = ui.input('URL do Vídeo').classes('flex-grow').props('clearable placeholder="https://..."')
with ui.row().classes('items-center mt-2'): self.btn_check = ui.button('Verificar', on_click=self.check_url).props('icon=search color=secondary')
fmt_select = ui.select(
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)'}, {'best': 'Melhor Qualidade (MKV)', '1080p': 'Limitado a 1080p (MKV)', 'audio': 'Apenas Áudio (MP3)'},
value='best', label='Formato' value='best', label='Formato'
).classes('w-64') ).classes('w-64')
self.btn_download = ui.button('Baixar', on_click=lambda: self.start_download(url_input.value, fmt_select.value))\ self.btn_download = ui.button('Baixar Agora', on_click=self.start_download)\
.props('icon=download color=primary') .props('icon=download color=primary').classes('w-40')
self.btn_download.disable() # Começa desabilitado até verificar
# --- MONITORAMENTO --- # --- PREVIEW (Melhoria 7) ---
# CORREÇÃO AQUI: Criamos o card primeiro, depois definimos visibilidade self.preview_card = ui.card().classes('w-full p-2 mb-4 bg-gray-100 flex-row gap-4 items-center')
self.card_status = ui.card().classes('w-full p-4') self.preview_card.visible = False
self.card_status.visible = False # Esconde inicialmente 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: with self.status_card:
ui.label('Progresso').classes('font-bold') 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.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') self.lbl_log = ui.label('---').classes('text-sm text-gray-500 font-mono')
with ui.row().classes('w-full justify-end mt-2'): 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) # Timer para atualizar UI a partir do estado em memória
self.timer = ui.timer(0.5, self.ui_update_loop)
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
# --- INICIALIZADOR --- # --- INICIALIZADOR ---
def create_ui(): def create_ui():
dl = DownloaderInterface() 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: with dl.container:
dl.render() dl.render()

View File

@@ -1,200 +1,376 @@
from nicegui import ui, app from nicegui import ui
import os import os
import threading import threading
import time import time
import subprocess import subprocess
import json import json
import re 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" ROOT_DIR = "/downloads"
OUTPUT_BASE = "/downloads/finalizados" 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(): def prepare_driver_environment():
"""Configura o ambiente para usar o driver i965 e remove os problemáticos."""
os.environ["LIBVA_DRIVER_NAME"] = "i965" 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 BAD_DRIVERS:
for driver in drivers_ruins:
if os.path.exists(driver): if os.path.exists(driver):
try: os.remove(driver) try:
except: pass os.remove(driver)
except Exception as e:
print(f"Erro ao remover driver: {e}")
# --- BACKEND: UTILS FFMPEG ---
def get_video_duration(filepath): def get_video_duration(filepath):
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filepath] """Usa ffprobe para descobrir a duração total do vídeo em segundos."""
try: return float(subprocess.check_output(cmd).decode().strip()) cmd = [
except: return None "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): def parse_time_to_seconds(time_str):
h, m, s = time_str.split(':') """Converte o timecode do FFmpeg (HH:MM:SS.ms) para segundos float."""
return int(h) * 3600 + int(m) * 60 + float(s)
def get_streams_map(filepath):
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath]
try: try:
res = subprocess.run(cmd, capture_output=True, text=True, env=os.environ) parts = time_str.split(':')
data = json.loads(res.stdout) h = int(parts[0])
except: return ["-map", "0"] m = int(parts[1])
s = float(parts[2])
map_args = ["-map", "0:v"] return h * 3600 + m * 60 + s
audio_found = False except:
for s in data.get('streams', []): return 0.0
if s['codec_type'] == 'audio':
lang = s.get('tags', {}).get('language', 'und').lower() def format_size(size_bytes):
if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']: """Formata bytes para leitura humana (MB, GB)."""
map_args.extend(["-map", f"0:{s['index']}"]) if size_bytes == 0:
audio_found = True return "0B"
if not audio_found: map_args.extend(["-map", "0:a"])
# --- CORREÇÃO AQUI (Math em vez de os.path) ---
for s in data.get('streams', []): size_name = ("B", "KB", "MB", "GB", "TB")
if s['codec_type'] == 'subtitle': try:
lang = s.get('tags', {}).get('language', 'und').lower() i = int(math.log(size_bytes, 1024) // 1)
if lang in ['por', 'pt', 'pob', 'pt-br']: p = math.pow(1024, i)
map_args.extend(["-map", f"0:{s['index']}"]) s = round(size_bytes / p, 2)
return map_args 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): class EncoderWorker(threading.Thread):
def __init__(self, input_folder): def __init__(self, input_folder, delete_original=False):
super().__init__() super().__init__()
self.input_folder = input_folder self.input_folder = input_folder
self.delete_original = delete_original
self.daemon = True self.daemon = True
def run(self): def run(self):
global CURRENT_STATUS, HISTORY_LOG
prepare_driver_environment() 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): for r, d, f in os.walk(self.input_folder):
if "finalizados" in r or "temp" in r: continue if "finalizados" in r or "temp" in r: continue
for file in f: for file in f:
if file.lower().endswith(('.mkv', '.mp4', '.avi')): 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) CURRENT_STATUS["total_files"] = len(files_to_process)
stop_signal = False
for i, fpath in enumerate(files): for i, fpath in enumerate(files_to_process):
# Verifica Parada antes de começar o próximo if CURRENT_STATUS["stop_requested"]:
if os.path.exists(STATUS_FILE): break
with open(STATUS_FILE, 'r') as f:
if json.load(f).get('stop_requested'):
stop_signal = True
break
fname = os.path.basename(fpath) fname = os.path.basename(fpath)
# Status Inicial CURRENT_STATUS["file"] = fname
status = { CURRENT_STATUS["current_index"] = i + 1
"running": True, CURRENT_STATUS["pct_file"] = 0
"stop_requested": False, CURRENT_STATUS["pct_total"] = int((i / len(files_to_process)) * 100)
"file": fname,
"pct_file": 0, rel = os.path.relpath(fpath, self.input_folder)
"pct_total": int((i / total_files) * 100), out_file = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
"current_index": i + 1, out_file = os.path.splitext(out_file)[0] + ".mkv"
"total_files": total_files, os.makedirs(os.path.dirname(out_file), exist_ok=True)
"log": "Iniciando..."
} size_before = os.path.getsize(fpath)
with open(STATUS_FILE, 'w') as f: json.dump(status, f) cmd = build_ffmpeg_command(fpath, out_file)
total_sec = get_video_duration(fpath)
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
]
total_sec = get_video_duration(fpath) or 1
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ)
for line in proc.stdout: for line in proc.stdout:
# Verifica Parada DURANTE a conversão if CURRENT_STATUS["stop_requested"]:
if "time=" in line: # Checa a cada atualização de tempo proc.terminate()
if os.path.exists(STATUS_FILE): break
with open(STATUS_FILE, 'r') as f:
if json.load(f).get('stop_requested'): if "time=" in line:
proc.terminate() # Mata o FFmpeg
stop_signal = True
break
match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line) match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line)
if match: if match:
sec = parse_time_to_seconds(match.group(1)) sec = parse_time_to_seconds(match.group(1))
pct = min(int((sec/total_sec)*100), 100) pct = min(int((sec/total_sec)*100), 100)
status["pct_file"] = pct CURRENT_STATUS["pct_file"] = pct
speed = re.search(r"speed=\s*(\S+)", line)
if speed: status["log"] = f"Velocidade: {speed.group(1)}" speed_match = re.search(r"speed=\s*(\S+)", line)
with open(STATUS_FILE, 'w') as f: json.dump(status, f) if speed_match:
CURRENT_STATUS["speed"] = speed_match.group(1)
CURRENT_STATUS["log"] = f"Vel: {CURRENT_STATUS['speed']}"
proc.wait() proc.wait()
if stop_signal: final_status = "Erro"
# Limpa arquivo incompleto se foi cancelado
if os.path.exists(out): os.remove(out) if CURRENT_STATUS["stop_requested"]:
break 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 CURRENT_STATUS["running"] = False
final_msg = "Cancelado pelo usuário 🛑" if stop_signal else "Finalizado" CURRENT_STATUS["log"] = "Parado" if CURRENT_STATUS["stop_requested"] else "Finalizado"
with open(STATUS_FILE, 'w') as f: CURRENT_STATUS["pct_file"] = 100
json.dump({"running": False, "file": final_msg, "pct_file": 0 if stop_signal else 100, "pct_total": 100, "log": final_msg}, f) CURRENT_STATUS["pct_total"] = 100
# ==============================================================================
# --- SEÇÃO 5: FRONTEND ---
# ==============================================================================
# --- FRONTEND: UI ---
class EncoderInterface: class EncoderInterface:
def __init__(self): def __init__(self):
self.path = ROOT_DIR self.path = ROOT_DIR
self.container = None
self.view_mode = 'explorer'
self.timer = None 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): def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path): if os.path.exists(path) and os.path.isdir(path):
self.path = path self.path = path
self.refresh() self.refresh_ui()
else: else:
ui.notify('Erro ao acessar pasta', type='negative') 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): def start_encoding(self):
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE) should_delete = self.delete_switch.value if self.delete_switch else False
t = EncoderWorker(self.path)
CURRENT_STATUS["pct_file"] = 0
CURRENT_STATUS["pct_total"] = 0
t = EncoderWorker(self.path, delete_original=should_delete)
t.start() t.start()
ui.notify('Iniciado!', type='positive')
ui.notify('Iniciando Conversão...', type='positive')
self.view_mode = 'monitor' self.view_mode = 'monitor'
self.refresh() self.refresh_ui()
def stop_encoding(self): def stop_encoding(self):
# Escreve o sinal de parada no arquivo JSON CURRENT_STATUS["stop_requested"] = True
if os.path.exists(STATUS_FILE): ui.notify('Solicitando parada...', type='warning')
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()
def render_breadcrumbs(self): def render_breadcrumbs(self):
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'): 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') ui.icon('chevron_right', color='grey')
acc = os.path.join(acc, part) 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.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): def render_folder_list(self):
try: 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()) 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 except: return
with ui.column().classes('w-full gap-1 mt-2'): with ui.column().classes('w-full gap-1 mt-2'):
if self.path != ROOT_DIR: 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().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: 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().props('avatar'): ui.icon('folder', color='amber')
with ui.item_section(): ui.item_label(entry.name).classes('font-medium') 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): def render_monitor(self):
ui.label('Monitor de Conversão').classes('text-xl font-bold mb-4') ui.label('Monitor de Conversão').classes('text-xl font-bold mb-4')
lbl_file = ui.label('Inicializando...') lbl_file = ui.label('Inicializando...')
progress_file = ui.linear_progress(value=0).classes('w-full') 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') ui.separator().classes('my-4')
lbl_total = ui.label('Total: 0/0') lbl_total = ui.label('Total: 0/0')
progress_total = ui.linear_progress(value=0).classes('w-full') progress_total = ui.linear_progress(value=0).classes('w-full')
# Botões de Controle row_btns = ui.row().classes('mt-6 gap-4')
row_btns = ui.row().classes('mt-4 gap-2') with row_btns:
btn_stop = ui.button('🛑 Parar Tudo', on_click=self.stop_encoding).props('color=red')
# Botão de Parar (Só aparece se estiver rodando) btn_back = ui.button('Voltar / Novo', on_click=lambda: self.set_view('explorer')).props('outline')
btn_stop = ui.button('🛑 Parar Processo', on_click=self.stop_encoding).props('color=red') btn_back.set_visibility(False)
# 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)
def update_loop(): def update_monitor():
if not os.path.exists(STATUS_FILE): return if not CURRENT_STATUS["running"] and CURRENT_STATUS["pct_total"] >= 100:
try: btn_stop.set_visibility(False)
with open(STATUS_FILE, 'r') as f: data = json.load(f) btn_back.set_visibility(True)
lbl_file.text = "Todos os processos finalizados."
is_running = data.get('running', False)
lbl_file.text = f"Arquivo: {CURRENT_STATUS['file']}"
lbl_file.text = f"Arquivo: {data.get('file', '?')}" progress_file.value = CURRENT_STATUS['pct_file'] / 100
val_file = data.get('pct_file', 0) / 100 lbl_log.text = f"{int(CURRENT_STATUS['pct_file'])}% | {CURRENT_STATUS['log']}"
progress_file.value = val_file
lbl_status.text = f"Status: {int(val_file*100)}% | {data.get('log', '')}" lbl_total.text = f"Fila: {CURRENT_STATUS['current_index']} de {CURRENT_STATUS['total_files']}"
progress_total.value = CURRENT_STATUS['pct_total'] / 100
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
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(): def create_ui():
enc = EncoderInterface() return 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()

View File

@@ -337,7 +337,7 @@ def create_ui():
# Header # Header
with ui.row().classes('w-full bg-indigo-900 text-white items-center p-3 shadow-md'): with ui.row().classes('w-full bg-indigo-900 text-white items-center p-3 shadow-md'):
ui.icon('smart_display', size='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.label('(Filmes • Séries • Animes • Desenhos)').classes('text-xs text-gray-300 ml-1 mt-1')
ui.space() 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"}