depoly inteligente
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
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 count > 0:
|
||||||
if not os.path.exists(item_path): continue # Já foi movido ou deletado
|
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')
|
||||||
|
|
||||||
item_name = os.path.basename(item_path)
|
with ui.row().classes('w-full justify-end mt-4'):
|
||||||
target = os.path.join(self.dst_path, item_name)
|
ui.button('Cancelar', on_click=dialog.close).props('flat text-color=grey')
|
||||||
|
# Botão que realmente executa a ação
|
||||||
try:
|
ui.button('CONFIRMAR MOVIMENTAÇÃO',
|
||||||
if os.path.exists(target):
|
on_click=lambda: [dialog.close(), self.move_process_from_preset(paths)])\
|
||||||
ui.notify(f'Erro: {item_name} já existe no destino!', type='negative')
|
.props('color=green icon=check')
|
||||||
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'):
|
|
||||||
ui.button('Cancelar', on_click=dialog.close).props('flat')
|
|
||||||
ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox')
|
|
||||||
|
|
||||||
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:
|
||||||
|
is_selected = entry.path in self.selected_items
|
||||||
|
bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100"
|
||||||
|
|
||||||
with ui.scroll_area().classes('h-96 border rounded bg-white'):
|
with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r:
|
||||||
if not entries:
|
if is_source:
|
||||||
ui.label('Pasta Vazia').classes('p-4 text-gray-400 italic')
|
ui.checkbox(value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense')
|
||||||
|
|
||||||
for entry in entries:
|
icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description'
|
||||||
is_dir = entry.is_dir()
|
ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400')
|
||||||
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
|
lbl = ui.label(entry.name).classes('text-sm flex-grow truncate select-none')
|
||||||
is_selected = entry.path in self.selected_items
|
if entry.is_dir():
|
||||||
bg_color = 'bg-blue-100' if is_selected else 'hover:bg-gray-50'
|
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')
|
||||||
|
|
||||||
# Linha do Arquivo/Pasta
|
def prompt_save_preset(self):
|
||||||
with ui.row().classes(f'w-full items-center p-1 cursor-pointer border-b {bg_color}') as row:
|
with ui.dialog() as d, ui.card().classes('p-6'):
|
||||||
|
ui.label('Criar Novo Smart Deploy').classes('text-lg font-bold')
|
||||||
|
ui.label(f'Origem: {self.src_path}').classes('text-xs text-gray-500')
|
||||||
|
ui.label(f'Destino: {self.dst_path}').classes('text-xs text-gray-500')
|
||||||
|
name_input = ui.input('Nome do Atalho (ex: Filmes, 4K, Séries)')
|
||||||
|
with ui.row().classes('w-full justify-end mt-4'):
|
||||||
|
ui.button('Cancelar', on_click=d.close).props('flat')
|
||||||
|
ui.button('SALVAR', on_click=lambda: [self.save_preset(name_input.value), d.close()]).props('color=green')
|
||||||
|
d.open()
|
||||||
|
|
||||||
# Lógica de Clique na Linha (Texto)
|
def toggle_selection(self, path):
|
||||||
if is_source:
|
if path in self.selected_items: self.selected_items.remove(path)
|
||||||
if is_dir:
|
else: self.selected_items.append(path)
|
||||||
# Se for pasta na origem: Clique entra na pasta
|
self.refresh()
|
||||||
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)
|
|
||||||
if is_source:
|
|
||||||
# O checkbox permite selecionar pastas sem entrar nelas
|
|
||||||
# stop_propagation impede que o clique no checkbox acione o clique da linha (entrar na pasta)
|
|
||||||
ui.checkbox('', value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense').on('click', lambda e: e.stop_propagation())
|
|
||||||
|
|
||||||
# COLUNA 2: Ícone
|
|
||||||
ui.icon(icon, color=color).classes('mx-2')
|
|
||||||
|
|
||||||
# COLUNA 3: Nome
|
|
||||||
ui.label(entry.name).classes('text-sm truncate flex-grow select-none')
|
|
||||||
|
|
||||||
# --- 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()
|
||||||
@@ -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()
|
||||||
@@ -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 [
|
||||||
|
"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
|
||||||
|
]
|
||||||
|
|
||||||
map_args = ["-map", "0:v"]
|
input_streams = data.get('streams', [])
|
||||||
audio_found = False
|
map_args = ["-map", "0:v:0"]
|
||||||
for s in data.get('streams', []):
|
metadata_args = []
|
||||||
if s['codec_type'] == 'audio':
|
|
||||||
lang = s.get('tags', {}).get('language', 'und').lower()
|
found_pt_audio = False
|
||||||
if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']:
|
|
||||||
map_args.extend(["-map", f"0:{s['index']}"])
|
# ÁUDIO
|
||||||
audio_found = True
|
audio_idx = 0
|
||||||
if not audio_found: map_args.extend(["-map", "0:a"])
|
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', {})
|
||||||
|
|
||||||
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']:
|
if lang in ['por', 'pt', 'pob', 'pt-br']:
|
||||||
map_args.extend(["-map", f"0:{s['index']}"])
|
map_args.extend(["-map", f"0:{stream['index']}"])
|
||||||
return map_args
|
|
||||||
|
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,
|
|
||||||
"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)
|
rel = os.path.relpath(fpath, self.input_folder)
|
||||||
out = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
|
out_file = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
|
||||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
out_file = os.path.splitext(out_file)[0] + ".mkv"
|
||||||
|
os.makedirs(os.path.dirname(out_file), exist_ok=True)
|
||||||
|
|
||||||
map_args = get_streams_map(fpath)
|
size_before = os.path.getsize(fpath)
|
||||||
cmd = [
|
cmd = build_ffmpeg_command(fpath, out_file)
|
||||||
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
|
total_sec = get_video_duration(fpath)
|
||||||
"-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'):
|
|
||||||
proc.terminate() # Mata o FFmpeg
|
|
||||||
stop_signal = True
|
|
||||||
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')
|
||||||
|
btn_back = ui.button('Voltar / Novo', on_click=lambda: self.set_view('explorer')).props('outline')
|
||||||
|
btn_back.set_visibility(False)
|
||||||
|
|
||||||
# Botão de Parar (Só aparece se estiver rodando)
|
def update_monitor():
|
||||||
btn_stop = ui.button('🛑 Parar Processo', on_click=self.stop_encoding).props('color=red')
|
if not CURRENT_STATUS["running"] and CURRENT_STATUS["pct_total"] >= 100:
|
||||||
# Botão Voltar (Só aparece se acabou)
|
btn_stop.set_visibility(False)
|
||||||
btn_back = ui.button('Voltar para Pastas', on_click=self.back_to_explorer).props('outline')
|
btn_back.set_visibility(True)
|
||||||
btn_back.set_visibility(False)
|
lbl_file.text = "Todos os processos finalizados."
|
||||||
|
|
||||||
def update_loop():
|
lbl_file.text = f"Arquivo: {CURRENT_STATUS['file']}"
|
||||||
if not os.path.exists(STATUS_FILE): return
|
progress_file.value = CURRENT_STATUS['pct_file'] / 100
|
||||||
try:
|
lbl_log.text = f"{int(CURRENT_STATUS['pct_file'])}% | {CURRENT_STATUS['log']}"
|
||||||
with open(STATUS_FILE, 'r') as f: data = json.load(f)
|
|
||||||
|
|
||||||
is_running = data.get('running', False)
|
lbl_total.text = f"Fila: {CURRENT_STATUS['current_index']} de {CURRENT_STATUS['total_files']}"
|
||||||
|
progress_total.value = CURRENT_STATUS['pct_total'] / 100
|
||||||
|
|
||||||
lbl_file.text = f"Arquivo: {data.get('file', '?')}"
|
self.timer = ui.timer(0.5, update_monitor)
|
||||||
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)
|
# --- SEÇÃO 6: EXPORTAÇÃO PARA O MAIN.PY ---
|
||||||
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)
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -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
1
data/presets.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"Filmes": {"src": "/downloads/finalizados/Filmes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Filmes"}}
|
||||||
@@ -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"}
|
|
||||||
Reference in New Issue
Block a user