Compare commits

...

11 Commits

24 changed files with 2319 additions and 902 deletions

1
app/config.json Normal file
View File

@@ -0,0 +1 @@
{"tmdb_api_key": "12856f632876dc743b6f6775f4e5bd7d"}

BIN
app/img/icone.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
app/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
app/img/logotexto.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
app/img/logotextofundo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -1,18 +1,28 @@
from nicegui import ui, app
# ADICIONE 'downloader' AQUI:
from modules import file_manager, renamer, encoder, downloader, deployer
app.add_static_files('/files', '/downloads')
from modules import file_manager, renamer, encoder, downloader, deployer, automator
# ATUALIZE AS ABAS:
with ui.tabs().classes('w-full') as tabs:
# --- CONFIGURAÇÃO DE ARQUIVOS ESTÁTICOS ---
app.add_static_files('/files', '/downloads')
app.add_static_files('/img', '/app/img')
# --- CABEÇALHO (HEADER) ---
# Cor alterada para 'bg-blue-700'
with ui.header(elevated=True).classes('items-center justify-center bg-slate-700 p-2'):
# Tamanho específico solicitado: w-1/2 com máximo de 100px
ui.image('/img/logotextofundo.png').classes('w-1/2 max-w-[200px] object-contain')
# --- NAVEGAÇÃO (TABS) ---
with ui.tabs().classes('w-full sticky top-0 z-10 bg-white shadow-sm') as tabs:
t_files = ui.tab('Gerenciador', icon='folder')
t_rename = ui.tab('Renomeador', icon='edit')
t_encode = ui.tab('Encoder', icon='movie')
t_down = ui.tab('Downloader', icon='download') # NOVA ABA
t_deploy = ui.tab('Mover Final', icon='publish') # NOVA ABA
t_down = ui.tab('Downloader', icon='download')
t_deploy = ui.tab('Mover Final', icon='publish')
# NOVA ABA AQUI
t_auto = ui.tab('Automação', icon='auto_mode')
# ATUALIZE OS PAINÉIS:
with ui.tab_panels(tabs, value=t_files).classes('w-full p-0'):
# --- PAINÉIS DE CONTEÚDO ---
with ui.tab_panels(tabs, value=t_files).classes('w-full p-0 pb-12'): # pb-12 dá espaço para o footer não cobrir o conteúdo
with ui.tab_panel(t_files).classes('p-0'):
file_manager.create_ui()
@@ -23,11 +33,26 @@ with ui.tab_panels(tabs, value=t_files).classes('w-full p-0'):
with ui.tab_panel(t_encode):
encoder.create_ui()
# NOVO PAINEL:
with ui.tab_panel(t_down):
downloader.create_ui()
with ui.tab_panel(t_deploy):
deployer.create_ui()
# NOVO PAINEL AQUI
with ui.tab_panel(t_auto):
automator.create_ui()
ui.run(title='PyMedia Manager', port=8080, reload=True, storage_secret='secret')
# --- RODAPÉ (FOOTER) ---
# Fixo na parte inferior, mesma cor do header, texto centralizado
with ui.footer().classes('bg-slate-700 justify-center items-center py-1'):
# Texto com estilo levemente menor e fonte monoespaçada para dar o ar de "sistema/server"
ui.label('Criado por Creidsu. Clei-Server').classes('text-xs text-white opacity-90 font-mono tracking-wide')
# --- INICIALIZAÇÃO ---
ui.run(
title='PyMedia Manager',
port=8080,
reload=True,
storage_secret='secret',
favicon='/app/img/icone.ico' # Caminho absoluto
)

Binary file not shown.

459
app/modules/automator.py Normal file
View File

@@ -0,0 +1,459 @@
from nicegui import ui, run
import os
import json
import threading
import time
import asyncio
import requests
import shutil
import subprocess
import re
from datetime import datetime
from unittest.mock import patch, MagicMock
# Importando seus módulos
from modules import renamer, encoder, file_manager
CONFIG_FILE = '/app/data/automation.json'
RENAMER_CONFIG = '/app/config.json'
class AutomationManager:
def __init__(self):
self.is_running = False
self.logs = []
self.config = self.load_config()
# Controle de Estado e Pausa
self.resolution_event = threading.Event()
self.items_to_resolve = []
self.waiting_for_user = False
self.organizer_instance = None # Para reusar lógica de path
# Estado Visual
self.total_progress = 0.0
self.tree_data = []
self.container = None
self.dialog_resolver = None # Referência para o pop-up
# --- CONFIGURAÇÃO ---
def load_config(self):
default = {
"telegram_token": "",
"telegram_chat_id": "",
"monitor_folder": "/downloads",
"destinations": {
"Filmes": "/media/Filmes",
"Séries": "/media/Series",
"Animes": "/media/Animes",
"Desenhos": "/media/Desenhos"
}
}
tmdb_key = ""
if os.path.exists(RENAMER_CONFIG):
try:
with open(RENAMER_CONFIG, 'r') as f:
tmdb_key = json.load(f).get('tmdb_api_key', '')
except: pass
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, 'r') as f:
data = json.load(f)
if not data.get('tmdb_api_key_view'): data['tmdb_api_key_view'] = tmdb_key
return data
except: pass
default['tmdb_api_key_view'] = tmdb_key
return default
def save_config(self):
with open(CONFIG_FILE, 'w') as f:
json.dump(self.config, f, indent=4)
ui.notify('Configurações salvas!', type='positive')
async def pick_folder(self, key_category=None, target_key=None, start_path='/'):
path = start_path if os.path.exists(start_path) else '/'
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col'):
ui.label(f'Selecionar: {target_key}').classes('font-bold')
lbl = ui.label(path).classes('text-xs bg-gray-100 p-2 border rounded break-all')
scroll = ui.scroll_area().classes('flex-grow border rounded p-1 mt-2 bg-white')
async def load(p):
nonlocal path
path = p
lbl.text = path
scroll.clear()
try:
with scroll:
if p != '/': ui.button('..', on_click=lambda: load(os.path.dirname(p))).props('flat dense icon=arrow_upward align=left w-full')
for d in sorted([x for x in os.scandir(p) if x.is_dir()], key=lambda e: e.name.lower()):
ui.button(d.name, on_click=lambda x=d.path: load(x)).props('flat dense icon=folder align=left w-full color=amber-8')
except: pass
def confirm():
if key_category == 'destinations': self.config['destinations'][target_key] = path
else: self.config[target_key] = path
self.save_config()
dialog.close()
self.refresh_ui()
with ui.row().classes('w-full justify-end mt-auto'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('OK', on_click=confirm).props('flat color=green')
await load(path)
dialog.open()
def send_telegram(self, message):
token = self.config.get('telegram_token')
chat_id = self.config.get('telegram_chat_id')
if not token or not chat_id: return
try:
url = f"https://api.telegram.org/bot{token}/sendMessage"
data = {"chat_id": chat_id, "text": message, "parse_mode": "Markdown"}
requests.post(url, data=data, timeout=5)
except: pass
# --- LÓGICA DE RESOLUÇÃO MANUAL (O NOVO RECURSO) ---
def open_resolution_dialog(self):
"""Abre o modal para o usuário resolver ambiguidades"""
with ui.dialog() as self.dialog_resolver, ui.card().classes('w-[800px] h-[80vh] flex flex-col'):
ui.label('⚠️ Resolução de Conflitos').classes('text-xl font-bold text-orange-600 mb-2')
ui.label('O sistema encontrou ambiguidades. Por favor, identifique os arquivos manualmente.').classes('text-sm text-gray-600 mb-4')
scroll = ui.scroll_area().classes('flex-grow border rounded p-2 bg-gray-50')
with scroll:
for item in self.items_to_resolve:
with ui.card().classes('w-full mb-3 p-2 border-l-4 border-orange-400'):
ui.label(f"Arquivo: {item['original_file']}").classes('font-bold text-sm')
# Opções do Dropdown
options = {None: 'Ignorar este arquivo'}
if item.get('candidates'):
for cand in item['candidates']:
# Cria label bonito: "Fallout (2024) - Série"
label = f"{cand.get('name') or cand.get('title')} ({cand.get('first_air_date', '')[:4] if 'first_air_date' in cand else cand.get('release_date', '')[:4]}) - {cand.get('media_type')}"
# Gambiarra para armazenar dict como value (usando index ou json, mas aqui vamos usar o ID do candidato como chave se possível, ou index)
# Para simplificar no NiceGUI select, vamos usar index
options[self.items_to_resolve.index(item), item['candidates'].index(cand)] = label
# Função de callback ao selecionar
def on_select(e, it=item):
if e.value is None:
it['status'] = 'SKIPPED'
it['selected_match'] = None
else:
_, cand_idx = e.value
it['selected_match'] = it['candidates'][cand_idx]
it['status'] = 'OK'
# Recalcula o caminho usando a lógica do renamer
if self.organizer_instance:
self.organizer_instance.calculate_path(it)
ui.select(options, label='Selecione o correto:', on_change=on_select).classes('w-full')
def confirm_resolution():
self.waiting_for_user = False
self.resolution_event.set() # Libera a Thread
self.dialog_resolver.close()
ui.notify('Resoluções aplicadas. Continuando...', type='positive')
ui.button('CONFIRMAR E CONTINUAR', on_click=confirm_resolution).classes('w-full bg-green-600 text-white mt-auto')
self.dialog_resolver.open()
# --- WORKER THREAD ---
def worker_thread(self):
self.is_running = True
self.total_progress = 0
self.logs.append(f"[{datetime.now().strftime('%H:%M')}] Iniciando Pipeline...")
self.send_telegram("🚀 *PyMedia*: Iniciando análise...")
self.tree_data = [
{'id': 'step_scan', 'label': 'Identificando Conteúdo', 'status': 'running', 'progress': 0},
{'id': 'step_resolve', 'label': 'Verificação de Pendências', 'status': 'pending'}, # Novo passo
{'id': 'step_process', 'label': 'Execução (Encode & Deploy)', 'status': 'pending', 'children': []}
]
try:
monitor_path = self.config.get('monitor_folder')
if not os.path.exists(monitor_path): raise Exception("Pasta não existe!")
# 1. SCAN (MOCK)
dummy_ui = MagicMock()
with patch('modules.renamer.ui', new=dummy_ui):
self.organizer_instance = renamer.MediaOrganizer() # Guarda instancia
self.organizer_instance.path = monitor_path
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(self.organizer_instance.analyze_folder())
loop.close()
items = self.organizer_instance.preview_data
if not items:
self.logs.append("Nenhum arquivo encontrado.")
self.tree_data[0]['status'] = 'done'
self.is_running = False
return
self.tree_data[0]['status'] = 'done'
self.tree_data[1]['status'] = 'running'
# 2. IDENTIFICAÇÃO DE PENDÊNCIAS (PAUSA SE NECESSÁRIO)
self.items_to_resolve = [i for i in items if i['status'] in ['AMBIGUO', 'CHECK', 'NAO_ENCONTRADO']]
if self.items_to_resolve:
self.logs.append(f"⚠️ {len(self.items_to_resolve)} arquivos precisam de atenção manual.")
self.send_telegram("⚠️ Intervenção necessária: Arquivos ambíguos detectados.")
# SINALIZA UI PARA ABRIR POPUP
self.resolution_event.clear()
self.waiting_for_user = True
# A Thread dorme aqui até o botão "Continuar" ser clicado
self.resolution_event.wait()
# Thread acorda!
self.logs.append("✅ Pendências resolvidas pelo usuário.")
self.tree_data[1]['status'] = 'done'
self.tree_data[2]['status'] = 'running'
# 3. FILTRAGEM FINAL E PREPARAÇÃO
valid_items = []
for item in items:
# Se o usuário ignorou ou ainda está com problema
if item.get('status') == 'SKIPPED':
self.logs.append(f"Ignorado pelo usuário: {os.path.basename(item['original_file'])}")
item['visual_status'] = 'warning'
continue
if item['status'] != 'OK' or not item['target_path']:
self.logs.append(f"⚠️ Ignorado (Ainda ambíguo): {os.path.basename(item['original_file'])}")
item['visual_status'] = 'warning'
continue
# H.265 Check
full_path = os.path.join(item['original_root'], item['original_file'])
try:
cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "default=noprint_wrappers=1:nokey=1", full_path]
codec = subprocess.check_output(cmd, timeout=5).decode().strip().lower()
if codec in ['hevc', 'h265']:
item['visual_status'] = 'skipped'
self.logs.append(f"❌ Ignorado (H.265): {item['original_file']}")
continue
except: pass
item['visual_status'] = 'pending'
valid_items.append(item)
# Atualiza árvore visual completa
self.tree_data[2]['children'] = self.build_tree_structure(items) # Mostra todos
# 4. EXECUÇÃO
total = len(valid_items)
if total > 0:
self.send_telegram(f"📋 Processando {total} arquivos aprovados.")
for i, item in enumerate(valid_items):
src = os.path.join(item['original_root'], item['original_file'])
category = item.get('category', 'Filmes')
base_dest = self.config['destinations'].get(category)
if not base_dest: continue
rel_path = os.path.relpath(item['target_path'], os.path.join(renamer.DEST_DIR, category))
final_dst = os.path.join(base_dest, rel_path)
self.update_node_status(item['id'], 'running')
# Encode
temp_encode_path = os.path.join('/downloads/temp_automator', os.path.basename(final_dst))
os.makedirs(os.path.dirname(temp_encode_path), exist_ok=True)
cmd = encoder.build_ffmpeg_command(src, temp_encode_path)
duration = encoder.get_video_duration(src)
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ)
success = False
for line in process.stdout:
if "time=" in line:
match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line)
if match:
sec = encoder.parse_time_to_seconds(match.group(1))
pct = min(int((sec/duration)*100), 100)
self.update_node_status(item['id'], 'running', pct)
self.total_progress = (i * (100/total)) + ((100 / total) * (pct / 100))
process.wait()
if process.returncode == 0: success = True
else: self.update_node_status(item['id'], 'error')
if success:
try:
os.makedirs(os.path.dirname(final_dst), exist_ok=True)
shutil.move(temp_encode_path, final_dst)
for sub in item['subtitles']:
sub_name = os.path.splitext(os.path.basename(final_dst))[0] + sub['suffix']
sub_dst = os.path.join(os.path.dirname(final_dst), sub_name)
shutil.copy2(sub['src'], sub_dst)
self.update_node_status(item['id'], 'done', 100)
except: self.update_node_status(item['id'], 'error')
self.send_telegram("✅ Processo concluído.")
else:
self.logs.append("Nada a processar.")
self.tree_data[2]['status'] = 'done'
self.total_progress = 100
except Exception as e:
self.logs.append(f"ERRO: {str(e)}")
import traceback
print(traceback.format_exc())
self.is_running = False
def build_tree_structure(self, items):
# (Mesma lógica de antes, apenas garante que usa 'visual_status' se existir)
tree = []
categories = {}
for item in items:
cat = item.get('category') or "Pendentes"
if cat not in categories: categories[cat] = {'id': f"cat_{cat}", 'label': cat, 'children': [], 'map': {}}
v_status = item.get('visual_status', 'pending')
# Se foi ignorado ou ambíguo não resolvido
if item.get('status') == 'SKIPPED': v_status = 'warning'
added = False
if cat in ['Séries', 'Animes', 'Desenhos'] and item.get('target_path'):
parts = item['target_path'].split(os.sep)
if len(parts) >= 3:
show = parts[-3]; sea = parts[-2]; file = parts[-1]
if show not in categories[cat]['map']:
categories[cat]['map'][show] = {'id': f"s_{show}", 'label': show, 'children': [], 'map': {}}
categories[cat]['children'].append(categories[cat]['map'][show])
if sea not in categories[cat]['map'][show]['map']:
categories[cat]['map'][show]['map'][sea] = {'id': f"sea_{show}_{sea}", 'label': sea, 'children': []}
categories[cat]['map'][show]['children'].append(categories[cat]['map'][show]['map'][sea])
categories[cat]['map'][show]['map'][sea]['children'].append({'id': item['id'], 'label': file, 'status': v_status, 'pct': 0})
added = True
if not added:
lbl = os.path.basename(item['original_file'])
if item.get('status') == 'SKIPPED': lbl += " (Ignorado)"
categories[cat]['children'].append({'id': item['id'], 'label': lbl, 'status': v_status, 'pct': 0})
for k in categories: tree.append(categories[k])
return tree
def update_node_status(self, fid, status, pct=0):
def search(nodes):
for node in nodes:
if node.get('id') == fid:
node['status'] = status; node['pct'] = pct; return True
if 'children' in node:
if search(node['children']): return True
return False
if len(self.tree_data) > 2: search(self.tree_data[2].get('children', []))
def start_process(self):
if self.is_running: return
threading.Thread(target=self.worker_thread, daemon=True).start()
# --- UI LOOPS ---
def check_for_resolution_request(self):
"""Verifica se a thread está pedindo ajuda do usuário"""
if self.waiting_for_user and (not self.dialog_resolver or not self.dialog_resolver.value):
# Se a flag tá ativa e o dialog FECHADO, abre ele
self.open_resolution_dialog()
def update_tree_ui(self):
# Loop Principal da UI
if self.log_container:
self.log_container.clear()
for l in self.logs[-20:]: self.log_container.push(l)
if self.btn_start: self.btn_start.set_visibility(not self.is_running)
if hasattr(self, 'progress_bar_total'):
self.progress_bar_total.value = self.total_progress / 100
self.lbl_pct_total.text = f"{int(self.total_progress)}%"
self.tree_container.clear()
with self.tree_container:
if not self.tree_data: ui.label('Aguardando início...').classes('text-gray-400 italic mt-10')
else: self.render_tree_recursive(self.tree_data)
# Checa se precisa abrir o popup
self.check_for_resolution_request()
def render_tree_recursive(self, nodes, depth=0):
# (Mesmo código de renderização anterior)
for node in nodes:
status = node.get('status', '')
pct = node.get('pct', 0)
icon = 'circle'; color = 'grey'; spin = False
if status == 'pending': icon = 'hourglass_empty'
elif status == 'running': icon = 'sync'; color = 'blue'; spin = True
elif status == 'done': icon = 'check_circle'; color = 'green'
elif status == 'error': icon = 'error'; color = 'red'
elif status == 'warning': icon = 'warning'; color = 'orange'
elif status == 'skipped': icon = 'block'; color = 'red'
margin = f"ml-{depth * 6}"
bg = "bg-blue-100" if status == 'running' else ""
with ui.row().classes(f'w-full items-center gap-2 py-1 px-2 {margin} {bg} rounded'):
if spin: ui.spinner(size='xs').classes('mr-1')
else: ui.icon(icon, color=color, size='xs').classes('mr-1')
type_icon = 'folder' if 'children' in node else 'movie'
ui.icon(type_icon, color='amber-8' if type_icon=='folder' else 'slate-500').classes('opacity-70')
ui.label(node['label']).classes('text-sm truncate flex-grow')
if status == 'running' and 'children' not in node:
ui.linear_progress(value=pct/100, show_value=False).classes('w-24 h-3 rounded')
if 'children' in node: self.render_tree_recursive(node['children'], depth + 1)
def create_ui(self):
self.container = ui.column().classes('w-full h-[calc(100vh-100px)]')
self.render_layout()
def refresh_ui(self):
if self.container: self.container.clear(); self.render_layout()
def render_layout(self):
# (Mesmo layout anterior)
with self.container:
with ui.row().classes('w-full items-center mb-2'):
ui.icon('auto_mode', size='md', color='indigo')
ui.label('Painel de Automação').classes('text-2xl font-bold text-indigo-900')
with ui.row().classes('w-full gap-4 h-full'):
with ui.column().classes('w-full md:w-1/3 gap-2'):
with ui.card().classes('w-full bg-gray-50 p-3'):
ui.label('Configurações').classes('font-bold')
ui.input('Telegram Bot Token').bind_value(self.config, 'telegram_token').props('password dense').classes('w-full')
ui.input('Telegram Chat ID').bind_value(self.config, 'telegram_chat_id').classes('w-full mb-2')
ui.button('Salvar', on_click=self.save_config).props('flat dense icon=save color=primary w-full')
ui.separator().classes('my-2')
ui.label('Monitorar:').classes('text-xs font-bold')
ui.button(self.config['monitor_folder'], icon='folder', on_click=lambda: self.pick_folder(target_key='monitor_folder', start_path='/downloads')).props('flat dense align=left w-full text-xs bg-white border')
ui.label('Destinos:').classes('text-xs font-bold mt-2')
for cat in ['Filmes', 'Séries', 'Animes', 'Desenhos']:
dest = self.config['destinations'].get(cat, '?')
ui.button(f"{cat}: {os.path.basename(dest)}", icon='arrow_forward', on_click=lambda c=cat: self.pick_folder(key_category='destinations', target_key=c, start_path='/media')).props('flat dense align=left w-full text-xs bg-white border')
with ui.card().classes('w-full flex-grow bg-slate-900 text-white p-2'):
ui.label('Log do Sistema').classes('text-xs font-bold text-gray-400')
self.log_container = ui.log().classes('w-full h-full font-mono text-[10px]')
with ui.card().classes('w-full md:w-2/3 h-full border-t-4 border-indigo-500 flex flex-col'):
with ui.column().classes('w-full border-b pb-4 mb-2'):
with ui.row().classes('w-full justify-between items-center'):
ui.label('Status da Fila').classes('font-bold text-lg')
self.btn_start = ui.button('INICIAR', on_click=self.start_process).props('color=indigo icon=play_arrow')
with ui.row().classes('w-full items-center gap-2'):
self.progress_bar_total = ui.linear_progress(value=0).classes('flex-grow h-6 rounded-lg')
self.lbl_pct_total = ui.label('0%').classes('font-bold min-w-[3rem] text-right')
self.tree_container = ui.column().classes('w-full flex-grow overflow-y-auto p-2 bg-gray-50 rounded border')
ui.timer(0.5, self.update_tree_ui)
manager = AutomationManager()
def create_ui(): manager.create_ui()

