melhorado explorer com teclas de atalhos funcionando e configurações de perfis do ffmpeg

This commit is contained in:
2026-02-13 23:52:00 +00:00
parent 15eb14bf28
commit 8238d71367
7 changed files with 487 additions and 415 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ app/data/clei.db
app/ui/__pycache__/settings.cpython-311.pyc
app/ui/__pycache__/manual_tools.cpython-311.pyc
app/__pycache__/main.cpython-311.pyc
app/ui/__pycache__/manual_tools.cpython-311.pyc
app/ui/__pycache__/settings.cpython-311.pyc

View File

@@ -15,7 +15,6 @@ class FFmpegEngine:
state.log("⚠️ AVISO: Nenhum perfil FFmpeg ativo! Usando defaults.")
def get_file_info(self, filepath):
"""Extrai metadados do arquivo usando ffprobe"""
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath]
try:
output = subprocess.check_output(cmd).decode('utf-8')
@@ -34,68 +33,74 @@ class FFmpegEngine:
metadata = self.get_file_info(input_file)
if not metadata: raise Exception("Arquivo inválido.")
# Descobre o codec de vídeo de entrada
# Info de entrada
video_stream = next((s for s in metadata['streams'] if s['codec_type'] == 'video'), None)
input_codec = video_stream['codec_name'] if video_stream else 'unknown'
# --- DETECÇÃO DE HARDWARE (Haswell Safe Logic) ---
# Intel 4ª Geração (Haswell):
# Decode: Suporta H264, MPEG2. NÃO SUPORTA HEVC/x265.
can_hw_decode = False
hw_decodable_codecs = ['h264', 'mpeg2video', 'vc1', 'mjpeg']
if 'vaapi' in p.video_codec:
if input_codec in hw_decodable_codecs:
can_hw_decode = True
else:
state.log(f"⚙️ Modo Híbrido (Haswell): CPU lendo {input_codec} -> GPU codificando.")
cmd = ['ffmpeg', '-y']
video_filters = []
# --- INPUT (LEITURA) ---
if 'vaapi' in p.video_codec:
# Inicializa o dispositivo VAAPI
# --- LÓGICA MODULAR DE HARDWARE ---
# 1. INTEL/AMD VAAPI
if p.hardware_type == 'vaapi':
# Setup do Device (Sempre necessário para VAAPI)
cmd.extend(['-init_hw_device', 'vaapi=intel:/dev/dri/renderD128'])
cmd.extend(['-filter_hw_device', 'intel'])
if can_hw_decode:
# Se a GPU sabe ler, usa aceleração total
# Verificação de Modo Híbrido (Intel 4th Gen / Compatibilidade)
use_hw_decode = True
if p.hybrid_decode:
# Lista de codecs seguros para Haswell
safe_codecs = ['h264', 'mpeg2video', 'vc1', 'mjpeg']
if input_codec not in safe_codecs:
use_hw_decode = False
state.log(f"⚙️ Modo Híbrido Ativado: CPU decodificando {input_codec} -> GPU codificando.")
# Aplica Input HW Accel se permitido
if use_hw_decode:
cmd.extend(['-hwaccel', 'vaapi'])
cmd.extend(['-hwaccel_output_format', 'vaapi'])
cmd.extend(['-hwaccel_device', 'intel'])
else:
# Se não usar HW decode, precisa subir pra GPU antes de encodar
video_filters.append('format=nv12,hwupload')
elif 'qsv' in p.video_codec:
cmd.extend(['-hwaccel', 'qsv', '-c:v', 'h264_qsv'])
elif 'nvenc' in p.video_codec:
# 2. NVIDIA NVENC (Se suportado pelo container/host)
elif p.hardware_type == 'nvenc':
cmd.extend(['-hwaccel', 'cuda'])
# Nvidia geralmente aguenta tudo, mas se precisar de resize, adiciona filtros aqui
# 3. INTEL QSV (QuickSync Proprietário)
elif p.hardware_type == 'qsv':
cmd.extend(['-hwaccel', 'qsv', '-c:v', 'h264_qsv'])
# --- INPUT ---
cmd.extend(['-threads', '4', '-i', input_file])
# --- PROCESSAMENTO E SAÍDA ---
# --- VÍDEO OUTPUT ---
cmd.extend(['-map', '0:v:0'])
video_filters = []
if p.video_codec == 'copy':
cmd.extend(['-c:v', 'copy'])
else:
# Se for VAAPI Híbrido (leitura CPU), precisamos subir pra GPU
if 'vaapi' in p.video_codec and not can_hw_decode:
video_filters.append('format=nv12,hwupload')
# Aplica filtros acumulados
if video_filters:
cmd.extend(['-vf', ",".join(video_filters)])
cmd.extend(['-c:v', p.video_codec])
# Qualidade baseada no encoder
if 'vaapi' in p.video_codec:
cmd.extend(['-qp', str(p.crf)])
cmd.extend(['-qp', str(p.crf)]) # Constant Quality para VAAPI
elif 'nvenc' in p.video_codec:
cmd.extend(['-cq', str(p.crf), '-preset', p.preset])
elif 'libx264' in p.video_codec:
else:
# CPU / Libx264
cmd.extend(['-crf', str(p.crf), '-preset', p.preset])
# --- ÁUDIO (AAC) ---
# --- ÁUDIO (AAC Stereo Compatível) ---
allowed_langs = [l.strip().lower() for l in (p.audio_langs or "").split(',')]
audio_streams = [s for s in metadata['streams'] if s['codec_type'] == 'audio']
@@ -122,6 +127,7 @@ class FFmpegEngine:
cmd.extend([f'-c:s:{scount}', 'copy'])
scount += 1
# Metadados
clean_title = os.path.splitext(os.path.basename(output_file))[0]
cmd.extend(['-metadata', f'title={clean_title}'])
cmd.append(output_file)

View File

