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)
except: return {}
return {}
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() self.refresh()
def navigate_dst(self, path): def delete_preset(self, name):
if os.path.exists(path) and os.path.isdir(path): if name in self.presets:
self.dst_path = path del self.presets[name]
with open(CONFIG_PATH, 'w') as f:
json.dump(self.presets, f)
self.refresh() self.refresh()
def refresh(self): # --- 2. DIÁLOGO DE CONFIRMAÇÃO DO PRESET (NOVO) ---
if self.container: def confirm_preset_execution(self, name, paths):
self.container.clear() """Abre janela para confirmar antes de rodar o Smart Deploy"""
with self.container: src = paths['src']
self.render_layout() dst = paths['dst']
# --- LÓGICA DE SELEÇÃO --- # Verificação básica antes de abrir o diálogo
def toggle_selection(self, path): if not os.path.exists(src):
if path in self.selected_items: ui.notify(f'Erro: Pasta de origem não existe: {src}', type='negative')
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()
# --- AÇÃO DE MOVER ---
def execute_move(self):
if not self.selected_items:
ui.notify('Selecione itens na esquerda para mover.', type='warning')
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)
count = 0 except: 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')
def confirm(): ui.label('Para:').classes('text-xs text-gray-500 mt-2')
nonlocal count, errors ui.label(dst).classes('font-mono text-sm bg-gray-100 p-1 rounded w-full break-all')
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: if count > 0:
ui.notify(f'{count} itens movidos com sucesso!', type='positive') 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')
self.selected_items = [] # Limpa seleção após sucesso with ui.row().classes('w-full justify-end mt-4'):
self.refresh() ui.button('Cancelar', on_click=dialog.close).props('flat text-color=grey')
# Botão que realmente executa a ação
with ui.row().classes('w-full justify-end'): ui.button('CONFIRMAR MOVIMENTAÇÃO',
ui.button('Cancelar', on_click=dialog.close).props('flat') on_click=lambda: [dialog.close(), self.move_process_from_preset(paths)])\
ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox') .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')
return
with ui.scroll_area().classes('h-96 border rounded bg-white'):
if not entries: if not entries:
ui.label('Pasta Vazia').classes('p-4 text-gray-400 italic') ui.label('Pasta vazia').classes('p-4 text-gray-400 italic')
for entry in entries: 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 is_selected = entry.path in self.selected_items
bg_color = 'bg-blue-100' if is_selected else 'hover:bg-gray-50' bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100"
# Linha do Arquivo/Pasta with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r:
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)
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))
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))
# COLUNA 1: Checkbox (Apenas na Origem) icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description'
if is_source: ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400')
# 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 lbl = ui.label(entry.name).classes('text-sm flex-grow truncate select-none')
ui.icon(icon, color=color).classes('mx-2') 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 3: Nome def prompt_save_preset(self):
ui.label(entry.name).classes('text-sm truncate flex-grow select-none') with ui.dialog() as d, ui.card().classes('p-6'):
ui.label('Criar Novo Smart Deploy').classes('text-lg font-bold')
# --- LAYOUT PRINCIPAL --- ui.label(f'Origem: {self.src_path}').classes('text-xs text-gray-500')
def render_layout(self): ui.label(f'Destino: {self.dst_path}').classes('text-xs text-gray-500')
with ui.row().classes('w-full h-full gap-4'): name_input = ui.input('Nome do Atalho (ex: Filmes, 4K, Séries)')
# 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'): with ui.row().classes('w-full justify-end mt-4'):
ui.button('Mover Selecionados >>>', on_click=self.execute_move)\ ui.button('Cancelar', on_click=d.close).props('flat')
.props('icon=arrow_forward color=green')\ ui.button('SALVAR', on_click=lambda: [self.save_preset(name_input.value), d.close()]).props('color=green')
.bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0) d.open()
def toggle_selection(self, path):
if path in self.selected_items: self.selected_items.remove(path)
else: self.selected_items.append(path)
self.refresh()
# --- 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.worker = None
# Estado Local (Em memória)
self.state = {
"running": False,
"file": "Aguardando...",
"progress": 0,
"log": "---",
"status": "idle"
}
# Elementos UI
self.url_input = None
self.fmt_select = None
self.btn_check = None
self.btn_download = None self.btn_download = None
self.card_status = None
# Elementos dinâmicos
self.lbl_file = None
self.progress = None
self.lbl_log = None
self.btn_stop = None self.btn_stop = None
self.btn_reset = None
def start_download(self, url, fmt): 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: if not url:
ui.notify('Cole uma URL!', type='warning') ui.notify('Insira uma URL primeiro!', type='warning')
return return
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE) self.btn_check.props('loading')
t = DownloadWorker(url, fmt) self.lbl_log.text = "Buscando informações do vídeo..."
t.start()
ui.notify('Iniciando...') # Roda em thread separada para não travar a UI
self.render_update() 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://..."')
self.btn_check = ui.button('Verificar', on_click=self.check_url).props('icon=search color=secondary')
with ui.row().classes('items-center mt-2'): with ui.row().classes('items-center mt-2 gap-4'):
fmt_select = ui.select( 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.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
with self.card_status:
ui.label('Progresso').classes('font-bold')
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(':')
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) data = json.loads(res.stdout)
except: return ["-map", "0"] except:
return [
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
# --- BACKEND: WORKER THREAD ---
class EncoderWorker(threading.Thread):
def __init__(self, input_folder):
super().__init__()
self.input_folder = input_folder
self.daemon = True
def run(self):
prepare_driver_environment()
files = []
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))
total_files = len(files)
stop_signal = False
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
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", "ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
"-hwaccel_output_format", "vaapi", "-i", fpath "-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 += map_args
cmd += [ cmd += [
"-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0", "-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0",
"-c:a", "copy", "-c:s", "copy", out "-c:a", "copy", "-c:s", "copy"
] ]
cmd += metadata_args
cmd.append(output_file)
total_sec = get_video_duration(fpath) or 1 return cmd
# ==============================================================================
# --- SEÇÃO 4: WORKER THREAD ---
# ==============================================================================
class EncoderWorker(threading.Thread):
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()
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_to_process.append(os.path.join(r, file))
CURRENT_STATUS["total_files"] = len(files_to_process)
for i, fpath in enumerate(files_to_process):
if CURRENT_STATUS["stop_requested"]:
break
fname = os.path.basename(fpath)
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)
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):
with open(STATUS_FILE, 'r') as f:
if json.load(f).get('stop_requested'):
proc.terminate() # Mata o FFmpeg
stop_signal = True
break break
if "time=" in line:
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)
break
# Status Final if CURRENT_STATUS["stop_requested"]:
final_msg = "Cancelado pelo usuário 🛑" if stop_signal else "Finalizado ✅" if os.path.exists(out_file): os.remove(out_file)
with open(STATUS_FILE, 'w') as f: final_status = "Cancelado"
json.dump({"running": False, "file": final_msg, "pct_file": 0 if stop_signal else 100, "pct_total": 100, "log": final_msg}, f)
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": "-"
})
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: 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')
# 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) 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:
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_stop.set_visibility(False)
btn_back.set_visibility(True) btn_back.set_visibility(True)
lbl_file.text = "Todos os processos finalizados."
except: pass 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']}"
self.timer = ui.timer(1.0, update_loop) 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(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"}