View File

@@ -1,222 +1,296 @@
from nicegui import ui
from nicegui import ui, run
import os
import shutil
import json
import asyncio
import datetime
from collections import deque
# Configurações de Raiz
# --- CONFIGURAÇÕES DE DIRETÓRIOS ---
SRC_ROOT = "/downloads"
DST_ROOT = "/media"
CONFIG_PATH = "/app/data/presets.json"
LOG_PATH = "/app/data/history.log"
class DeployManager:
def __init__(self):
self.src_path = SRC_ROOT
self.dst_path = DST_ROOT
self.selected_items = [] # Lista de caminhos selecionados
self.selected_items = []
self.container = None
self.presets = self.load_presets()
self.pendencies = []
self.logs = self.load_logs_from_file()
# --- NAVEGAÇÃO ---
def navigate_src(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.src_path = path
# Nota: Não limpamos a seleção ao navegar para permitir selecionar coisas de pastas diferentes se quiser
# self.selected_items = []
self.refresh()
# --- GERENCIAMENTO DE LOGS ---
def load_logs_from_file(self):
if not os.path.exists(LOG_PATH):
return []
try:
with open(LOG_PATH, 'r', encoding='utf-8') as f:
last_lines = list(deque(f, maxlen=50))
return [line.strip() for line in reversed(last_lines)]
except:
return []
def navigate_dst(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.dst_path = path
self.refresh()
def add_log(self, message, type="info"):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
full_msg = f"[{timestamp}] {message}"
self.logs.insert(0, full_msg)
if len(self.logs) > 50: self.logs.pop()
try:
with open(LOG_PATH, 'a', encoding='utf-8') as f:
f.write(full_msg + "\n")
except: pass
def refresh(self):
if self.container:
self.container.clear()
with self.container:
self.render_layout()
# --- 1. PERSISTÊNCIA ---
def load_presets(self):
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r') as f: return json.load(f)
except: return {}
return {}
# --- LÓGICA DE SELEÇÃO ---
def toggle_selection(self, path):
if path in self.selected_items:
self.selected_items.remove(path)
else:
self.selected_items.append(path)
# Recarrega para mostrar o checkbox marcado/desmarcado e a cor de fundo
def save_preset(self, name):
if not name: return
self.presets[name] = {'src': self.src_path, 'dst': self.dst_path}
with open(CONFIG_PATH, 'w') as f: json.dump(self.presets, f)
ui.notify(f'Preset "{name}" salvo!')
self.refresh()
# --- AÇÃO DE MOVER ---
def execute_move(self):
if not self.selected_items:
ui.notify('Selecione itens na esquerda para mover.', type='warning')
def delete_preset(self, name):
if name in self.presets:
del self.presets[name]
with open(CONFIG_PATH, 'w') as f: json.dump(self.presets, f)
self.refresh()
# --- 2. DIÁLOGO ---
def confirm_preset_execution(self, name, paths):
src, dst = paths['src'], paths['dst']
if not os.path.exists(src):
ui.notify(f'Erro: Origem não existe: {src}', type='negative')
return
if self.src_path == self.dst_path:
ui.notify('Origem e Destino são iguais!', type='warning')
return
try: count = len([f for f in os.listdir(src) if not f.startswith('.')])
except: count = 0
count = 0
errors = 0
with ui.dialog() as dialog, ui.card():
ui.label('Confirmar Movimentação Definitiva').classes('text-lg font-bold')
ui.label(f'Destino: {self.dst_path}')
ui.label(f'Itens selecionados: {len(self.selected_items)}')
# Lista itens no dialog para conferência
with ui.scroll_area().classes('h-32 w-full border p-2 bg-gray-50'):
for item in self.selected_items:
ui.label(os.path.basename(item)).classes('text-xs')
ui.label(f'Executar: {name}?').classes('text-xl font-bold text-blue-900')
ui.label(f'Origem: {src}').classes('text-xs bg-gray-100 p-1 w-full break-all')
ui.label(f'Destino: {dst}').classes('text-xs bg-gray-100 p-1 w-full break-all')
ui.label(f'{count} itens encontrados.').classes('font-bold text-green-700 mt-2') if count > 0 else None
def confirm():
nonlocal count, errors
async def execute_action():
dialog.close()
ui.notify('Iniciando movimentação...', type='info')
for item_path in self.selected_items:
if not os.path.exists(item_path): continue # Já foi movido ou deletado
item_name = os.path.basename(item_path)
target = os.path.join(self.dst_path, item_name)
try:
if os.path.exists(target):
ui.notify(f'Erro: {item_name} já existe no destino!', type='negative')
errors += 1
continue
shutil.move(item_path, target)
# Tenta ajustar permissões após mover para garantir que o Jellyfin leia
try:
if os.path.isdir(target):
os.system(f'chmod -R 777 "{target}"')
else:
os.chmod(target, 0o777)
except: pass
count += 1
except Exception as e:
ui.notify(f'Erro ao mover {item_name}: {e}', type='negative')
errors += 1
if count > 0:
ui.notify(f'{count} itens movidos com sucesso!', type='positive')
self.selected_items = [] # Limpa seleção após sucesso
self.refresh()
await asyncio.sleep(0.1)
await self.move_process_from_preset(paths)
with ui.row().classes('w-full justify-end'):
with ui.row().classes('w-full justify-end mt-4'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox')
ui.button('CONFIRMAR', on_click=execute_action).props('color=green')
dialog.open()
# --- RENDERIZADORES AUXILIARES ---
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'):
ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm')
# --- 3. MOVIMENTAÇÃO BLINDADA (SAFE MERGE) ---
async def move_process(self, items_to_move, target_folder):
"""
Nova lógica: Nunca move pastas inteiras.
Sempre cria a estrutura no destino e move apenas arquivos.
"""
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)
# --- CASO 1: É UMA PASTA? ---
if os.path.isdir(item_path):
# Não verifica se existe ou não. Simplesmente garante que a pasta
# exista no destino (mkdir -p) e entra nela.
try:
if not os.path.exists(destination):
os.makedirs(destination, exist_ok=True)
self.apply_permissions(destination) # Garante permissão na nova pasta
# Pega conteúdo e RECURSIVIDADE (mergulha na pasta)
sub_items = [os.path.join(item_path, f) for f in os.listdir(item_path)]
await self.move_process(sub_items, destination)
# Limpeza: Se a pasta de origem ficou vazia, remove ela
if not os.listdir(item_path):
os.rmdir(item_path)
except Exception as e:
self.add_log(f"❌ Erro na pasta {name}: {e}", "negative")
continue
# --- CASO 2: É UM ARQUIVO? ---
# Se já existe o arquivo exato no destino -> Pendência
if os.path.exists(destination):
self.add_log(f"⚠️ Conflito de arquivo: {name}", "warning")
self.pendencies.append({'name': name, 'src': item_path, 'dst': destination})
self.refresh()
continue
# Se não existe -> Move o arquivo
try:
await run.cpu_bound(shutil.move, item_path, destination)
self.apply_permissions(destination)
self.add_log(f"✅ Arquivo movido: {name}", "positive")
except Exception as e:
self.add_log(f"❌ Erro arquivo {name}: {e}", "negative")
self.selected_items = []
self.refresh()
async def move_process_from_preset(self, paths):
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 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 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:
# Como agora só arquivos caem aqui, remove e substitui
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 ---
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 mb-2'):
ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm')
rel = os.path.relpath(current_path, root_dir)
if rel != '.':
acc = root_dir
parts = rel.split(os.sep)
for part in parts:
for part in rel.split(os.sep):
ui.label('/')
acc = os.path.join(acc, part)
ui.button(part, on_click=lambda p=acc: nav_callback(p)).props('flat dense no-caps size=sm')
if current_path != root_dir:
ui.space()
parent = os.path.dirname(current_path)
ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm')
ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm color=primary')
def navigate_src(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.src_path = path; self.refresh()
def navigate_dst(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.dst_path = path; self.refresh()
# --- 6. UI ---
def refresh(self):
if self.container:
self.container.clear()
with self.container: self.render_layout()
def render_layout(self):
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'):
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')
with ui.row().classes('w-full gap-6 mt-4'):
with ui.column().classes('flex-grow w-1/2'):
ui.label('📂 ORIGEM').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)
with ui.column().classes('flex-grow w-1/2'):
ui.label('🎯 DESTINO').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)
with ui.row().classes('w-full gap-6 mt-6'):
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')
ui.button(icon='close', on_click=lambda idx=i: self.handle_pendency(idx, 'ignore')).props('flat dense color=red')
with ui.card().classes('flex-grow h-64 bg-slate-900 text-slate-200 shadow-none'):
ui.label('📜 Logs').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:
color = 'text-red-400' if '' in log else 'text-green-400' if '' in log else 'text-slate-300'
ui.label(f"> {log}").classes(f'text-[10px] font-mono leading-tight {color}')
ui.button('INICIAR MOVIMENTAÇÃO', 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)
def render_file_list(self, path, is_source):
try:
entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower()))
except:
ui.label('Erro ao ler pasta').classes('text-red')
return
with ui.scroll_area().classes('h-[400px] border-2 rounded-lg bg-white w-full shadow-inner'):
if not entries: ui.label('Pasta vazia').classes('p-4 text-gray-400 italic')
for entry in entries:
is_selected = entry.path in self.selected_items
bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100"
with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r:
if is_source: ui.checkbox(value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense')
icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description'
ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400')
ui.label(entry.name).classes('text-sm flex-grow truncate select-none')
if entry.is_dir(): r.on('click', lambda p=entry.path: self.navigate_src(p) if is_source else self.navigate_dst(p))
elif is_source: r.on('click', lambda p=entry.path: self.toggle_selection(p))
except: ui.label('Erro ao acessar diretório.').classes('text-red-500 p-4 font-bold')
with ui.scroll_area().classes('h-96 border rounded bg-white'):
if not entries:
ui.label('Pasta Vazia').classes('p-4 text-gray-400 italic')
for entry in entries:
is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description'
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4')): icon = 'movie'
color = 'amber' if is_dir else 'grey'
# Verifica se está selecionado
is_selected = entry.path in self.selected_items
bg_color = 'bg-blue-100' if is_selected else 'hover:bg-gray-50'
# Linha do Arquivo/Pasta
with ui.row().classes(f'w-full items-center p-1 cursor-pointer border-b {bg_color}') as row:
# Lógica de Clique na Linha (Texto)
if is_source:
if is_dir:
# Se for pasta na origem: Clique entra na pasta
row.on('click', lambda p=entry.path: self.navigate_src(p))
else:
# Se for arquivo na origem: Clique seleciona
row.on('click', lambda p=entry.path: self.toggle_selection(p))
else:
# No destino: Clique sempre navega (se for pasta)
if is_dir:
row.on('click', lambda p=entry.path: self.navigate_dst(p))
def prompt_save_preset(self):
with ui.dialog() as d, ui.card().classes('p-6'):
ui.label('Criar Preset').classes('text-lg font-bold')
name_input = ui.input('Nome')
with ui.row().classes('w-full justify-end mt-4'):
ui.button('Cancelar', on_click=d.close).props('flat')
ui.button('SALVAR', on_click=lambda: [self.save_preset(name_input.value), d.close()]).props('color=green')
d.open()
# COLUNA 1: Checkbox (Apenas na Origem)
if is_source:
# O checkbox permite selecionar pastas sem entrar nelas
# stop_propagation impede que o clique no checkbox acione o clique da linha (entrar na pasta)
ui.checkbox('', value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense').on('click', lambda e: e.stop_propagation())
# COLUNA 2: Ícone
ui.icon(icon, color=color).classes('mx-2')
def toggle_selection(self, path):
if path in self.selected_items: self.selected_items.remove(path)
else: self.selected_items.append(path)
self.refresh()
# 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():
os.makedirs("/app/data", exist_ok=True)
dm = DeployManager()
# Garante pastas
for d in [SRC_ROOT, DST_ROOT]:
if not os.path.exists(d):
try: os.makedirs(d)
except: pass
dm.container = ui.column().classes('w-full h-full p-4')
dm.container = ui.column().classes('w-full h-full p-4 max-w-7xl mx-auto')
dm.refresh()

View File

@@ -1,32 +1,19 @@
from nicegui import ui
from nicegui import ui, run
import os
import threading
import json
import time
import yt_dlp
# --- CONFIGURAÇÕES ---
DOWNLOAD_DIR = "/downloads/Youtube"
STATUS_FILE = "/app/data/dl_status.json"
# --- UTILITÁRIOS ---
def save_status(data):
try:
with open(STATUS_FILE, 'w') as f: json.dump(data, f)
except: pass
def load_status():
if not os.path.exists(STATUS_FILE): return None
try:
with open(STATUS_FILE, 'r') as f: return json.load(f)
except: return None
DOWNLOAD_DIR = "/downloads/ytdlp"
# --- WORKER (BACKEND) ---
class DownloadWorker(threading.Thread):
def __init__(self, url, format_type):
def __init__(self, url, format_type, status_callback):
super().__init__()
self.url = url
self.format_type = format_type
self.callback = status_callback # Função para atualizar o estado na Interface
self.daemon = True
self.stop_requested = False
@@ -42,33 +29,38 @@ class DownloadWorker(threading.Thread):
speed = d.get('speed', 0) or 0
speed_str = f"{speed / 1024 / 1024:.2f} MiB/s"
filename = os.path.basename(d.get('filename', 'Baixando...'))
eta = d.get('_eta_str', '?')
save_status({
# Atualiza estado em memória via callback
self.callback({
"running": True,
"file": filename,
"progress": pct,
"log": f"Baixando: {speed_str} | {d.get('_eta_str', '?')} restantes",
"stop_requested": False
"log": f"Baixando: {speed_str} | ETA: {eta}",
"status": "downloading"
})
elif d['status'] == 'finished':
save_status({
self.callback({
"running": True,
"file": "Processando...",
"progress": 99,
"log": "Convertendo/Juntando arquivos...",
"stop_requested": False
"log": "Convertendo/Juntando arquivos (ffmpeg)...",
"status": "processing"
})
def run(self):
if not os.path.exists(DOWNLOAD_DIR): os.makedirs(DOWNLOAD_DIR, exist_ok=True)
if not os.path.exists(DOWNLOAD_DIR):
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
ydl_opts = {
'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s',
'progress_hooks': [self.progress_hook],
'nocheckcertificate': True,
'ignoreerrors': True,
'ffmpeg_location': '/usr/bin/ffmpeg'
'ignoreerrors': False, # Mudado para False para pegarmos os erros reais
'ffmpeg_location': '/usr/bin/ffmpeg',
'writethumbnail': True, # Garante metadados no arquivo final
'addmetadata': True,
}
if self.format_type == 'best':
@@ -76,111 +68,214 @@ class DownloadWorker(threading.Thread):
ydl_opts['merge_output_format'] = 'mkv'
elif self.format_type == 'audio':
ydl_opts['format'] = 'bestaudio/best'
ydl_opts['postprocessors'] = [{'key': 'FFmpegExtractAudio','preferredcodec': 'mp3','preferredquality': '192'}]
ydl_opts['postprocessors'] = [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192'
}]
elif self.format_type == '1080p':
ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]'
ydl_opts['merge_output_format'] = 'mkv'
try:
save_status({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando..."})
self.callback({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando...", "status": "starting"})
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([self.url])
save_status({"running": False, "file": "Concluído!", "progress": 100, "log": "Sucesso."})
self.callback({"running": False, "file": "Concluído!", "progress": 100, "log": "Download finalizado com sucesso.", "status": "success"})
except Exception as e:
msg = "Cancelado." if "Cancelado" in str(e) else str(e)
save_status({"running": False, "file": "Parado", "progress": 0, "log": msg})
msg = str(e)
if "Cancelado" in msg:
log_msg = "Download cancelado pelo usuário."
else:
log_msg = f"Erro: {msg}"
self.callback({"running": False, "file": "Erro/Parado", "progress": 0, "log": log_msg, "status": "error"})
# --- FUNÇÃO AUXILIAR DE METADADOS (IO BOUND) ---
def fetch_meta(url):
try:
ydl_opts = {'quiet': True, 'nocheckcertificate': True, 'ignoreerrors': True}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
return ydl.extract_info(url, download=False)
except:
return None
# --- INTERFACE (FRONTEND) ---
class DownloaderInterface:
def __init__(self):
self.container = None
self.timer = None
self.btn_download = None
self.card_status = None
self.worker = None
# Elementos dinâmicos
self.lbl_file = None
self.progress = None
self.lbl_log = None
self.btn_stop = None
# Estado Local (Em memória)
self.state = {
"running": False,
"file": "Aguardando...",
"progress": 0,
"log": "---",
"status": "idle"
}
def start_download(self, url, fmt):
if not url:
ui.notify('Cole uma URL!', type='warning')
return
# Elementos UI
self.url_input = None
self.fmt_select = None
self.btn_check = None
self.btn_download = None
self.btn_stop = None
self.btn_reset = None
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE)
t = DownloadWorker(url, fmt)
t.start()
ui.notify('Iniciando...')
self.render_update()
self.preview_card = None
self.preview_img = None
self.preview_title = None
self.status_card = None
self.lbl_file = None
self.progress_bar = None
self.lbl_log = None
def update_state(self, new_data):
"""Callback chamada pelo Worker (thread) para atualizar o dict de estado."""
self.state.update(new_data)
async def check_url(self):
url = self.url_input.value
if not url:
ui.notify('Insira uma URL primeiro!', type='warning')
return
self.btn_check.props('loading')
self.lbl_log.text = "Buscando informações do vídeo..."
# Roda em thread separada para não travar a UI
info = await run.io_bound(fetch_meta, url)
self.btn_check.props(remove='loading')
if info and 'title' in info:
self.preview_card.visible = True
self.preview_title.text = info.get('title', 'Sem título')
self.preview_img.set_source(info.get('thumbnail', ''))
self.btn_download.enable()
self.status_card.visible = True
self.lbl_log.text = "Vídeo encontrado. Pronto para baixar."
else:
ui.notify('Não foi possível obter dados do vídeo. Verifique o link.', type='negative')
self.lbl_log.text = "Erro ao buscar metadados."
def start_download(self):
url = self.url_input.value
fmt = self.fmt_select.value
# Reset visual
self.state['progress'] = 0
self.btn_download.disable()
self.btn_check.disable()
self.url_input.disable()
self.btn_reset.visible = False
# Inicia Worker
self.worker = DownloadWorker(url, fmt, self.update_state)
self.worker.start()
ui.notify('Download iniciado!')
def stop_download(self):
data = load_status()
if data:
data['stop_requested'] = True
save_status(data)
ui.notify('Parando...')
if self.worker and self.worker.is_alive():
self.worker.stop_requested = True
self.worker.join(timeout=1.0)
ui.notify('Solicitação de cancelamento enviada.')
def reset_ui(self):
"""Reseta a interface para um novo download"""
self.url_input.value = ''
self.url_input.enable()
self.btn_check.enable()
self.btn_download.disable()
self.preview_card.visible = False
self.status_card.visible = False
self.btn_reset.visible = False
self.lbl_log.text = '---'
self.state = {"running": False, "file": "Aguardando...", "progress": 0, "log": "---", "status": "idle"}
def ui_update_loop(self):
"""Timer que atualiza os elementos visuais com base no self.state"""
# Sincroniza dados da memória com os componentes
self.lbl_file.text = f"Arquivo: {self.state.get('file')}"
self.progress_bar.value = self.state.get('progress', 0) / 100
self.lbl_log.text = self.state.get('log')
status = self.state.get('status')
is_running = self.state.get('running', False)
# Controle de visibilidade do botão Cancelar
if self.btn_stop:
self.btn_stop.visible = is_running
# Tratamento de finalização/erro para mostrar botão de "Novo"
if status in ['success', 'error'] and not is_running:
self.btn_reset.visible = True
if status == 'error':
self.lbl_log.classes('text-red-500', remove='text-gray-500')
else:
self.lbl_log.classes('text-green-600', remove='text-gray-500')
else:
self.lbl_log.classes('text-gray-500', remove='text-red-500 text-green-600')
def render(self):
ui.label('📺 YouTube Downloader').classes('text-xl font-bold mb-2')
ui.label('📺 YouTube Downloader (Docker)').classes('text-xl font-bold mb-2')
# --- INPUT ---
# --- ÁREA DE INPUT ---
with ui.card().classes('w-full p-4 mb-4'):
url_input = ui.input('URL do Vídeo').classes('w-full').props('clearable placeholder="https://youtube.com/..."')
with ui.row().classes('items-center mt-2'):
fmt_select = ui.select(
with ui.row().classes('w-full items-center gap-2'):
self.url_input = ui.input('URL do Vídeo').classes('flex-grow').props('clearable placeholder="https://..."')
self.btn_check = ui.button('Verificar', on_click=self.check_url).props('icon=search color=secondary')
with ui.row().classes('items-center mt-2 gap-4'):
self.fmt_select = ui.select(
{'best': 'Melhor Qualidade (MKV)', '1080p': 'Limitado a 1080p (MKV)', 'audio': 'Apenas Áudio (MP3)'},
value='best', label='Formato'
).classes('w-64')
self.btn_download = ui.button('Baixar', on_click=lambda: self.start_download(url_input.value, fmt_select.value))\
.props('icon=download color=primary')
self.btn_download = ui.button('Baixar Agora', on_click=self.start_download)\
.props('icon=download color=primary').classes('w-40')
self.btn_download.disable() # Começa desabilitado até verificar
# --- MONITORAMENTO ---
# CORREÇÃO AQUI: Criamos o card primeiro, depois definimos visibilidade
self.card_status = ui.card().classes('w-full p-4')
self.card_status.visible = False # Esconde inicialmente
# --- PREVIEW (Melhoria 7) ---
self.preview_card = ui.card().classes('w-full p-2 mb-4 bg-gray-100 flex-row gap-4 items-center')
self.preview_card.visible = False
with self.preview_card:
self.preview_img = ui.image().classes('w-32 h-24 rounded object-cover')
with ui.column():
ui.label('Vídeo Detectado:').classes('text-xs text-gray-600 uppercase font-bold')
self.preview_title = ui.label('').classes('font-bold text-md leading-tight')
# --- STATUS E MONITORAMENTO ---
self.status_card = ui.card().classes('w-full p-4')
self.status_card.visible = False
with self.card_status:
ui.label('Progresso').classes('font-bold')
with self.status_card:
with ui.row().classes('w-full justify-between items-center'):
ui.label('Status do Processo').classes('font-bold')
self.btn_reset = ui.button('Baixar Outro', on_click=self.reset_ui)\
.props('icon=refresh flat color=primary').classes('text-sm')
self.btn_reset.visible = False
self.lbl_file = ui.label('Aguardando...')
self.progress = ui.linear_progress(value=0).classes('w-full')
self.progress_bar = ui.linear_progress(value=0).classes('w-full my-2')
self.lbl_log = ui.label('---').classes('text-sm text-gray-500 font-mono')
with ui.row().classes('w-full justify-end mt-2'):
self.btn_stop = ui.button('🛑 Cancelar', on_click=self.stop_download).props('color=red flat')
self.btn_stop = ui.button('🛑 Cancelar Download', on_click=self.stop_download).props('color=red flat')
self.timer = ui.timer(1.0, self.render_update)
def render_update(self):
data = load_status()
if not data:
if self.card_status: self.card_status.visible = False
if self.btn_download: self.btn_download.enable()
return
# Atualiza UI
is_running = data.get('running', False)
if self.btn_download:
if is_running: self.btn_download.disable()
else: self.btn_download.enable()
if self.card_status: self.card_status.visible = True
if self.lbl_file: self.lbl_file.text = f"Arquivo: {data.get('file', '?')}"
if self.progress: self.progress.value = data.get('progress', 0) / 100
if self.lbl_log: self.lbl_log.text = data.get('log', '')
if self.btn_stop: self.btn_stop.visible = is_running
# Timer para atualizar UI a partir do estado em memória
self.timer = ui.timer(0.5, self.ui_update_loop)
# --- INICIALIZADOR ---
def create_ui():
dl = DownloaderInterface()
dl.container = ui.column().classes('w-full h-full p-4 gap-4')
dl.container = ui.column().classes('w-full h-full p-4 max-w-4xl mx-auto')
with dl.container:
dl.render()

View File

@@ -1,200 +1,374 @@
from nicegui import ui, app
from nicegui import ui
import os
import threading
import time
import subprocess
import json
import re
import math
import shutil
from collections import deque
from datetime import datetime
# ==============================================================================
# --- SEÇÃO 1: CONFIGURAÇÕES GLOBAIS E CONSTANTES ---
# ==============================================================================
ROOT_DIR = "/downloads"
OUTPUT_BASE = "/downloads/finalizados"
STATUS_FILE = "/app/data/status.json"
# --- BACKEND: PREPARAÇÃO DE DRIVERS ---
# Caminhos dos drivers Intel problemáticos (NÃO ALTERAR)
BAD_DRIVERS = [
"/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so",
"/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"
]
# Extensões de legenda que vamos procurar
SUBTITLE_EXTS = ('.srt', '.sub', '.sbv', '.ass', '.vtt', '.ssa')
# VARIÁVEIS DE ESTADO (MEMÓRIA RAM)
CURRENT_STATUS = {
"running": False,
"stop_requested": False,
"file": "",
"pct_file": 0.0,
"pct_total": 0.0,
"current_index": 0,
"total_files": 0,
"log": "Aguardando...",
"speed": "N/A"
}
# Histórico dos últimos 50 processamentos
HISTORY_LOG = deque(maxlen=50)
# ==============================================================================
# --- SEÇÃO 2: UTILITÁRIOS (Backend) ---
# ==============================================================================
def prepare_driver_environment():
"""Configura o ambiente para usar o driver i965 e remove os problemáticos."""
os.environ["LIBVA_DRIVER_NAME"] = "i965"
drivers_ruins = ["/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so", "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"]
for driver in drivers_ruins:
for driver in BAD_DRIVERS:
if os.path.exists(driver):
try: os.remove(driver)
try:
os.remove(driver)
except: pass
# --- BACKEND: UTILS FFMPEG ---
def get_video_duration(filepath):
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filepath]
try: return float(subprocess.check_output(cmd).decode().strip())
except: return None
"""Usa ffprobe para descobrir a duração total do vídeo em segundos."""
cmd = [
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", filepath
]
try:
output = subprocess.check_output(cmd).decode().strip()
return float(output)
except:
return 1.0
def parse_time_to_seconds(time_str):
h, m, s = time_str.split(':')
return int(h) * 3600 + int(m) * 60 + float(s)
def get_streams_map(filepath):
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath]
"""Converte o timecode do FFmpeg (HH:MM:SS.ms) para segundos float."""
try:
res = subprocess.run(cmd, capture_output=True, text=True, env=os.environ)
data = json.loads(res.stdout)
except: return ["-map", "0"]
map_args = ["-map", "0:v"]
audio_found = False
for s in data.get('streams', []):
if s['codec_type'] == 'audio':
lang = s.get('tags', {}).get('language', 'und').lower()
if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']:
map_args.extend(["-map", f"0:{s['index']}"])
audio_found = True
if not audio_found: map_args.extend(["-map", "0:a"])
for s in data.get('streams', []):
if s['codec_type'] == 'subtitle':
lang = s.get('tags', {}).get('language', 'und').lower()
if lang in ['por', 'pt', 'pob', 'pt-br']:
map_args.extend(["-map", f"0:{s['index']}"])
return map_args
parts = time_str.split(':')
h = int(parts[0])
m = int(parts[1])
s = float(parts[2])
return h * 3600 + m * 60 + s
except:
return 0.0
def format_size(size_bytes):
"""Formata bytes para leitura humana (MB, GB)."""
if size_bytes == 0: return "0B"
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 ""
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()
def handle_external_subtitles(src_video_path, dest_folder, delete_original=False):
"""
Procura legendas externas com o mesmo nome base do vídeo e as copia/move.
"""
try:
src_folder = os.path.dirname(src_video_path)
video_name = os.path.basename(src_video_path)
video_stem = os.path.splitext(video_name)[0]
for file in os.listdir(src_folder):
if file.lower().endswith(SUBTITLE_EXTS):
if file.startswith(video_stem):
remaining = file[len(video_stem):]
if not remaining or remaining.startswith('.'):
src_sub = os.path.join(src_folder, file)
dest_sub = os.path.join(dest_folder, file)
shutil.copy2(src_sub, dest_sub)
if delete_original:
os.remove(src_sub)
except Exception as e:
print(f"Erro legendas: {e}")
# ==============================================================================
# --- SEÇÃO 3: LÓGICA DO FFMPEG ---
# ==============================================================================
def build_ffmpeg_command(input_file, output_file):
"""Constrói o comando FFmpeg inteligente."""
cmd_probe = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", input_file]
try:
res = subprocess.run(cmd_probe, capture_output=True, text=True, env=os.environ)
data = json.loads(res.stdout)
except:
return [
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
"-hwaccel_output_format", "vaapi", "-i", input_file, "-map", "0",
"-c:v", "h264_vaapi", "-qp", "25", "-c:a", "copy", "-c:s", "copy", output_file
]
input_streams = data.get('streams', [])
map_args = ["-map", "0:v:0"]
metadata_args = []
found_pt_audio = False
# ÁUDIO
audio_idx = 0
for stream in input_streams:
if stream['codec_type'] == 'audio':
tags = stream.get('tags', {})
lang = tags.get('language', 'und').lower()
title = tags.get('title', '')
if lang in ['por', 'pt', 'pob', 'pt-br', 'eng', 'en', 'jpn', 'ja', 'und']:
map_args.extend(["-map", f"0:{stream['index']}"])
new_title = clean_metadata_title(title)
if not new_title:
if lang in ['por', 'pt', 'pob', 'pt-br']: new_title = "Português"
elif lang in ['eng', 'en']: new_title = "Inglês"
elif lang in ['jpn', 'ja']: new_title = "Japonês"
metadata_args.extend([f"-metadata:s:a:{audio_idx}", f"title={new_title}"])
if lang in ['por', 'pt', 'pob', 'pt-br'] and not found_pt_audio:
metadata_args.extend([f"-disposition:a:{audio_idx}", "default"])
found_pt_audio = True
else:
metadata_args.extend([f"-disposition:a:{audio_idx}", "0"])
audio_idx += 1
if audio_idx == 0: map_args.extend(["-map", "0:a"])
# LEGENDAS INTERNAS
sub_idx = 0
for stream in input_streams:
if stream['codec_type'] == 'subtitle':
tags = stream.get('tags', {})
lang = tags.get('language', 'und').lower()
title = tags.get('title', '')
is_forced = 'forced' in stream.get('disposition', {})
if lang in ['por', 'pt', 'pob', 'pt-br']:
map_args.extend(["-map", f"0:{stream['index']}"])
new_title = clean_metadata_title(title)
metadata_args.extend([f"-metadata:s:s:{sub_idx}", f"title={new_title}"])
if is_forced or "forç" in (title or "").lower():
metadata_args.extend([f"-disposition:s:{sub_idx}", "forced"])
else:
metadata_args.extend([f"-disposition:s:{sub_idx}", "0"])
sub_idx += 1
cmd = [
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
"-hwaccel_output_format", "vaapi", "-i", input_file
]
cmd += map_args
cmd += ["-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0", "-c:a", "copy", "-c:s", "copy"]
cmd += metadata_args
cmd.append(output_file)
return cmd
# ==============================================================================
# --- SEÇÃO 4: WORKER THREAD ---
# ==============================================================================
# --- BACKEND: WORKER THREAD ---
class EncoderWorker(threading.Thread):
def __init__(self, input_folder):
def __init__(self, input_folder, delete_original=False):
super().__init__()
self.input_folder = input_folder
self.delete_original = delete_original
self.daemon = True
def run(self):
global CURRENT_STATUS, HISTORY_LOG
prepare_driver_environment()
files = []
CURRENT_STATUS["running"] = True
CURRENT_STATUS["stop_requested"] = False
CURRENT_STATUS["log"] = "Escaneando arquivos..."
files_to_process = []
for r, d, f in os.walk(self.input_folder):
if "finalizados" in r or "temp" in r: continue
for file in f:
if file.lower().endswith(('.mkv', '.mp4', '.avi')):
files.append(os.path.join(r, file))
files_to_process.append(os.path.join(r, file))
total_files = len(files)
stop_signal = False
CURRENT_STATUS["total_files"] = len(files_to_process)
for i, fpath in enumerate(files):
# Verifica Parada antes de começar o próximo
if os.path.exists(STATUS_FILE):
with open(STATUS_FILE, 'r') as f:
if json.load(f).get('stop_requested'):
stop_signal = True
break
for i, fpath in enumerate(files_to_process):
if CURRENT_STATUS["stop_requested"]: break
fname = os.path.basename(fpath)
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)
# Status Inicial
status = {
"running": True,
"stop_requested": False,
"file": fname,
"pct_file": 0,
"pct_total": int((i / total_files) * 100),
"current_index": i + 1,
"total_files": total_files,
"log": "Iniciando..."
}
with open(STATUS_FILE, 'w') as f: json.dump(status, f)
rel = os.path.relpath(fpath, self.input_folder)
out = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
os.makedirs(os.path.dirname(out), exist_ok=True)
map_args = get_streams_map(fpath)
cmd = [
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
"-hwaccel_output_format", "vaapi", "-i", fpath
]
cmd += map_args
cmd += [
"-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0",
"-c:a", "copy", "-c:s", "copy", out
]
out_file = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
out_file = os.path.splitext(out_file)[0] + ".mkv"
dest_folder = os.path.dirname(out_file)
os.makedirs(dest_folder, exist_ok=True)
size_before = os.path.getsize(fpath)
cmd = build_ffmpeg_command(fpath, out_file)
total_sec = get_video_duration(fpath)
total_sec = get_video_duration(fpath) or 1
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ)
for line in proc.stdout:
# Verifica Parada DURANTE a conversão
if "time=" in line: # Checa a cada atualização de tempo
if os.path.exists(STATUS_FILE):
with open(STATUS_FILE, 'r') as f:
if json.load(f).get('stop_requested'):
proc.terminate() # Mata o FFmpeg
stop_signal = True
break
if CURRENT_STATUS["stop_requested"]:
proc.terminate()
break
if "time=" in line:
match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line)
if match:
sec = parse_time_to_seconds(match.group(1))
pct = min(int((sec/total_sec)*100), 100)
status["pct_file"] = pct
speed = re.search(r"speed=\s*(\S+)", line)
if speed: status["log"] = f"Velocidade: {speed.group(1)}"
with open(STATUS_FILE, 'w') as f: json.dump(status, f)
CURRENT_STATUS["pct_file"] = pct
speed = re.search(r"speed=\s*(\S+)", line)
if speed: CURRENT_STATUS["log"] = f"Vel: {speed.group(1)}"
proc.wait()
if stop_signal:
# Limpa arquivo incompleto se foi cancelado
if os.path.exists(out): os.remove(out)
break
final_status = "Erro"
if CURRENT_STATUS["stop_requested"]:
if os.path.exists(out_file): os.remove(out_file)
final_status = "Cancelado"
elif proc.returncode == 0:
final_status = "✅ Sucesso"
size_after = os.path.getsize(out_file) if os.path.exists(out_file) else 0
diff = size_after - size_before
HISTORY_LOG.appendleft({
"time": datetime.now().strftime("%H:%M:%S"),
"file": fname,
"status": final_status,
"orig_size": format_size(size_before),
"final_size": format_size(size_after),
"diff": ("+" if diff > 0 else "") + format_size(diff)
})
handle_external_subtitles(fpath, dest_folder, self.delete_original)
if self.delete_original:
try:
os.remove(fpath)
CURRENT_STATUS["log"] = "Original excluído."
except: pass
else:
HISTORY_LOG.appendleft({
"time": datetime.now().strftime("%H:%M:%S"),
"file": fname,
"status": "❌ Falha",
"orig_size": format_size(size_before),
"final_size": "-",
"diff": "-"
})
# Status Final
final_msg = "Cancelado pelo usuário 🛑" if stop_signal else "Finalizado"
with open(STATUS_FILE, 'w') as f:
json.dump({"running": False, "file": final_msg, "pct_file": 0 if stop_signal else 100, "pct_total": 100, "log": final_msg}, f)
CURRENT_STATUS["running"] = False
CURRENT_STATUS["log"] = "Parado" if CURRENT_STATUS["stop_requested"] else "Finalizado"
CURRENT_STATUS["pct_file"] = 100
CURRENT_STATUS["pct_total"] = 100
# ==============================================================================
# --- SEÇÃO 5: FRONTEND ---
# ==============================================================================
# --- FRONTEND: UI ---
class EncoderInterface:
def __init__(self):
self.path = ROOT_DIR
self.container = None
self.view_mode = 'explorer'
self.timer = None
self.delete_switch = None
self.main_container = None
if CURRENT_STATUS["running"]:
self.view_mode = 'monitor'
else:
self.view_mode = 'explorer'
self.main_container = ui.column().classes('w-full h-full gap-4')
self.refresh_ui()
def refresh_ui(self):
self.main_container.clear()
with self.main_container:
if self.view_mode == 'explorer':
self.render_breadcrumbs()
self.render_options()
self.render_folder_list()
self.render_history_btn()
elif self.view_mode == 'monitor':
self.render_monitor()
elif self.view_mode == 'history':
self.render_history_table()
def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.path = path
self.refresh()
self.refresh_ui()
else:
ui.notify('Erro ao acessar pasta', type='negative')
def refresh(self):
if self.container:
self.container.clear()
with self.container:
if self.view_mode == 'explorer':
self.render_breadcrumbs()
self.render_folder_list()
else:
self.render_monitor()
def start_encoding(self):
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE)
t = EncoderWorker(self.path)
should_delete = self.delete_switch.value if self.delete_switch else False
CURRENT_STATUS["pct_file"] = 0
CURRENT_STATUS["pct_total"] = 0
t = EncoderWorker(self.path, delete_original=should_delete)
t.start()
ui.notify('Iniciado!', type='positive')
ui.notify('Iniciando Conversão...', type='positive')
self.view_mode = 'monitor'
self.refresh()
self.refresh_ui()
def stop_encoding(self):
# Escreve o sinal de parada no arquivo JSON
if os.path.exists(STATUS_FILE):
try:
with open(STATUS_FILE, 'r+') as f:
data = json.load(f)
data['stop_requested'] = True
f.seek(0)
json.dump(data, f)
f.truncate()
ui.notify('Parando processo... aguarde.', type='warning')
except: pass
def back_to_explorer(self):
self.view_mode = 'explorer'
self.refresh()
CURRENT_STATUS["stop_requested"] = True
ui.notify('Solicitando parada...', type='warning')
def render_breadcrumbs(self):
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'):
@@ -207,8 +381,12 @@ class EncoderInterface:
ui.icon('chevron_right', color='grey')
acc = os.path.join(acc, part)
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary')
ui.space()
ui.button("🚀 Converter Esta Pasta", on_click=self.start_encoding).props('push color=primary')
def render_options(self):
with ui.card().classes('w-full mt-2 p-2 bg-blue-50'):
with ui.row().classes('items-center w-full justify-between'):
self.delete_switch = ui.switch('Excluir original ao finalizar com sucesso?').props('color=red')
ui.button("🚀 Iniciar Conversão", on_click=self.start_encoding).props('push color=primary')
def render_folder_list(self):
try:
@@ -216,72 +394,69 @@ class EncoderInterface:
except: return
with ui.column().classes('w-full gap-1 mt-2'):
if self.path != ROOT_DIR:
with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-blue-50 hover:bg-blue-100 cursor-pointer rounded'):
with ui.item_section().props('avatar'): ui.icon('arrow_upward', color='grey')
with ui.item_section(): ui.item_label('Voltar / Subir Nível')
with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-gray-200 hover:bg-gray-300 cursor-pointer rounded'):
# Sintaxe correta: usando context manager (with)
with ui.item_section().props('avatar'):
ui.icon('arrow_upward', color='grey')
with ui.item_section():
ui.item_label('.. (Subir nível)')
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_section().props('avatar'): ui.icon('folder', color='amber')
with ui.item_section(): ui.item_label(entry.name).classes('font-medium')
with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-blue-50 cursor-pointer rounded border-b border-gray-100'):
with ui.item_section().props('avatar'):
ui.icon('folder', color='amber')
with ui.item_section():
ui.item_label(entry.name).classes('font-medium')
def render_history_btn(self):
ui.separator().classes('mt-4')
ui.button('Ver Histórico (Últimos 50)', on_click=lambda: self.set_view('history')).props('outline w-full')
def set_view(self, mode):
self.view_mode = mode
self.refresh_ui()
def render_history_table(self):
ui.label('Histórico de Conversões').classes('text-xl font-bold mb-4')
columns = [
{'name': 'time', 'label': 'Hora', 'field': 'time', 'align': 'left'},
{'name': 'file', 'label': 'Arquivo', 'field': 'file', 'align': 'left'},
{'name': 'status', 'label': 'Status', 'field': 'status', 'align': 'center'},
{'name': 'orig', 'label': 'Tam. Orig.', 'field': 'orig_size'},
{'name': 'final', 'label': 'Tam. Final', 'field': 'final_size'},
{'name': 'diff', 'label': 'Diferença', 'field': 'diff'},
]
ui.table(columns=columns, rows=list(HISTORY_LOG), row_key='file').classes('w-full')
ui.button('Voltar', on_click=lambda: self.set_view('explorer')).props('outline mt-4')
def render_monitor(self):
ui.label('Monitor de Conversão').classes('text-xl font-bold mb-4')
lbl_file = ui.label('Inicializando...')
progress_file = ui.linear_progress(value=0).classes('w-full')
lbl_status = ui.label('---')
lbl_log = ui.label('---').classes('text-caption text-grey')
ui.separator().classes('my-4')
lbl_total = ui.label('Total: 0/0')
progress_total = ui.linear_progress(value=0).classes('w-full')
# Botões de Controle
row_btns = ui.row().classes('mt-4 gap-2')
# Botão de Parar (Só aparece se estiver rodando)
btn_stop = ui.button('🛑 Parar Processo', on_click=self.stop_encoding).props('color=red')
# Botão Voltar (Só aparece se acabou)
btn_back = ui.button('Voltar para Pastas', on_click=self.back_to_explorer).props('outline')
btn_back.set_visibility(False)
row_btns = ui.row().classes('mt-6 gap-4')
with row_btns:
btn_stop = ui.button('🛑 Parar Tudo', on_click=self.stop_encoding).props('color=red')
btn_back = ui.button('Voltar / Novo', on_click=lambda: self.set_view('explorer')).props('outline')
btn_back.set_visibility(False)
def update_loop():
if not os.path.exists(STATUS_FILE): return
try:
with open(STATUS_FILE, 'r') as f: data = json.load(f)
is_running = data.get('running', False)
lbl_file.text = f"Arquivo: {data.get('file', '?')}"
val_file = data.get('pct_file', 0) / 100
progress_file.value = val_file
lbl_status.text = f"Status: {int(val_file*100)}% | {data.get('log', '')}"
if 'total_files' in data:
curr = data.get('current_index', 0)
tot = data.get('total_files', 0)
lbl_total.text = f"Fila: {curr} de {tot} arquivos"
val_total = data.get('pct_total', 0) / 100
progress_total.value = val_total
# Controle de Visibilidade dos Botões
if is_running:
btn_stop.set_visibility(True)
btn_back.set_visibility(False)
else:
btn_stop.set_visibility(False)
btn_back.set_visibility(True)
except: pass
def update_monitor():
if not CURRENT_STATUS["running"] and CURRENT_STATUS["pct_total"] >= 100:
btn_stop.set_visibility(False)
btn_back.set_visibility(True)
lbl_file.text = "Todos os processos finalizados."
lbl_file.text = f"Arquivo: {CURRENT_STATUS['file']}"
progress_file.value = CURRENT_STATUS['pct_file'] / 100
lbl_log.text = f"{int(CURRENT_STATUS['pct_file'])}% | {CURRENT_STATUS['log']}"
lbl_total.text = f"Fila: {CURRENT_STATUS['current_index']} de {CURRENT_STATUS['total_files']}"
progress_total.value = CURRENT_STATUS['pct_total'] / 100
self.timer = ui.timer(1.0, update_loop)
self.timer = ui.timer(0.5, update_monitor)
def create_ui():
enc = EncoderInterface()
if os.path.exists(STATUS_FILE):
try:
with open(STATUS_FILE, 'r') as f:
if json.load(f).get('running'): enc.view_mode = 'monitor'
except: pass
enc.container = ui.column().classes('w-full h-full p-4 gap-4')
enc.refresh()
return EncoderInterface()

View File

@@ -1,327 +1,514 @@
from nicegui import ui, app
from nicegui import ui, run
import os
import shutil
import asyncio
import datetime
import subprocess
import json
ROOT_DIR = "/downloads"
# --- UTILITÁRIOS ---
def get_human_size(size):
# --- UTILITÁRIOS ASSÍNCRONOS ---
async def get_human_size(size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024: return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TB"
def get_subfolders(root):
folders = [root]
try:
for r, d, f in os.walk(root):
if "finalizados" in r or "temp" in r: continue
for folder in d:
if not folder.startswith('.'): folders.append(os.path.join(r, folder))
except: pass
return sorted(folders)
async def get_media_info_async(filepath):
def _probe():
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return json.loads(res.stdout)
except: return None
# --- LEITOR DE METADADOS (FFPROBE) ---
def get_media_info(filepath):
"""Lê as faixas de áudio e legenda do arquivo"""
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
try:
res = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(res.stdout)
info = {
"duration": float(data['format'].get('duration', 0)),
"bitrate": int(data['format'].get('bit_rate', 0)),
"video": [],
"audio": [],
"subtitle": []
}
data = await run.io_bound(_probe)
if not data: return None
info = {
"filename": os.path.basename(filepath),
"size": int(data['format'].get('size', 0)),
"duration": float(data['format'].get('duration', 0)),
"bitrate": int(data['format'].get('bit_rate', 0)),
"video": [], "audio": [], "subtitle": []
}
for s in data.get('streams', []):
type = s['codec_type']
lang = s.get('tags', {}).get('language', 'und')
title = s.get('tags', {}).get('title', '')
codec = s.get('codec_name', 'unknown')
for s in data.get('streams', []):
stype = s['codec_type']
lang = s.get('tags', {}).get('language', 'und').upper()
codec = s.get('codec_name', 'unknown').upper()
if stype == 'video':
info['video'].append({"codec": codec, "res": f"{s.get('width','?')}x{s.get('height','?')}", "fps": s.get('r_frame_rate', '')})
elif stype == 'audio':
info['audio'].append({"lang": lang, "codec": codec, "ch": s.get('channels', 0), "title": s.get('tags', {}).get('title', '')})
elif stype == 'subtitle':
info['subtitle'].append({"lang": lang, "codec": codec})
desc = f"[{lang.upper()}] {codec}"
if title: desc += f" - {title}"
if type == 'video':
w = s.get('width', 0)
h = s.get('height', 0)
info['video'].append(f"{codec.upper()} ({w}x{h})")
elif type == 'audio':
ch = s.get('channels', 0)
info['audio'].append(f"{desc} ({ch}ch)")
elif type == 'subtitle':
info['subtitle'].append(desc)
return info
except:
return None
return info
# --- CLASSE GERENCIADORA ---
class FileManager:
def __init__(self):
self.path = ROOT_DIR
self.view_mode = 'grid'
self.container = None
self.is_selecting = False
self.selected_items = set()
self.search_term = ""
self.refreshing = False
def navigate(self, path):
# Elementos UI
self.container_content = None
self.footer = None
self.lbl_selection_count = None
self.btn_select_mode = None
self.header_row = None # Para atualizar breadcrumbs
# --- NAVEGAÇÃO ---
async def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.path = path
self.refresh()
self.selected_items.clear()
self.is_selecting = False
self.search_term = ""
self.update_footer_state()
# Atualiza o header completamente para refazer breadcrumbs
if self.header_row:
self.header_row.clear()
with self.header_row:
self.build_header_content()
await self.refresh()
else:
ui.notify('Caminho inválido', type='negative')
ui.notify('Caminho inválido.', type='negative')
def navigate_up(self):
async def navigate_up(self):
parent = os.path.dirname(self.path)
if self.path != ROOT_DIR: await self.navigate(parent)
# --- UPLOAD CORRIGIDO FINAL (NOME E CONTEÚDO) ---
def open_upload_dialog(self):
with ui.dialog() as dialog, ui.card():
ui.label(f'Upload para: {os.path.basename(self.path)}').classes('font-bold')
async def handle(e):
try:
# 1. Recuperação do Nome (Prioridade para .filename do Starlette)
name = None
# Verifica se existe o objeto interno 'file' (detectado nos logs anteriores)
if hasattr(e, 'file'):
# .filename é onde fica o nome original do arquivo no upload web
name = getattr(e.file, 'filename', None)
# Se não achar, tenta .name
if not name:
name = getattr(e.file, 'name', None)
# Se ainda não achou, tenta direto no evento (fallback)
if not name:
name = getattr(e, 'name', 'arquivo_sem_nome')
# 2. Leitura do Conteúdo (Assíncrona)
content = b''
if hasattr(e, 'file'):
# Tenta resetar o ponteiro de leitura para o início
if hasattr(e.file, 'seek'):
await e.file.seek(0)
# Lê os bytes (await é necessário aqui)
if hasattr(e.file, 'read'):
content = await e.file.read()
if not content:
ui.notify('Erro: Arquivo vazio ou ilegível', type='warning')
return
# 3. Salva no disco
target = os.path.join(self.path, name)
# Executa a gravação em thread separada para não travar o servidor
await run.io_bound(self._save_file_bytes, target, content)
ui.notify(f'Sucesso: {name}', type='positive')
except Exception as ex:
# Imprime no log do container para diagnóstico se falhar
print(f"ERRO CRITICO UPLOAD: {ex}")
ui.notify(f'Erro: {ex}', type='negative')
# Componente UI
ui.upload(on_upload=handle, auto_upload=True, multiple=True).props('accept=*').classes('w-full')
async def close_and_refresh():
dialog.close()
await self.refresh()
ui.button('Fechar e Atualizar', on_click=close_and_refresh).props('flat w-full')
dialog.open()
# Função auxiliar simples para salvar bytes
def _save_file_bytes(self, target, content_bytes):
with open(target, 'wb') as f:
f.write(content_bytes)
# --- LÓGICA DE SELEÇÃO ---
def toggle_select_mode(self):
self.is_selecting = not self.is_selecting
if not self.is_selecting:
self.selected_items.clear()
self.update_footer_state()
ui.timer(0, self.refresh, once=True)
def toggle_selection(self, item_path):
if item_path in self.selected_items:
self.selected_items.remove(item_path)
else:
self.selected_items.add(item_path)
self.update_footer_state()
ui.timer(0, self.refresh, once=True)
def select_all(self, entries):
current = {e.path for e in entries}
if self.selected_items.issuperset(current):
self.selected_items.difference_update(current)
else:
self.selected_items.update(current)
self.update_footer_state()
ui.timer(0, self.refresh, once=True)
def update_footer_state(self):
if self.footer:
self.footer.set_visibility(self.is_selecting)
if self.lbl_selection_count:
self.lbl_selection_count.text = f'{len(self.selected_items)} item(s) selecionados'
# --- AÇÕES EM LOTE ---
async def delete_selected(self):
if not self.selected_items: return
with ui.dialog() as dialog, ui.card():
ui.label(f'Excluir {len(self.selected_items)} itens?').classes('font-bold text-red')
async def confirm():
dialog.close()
count = 0
items_copy = list(self.selected_items)
for item in items_copy:
try:
if os.path.isdir(item): await run.io_bound(shutil.rmtree, item)
else: await run.io_bound(os.remove, item)
count += 1
except: pass
ui.notify(f'{count} excluídos.', type='positive')
self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-end'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Confirmar', on_click=confirm).props('color=red')
dialog.open()
async def open_move_dialog(self, target_items=None):
items_to_move = target_items if target_items else list(self.selected_items)
if not items_to_move:
ui.notify('Nada para mover.', type='warning')
return
browser_path = ROOT_DIR
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col p-4'):
ui.label(f'Mover {len(items_to_move)} item(s) para...').classes('font-bold')
lbl_path = ui.label(ROOT_DIR).classes('text-xs bg-gray-100 p-2 w-full truncate border rounded')
scroll = ui.scroll_area().classes('flex-grow border rounded p-1 bg-white')
async def load_folders(p):
nonlocal browser_path
browser_path = p
lbl_path.text = p
scroll.clear()
try:
entries = await run.io_bound(os.scandir, p)
sorted_e = sorted([e for e in entries if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower())
with scroll:
if p != ROOT_DIR:
ui.button('.. (Voltar)', on_click=lambda: load_folders(os.path.dirname(p))).props('flat dense icon=arrow_upward w-full align=left')
for e in sorted_e:
ui.button(e.name, on_click=lambda path=e.path: load_folders(path)).props('flat dense w-full align=left icon=folder color=amber')
except: pass
ui.timer(0, lambda: load_folders(ROOT_DIR), once=True)
async def execute_move():
dialog.close()
ui.notify('Movendo...', type='info')
count = 0
for item in items_to_move:
try:
tgt = os.path.join(browser_path, os.path.basename(item))
if item != tgt:
await run.io_bound(shutil.move, item, tgt)
count += 1
except Exception as e: ui.notify(f"Erro: {e}", type='negative')
ui.notify(f'{count} movidos!', type='positive')
if not target_items:
self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-between mt-auto'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Mover Aqui', on_click=execute_move).props('color=green icon=drive_file_move')
dialog.open()
# --- INTERFACE PRINCIPAL ---
def create_layout(self):
# 1. Header (Fixo)
self.header_row = ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2 sticky top-0 z-20 shadow-sm')
with self.header_row:
self.build_header_content()
# 2. Área de Conteúdo
self.container_content = ui.column().classes('w-full gap-4 pb-24')
# 3. Footer (SIMULADO com ROW FIXED)
with ui.row().classes('fixed bottom-0 left-0 w-full z-50 bg-white border-t p-2 justify-center gap-4 shadow-[0_-4px_10px_rgba(0,0,0,0.1)]') as f:
self.footer = f
self.lbl_selection_count = ui.label('0 selecionados').classes('font-bold self-center')
ui.button('Mover Seleção', on_click=lambda: self.open_move_dialog(None)).props('color=amber icon=drive_file_move dense')
ui.button('Excluir Seleção', on_click=self.delete_selected).props('color=red icon=delete dense')
ui.button(icon='close', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Sair da Seleção')
self.footer.visible = False
def build_header_content(self):
# Botão Subir Nível
if self.path != ROOT_DIR:
self.navigate(parent)
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir Nível')
else:
ui.button(icon='home').props('flat round dense disabled text-color=grey')
# BREADCRUMBS (Barra de Endereço Restaurada)
rel = os.path.relpath(self.path, ROOT_DIR)
parts = rel.split(os.sep) if rel != '.' else []
with ui.row().classes('items-center gap-0 overflow-hidden flex-grow'):
ui.button('root', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-1 text-xs')
acc = ROOT_DIR
for part in parts:
acc = os.path.join(acc, part)
ui.label('/').classes('text-gray-400')
# Precisamos capturar o valor de acc no lambda
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-1 text-xs')
# Botões de Ação
self.btn_select_mode = ui.button(icon='check_box', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Modo Seleção')
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense').tooltip('Nova Pasta')
ui.button(icon='cloud_upload', on_click=self.open_upload_dialog).props('flat round dense').tooltip('Upload')
ui.button(icon='view_list', on_click=self.toggle_view).props('flat round dense')
ui.button(icon='refresh', on_click=self.refresh).props('flat round dense')
def toggle_view(self):
self.view_mode = 'list' if self.view_mode == 'grid' else 'grid'
self.refresh()
def refresh(self):
if self.container:
self.container.clear()
with self.container:
self.render_header()
self.render_content()
# --- PLAYER DE VÍDEO ---
def open_player(self, path):
filename = os.path.basename(path)
# Converte caminho local (/downloads/pasta/video.mkv) para URL (/files/pasta/video.mkv)
# O prefixo /files foi configurado no main.py
rel_path = os.path.relpath(path, ROOT_DIR)
video_url = f"/files/{rel_path}"
# Pega dados técnicos
info = get_media_info(path)
with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl h-[80vh] p-0 gap-0'):
# Header
with ui.row().classes('w-full bg-gray-100 p-2 justify-between items-center'):
ui.label(filename).classes('font-bold text-lg truncate')
ui.button(icon='close', on_click=dialog.close).props('flat round dense')
with ui.row().classes('w-full h-full'):
# Coluna Esquerda: Player
with ui.column().classes('w-2/3 h-full bg-black justify-center'):
# Player HTML5 Nativo
ui.video(video_url).classes('w-full max-h-full')
ui.label('Nota: Áudios AC3/DTS podem ficar mudos no navegador.').classes('text-gray-500 text-xs text-center w-full')
# Coluna Direita: Informações
with ui.column().classes('w-1/3 h-full p-4 overflow-y-auto bg-white border-l'):
ui.label('📋 Detalhes do Arquivo').classes('text-lg font-bold mb-4 text-blue-600')
if info:
# Vídeo
ui.label('Vídeo').classes('font-bold text-xs text-gray-500 uppercase')
for v in info['video']:
ui.label(f"📺 {v}").classes('ml-2 text-sm')
ui.separator().classes('my-2')
# Áudio
ui.label('Áudio').classes('font-bold text-xs text-gray-500 uppercase')
if info['audio']:
for a in info['audio']:
ui.label(f"🔊 {a}").classes('ml-2 text-sm')
else:
ui.label("Sem áudio").classes('ml-2 text-sm text-gray-400')
ui.separator().classes('my-2')
# Legenda
ui.label('Legendas').classes('font-bold text-xs text-gray-500 uppercase')
if info['subtitle']:
for s in info['subtitle']:
ui.label(f"💬 {s}").classes('ml-2 text-sm')
else:
ui.label("Sem legendas").classes('ml-2 text-sm text-gray-400')
else:
ui.label('Não foi possível ler os metadados.').classes('text-red')
dialog.open()
# --- DIÁLOGOS DE AÇÃO ---
def open_delete_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Excluir item?').classes('font-bold')
ui.label(os.path.basename(path))
with ui.row().classes('w-full justify-end'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
def confirm():
try:
if os.path.isdir(path): shutil.rmtree(path)
else: os.remove(path)
dialog.close()
self.refresh()
ui.notify('Excluído!')
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Excluir', on_click=confirm).props('color=red')
dialog.open()
def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Renomear')
name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full')
def save():
try:
os.rename(path, os.path.join(os.path.dirname(path), name.value))
dialog.close()
self.refresh()
ui.notify('Renomeado!')
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Salvar', on_click=save)
dialog.open()
def open_move_dialog(self, path):
folders = get_subfolders(ROOT_DIR)
if os.path.isdir(path) and path in folders: folders.remove(path)
opts = {f: f.replace(ROOT_DIR, "Raiz") if f != ROOT_DIR else "Raiz" for f in folders}
with ui.dialog() as dialog, ui.card().classes('w-96'):
ui.label('Mover Para')
target = ui.select(opts, value=ROOT_DIR, with_input=True).classes('w-full')
def confirm():
try:
shutil.move(path, target.value)
dialog.close()
self.refresh()
ui.notify('Movido!')
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Mover', on_click=confirm)
dialog.open()
ui.timer(0, self.refresh, once=True)
def open_create_folder(self):
with ui.dialog() as dialog, ui.card():
ui.label('Nova Pasta')
name = ui.input('Nome')
def create():
async def create():
try:
os.makedirs(os.path.join(self.path, name.value))
await run.io_bound(os.makedirs, os.path.join(self.path, name.value))
dialog.close()
self.refresh()
await self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Criar', on_click=create)
dialog.open()
def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Renomear')
name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full')
async def save():
try:
new_path = os.path.join(os.path.dirname(path), name.value)
await run.io_bound(os.rename, path, new_path)
dialog.close()
await self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Salvar', on_click=save)
dialog.open()
# --- MENU DE CONTEXTO ---
# --- RENDERIZAÇÃO DE CONTEÚDO ---
async def refresh(self):
if self.refreshing: return
self.refreshing = True
color = 'green' if self.is_selecting else 'grey'
if self.btn_select_mode:
self.btn_select_mode.props(f'text-color={color}')
if self.container_content:
self.container_content.clear()
try:
entries = await run.io_bound(os.scandir, self.path)
entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
if self.is_selecting:
with self.container_content:
with ui.row().classes('w-full px-2 py-1 bg-green-50 items-center justify-between text-xs text-green-800 rounded border border-green-200'):
ui.checkbox(f'Selecionar Todos ({len(entries)})', on_change=lambda: self.select_all(entries)).props('dense size=xs')
with self.container_content:
if not entries:
ui.label('Pasta Vazia').classes('w-full text-center text-gray-400 mt-10')
elif self.view_mode == 'grid':
with ui.grid().classes('w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3'):
for entry in entries:
self.render_card(entry)
else:
with ui.column().classes('w-full gap-0'):
for entry in entries:
self.render_list_item(entry)
except Exception as e:
with self.container_content:
ui.label(f'Erro ao ler pasta: {e}').classes('text-red')
self.refreshing = False
def render_card(self, entry):
is_selected = entry.path in self.selected_items
is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description'
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie'
color = 'amber-8' if is_dir else 'blue-grey'
if icon == 'movie': color = 'purple-6'
bg_cls = 'bg-green-100 ring-2 ring-green-500' if is_selected else 'bg-white hover:shadow-md'
if self.is_selecting and not is_selected: bg_cls += ' border-dashed border-2 border-gray-300'
with ui.card().classes(f'w-full aspect-square p-2 items-center justify-center relative group cursor-pointer select-none {bg_cls}') as card:
self.bind_context_menu(card, entry)
if self.is_selecting:
card.on('click', lambda: self.toggle_selection(entry.path))
with ui.column().classes('absolute top-1 left-1 z-10'):
ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense').on('click', lambda e: e.stop_propagation())
else:
if is_dir: card.on('click', lambda e, p=entry.path: self.navigate(p))
elif icon == 'movie': card.on('click', lambda e, p=entry.path: self.open_inspector(p))
ui.icon(icon, size='3rem', color=color)
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all mt-2')
def render_list_item(self, entry):
is_selected = entry.path in self.selected_items
bg_cls = 'bg-green-100' if is_selected else 'hover:bg-gray-50'
icon = 'folder' if entry.is_dir() else 'description'
with ui.row().classes(f'w-full items-center px-2 py-2 border-b cursor-pointer group {bg_cls}') as row:
if self.is_selecting:
ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense')
row.on('click', lambda: self.toggle_selection(entry.path))
else:
if entry.is_dir(): row.on('click', lambda p=entry.path: self.navigate(p))
self.bind_context_menu(row, entry)
ui.icon(icon, color='amber' if entry.is_dir() else 'grey')
ui.label(entry.name).classes('text-sm font-medium flex-grow')
# --- MENU DE CONTEXTO (CORRIGIDO) ---
def bind_context_menu(self, element, entry):
with ui.menu() as m:
if not entry.is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')):
ui.menu_item('▶️ Reproduzir / Detalhes', on_click=lambda: self.open_player(entry.path))
ui.separator()
# Opções de mídia apenas para vídeos
if not entry.is_dir() and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')):
ui.menu_item('Media Info', on_click=lambda: self.open_inspector(entry.path))
ui.menu_item('Renomear', on_click=lambda: self.open_rename_dialog(entry.path))
ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog(entry.path))
ui.separator()
ui.menu_item('Excluir', on_click=lambda: self.open_delete_dialog(entry.path)).props('text-color=red')
ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog([entry.path]))
async def delete_single():
try:
ui.notify(f'Excluindo {entry.name}...')
# CORREÇÃO AQUI: Adicionados parênteses () em is_dir()
# Sem eles, o Python acha que sempre é True (porque o método existe)
if entry.is_dir():
await run.io_bound(shutil.rmtree, entry.path)
else:
await run.io_bound(os.remove, entry.path)
await self.refresh()
ui.notify('Item excluído.', type='positive')
except Exception as e:
ui.notify(f"Erro ao excluir: {e}", type='negative')
print(f"DEBUG DELETE ERROR: {e}")
ui.menu_item('Excluir', on_click=delete_single).props('text-color=red')
element.on('contextmenu.prevent', lambda: m.open())
# --- RENDERIZADORES ---
def render_header(self):
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2'):
if self.path != ROOT_DIR:
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir')
else:
ui.button(icon='home').props('flat round dense disabled text-color=grey')
rel = os.path.relpath(self.path, ROOT_DIR)
parts = rel.split(os.sep) if rel != '.' else []
with ui.row().classes('items-center gap-0'):
ui.button('/', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-2')
acc = ROOT_DIR
for part in parts:
acc = os.path.join(acc, part)
ui.label('/')
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-2')
# --- INSPECTOR (RESTAURADO E RICO) ---
async def open_inspector(self, path):
dialog = ui.dialog()
with dialog, ui.card().classes('w-full max-w-3xl'):
with ui.row().classes('w-full justify-between items-start'):
ui.label(os.path.basename(path)).classes('text-lg font-bold break-all w-10/12 text-blue-800')
ui.button(icon='close', on_click=dialog.close).props('flat dense round')
ui.space()
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense')
ui.button(icon='view_list' if self.view_mode == 'grid' else 'grid_view', on_click=self.toggle_view).props('flat round dense')
ui.button(icon='refresh', on_click=self.refresh).props('flat round dense')
content = ui.column().classes('w-full')
with content: ui.spinner('dots').classes('self-center')
dialog.open()
info = await get_media_info_async(path)
content.clear()
if not info:
with content: ui.label('Não foi possível ler os metadados.').classes('text-red font-bold')
return
def render_content(self):
try:
entries = sorted(os.scandir(self.path), key=lambda e: (not e.is_dir(), e.name.lower()))
except: return
with content:
# 1. Resumo Geral (Stats)
with ui.row().classes('w-full bg-blue-50 p-2 rounded mb-4 gap-4'):
ui.label(f"⏱️ {datetime.timedelta(seconds=int(info['duration']))}").tooltip('Duração')
ui.label(f"📦 {await get_human_size(info['size'])}").tooltip('Tamanho')
ui.label(f"🚀 {int(info['bitrate']/1000)} kbps").tooltip('Bitrate Total')
if not entries:
ui.label('Pasta vazia').classes('w-full text-center text-gray-400 mt-10')
return
# 2. Vídeo
ui.label('Vídeo').classes('text-xs font-bold text-gray-500 uppercase mt-2')
for v in info['video']:
with ui.card().classes('w-full p-2 bg-gray-50 border-l-4 border-blue-500'):
ui.label(f"{v['codec']}{v['res']}{v['fps']} fps").classes('font-bold')
if self.view_mode == 'grid':
with ui.grid().classes('w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3'):
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', '.avi')): icon = 'movie'
color = 'amber-8' if is_dir else 'blue-grey'
if icon == 'movie': color = 'purple-6'
# 3. Áudio
ui.label('Áudio').classes('text-xs font-bold text-gray-500 uppercase mt-4')
if info['audio']:
with ui.grid().classes('grid-cols-[auto_1fr_auto] w-full gap-2 text-sm'):
for a in info['audio']:
ui.label(a['lang']).classes('font-bold bg-gray-200 px-2 rounded text-center')
ui.label(f"{a['codec']} - {a['title']}")
ui.label(a['ch']).classes('text-gray-500')
else:
ui.label('Sem faixas de áudio').classes('italic text-gray-400')
with ui.card().classes('w-full aspect-square p-2 items-center justify-center relative group hover:shadow-md cursor-pointer select-none') as card:
if is_dir:
card.on('click', lambda p=entry.path: self.navigate(p))
elif icon == 'movie':
# Duplo clique no vídeo abre o player
card.on('dblclick', lambda p=entry.path: self.open_player(p))
self.bind_context_menu(card, entry)
ui.icon(icon, size='3rem', color=color)
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all')
if not is_dir: ui.label(get_human_size(entry.stat().st_size)).classes('text-[10px] text-gray-400')
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90'):
with ui.menu():
if icon == 'movie':
ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p))
ui.separator()
ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p))
ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p))
ui.separator()
ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p)).props('text-color=red')
else:
with ui.column().classes('w-full gap-0'):
for entry in entries:
is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description'
color = 'amber-8' if is_dir else 'blue-grey'
with ui.row().classes('w-full items-center px-2 py-2 border-b hover:bg-blue-50 cursor-pointer group') as row:
if is_dir: row.on('click', lambda p=entry.path: self.navigate(p))
elif entry.name.lower().endswith(('.mkv', '.mp4')):
row.on('dblclick', lambda p=entry.path: self.open_player(p))
self.bind_context_menu(row, entry)
ui.icon(icon, color=color).classes('mr-2')
with ui.column().classes('flex-grow gap-0'):
ui.label(entry.name).classes('text-sm font-medium break-all')
if not is_dir:
ui.label(get_human_size(entry.stat().st_size)).classes('text-xs text-gray-500 mr-4')
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('sm:opacity-0 group-hover:opacity-100'):
with ui.menu():
if not is_dir:
ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p))
ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p))
ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p))
ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p))
# 4. Legendas
ui.label('Legendas').classes('text-xs font-bold text-gray-500 uppercase mt-4')
if info['subtitle']:
with ui.row().classes('w-full gap-2'):
for s in info['subtitle']:
color = 'green' if s['codec'] in ['subrip', 'ass'] else 'grey'
ui.chip(f"{s['lang']} ({s['codec']})", color=color).props('dense icon=subtitles')
else:
ui.label('Sem legendas internas').classes('italic text-gray-400')
# --- INICIALIZADOR ---
def create_ui():
fm = FileManager()
fm.container = ui.column().classes('w-full h-full p-2 md:p-4 gap-4')
fm.refresh()
fm.create_layout()
ui.timer(0, fm.refresh, once=True)