@@ -36,20 +36,27 @@ class Category(BaseModel):
class FFmpegProfile(BaseModel):
name = CharField()
# Configuração de Codec
video_codec = CharField(default='h264_vaapi')
preset = CharField(default='medium')
crf = IntegerField(default=23)
# Configuração de Hardware (NOVO)
hardware_type = CharField(default='vaapi') # vaapi, nvenc, qsv, cpu
hybrid_decode = BooleanField(default=False) # Fix para Intel antigo (CPU Decode -> GPU Encode)
# Áudio/Legenda
audio_langs = CharField(default='por,eng,jpn')
subtitle_langs = CharField(default='por')
is_active = BooleanField(default=False)
def init_db():
# --- CORREÇÃO AQUI: Verifica se já está conectado ---
if db.is_closed():
db.connect()
db.create_tables([AppConfig, Category, FFmpegProfile], safe=True)
# --- MIGRAÇÕES DE BANCO (Adiciona colunas se não existirem) ---
try: db.execute_sql('ALTER TABLE category ADD COLUMN content_type VARCHAR DEFAULT "mixed"')
except: pass
try: db.execute_sql('ALTER TABLE category ADD COLUMN genre_filters VARCHAR DEFAULT ""')
@@ -57,7 +64,18 @@ def init_db():
try: db.execute_sql('ALTER TABLE category ADD COLUMN country_filters VARCHAR DEFAULT ""')
except: pass
# Migração FFmpeg (Hardware Type)
try:
db.execute_sql('ALTER TABLE ffmpegprofile ADD COLUMN hardware_type VARCHAR DEFAULT "vaapi"')
db.execute_sql('ALTER TABLE ffmpegprofile ADD COLUMN hybrid_decode BOOLEAN DEFAULT 0')
except: pass
# Perfil padrão se estiver vazio
if FFmpegProfile.select().count() == 0:
FFmpegProfile.create(name="Padrão VAAPI (Intel)", video_codec="h264_vaapi", is_active=True)
# Não fechamos a conexão aqui para manter o pool ativo no container
FFmpegProfile.create(
name="Padrão VAAPI (Intel/AMD)",
video_codec="h264_vaapi",
hardware_type="vaapi",
hybrid_decode=False, # Padrão para hardware novo
is_active=True
)

View File

@@ -1,21 +1,101 @@
from nicegui import ui, run
from nicegui import ui, run, app
import os
import shutil
import asyncio
import datetime
import subprocess
import json
import hashlib
from pathlib import Path
from core.state import state # Para integrar com o Watcher
# Define a raiz como /downloads, mas permite navegar se o container tiver permissão
# --- CONFIGURAÇÕES ---
ROOT_DIR = "/downloads"
THUMB_DIR = "/app/data/thumbs"
os.makedirs(THUMB_DIR, exist_ok=True)
# --- UTILITÁRIOS ASSÍNCRONOS ---
# Registra rota para servir as miniaturas
app.add_static_files('/thumbs', THUMB_DIR)
# Mapeamento de Extensões para Ícones e Cores
ICON_MAP = {
'video': {'icon': 'movie', 'color': 'purple-6'},
'subtitle': {'icon': 'subtitles', 'color': 'orange-5', 'exts': {'.srt', '.sub', '.ass', '.vtt'}},
'image': {'icon': 'image', 'color': 'pink-5', 'exts': {'.jpg', '.jpeg', '.png', '.gif', '.webp'}},
'text': {'icon': 'description', 'color': 'grey-7', 'exts': {'.txt', '.nfo', '.log', '.md'}},
'folder': {'icon': 'folder', 'color': 'amber-8'},
'default': {'icon': 'insert_drive_file', 'color': 'blue-grey'}
}
# --- GERENCIADOR DE MINIATURAS (FILA) ---
class ThumbnailManager:
def __init__(self):
self.queue = asyncio.Queue()
self.processing = False
async def add(self, filepath, img_element):
"""Adiciona um vídeo na fila para gerar thumbnail"""
vid_hash = hashlib.md5(filepath.encode()).hexdigest()
thumb_path = os.path.join(THUMB_DIR, f"{vid_hash}.jpg")
# Se já existe, mostra direto
if os.path.exists(thumb_path):
img_element.set_source(f'/thumbs/{vid_hash}.jpg')
img_element.classes(remove='opacity-0')
return
# Se não, põe na fila
await self.queue.put((filepath, thumb_path, img_element))
if not self.processing:
asyncio.create_task(self.process_queue())
async def process_queue(self):
self.processing = True
while not self.queue.empty():
try:
filepath, thumb_path, img_element = await self.queue.get()
# Verifica se o elemento de imagem ainda existe na tela (se o usuário não mudou de pasta)
if img_element.is_deleted:
continue
if not os.path.exists(thumb_path):
# Gera thumbnail (frame em 10s)
cmd = [
'ffmpeg', '-y', '-ss', '00:00:10', '-i', filepath,
'-frames:v', '1', '-vf', 'scale=320:-1', '-q:v', '5', thumb_path
]
await run.io_bound(subprocess.run, cmd, capture_output=True)
if os.path.exists(thumb_path):
# Força atualização da imagem (timestamp evita cache do navegador)
img_element.set_source(f'/thumbs/{os.path.basename(thumb_path)}?t={datetime.datetime.now().timestamp()}')
img_element.classes(remove='opacity-0')
# Pequena pausa para não travar a CPU
await asyncio.sleep(0.05)
except Exception as e:
print(f"Erro Thumb: {e}")
self.processing = False
thumb_manager = ThumbnailManager()
# --- UTILITÁRIOS ---
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_file_type(filename):
ext = os.path.splitext(filename)[1].lower()
if ext in ['.mkv', '.mp4', '.avi', '.mov', '.wmv']: return 'video'
for type_name, data in ICON_MAP.items():
if 'exts' in data and ext in data['exts']: return type_name
return 'default'
async def get_media_info_async(filepath):
def _probe():
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
@@ -26,10 +106,7 @@ async def get_media_info_async(filepath):
data = await run.io_bound(_probe)
if not data: return None
# Tratamento de erros se chaves não existirem
fmt = data.get('format', {})
info = {
"filename": os.path.basename(filepath),
"size": int(fmt.get('size', 0)),
@@ -37,28 +114,16 @@ async def get_media_info_async(filepath):
"bitrate": int(fmt.get('bit_rate', 0)),
"video": [], "audio": [], "subtitle": []
}
for s in data.get('streams', []):
stype = s.get('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', '')
})
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', '')
})
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})
return info
# --- CLASSE GERENCIADORA ---
@@ -68,7 +133,7 @@ class FileManager:
self.view_mode = 'grid'
self.is_selecting = False
self.selected_items = set()
self.search_term = ""
self.cached_entries = []
self.refreshing = False
# Elementos UI
@@ -78,103 +143,120 @@ class FileManager:
self.btn_select_mode = None
self.header_row = None
# Bind Teclado Global
self.keyboard = ui.keyboard(on_key=self.handle_key)
async def handle_key(self, e):
if not e.action.keydown: return
if e.key == 'F2':
if len(self.selected_items) == 1:
self.open_rename_dialog(list(self.selected_items)[0])
elif e.key == 'Delete':
await self.confirm_delete_selected()
elif e.key == 'Escape':
if self.is_selecting: self.toggle_select_mode()
elif e.key == 'a' and e.modifiers.ctrl:
if not self.is_selecting: self.toggle_select_mode()
self.select_all(self.cached_entries)
# --- WRAPPERS SEGUROS PARA LAMBDA ASYNC ---
# Isso resolve o erro "coroutine never awaited"
def safe_nav(self, path):
asyncio.create_task(self.navigate(path))
def safe_nav_up(self):
asyncio.create_task(self.navigate_up())
# --- NAVEGAÇÃO ---
async def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.path = path
self.selected_items.clear()
self.is_selecting = False
self.search_term = ""
self.update_footer_state()
if self.header_row:
self.header_row.clear()
with self.header_row:
self.build_header_content()
with self.header_row: self.build_header_content()
await self.refresh()
else:
ui.notify('Caminho inválido.', type='negative')
async def navigate_up(self):
parent = os.path.dirname(self.path)
# Permite subir até a raiz do sistema se necessário, mas idealmente trava no ROOT
# Se quiser travar: if self.path != ROOT_DIR: await self.navigate(parent)
if parent and os.path.exists(parent):
await self.navigate(parent)
if parent and os.path.exists(parent): await self.navigate(parent)
# --- UPLOAD ---
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:
name = None
if hasattr(e, 'file'):
name = getattr(e.file, 'filename', None) or getattr(e.file, 'name', None)
if not name: name = getattr(e, 'name', 'arquivo_sem_nome')
content = b''
if hasattr(e, 'content'): content = e.content.read() # NiceGUI as vezes passa assim
if hasattr(e, 'content'): content = e.content.read()
elif hasattr(e, 'file'):
if hasattr(e.file, 'seek'): await e.file.seek(0)
if hasattr(e.file, 'read'): content = await e.file.read()
if not content:
ui.notify('Erro: Arquivo vazio', type='warning'); return
if not content: ui.notify('Erro: Arquivo vazio', type='warning'); return
target = os.path.join(self.path, name)
await run.io_bound(self._save_file_bytes, target, content)
await run.io_bound(self._save_file, target, content)
ui.notify(f'Sucesso: {name}', type='positive')
except Exception as ex:
ui.notify(f'Erro: {ex}', type='negative')
except Exception as ex: ui.notify(f'Erro: {ex}', type='negative')
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')
ui.button('Fechar', on_click=lambda: (dialog.close(), self.safe_nav(self.path))).props('flat w-full')
dialog.open()
def _save_file_bytes(self, target, content_bytes):
with open(target, 'wb') as f: f.write(content_bytes)
# --- 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()
self.refresh_ui_only()
def _save_file(self, target, content):
with open(target, 'wb') as f: f.write(content)
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()
self.refresh_ui_only() # Refresh leve
# --- EDITOR DE TEXTO ---
async def open_text_editor(self, filepath):
if os.path.getsize(filepath) > 1024 * 1024:
ui.notify('Arquivo muito grande.', type='warning'); return
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: content = f.read()
except: ui.notify('Erro ao ler arquivo.', type='negative'); return
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()
self.refresh_ui_only()
with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl h-[80vh] flex flex-col'):
ui.label(f'Editando: {os.path.basename(filepath)}').classes('font-bold')
editor = ui.textarea(value=content).classes('w-full flex-grow font-mono text-sm').props('outlined')
async def save():
try:
with open(filepath, 'w', encoding='utf-8') as f: f.write(editor.value)
ui.notify('Salvo!', type='positive'); dialog.close()
except Exception as e: ui.notify(f'Erro: {e}', type='negative')
with ui.row().classes('w-full justify-end'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Salvar', on_click=save).props('icon=save color=green')
dialog.open()
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)'
# --- BOT INTEGRATION ---
async def send_to_cleiflow(self, filepath):
if not state.watcher: ui.notify('Watcher offline.', type='warning'); return
ui.notify(f'Processando {os.path.basename(filepath)}...', type='info')
asyncio.create_task(state.watcher.process_pipeline(Path(filepath)))
# --- EXCLUSÃO SEGURA ---
async def confirm_delete(self, items_to_delete):
if not items_to_delete: return
is_single = len(items_to_delete) == 1
title = "Confirmar Exclusão"
msg = f"Excluir permanentemente:\n{os.path.basename(items_to_delete[0])}?" if is_single else f"Excluir {len(items_to_delete)} itens selecionados?"
# --- AÇÕES ---
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():
ui.label(title).classes('text-xl font-bold text-red-600')
ui.label(msg).classes('text-gray-700 whitespace-pre-wrap')
async def execute():
dialog.close()
count = 0
items_copy = list(self.selected_items)
for item in items_copy:
for item in items_to_delete:
try:
if os.path.isdir(item): await run.io_bound(shutil.rmtree, item)
else: await run.io_bound(os.remove, item)
@@ -184,19 +266,21 @@ class FileManager:
self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-end'):
with ui.row().classes('w-full justify-end mt-4'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Confirmar', on_click=confirm).props('color=red')
ui.button('EXCLUIR', on_click=execute).props('color=red icon=delete')
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
async def confirm_delete_selected(self):
await self.confirm_delete(list(self.selected_items))
# --- MOVIMENTAÇÃO ---
async def open_move_dialog(self, target_items=None):
items = target_items if target_items else list(self.selected_items)
if not items: 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')
ui.label(f'Mover {len(items)} item(s)').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')
@@ -216,85 +300,26 @@ class FileManager:
ui.timer(0, lambda: load_folders(ROOT_DIR), once=True)
async def execute_move():
async def execute():
dialog.close(); ui.notify('Movendo...', type='info')
count = 0
for item in items_to_move:
for item in items:
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')
ui.button('Mover Aqui', on_click=execute).props('color=green icon=drive_file_move')
dialog.open()
# --- LAYOUT ---
def create_layout(self):
# Header
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()
# Conteúdo
self.container_content = ui.column().classes('w-full gap-4 pb-24')
# Footer (Simulado)
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', on_click=lambda: self.open_move_dialog(None)).props('color=amber icon=drive_file_move dense')
ui.button('Excluir', 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')
self.footer.visible = False
def build_header_content(self):
if self.path != ROOT_DIR:
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense')
else:
ui.button(icon='home').props('flat round dense disabled text-color=grey')
# Breadcrumbs
rel = os.path.relpath(self.path, ROOT_DIR) if self.path.startswith(ROOT_DIR) else self.path
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')
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-1 text-xs')
# Toolbar
self.btn_select_mode = ui.button(icon='check_box', on_click=self.toggle_select_mode).props('flat round dense')
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense')
ui.button(icon='cloud_upload', on_click=self.open_upload_dialog).props('flat round dense')
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_ui_only()
def open_create_folder(self):
with ui.dialog() as dialog, ui.card():
ui.label('Nova Pasta'); name = ui.input('Nome')
async def create():
try:
await run.io_bound(os.makedirs, os.path.join(self.path, name.value))
dialog.close(); await self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Criar', on_click=create)
dialog.open()
# --- UI HELPERS ---
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')
@@ -304,39 +329,109 @@ class FileManager:
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)
ui.button('Salvar', on_click=save).props('color=blue')
dialog.open()
# --- RENDERIZAÇÃO ---
def open_create_folder(self):
with ui.dialog() as dialog, ui.card():
ui.label('Nova Pasta'); name = ui.input('Nome')
async def create():
try:
await run.io_bound(os.makedirs, os.path.join(self.path, name.value))
dialog.close(); await self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Criar', on_click=create).props('color=green')
dialog.open()
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()
self.refresh_ui_only()
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()
self.refresh_ui_only()
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()
self.refresh_ui_only()
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)'
# --- LAYOUT PRINCIPAL ---
def create_layout(self):
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()
self.container_content = ui.column().classes('w-full gap-4 pb-24')
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', on_click=lambda: self.open_move_dialog(None)).props('color=amber icon=drive_file_move dense')
ui.button('Excluir', on_click=self.confirm_delete_selected).props('color=red icon=delete dense')
ui.button(icon='close', on_click=self.toggle_select_mode).props('flat round dense')
self.footer.visible = False
def build_header_content(self):
if self.path != ROOT_DIR:
ui.button(icon='arrow_upward', on_click=self.safe_nav_up).props('flat round dense')
else:
ui.button(icon='home').props('flat round dense disabled text-color=grey')
rel = os.path.relpath(self.path, ROOT_DIR) if self.path.startswith(ROOT_DIR) else self.path
parts = rel.split(os.sep) if rel != '.' else []
with ui.row().classes('items-center gap-0 overflow-hidden flex-grow'):
# CORREÇÃO: Usando safe_nav para evitar erro de coroutine
ui.button('root', on_click=lambda: self.safe_nav(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')
ui.button(part, on_click=lambda p=acc: self.safe_nav(p)).props('flat dense no-caps min-w-0 px-1 text-xs')
self.btn_select_mode = ui.button(icon='check_box', on_click=self.toggle_select_mode).props('flat round dense')
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense')
ui.button(icon='cloud_upload', on_click=self.open_upload_dialog).props('flat round dense')
ui.button(icon='view_list', on_click=self.toggle_view).props('flat round dense')
ui.button(icon='refresh', on_click=lambda: self.safe_nav(self.path)).props('flat round dense')
def toggle_view(self):
self.view_mode = 'list' if self.view_mode == 'grid' else 'grid'
self.refresh_ui_only()
# --- REFRESH ---
async def refresh(self):
if self.refreshing: return
self.refreshing = True
# Recarrega arquivos do disco
try:
entries = await run.io_bound(os.scandir, self.path)
self.cached_entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
except Exception as e:
self.cached_entries = []
ui.notify(f"Erro leitura: {e}", type='negative')
ui.notify(f"Erro: {e}", type='negative')
self.refresh_ui_only()
self.refreshing = False
def refresh_ui_only(self):
if self.container_content:
self.container_content.clear()
color = 'green' if self.is_selecting else 'grey'
if self.btn_select_mode: self.btn_select_mode.props(f'text-color={color}')
# Select All Bar
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'Todos ({len(self.cached_entries)})', on_change=lambda: self.select_all(self.cached_entries)).props('dense size=xs')
# Content
with self.container_content:
if not self.cached_entries:
ui.label('Pasta Vazia').classes('w-full text-center text-gray-400 mt-10')
@@ -350,54 +445,65 @@ class FileManager:
def render_card(self, entry):
is_sel = 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'
ftype = 'folder' if is_dir else get_file_type(entry.name)
icon = ICON_MAP[ftype]['icon']
color = ICON_MAP[ftype]['color']
color = 'amber-8' if is_dir else ('purple-6' if icon=='movie' else 'blue-grey')
bg = 'bg-green-100 ring-2 ring-green-500' if is_sel else 'bg-white hover:shadow-md'
with ui.card().classes(f'w-full aspect-square p-2 items-center justify-center relative group cursor-pointer select-none {bg}') as card:
self.bind_context_menu(card, entry)
if ftype == 'video' and not is_dir:
img = ui.image().classes('w-full h-full object-cover rounded opacity-0 transition-opacity duration-500 absolute top-0 left-0')
asyncio.create_task(thumb_manager.add(entry.path, img))
with ui.column().classes('items-center justify-center z-0'):
ui.icon(icon, size='3rem', color=color)
else:
ui.icon(icon, size='3rem', color=color)
if self.is_selecting:
card.on('click', lambda: self.toggle_selection(entry.path))
ui.checkbox(value=is_sel, on_change=lambda: self.toggle_selection(entry.path)).props('dense').classes('absolute top-1 left-1 z-10').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))
# CORREÇÃO: Usando safe_nav aqui também
if is_dir: card.on('click', lambda e, p=entry.path: self.safe_nav(p))
elif ftype == 'video': card.on('click', lambda e, p=entry.path: self.open_inspector(p))
elif ftype in ['subtitle', 'text']: card.on('click', lambda e, p=entry.path: self.open_text_editor(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')
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all mt-auto z-10 bg-white/80 rounded px-1')
def render_list_item(self, entry):
is_sel = entry.path in self.selected_items
is_dir = entry.is_dir()
ftype = 'folder' if is_dir else get_file_type(entry.name)
icon = ICON_MAP[ftype]['icon']
color = ICON_MAP[ftype]['color']
bg = 'bg-green-100' if is_sel 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}') as row:
if self.is_selecting:
ui.checkbox(value=is_sel, 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))
if is_dir: row.on('click', lambda p=entry.path: self.safe_nav(p))
elif ftype in ['subtitle', 'text']: row.on('click', lambda p=entry.path: self.open_text_editor(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')
ui.icon(icon, color=color.split('-')[0])
ui.label(entry.name).classes('text-sm font-medium flex-grow truncate')
def bind_context_menu(self, element, entry):
ftype = 'folder' if entry.is_dir() else get_file_type(entry.name)
with ui.menu() as m:
if not entry.is_dir() and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')):
if ftype == 'video':
ui.menu_item('Media Info', on_click=lambda: self.open_inspector(entry.path))
ui.menu_item('Processar no Clei-Flow', on_click=lambda: self.send_to_cleiflow(entry.path))
if ftype in ['subtitle', 'text']:
ui.menu_item('Editar Texto', on_click=lambda: self.open_text_editor(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]))
async def delete_single():
try:
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('Excluído.', type='positive')
except Exception as e: ui.notify(str(e), type='negative')
ui.menu_item('Excluir', on_click=delete_single).props('text-color=red')
ui.menu_item('Excluir', on_click=lambda: self.confirm_delete([entry.path])).props('text-color=red')
element.on('contextmenu.prevent', lambda: m.open())
# --- INSPECTOR ---
@@ -407,28 +513,22 @@ class FileManager:
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')
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('Erro ao ler metadados.').classes('text-red'); return
with content:
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']))}")
ui.label(f"📦 {await get_human_size(info['size'])}")
ui.label(f"🚀 {int(info['bitrate']/1000)} kbps")
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')
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'):
@@ -436,7 +536,6 @@ class FileManager:
ui.label(a['lang']).classes('font-bold bg-gray-200 px-2 rounded text-center')
ui.label(f"{a['codec']} - {a['title']}")
ui.label(str(a['ch'])).classes('text-gray-500')
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'):
@@ -447,4 +546,5 @@ class FileManager:
def show():
fm = FileManager()
fm.create_layout()
ui.timer(0, fm.refresh, once=True)
# Usando safe_nav para evitar o erro inicial também
ui.timer(0, lambda: fm.safe_nav(ROOT_DIR), once=True)

View File

@@ -2,7 +2,7 @@ from nicegui import ui, app
import os
import asyncio
from database import Category, FFmpegProfile, AppConfig
from core.state import state # <--- ACESSAMOS O BOT POR AQUI AGORA
from core.state import state
# --- CONSTANTES ---
TMDB_GENRES = {
@@ -26,315 +26,261 @@ ISO_LANGS = {
'zho': 'Chinês (zho)', 'und': 'Indefinido (und)'
}
# --- SELETOR DE PASTAS FLEXÍVEL ---
async def pick_folder_dialog(start_path='/', allowed_root='/'):
"""
allowed_root: '/' permite tudo, '/media' restringe.
"""
# Se start_path for vazio ou None, começa na raiz permitida
if not start_path: start_path = allowed_root
# Se o caminho salvo não começar com a raiz permitida, reseta
if allowed_root != '/' and not start_path.startswith(allowed_root):
start_path = allowed_root
# Codecs disponíveis por Hardware
HARDWARE_OPTS = {
'vaapi': 'Intel/AMD VAAPI (Linux)',
'nvenc': 'Nvidia NVENC',
'qsv': 'Intel QuickSync (Proprietário)',
'cpu': 'CPU (Software)',
'copy': 'Copy (Sem Conversão)'
}
CODEC_OPTS = {
'vaapi': {'h264_vaapi': 'H.264 (VAAPI)', 'hevc_vaapi': 'H.265 (VAAPI)', 'vp9_vaapi': 'VP9 (VAAPI)'},
'nvenc': {'h264_nvenc': 'H.264 (NVENC)', 'hevc_nvenc': 'H.265 (NVENC)'},
'qsv': {'h264_qsv': 'H.264 (QSV)', 'hevc_qsv': 'H.265 (QSV)'},
'cpu': {'libx264': 'H.264 (x264)', 'libx265': 'H.265 (x265)'},
'copy': {'copy': 'Copiar Stream'}
}
# --- SELETOR DE PASTAS ---
async def pick_folder_dialog(start_path='/', allowed_root='/'):
if not start_path: start_path = allowed_root
if allowed_root != '/' and not start_path.startswith(allowed_root): start_path = allowed_root
result = {'path': None}
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col'):
ui.label(f'Selecionar Pasta ({allowed_root})').classes('font-bold text-lg mb-2')
path_label = ui.label(start_path).classes('text-xs bg-gray-100 p-2 border rounded w-full break-all font-mono')
scroll = ui.scroll_area().classes('flex-grow border rounded p-1 mt-2 bg-white')
async def load_dir(path):
# Segurança: Garante que não saiu da jaula (se houver jaula)
if allowed_root != '/' and not path.startswith(allowed_root):
path = allowed_root
path_label.text = path
scroll.clear()
if allowed_root != '/' and not path.startswith(allowed_root): path = allowed_root
path_label.text = path; scroll.clear()
try:
# Botão Voltar (..)
# Só mostra se não estiver na raiz do sistema OU na raiz permitida
show_back = True
if path == '/': show_back = False
if allowed_root != '/' and path == allowed_root: show_back = False
if show_back:
if path != '/' and path != allowed_root:
parent = os.path.dirname(path)
with scroll:
ui.button('.. (Voltar)', on_click=lambda: load_dir(parent)).props('flat dense icon=arrow_upward align=left w-full')
# Lista Pastas
with scroll: ui.button('.. (Voltar)', on_click=lambda: load_dir(parent)).props('flat dense icon=arrow_upward align=left w-full')
with scroll:
if os.path.exists(path):
entries = sorted([e for e in os.scandir(path) if e.is_dir()], key=lambda x: x.name.lower())
for entry in entries:
for entry in sorted([e for e in os.scandir(path) if e.is_dir()], key=lambda x: x.name.lower()):
ui.button(entry.name, on_click=lambda p=entry.path: load_dir(p)).props('flat dense icon=folder align=left w-full color=amber-8')
else:
ui.label('Caminho não encontrado.').classes('text-gray-400 italic p-2')
except Exception as e:
with scroll: ui.label(f'Acesso negado: {e}').classes('text-red text-xs')
def select_this():
result['path'] = path_label.text
dialog.close()
with scroll: ui.label(f'Erro: {e}').classes('text-red text-xs')
def select_this(): result['path'] = path_label.text; dialog.close()
with ui.row().classes('w-full justify-between mt-auto pt-2'):
ui.button('Cancelar', on_click=dialog.close).props('flat color=grey')
ui.button('Selecionar Esta', on_click=select_this).props('flat icon=check color=green')
await load_dir(start_path)
await dialog
ui.button('Selecionar', on_click=select_this).props('flat icon=check color=green')
await load_dir(start_path); await dialog
return result['path']
# --- TELA PRINCIPAL ---
def show():
# Helper para pegar o bot de forma segura via State
def get_bot():
if state.watcher and state.watcher.bot:
return state.watcher.bot
return None
def get_bot(): return state.watcher.bot if state.watcher else None
with ui.column().classes('w-full p-6'):
ui.label('Configurações').classes('text-3xl font-light text-gray-800 mb-4')
with ui.tabs().classes('w-full') as tabs:
tab_ident = ui.tab('Identificação', icon='search')
tab_cats = ui.tab('Categorias', icon='category')
tab_deploy = ui.tab('Deploy & Caminhos', icon='move_to_inbox')
tab_deploy = ui.tab('Deploy', icon='move_to_inbox')
tab_ffmpeg = ui.tab('Motor (FFmpeg)', icon='movie')
tab_telegram = ui.tab('Telegram', icon='send')
with ui.tab_panels(tabs).classes('w-full bg-gray-50 p-4 rounded border'):
# --- ABA 1: IDENTIFICAÇÃO ---
# --- IDENTIFICAÇÃO ---
with ui.tab_panel(tab_ident):
with ui.card().classes('w-full max-w-2xl mx-auto p-6'):
ui.label('🔍 Configuração do Identificador').classes('text-2xl font-bold mb-4 text-indigo-600')
tmdb_key = AppConfig.get_val('tmdb_api_key', '')
ui.input('API Key do TMDb', value=tmdb_key, password=True, on_change=lambda e: AppConfig.set_val('tmdb_api_key', e.value)).classes('w-full mb-4')
with ui.grid(columns=2).classes('w-full gap-4'):
ui.input('Idioma', value=AppConfig.get_val('tmdb_language', 'pt-BR'), on_change=lambda e: AppConfig.set_val('tmdb_language', e.value))
ui.number('Confiança Auto (%)', value=int(AppConfig.get_val('min_confidence', '90')), min=50, max=100, on_change=lambda e: AppConfig.set_val('min_confidence', str(int(e.value))))
# --- ABA 2: CATEGORIAS (COM EDIÇÃO) ---
# --- CATEGORIAS ---
with ui.tab_panel(tab_cats):
ui.label('Regras de Organização').classes('text-xl text-gray-700 mb-2')
editing_id = {'val': None}
cats_container = ui.column().classes('w-full gap-2')
form_card = ui.card().classes('w-full mb-4 bg-gray-100 p-4')
editing_id = {'val': None}
def load_cats():
cats_container.clear()
cats = list(Category.select())
for cat in cats:
for cat in list(Category.select()):
g_ids = cat.genre_filters.split(',') if cat.genre_filters else []
c_codes = cat.country_filters.split(',') if cat.country_filters else []
g_names = [TMDB_GENRES.get(gid, gid) for gid in g_ids if gid]
c_names = [COMMON_COUNTRIES.get(cc, cc) for cc in c_codes if cc]
desc = f"{cat.content_type.upper()}"
if g_names: desc += f" | {', '.join(g_names)}"
if c_names: desc += f" | {', '.join(c_names)}"
color_cls = 'bg-white border-gray-200'
if 'Animação' in str(g_names) and 'Japão' in str(c_names): color_cls = 'bg-purple-50 border-purple-200'
color_cls = 'bg-purple-50 border-purple-200' if 'Animação' in str(g_names) and 'Japão' in str(c_names) else 'bg-white border-gray-200'
with cats_container, ui.card().classes(f'w-full flex-row items-center justify-between p-3 border {color_cls}'):
with ui.row().classes('items-center gap-4'):
icon = 'movie' if cat.content_type == 'movie' else ('tv' if cat.content_type == 'series' else 'shuffle')
ui.icon(icon).classes('text-gray-600')
ui.icon('movie' if cat.content_type == 'movie' else 'tv').classes('text-gray-600')
with ui.column().classes('gap-0'):
ui.label(cat.name).classes('font-bold text-lg')
ui.label(desc).classes('text-xs text-gray-500')
with ui.row():
ui.button(icon='edit', on_click=lambda c=cat: start_edit(c)).props('flat dense color=blue')
ui.button(icon='delete', on_click=lambda c=cat: delete_cat(c)).props('flat dense color=red')
ui.button(icon='delete', on_click=lambda c=cat: (c.delete_instance(), load_cats())).props('flat dense color=red')
def start_edit(cat):
editing_id['val'] = cat.id
name_input.value = cat.name
type_select.value = cat.content_type
name_input.value = cat.name; type_select.value = cat.content_type
genre_select.value = cat.genre_filters.split(',') if cat.genre_filters else []
country_select.value = cat.country_filters.split(',') if cat.country_filters else []
form_label.text = f"Editando: {cat.name}"
btn_save.text = "Atualizar Regra"
btn_save.props('color=blue icon=save')
btn_cancel.classes(remove='hidden')
form_card.classes(replace='bg-blue-50')
btn_save.text = "Atualizar"; btn_cancel.classes(remove='hidden')
def cancel_edit():
editing_id['val'] = None
name_input.value = ''
genre_select.value = []
country_select.value = []
form_label.text = "Nova Biblioteca"
btn_save.text = "Adicionar Regra"
btn_save.props('color=green icon=add')
btn_cancel.classes(add='hidden')
form_card.classes(replace='bg-gray-100')
editing_id['val'] = None; name_input.value = ''; genre_select.value = []; country_select.value = []
btn_save.text = "Adicionar"; btn_cancel.classes(add='hidden')
def save_cat():
if not name_input.value: return
g_str = ",".join(genre_select.value) if genre_select.value else ""
c_str = ",".join(country_select.value) if country_select.value else ""
data = {'name': name_input.value, 'content_type': type_select.value, 'genre_filters': ",".join(genre_select.value), 'country_filters': ",".join(country_select.value)}
if not editing_id['val']: data['target_path'] = f"/media/{name_input.value}"
try:
if editing_id['val']:
Category.update(
name=name_input.value,
content_type=type_select.value,
genre_filters=g_str,
country_filters=c_str
).where(Category.id == editing_id['val']).execute()
ui.notify('Categoria atualizada!', type='positive')
else:
Category.create(
name=name_input.value,
target_path=f"/media/{name_input.value}",
content_type=type_select.value,
genre_filters=g_str,
country_filters=c_str
)
ui.notify('Categoria criada!', type='positive')
cancel_edit()
load_cats()
except Exception as e: ui.notify(f'Erro: {e}', type='negative')
def delete_cat(cat):
cat.delete_instance()
if editing_id['val'] == cat.id: cancel_edit()
load_cats()
if editing_id['val']: Category.update(**data).where(Category.id == editing_id['val']).execute()
else: Category.create(**data)
# Correção: Notifica ANTES de recarregar a lista (para não perder o contexto do botão)
ui.notify('Salvo!', type='positive')
cancel_edit(); load_cats();
with form_card:
with ui.row().classes('w-full justify-between items-center'):
form_label = ui.label('Nova Biblioteca').classes('font-bold text-gray-700')
btn_cancel = ui.button('Cancelar', on_click=cancel_edit).props('flat dense color=red').classes('hidden')
with ui.row().classes('w-full justify-between'):
ui.label('Editor').classes('font-bold'); btn_cancel = ui.button('Cancelar', on_click=cancel_edit).props('flat dense color=red').classes('hidden')
with ui.grid(columns=4).classes('w-full gap-2'):
name_input = ui.input('Nome')
type_select = ui.select({'mixed': 'Misto', 'movie': 'Só Filmes', 'series': 'Só Séries'}, value='mixed', label='Tipo')
genre_select = ui.select(TMDB_GENRES, multiple=True, label='Gêneros').props('use-chips')
country_select = ui.select(COMMON_COUNTRIES, multiple=True, label='Países').props('use-chips')
btn_save = ui.button('Adicionar Regra', on_click=save_cat).props('icon=add color=green').classes('mt-2 w-full')
btn_save = ui.button('Adicionar', on_click=save_cat).props('icon=add color=green').classes('mt-2 w-full')
load_cats()
# --- ABA 3: DEPLOY (DESTINOS VS ORIGEM) ---
# --- DEPLOY ---
with ui.tab_panel(tab_deploy):
# ORIGEM (Permite Raiz /)
with ui.card().classes('w-full mb-6 border-l-4 border-amber-500 bg-amber-50'):
ui.label('📡 Origem dos Arquivos').classes('text-lg font-bold mb-2 text-amber-900')
ui.label('📡 Origem').classes('text-lg font-bold mb-2 text-amber-900')
monitor_path = AppConfig.get_val('monitor_path', '/downloads')
async def pick_source():
# AQUI: allowed_root='/' permite navegar em tudo (incluindo /downloads)
p = await pick_folder_dialog(mon_input.value, allowed_root='/')
if p: mon_input.value = p
with ui.row().classes('w-full items-center gap-2'):
mon_input = ui.input('Pasta Monitorada', value=monitor_path).classes('flex-grow font-mono bg-white rounded px-2')
ui.button(icon='folder', on_click=pick_source).props('flat dense color=amber-9')
ui.button('Salvar Origem', on_click=lambda: (AppConfig.set_val('monitor_path', mon_input.value), ui.notify('Salvo!'))).classes('mt-2 bg-amber-600 text-white')
# DESTINOS (Restrito a /media para organização)
ui.label('📂 Mapeamento de Destinos (/media)').classes('text-xl text-gray-700 mt-4')
ui.label('📂 Destinos (/media)').classes('text-xl text-gray-700 mt-4')
paths_container = ui.column().classes('w-full gap-2')
def load_deploy_paths():
paths_container.clear()
for cat in list(Category.select()):
with paths_container, ui.card().classes('w-full p-4 flex-row items-center gap-4'):
ui.label(cat.name).classes('font-bold w-32')
path_input = ui.input(value=cat.target_path).classes('flex-grow font-mono')
async def pick_dest(i=path_input):
# AQUI: allowed_root='/media' trava a navegação
p = await pick_folder_dialog(i.value, allowed_root='/media')
if p: i.value = p
ui.button(icon='folder', on_click=pick_dest).props('flat dense color=amber-8')
ui.button(icon='save', on_click=lambda c=cat, p=path_input: (setattr(c, 'target_path', p.value), c.save(), ui.notify('Salvo!'))).props('flat round color=green')
load_deploy_paths()
# --- ABA 4: FFMPEG ---
# --- MOTOR (FFMPEG MODULAR) ---
with ui.tab_panel(tab_ffmpeg):
ui.label('Perfis de Conversão').classes('text-xl text-gray-700')
profiles_query = list(FFmpegProfile.select())
profiles_dict = {p.id: p.name for p in profiles_query}
active_profile = next((p for p in profiles_query if p.is_active), None)
def set_active_profile(e):
if not e.value: return
FFmpegProfile.update(is_active=False).execute()
FFmpegProfile.update(is_active=True).where(FFmpegProfile.id == int(e.value)).execute()
ui.notify(f'Perfil Ativado!', type='positive')
ui.label('Gerenciador de Perfis').classes('text-xl text-gray-700')
profiles_container = ui.column().classes('w-full gap-4')
select_profile = ui.select(profiles_dict, value=active_profile.id if active_profile else None, label='Perfil Ativo', on_change=set_active_profile).classes('w-64 mb-6')
for p in profiles_query:
with ui.expansion(f"{p.name}", icon='tune').classes('w-full bg-white mb-2 border rounded'):
with ui.column().classes('p-4 w-full'):
with ui.grid(columns=2).classes('w-full gap-4'):
c_name = ui.input('Nome', value=p.name)
c_codec = ui.select({'h264_vaapi':'VAAPI','libx264':'CPU','copy':'Copy'}, value=p.video_codec, label='Codec')
c_preset = ui.select(['fast', 'medium', 'slow'], value=p.preset, label='Preset')
c_crf = ui.number('CRF', value=p.crf)
c_audio = ui.select(ISO_LANGS, value=p.audio_langs.split(','), multiple=True, label='Áudios').props('use-chips')
c_sub = ui.select(ISO_LANGS, value=p.subtitle_langs.split(','), multiple=True, label='Legendas').props('use-chips')
def load_ffmpeg_profiles():
profiles_container.clear()
profiles = list(FFmpegProfile.select())
active_p = next((p for p in profiles if p.is_active), None)
def set_active(e):
if not e.value: return
FFmpegProfile.update(is_active=False).execute()
FFmpegProfile.update(is_active=True).where(FFmpegProfile.id == int(e.value)).execute()
ui.notify(f'Perfil Ativado!', type='positive')
with profiles_container:
ui.select({p.id: p.name for p in profiles}, value=active_p.id if active_p else None, label='Perfil Ativo (Global)', on_change=set_active).classes('w-full max-w-md mb-4 bg-green-50 p-2 rounded')
for p in profiles:
with ui.expansion(f"{p.name} ({p.hardware_type.upper()})", icon='tune').classes('w-full bg-white mb-2 border rounded'):
with ui.column().classes('p-4 w-full'):
with ui.grid(columns=2).classes('w-full gap-4'):
c_name = ui.input('Nome do Perfil', value=p.name)
c_hw = ui.select(HARDWARE_OPTS, value=p.hardware_type, label='Tipo de Hardware')
with ui.grid(columns=3).classes('w-full gap-4'):
hw_val = c_hw.value if c_hw.value in CODEC_OPTS else 'cpu'
c_codec = ui.select(CODEC_OPTS[hw_val], value=p.video_codec, label='Codec de Vídeo')
def update_codecs(e, el=c_codec):
el.options = CODEC_OPTS.get(e.value, CODEC_OPTS['cpu'])
el.value = list(el.options.keys())[0]
el.update()
c_hw.on('update:model-value', update_codecs)
c_preset = ui.select(['fast', 'medium', 'slow', 'veryfast'], value=p.preset, label='Preset')
c_crf = ui.number('CRF/Qualidade', value=p.crf)
def save_profile(prof=p, n=c_name, c=c_codec, pr=c_preset, cr=c_crf, a=c_audio, s=c_sub):
prof.name = n.value; prof.video_codec = c.value; prof.preset = pr.value
prof.crf = int(cr.value); prof.audio_langs = ",".join(a.value); prof.subtitle_langs = ",".join(s.value)
prof.save()
profiles_dict[prof.id] = prof.name
select_profile.options = profiles_dict
select_profile.update()
ui.notify('Salvo!')
ui.button('Salvar', on_click=save_profile).classes('mt-2')
if p.hardware_type == 'vaapi':
with ui.row().classes('bg-amber-50 p-2 rounded w-full items-center border border-amber-200'):
ui.icon('warning', color='amber').classes('mr-2')
c_hybrid = ui.checkbox('Modo Híbrido (Intel 4ª Gen / Haswell)', value=p.hybrid_decode).props('color=amber')
ui.label('Ative se o vídeo x265 travar ou falhar. (Lê na CPU, Grava na GPU)').classes('text-xs text-gray-500')
else:
c_hybrid = None
# --- ABA 5: TELEGRAM ---
with ui.grid(columns=2).classes('w-full gap-4'):
c_audio = ui.select(ISO_LANGS, value=p.audio_langs.split(','), multiple=True, label='Áudios').props('use-chips')
c_sub = ui.select(ISO_LANGS, value=p.subtitle_langs.split(','), multiple=True, label='Legendas').props('use-chips')
with ui.row().classes('w-full justify-end mt-4 gap-2'):
def save_p(prof=p, n=c_name, hw=c_hw, c=c_codec, pr=c_preset, cr=c_crf, a=c_audio, s=c_sub, hy=c_hybrid):
prof.name = n.value; prof.hardware_type = hw.value; prof.video_codec = c.value
prof.preset = pr.value; prof.crf = int(cr.value)
prof.audio_langs = ",".join(a.value); prof.subtitle_langs = ",".join(s.value)
if hy: prof.hybrid_decode = hy.value
prof.save()
# CORREÇÃO: Inversão da ordem (Notifica -> Recarrega)
ui.notify('Perfil Salvo!', type='positive')
load_ffmpeg_profiles()
def delete_p(prof=p):
if FFmpegProfile.select().count() <= 1:
ui.notify('Não pode excluir o último perfil!', type='negative'); return
prof.delete_instance()
# CORREÇÃO: Inversão da ordem
ui.notify('Perfil Excluído.')
load_ffmpeg_profiles()
ui.button('Excluir', on_click=delete_p, icon='delete', color='red').props('flat')
ui.button('Salvar Alterações', on_click=save_p, icon='save', color='green')
def create_new():
FFmpegProfile.create(name="Novo Perfil", hardware_type='cpu', video_codec='libx264')
load_ffmpeg_profiles()
ui.button('Criar Novo Perfil', on_click=create_new, icon='add').classes('w-full mt-4 bg-gray-200 text-gray-700')
load_ffmpeg_profiles()
# --- TELEGRAM ---
with ui.tab_panel(tab_telegram):
with ui.card().classes('w-full max-w-lg mx-auto p-6'):
ui.label('🤖 Integração Telegram').classes('text-2xl font-bold mb-4 text-blue-600')
token_input = ui.input('Bot Token', value=AppConfig.get_val('telegram_token', '')).props('password').classes('w-full mb-2')
chat_input = ui.input('Chat ID', value=AppConfig.get_val('telegram_chat_id', '')).classes('w-full mb-6')
async def save_and_restart():
AppConfig.set_val('telegram_token', token_input.value)
AppConfig.set_val('telegram_chat_id', chat_input.value)
ui.notify('Salvando...', type='warning')
t_tok = ui.input('Bot Token', value=AppConfig.get_val('telegram_token', '')).props('password').classes('w-full mb-2')
t_chat = ui.input('Chat ID', value=AppConfig.get_val('telegram_chat_id', '')).classes('w-full mb-6')
async def save_tg():
AppConfig.set_val('telegram_token', t_tok.value); AppConfig.set_val('telegram_chat_id', t_chat.value)
bot = get_bot()
if bot:
await bot.restart()
ui.notify('Bot Reiniciado!', type='positive')
else:
ui.notify('Salvo, mas Bot não está rodando.', type='warning')
async def test_connection():
if bot: await bot.restart()
ui.notify('Salvo!', type='positive')
async def test_tg():
bot = get_bot()
if bot:
ui.notify('Enviando mensagem...')
success = await bot.send_test_msg()
if success: ui.notify('Mensagem enviada!', type='positive')
else: ui.notify('Falha no envio.', type='negative')
else:
ui.notify('Bot offline.', type='negative')
if bot and await bot.send_test_msg(): ui.notify('Sucesso!', type='positive')
else: ui.notify('Falha.', type='negative')
with ui.row().classes('w-full gap-2'):
ui.button('Salvar e Reiniciar', on_click=save_and_restart).props('icon=save color=blue').classes('flex-grow')
ui.button('Testar', on_click=test_connection).props('icon=send color=green')
ui.button('Salvar', on_click=save_tg).classes('flex-grow'); ui.button('Testar', on_click=test_tg).props('color=green')