View File

@@ -1,181 +1,502 @@
from nicegui import ui
import os
import re
import shutil
import json
import asyncio
from pathlib import Path
from nicegui import ui
import tmdbsimple as tmdb
from guessit import guessit
# ==============================================================================
# 1. CONFIGURAÇÕES E CONSTANTES
# ==============================================================================
ROOT_DIR = "/downloads"
DEST_DIR = os.path.join(ROOT_DIR, "preparados") # Nova raiz de destino
CONFIG_FILE = 'config.json'
# --- UTILITÁRIOS ---
def extract_season_episode(filename):
"""Detecta Temporada e Episódio usando vários padrões"""
patterns = [
r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})',
r'(?i)S(\d{1,4})[\s._-]*EP(\d{1,4})',
r'(?i)(\d{1,4})x(\d{1,4})',
r'(?i)Season[\s._-]*(\d{1,4})[\s._-]*Episode[\s._-]*(\d{1,4})',
r'(?i)S(\d{1,4})[\s._-]*-\s*(\d{1,4})',
r'(?i)\[(\d{1,4})x(\d{1,4})\]',
]
for pattern in patterns:
match = re.search(pattern, filename)
if match: return match.group(1), match.group(2)
return None, None
# ID do Gênero Animação no TMDb
GENRE_ANIMATION = 16
class RenamerManager:
def __init__(self):
self.path = ROOT_DIR
self.container = None
self.preview_data = []
self.view_mode = 'explorer'
# Extensões para proteger pastas de exclusão
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm', '.m4v'}
# Extensões de legenda para mover junto
SUBTITLE_EXTENSIONS = {'.srt', '.sub', '.ass', '.vtt', '.idx'}
def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.path = path
self.refresh()
else:
ui.notify('Erro ao acessar pasta', type='negative')
def refresh(self):
if self.container:
self.container.clear()
with self.container:
if self.view_mode == 'explorer':
self.render_breadcrumbs()
self.render_folder_list()
else:
self.render_preview()
def analyze_folder(self):
self.preview_data = []
for root, dirs, files in os.walk(self.path):
if "finalizados" in root: continue
for file in files:
if file.lower().endswith(('.mkv', '.mp4', '.avi')):
season, episode = extract_season_episode(file)
if season and episode:
try:
s_fmt = f"{int(season):02d}"
e_fmt = f"{int(episode):02d}"
ext = os.path.splitext(file)[1]
# Estrutura: Temporada XX / Episódio YY.mkv
new_struct = f"Temporada {s_fmt}/Episódio {e_fmt}{ext}"
src = os.path.join(root, file)
dst = os.path.join(self.path, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}")
if src != dst:
self.preview_data.append({
'original': file,
'new': new_struct,
'src': src,
'dst': dst
})
except: pass
if not self.preview_data:
ui.notify('Nenhum padrão encontrado.', type='warning')
else:
self.view_mode = 'preview'
self.refresh()
def execute_rename(self):
count = 0
for item in self.preview_data:
try:
os.makedirs(os.path.dirname(item['dst']), exist_ok=True)
if not os.path.exists(item['dst']):
shutil.move(item['src'], item['dst'])
count += 1
except: pass
ui.notify(f'{count} Arquivos Organizados!', type='positive')
self.view_mode = 'explorer'
self.preview_data = []
self.refresh()
def cancel(self):
self.view_mode = 'explorer'
self.preview_data = []
self.refresh()
# --- RENDERIZADOR: BARRA DE NAVEGAÇÃO (CADEIA) ---
def render_breadcrumbs(self):
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'):
# Botão Raiz
ui.button('🏠', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense text-color=grey-8')
# Divide o caminho atual para criar os botões
if self.path != ROOT_DIR:
rel = os.path.relpath(self.path, ROOT_DIR)
parts = rel.split(os.sep)
acc = ROOT_DIR
for part in parts:
ui.icon('chevron_right', color='grey')
acc = os.path.join(acc, part)
# Botão da Pasta
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary')
ui.space()
# Botão de Ação Principal
ui.button("🔍 Analisar Pasta Atual", on_click=self.analyze_folder).props('push color=primary')
# --- RENDERIZADOR: LISTA DE PASTAS ---
def render_folder_list(self):
# ==============================================================================
# 2. PERSISTÊNCIA (Salvar API Key)
# ==============================================================================
def load_config():
if os.path.exists(CONFIG_FILE):
try:
# Lista apenas diretórios, ignora arquivos
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:
ui.label("Erro ao ler pasta").classes('text-red')
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
except: pass
return {}
def save_config(api_key):
with open(CONFIG_FILE, 'w') as f:
json.dump({'tmdb_api_key': api_key}, f)
# ==============================================================================
# 3. LÓGICA DE ORGANIZAÇÃO (CORE)
# ==============================================================================
class MediaOrganizer:
def __init__(self):
config = load_config()
self.api_key = config.get('tmdb_api_key', '')
self.path = ROOT_DIR
# Estado
self.preview_data = [] # Lista de arquivos para processar
self.folders_to_clean = set()
# Configura TMDb
if self.api_key:
tmdb.API_KEY = self.api_key
def set_api_key(self, key):
self.api_key = key.strip()
tmdb.API_KEY = self.api_key
save_config(self.api_key)
ui.notify('API Key salva com sucesso!', type='positive')
async def search_tmdb(self, title, year, media_type):
"""Consulta o TMDb e retorna candidatos com GÊNEROS."""
if not self.api_key: return []
search = tmdb.Search()
try:
loop = asyncio.get_event_loop()
if media_type == 'movie':
# Busca Filmes
res = await loop.run_in_executor(None, lambda: search.movie(query=title, year=year, language='pt-BR'))
else:
# Busca Séries
res = await loop.run_in_executor(None, lambda: search.tv(query=title, first_air_date_year=year, language='pt-BR'))
return res.get('results', [])
except Exception as e:
print(f"Erro TMDb: {e}")
return []
def detect_category(self, match_data, media_type):
"""
Define a categoria baseada nos metadados do TMDb.
Retorna: 'Filmes', 'Séries', 'Animes' ou 'Desenhos'
"""
if not match_data:
# Fallback se não tiver match
return 'Filmes' if media_type == 'movie' else 'Séries'
genre_ids = match_data.get('genre_ids', [])
# É ANIMAÇÃO? (ID 16)
if GENRE_ANIMATION in genre_ids:
# Verifica origem para diferenciar Anime de Desenho
original_lang = match_data.get('original_language', '')
origin_countries = match_data.get('origin_country', []) # Lista (comum em TV)
is_asian_lang = original_lang in ['ja', 'ko']
is_asian_country = any(c in ['JP', 'KR'] for c in origin_countries)
# Se for animação japonesa ou coreana -> Animes
if is_asian_lang or is_asian_country:
return 'Animes'
# Caso contrário (Disney, Pixar, Cartoon Network) -> Desenhos
return 'Desenhos'
# NÃO É ANIMAÇÃO (Live Action)
return 'Filmes' if media_type == 'movie' else 'Séries'
async def analyze_folder(self):
"""Analisa a pasta usando Guessit + TMDb."""
self.preview_data = []
self.folders_to_clean = set()
if not self.api_key:
ui.notify('Por favor, configure a API Key do TMDb primeiro.', type='negative')
return
with ui.column().classes('w-full gap-1 mt-2'):
# Botão para subir nível (se não estiver na raiz)
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_section().props('avatar'):
ui.icon('arrow_upward', color='grey')
with ui.item_section():
ui.item_label('Voltar / Subir Nível')
loading = ui.notification(message='Analisando arquivos (Guessit + TMDb + Categorias)...', spinner=True, timeout=None)
if not entries:
ui.label("Nenhuma subpasta aqui.").classes('text-gray-400 italic ml-4 mt-2')
try:
# Cria a estrutura base de destino
categories = ['Filmes', 'Séries', 'Animes', 'Desenhos']
for cat in categories:
os.makedirs(os.path.join(DEST_DIR, cat), exist_ok=True)
# Lista de Subpastas
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_section().props('avatar'):
ui.icon('folder', color='amber')
with ui.item_section():
ui.item_label(entry.name).classes('font-medium')
for root, dirs, files in os.walk(self.path):
# Ignora a pasta de destino "preparados" para não processar o que já foi feito
if "preparados" in root: continue
# --- RENDERIZADOR: PREVIEW ---
def render_preview(self):
with ui.column().classes('w-full items-center gap-4'):
ui.label(f'Detectados {len(self.preview_data)} arquivos para renomear').classes('text-xl font-bold text-green-700')
with ui.row():
ui.button('Cancelar', on_click=self.cancel).props('outline color=red')
ui.button('Confirmar Tudo', on_click=self.execute_rename).props('push color=green icon=check')
files_in_dir = set(files)
# Tabela Simples
with ui.card().classes('w-full p-0'):
with ui.column().classes('w-full gap-0'):
# Cabeçalho
with ui.row().classes('w-full bg-gray-200 p-2 font-bold'):
ui.label('Original').classes('w-1/2')
ui.label('Novo Caminho').classes('w-1/2')
for file in files:
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in VIDEO_EXTENSIONS: continue
# 1. Análise Local (Guessit)
guess = guessit(file)
title = guess.get('title')
year = guess.get('year')
media_type = guess.get('type')
# Itens
with ui.scroll_area().classes('h-96 w-full'):
for item in self.preview_data:
with ui.row().classes('w-full p-2 border-b border-gray-100 hover:bg-gray-50'):
ui.label(item['original']).classes('w-1/2 text-sm truncate')
ui.label(item['new']).classes('w-1/2 text-sm text-blue-600 font-mono truncate')
if not title: continue
# --- INICIALIZADOR ---
# 2. Consulta TMDb
candidates = await self.search_tmdb(title, year, media_type)
# 3. Lógica de Decisão
match_data = None
status = 'AMBIGUO'
if candidates:
first = candidates[0]
tmdb_date = first.get('release_date') if media_type == 'movie' else first.get('first_air_date')
tmdb_year = int(tmdb_date[:4]) if tmdb_date else None
if year and tmdb_year == year:
match_data = first
status = 'OK'
elif not year:
match_data = first
status = 'CHECK'
else:
match_data = first
status = 'CHECK'
else:
status = 'NAO_ENCONTRADO'
item = {
'id': len(self.preview_data),
'original_file': file,
'original_root': root,
'guess_title': title,
'guess_year': year,
'type': media_type,
'candidates': candidates,
'selected_match': match_data,
'status': status,
'target_path': None,
'category': None, # Nova propriedade
'subtitles': []
}
if match_data:
self.calculate_path(item)
# Procura Legendas
video_stem = Path(file).stem
for f in files_in_dir:
if f == file: continue
if os.path.splitext(f)[1].lower() in SUBTITLE_EXTENSIONS:
if f.startswith(video_stem):
suffix = f[len(video_stem):]
item['subtitles'].append({
'original': f,
'suffix': suffix,
'src': os.path.join(root, f)
})
self.preview_data.append(item)
self.folders_to_clean.add(root)
except Exception as e:
ui.notify(f'Erro fatal: {e}', type='negative')
print(e)
loading.dismiss()
if self.preview_data:
return True
else:
ui.notify('Nenhum vídeo novo encontrado.', type='warning')
return False
def calculate_path(self, item):
"""Gera o caminho baseado na Categoria e no Tipo."""
match = item['selected_match']
if not match:
item['target_path'] = None
return
ext = os.path.splitext(item['original_file'])[1]
# 1. Determinar Categoria (Filmes, Séries, Animes, Desenhos)
category = self.detect_category(match, item['type'])
item['category'] = category # Salva para mostrar na UI se quiser
# Nome base limpo
title_raw = match.get('title') if item['type'] == 'movie' else match.get('name')
title_raw = title_raw or item['guess_title']
final_title = title_raw.replace('/', '-').replace(':', '-').strip()
date = match.get('release_date') if item['type'] == 'movie' else match.get('first_air_date')
year_str = date[:4] if date else '0000'
# 2. Lógica de Pasta baseada na Categoria
base_folder = os.path.join(DEST_DIR, category)
# CASO 1: É FILME (Seja Live Action, Anime Movie ou Desenho Movie)
if item['type'] == 'movie':
new_filename = f"{final_title} ({year_str}){ext}"
if category == 'Filmes':
# Filmes Live Action -> Pasta Própria (opcional, aqui pus arquivo direto na pasta Filmes)
# Se quiser pasta por filme: os.path.join(base_folder, f"{final_title} ({year_str})", new_filename)
item['target_path'] = os.path.join(base_folder, new_filename)
else:
# Animes/Desenhos -> Arquivo solto na raiz da categoria (Misto)
item['target_path'] = os.path.join(base_folder, new_filename)
# CASO 2: É SÉRIE (Seja Live Action, Anime Serie ou Desenho Serie)
else:
# Séries sempre precisam de estrutura de pasta
guess = guessit(item['original_file'])
s = guess.get('season')
e = guess.get('episode')
if not s or not e:
item['status'] = 'ERRO_S_E'
item['target_path'] = None
return
if isinstance(s, list): s = s[0]
if isinstance(e, list): e = e[0]
s_fmt = f"{s:02d}"
e_fmt = f"{e:02d}"
# Caminho: Categoria / Nome da Série / Temporada XX / Episódio.ext
item['target_path'] = os.path.join(
base_folder,
final_title,
f"Temporada {s_fmt}",
f"Episódio {e_fmt}{ext}" # Renomeia o EP para padrão limpo
)
async def execute_move(self):
"""Move os arquivos confirmados."""
moved = 0
n = ui.notification('Organizando biblioteca...', spinner=True, timeout=None)
for item in self.preview_data:
if not item['target_path'] or item['status'] == 'NAO_ENCONTRADO':
continue
try:
# Mover Vídeo
src = os.path.join(item['original_root'], item['original_file'])
dst = item['target_path']
if os.path.exists(dst):
ui.notify(f"Pulei {os.path.basename(dst)} (Já existe)", type='warning')
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(src, dst)
moved += 1
# Mover Legendas
video_dst_stem = os.path.splitext(dst)[0]
for sub in item['subtitles']:
sub_dst = video_dst_stem + sub['suffix']
if not os.path.exists(sub_dst):
shutil.move(sub['src'], sub_dst)
except Exception as e:
ui.notify(f"Erro ao mover {item['original_file']}: {e}", type='negative')
# Limpeza
cleaned = 0
sorted_folders = sorted(list(self.folders_to_clean), key=len, reverse=True)
for folder in sorted_folders:
if not os.path.exists(folder) or folder == ROOT_DIR or "preparados" in folder: continue
try:
remaining = [f for f in os.listdir(folder) if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS]
has_subfolder = any(os.path.isdir(os.path.join(folder, f)) for f in os.listdir(folder))
if not remaining and not has_subfolder:
shutil.rmtree(folder)
cleaned += 1
except: pass
n.dismiss()
ui.notify(f'{moved} arquivos organizados em "preparados". {cleaned} pastas limpas.', type='positive')
return True
# ==============================================================================
# 4. INTERFACE GRÁFICA
# ==============================================================================
def create_ui():
rm = RenamerManager()
rm.container = ui.column().classes('w-full h-full p-4 gap-4')
rm.refresh()
organizer = MediaOrganizer()
with ui.column().classes('w-full h-full p-0 gap-0'):
# Header
with ui.row().classes('w-full bg-indigo-900 text-white items-center p-3 shadow-md'):
ui.icon('smart_display', size='md')
ui.label('Renomeador Inteligente').classes('text-lg font-bold ml-2')
ui.label('(Filmes • Séries • Animes • Desenhos)').classes('text-xs text-gray-300 ml-1 mt-1')
ui.space()
with ui.row().classes('items-center gap-2'):
key_input = ui.input('TMDb API Key', password=True).props('dense dark input-class=text-white outlined').classes('w-64')
key_input.value = organizer.api_key
ui.button(icon='save', on_click=lambda: organizer.set_api_key(key_input.value)).props('flat dense round color=white')
main_content = ui.column().classes('w-full p-4 gap-4')
# Dialogo
resolution_dialog = ui.dialog()
def open_resolution_dialog(item, row_refresh_callback):
with resolution_dialog, ui.card().classes('w-full max-w-4xl'):
ui.label(f"Arquivo: {item['original_file']}").classes('text-lg font-bold')
ui.label(f"Guessit: {item['guess_title']} ({item['guess_year']})").classes('text-gray-500 text-sm')
with ui.grid(columns=4).classes('w-full gap-4 mt-4'):
for cand in item['candidates']:
# Helper para UI
is_movie = (item['type'] == 'movie')
title = cand.get('title') if is_movie else cand.get('name')
date = cand.get('release_date') if is_movie else cand.get('first_air_date')
year = date[:4] if date else '????'
img = cand.get('poster_path')
img_url = f"https://image.tmdb.org/t/p/w200{img}" if img else 'https://via.placeholder.com/200'
# Previsão da categoria deste candidato
preview_cat = organizer.detect_category(cand, item['type'])
with ui.card().classes('cursor-pointer hover:bg-blue-50 p-0 gap-0 border relative').tight():
# Badge de categoria na imagem
ui.label(preview_cat).classes('absolute top-1 right-1 bg-black text-white text-xs px-1 rounded opacity-80')
ui.image(img_url).classes('h-48 w-full object-cover')
with ui.column().classes('p-2 w-full'):
ui.label(title).classes('font-bold text-sm leading-tight text-ellipsis overflow-hidden')
ui.label(year).classes('text-xs text-gray-500')
ui.button('Escolher', on_click=lambda c=cand: select_match(item, c, row_refresh_callback)).props('sm flat w-full')
ui.button('Cancelar', on_click=resolution_dialog.close).props('outline color=red').classes('mt-4 w-full')
resolution_dialog.open()
def select_match(item, match, refresh_cb):
item['selected_match'] = match
item['status'] = 'OK'
organizer.calculate_path(item) # Recalcula caminho e categoria
resolution_dialog.close()
refresh_cb()
# Telas
def render_explorer():
main_content.clear()
organizer.preview_data = []
with main_content:
# Caminho atual
with ui.row().classes('w-full bg-gray-100 p-2 rounded items-center shadow-sm'):
ui.icon('folder', color='grey')
ui.label(organizer.path).classes('font-mono ml-2 mr-auto text-sm md:text-base truncate')
async def run_analysis():
has_data = await organizer.analyze_folder()
if has_data: render_preview()
ui.button('ANALISAR PASTA', on_click=run_analysis).props('push color=indigo icon=search')
# Lista de Arquivos
try:
entries = sorted(list(os.scandir(organizer.path)), key=lambda e: (not e.is_dir(), e.name.lower()))
with ui.list().props('bordered separator dense').classes('w-full bg-white rounded shadow-sm'):
if organizer.path != ROOT_DIR:
ui.item(text='.. (Voltar)', on_click=lambda: navigate(os.path.dirname(organizer.path))).props('clickable icon=arrow_back')
for entry in entries:
if entry.name.startswith('.') or entry.name == "preparados": continue
if entry.is_dir():
with ui.item(on_click=lambda p=entry.path: navigate(p)).props('clickable'):
with ui.item_section().props('avatar'):
ui.icon('folder', color='amber')
ui.item_section(entry.name)
else:
is_vid = os.path.splitext(entry.name)[1].lower() in VIDEO_EXTENSIONS
icon = 'movie' if is_vid else 'insert_drive_file'
color = 'blue' if is_vid else 'grey'
with ui.item():
with ui.item_section().props('avatar'):
ui.icon(icon, color=color)
ui.item_section(entry.name).classes('text-sm')
except Exception as e:
ui.label(f"Erro: {e}").classes('text-red')
def render_preview():
main_content.clear()
with main_content:
with ui.row().classes('w-full items-center justify-between mb-2'):
ui.label('Pré-visualização da Organização').classes('text-xl font-bold')
with ui.row():
ui.button('Voltar', on_click=render_explorer).props('outline color=red dense')
async def run_move():
if await organizer.execute_move():
render_explorer()
ui.button('MOVER ARQUIVOS', on_click=run_move).props('push color=green icon=check dense')
# Tabela Headers
with ui.row().classes('w-full bg-gray-200 p-2 font-bold text-sm rounded hidden md:flex'):
ui.label('Arquivo Original').classes('w-1/3')
ui.label('Categoria / Destino').classes('w-1/3')
ui.label('Ação').classes('w-1/4 text-center')
with ui.scroll_area().classes('h-[600px] w-full border rounded bg-white'):
def render_row(item):
@ui.refreshable
def row_content():
with ui.row().classes('w-full p-2 border-b items-center hover:bg-gray-50 text-sm'):
# Coluna 1: Origem
with ui.column().classes('w-full md:w-1/3'):
ui.label(item['original_file']).classes('truncate font-medium w-full')
ui.label(f"Guessit: {item['type'].upper()}").classes('text-xs text-gray-500')
# Coluna 2: Destino Calculado
with ui.column().classes('w-full md:w-1/3'):
if item['target_path']:
cat = item.get('category', '???')
# Badge da Categoria
colors = {'Animes': 'pink', 'Desenhos': 'orange', 'Filmes': 'blue', 'Séries': 'green'}
cat_color = colors.get(cat, 'grey')
with ui.row().classes('items-center gap-2'):
ui.badge(cat, color=cat_color).props('dense')
rel_path = os.path.relpath(item['target_path'], DEST_DIR)
ui.label(rel_path).classes('font-mono break-all text-xs text-gray-700')
else:
ui.label('--- (Sem destino)').classes('text-gray-400')
# Coluna 3: Status/Ação
with ui.row().classes('w-full md:w-1/4 justify-center items-center gap-2'):
status = item['status']
color = 'green' if status == 'OK' else ('orange' if status == 'CHECK' else 'red')
ui.badge(status, color=color).props('outline')
btn_icon = 'search' if status != 'OK' else 'edit'
ui.button(icon=btn_icon, on_click=lambda: open_resolution_dialog(item, row_content.refresh)).props('flat round dense color=grey')
row_content()
for item in organizer.preview_data:
render_row(item)
def navigate(path):
organizer.path = path
render_explorer()
render_explorer()
if __name__ in {"__main__", "__mp_main__"}:
create_ui()

12
data/automation.json Normal file
View File

@@ -0,0 +1,12 @@
{
"telegram_token": "8543089985:AAFgDMOg9VlikAsPTi35FcF395W2uAtSzuY",
"telegram_chat_id": "8392926121",
"monitor_folder": "/downloads/completos",
"destinations": {
"Filmes": "/media/Jellyfin/onedrive/Jellyfin/Filmes",
"S\u00e9ries": "/media/Jellyfin/onedrive/Jellyfin/Series",
"Animes": "/media/Jellyfin/onedrive/Jellyfin/Animes",
"Desenhos": "/media/Jellyfin/onedrive/Jellyfin/Desenhos"
},
"tmdb_api_key_view": "12856f632876dc743b6f6775f4e5bd7d"
}

67
data/history.log Normal file
View File

@@ -0,0 +1,67 @@
[2026-02-03 11:42:42] ❌ Erro em O Gerente da Noite: Exception in subprocess:
Type: OSError
Message: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin'
Traceback (most recent call last):
File "/usr/lib/python3.10/shutil.py", line 816, in move
os.rename(src, real_dst)
OSError: [Errno 107] Transport endpoint is not connected: '/downloads/finalizados/Séries/O Gerente da Noite' -> '/media/Jellyfin/onedrive/Jellyfin/Series/O Gerente da Noite'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/local/lib/python3.10/dist-packages/nicegui/run.py", line 52, in safe_callback
return callback(*args, **kwargs)
File "/usr/lib/python3.10/shutil.py", line 832, in move
copytree(src, real_dst, copy_function=copy_function,
File "/usr/lib/python3.10/shutil.py", line 559, in copytree
return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
File "/usr/lib/python3.10/shutil.py", line 457, in _copytree
os.makedirs(dst, exist_ok=dirs_exist_ok)
File "/usr/lib/python3.10/os.py", line 215, in makedirs
makedirs(head, exist_ok=exist_ok)
File "/usr/lib/python3.10/os.py", line 215, in makedirs
makedirs(head, exist_ok=exist_ok)
File "/usr/lib/python3.10/os.py", line 225, in makedirs
mkdir(name, mode)
OSError: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin'
[2026-02-03 19:39:33] ✅ Movido: Meu Primeiro Amor de Verão (2024).mkv
[2026-02-03 19:51:51] ✅ Movido: O Gerente da Noite
[2026-02-03 22:51:16] ✅ Movido: Horny Milf Pristine Edge Pleasures Step Son And Step Dad At The Same Time POV Style - UsePOV.jpg
[2026-02-04 20:00:14] ❌ Erro em Se Esse Amor Desaparecesse Hoje (2025).mkv: Exception in subprocess:
Type: OSError
Message: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv'
Traceback (most recent call last):
File "/usr/lib/python3.10/shutil.py", line 816, in move
os.rename(src, real_dst)
OSError: [Errno 107] Transport endpoint is not connected: '/downloads/finalizados/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv' -> '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/local/lib/python3.10/dist-packages/nicegui/run.py", line 52, in safe_callback
return callback(*args, **kwargs)
File "/usr/lib/python3.10/shutil.py", line 836, in move
copy_function(src, real_dst)
File "/usr/lib/python3.10/shutil.py", line 434, in copy2
copyfile(src, dst, follow_symlinks=follow_symlinks)
File "/usr/lib/python3.10/shutil.py", line 256, in copyfile
with open(dst, 'wb') as fdst:
OSError: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv'
[2026-02-04 22:50:37] ✅ Movido: Se Esse Amor Desaparecesse Hoje (2025).mkv
[2026-02-05 13:23:06] ✅ Movido: Episódio 17.mp4
[2026-02-05 13:23:09] ✅ Movido: Episódio 03.mp4
[2026-02-05 13:23:11] ✅ Movido: Episódio 06.mp4
[2026-02-05 13:23:13] ✅ Movido: Episódio 07.mp4
[2026-02-05 13:23:13] ⚠️ Pendência: Episódio 14.mp4
[2026-02-05 13:23:27] 🔄 Substituído: Episódio 14.mp4
[2026-02-06 00:20:02] ✅ Movido: 21 SEXTURY - Hot Babe Veronica Leal Gets The Hottest Ass Creampie EVER After Hard Anal Sex.mp4
[2026-02-06 05:16:26] ✅ Movido: TUSHY Abella Danger and Lena Paul Dominate Her Boyfriend and Get Gaped.mp4
[2026-02-06 05:16:27] ✅ Movido: 21 NATURALS - Hot Redhead Veronica Leal Wants Her Tight Ass Fucked.mp4
[2026-02-08 00:51:31] ⚠️ Pendência: Jujutsu Kaisen
[2026-02-08 00:52:15] 🔄 Substituído: Jujutsu Kaisen
[2026-02-08 20:47:28] ✅ Movido: Step Brother cums too early on sisters Mouth and Swallows Cum.mp4
[2026-02-08 20:47:38] ✅ Movido: Horny Couple gets caught in Public Restroom making out.mp4
[2026-02-08 22:31:46] ✅ Movido: Frieren e a Jornada para o Além
[2026-02-08 22:44:01] ✅ Arquivo movido: Episódio 05.mkv

1
data/presets.json Normal file
View File

@@ -0,0 +1 @@
{"Filmes": {"src": "/downloads/finalizados/Filmes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Filmes"}, "S\u00e9ries": {"src": "/downloads/finalizados/S\u00e9ries", "dst": "/media/Jellyfin/onedrive/Jellyfin/Series"}, "Desenhos": {"src": "/downloads/finalizados/Desenhos", "dst": "/media/Jellyfin/onedrive/Jellyfin/Desenhos"}, "Animes": {"src": "/downloads/finalizados/Animes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Animes"}}

View File

@@ -1 +0,0 @@
{"running": false, "file": "Finalizado \u2705", "pct_file": 100, "pct_total": 100, "log": "Finalizado \u2705"}

View File

@@ -17,7 +17,7 @@ services:
- /home/creidsu/pymediamanager/data:/app/data
- /media/qbit/download:/downloads
- /media:/media/Jellyfin
# - /media/onedrive2/Stash:/media/HD_Externo
# - /home/creidsu/outra_pasta:/media/Outros
# - /media2:/media/HD_Externo
# - /media3:/media/Outros
ports:
- 8086:8080

View File

@@ -4,4 +4,5 @@ watchdog
guessit
requests
ffmpeg-python
yt-dlp
yt-dlp
tmdbsimple