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__/settings.cpython-311.pyc
app/ui/__pycache__/manual_tools.cpython-311.pyc app/ui/__pycache__/manual_tools.cpython-311.pyc
app/__pycache__/main.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.") state.log("⚠️ AVISO: Nenhum perfil FFmpeg ativo! Usando defaults.")
def get_file_info(self, filepath): def get_file_info(self, filepath):
"""Extrai metadados do arquivo usando ffprobe"""
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath] cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', filepath]
try: try:
output = subprocess.check_output(cmd).decode('utf-8') output = subprocess.check_output(cmd).decode('utf-8')
@@ -34,68 +33,74 @@ class FFmpegEngine:
metadata = self.get_file_info(input_file) metadata = self.get_file_info(input_file)
if not metadata: raise Exception("Arquivo inválido.") 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) 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' 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'] cmd = ['ffmpeg', '-y']
video_filters = []
# --- INPUT (LEITURA) --- # --- LÓGICA MODULAR DE HARDWARE ---
if 'vaapi' in p.video_codec:
# Inicializa o dispositivo VAAPI # 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(['-init_hw_device', 'vaapi=intel:/dev/dri/renderD128'])
cmd.extend(['-filter_hw_device', 'intel']) cmd.extend(['-filter_hw_device', 'intel'])
if can_hw_decode: # Verificação de Modo Híbrido (Intel 4th Gen / Compatibilidade)
# Se a GPU sabe ler, usa aceleração total 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', 'vaapi'])
cmd.extend(['-hwaccel_output_format', 'vaapi']) cmd.extend(['-hwaccel_output_format', 'vaapi'])
cmd.extend(['-hwaccel_device', 'intel']) 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: # 2. NVIDIA NVENC (Se suportado pelo container/host)
cmd.extend(['-hwaccel', 'qsv', '-c:v', 'h264_qsv']) elif p.hardware_type == 'nvenc':
elif 'nvenc' in p.video_codec:
cmd.extend(['-hwaccel', 'cuda']) 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]) cmd.extend(['-threads', '4', '-i', input_file])
# --- PROCESSAMENTO E SAÍDA --- # --- VÍDEO OUTPUT ---
cmd.extend(['-map', '0:v:0']) cmd.extend(['-map', '0:v:0'])
video_filters = []
if p.video_codec == 'copy': if p.video_codec == 'copy':
cmd.extend(['-c:v', 'copy']) cmd.extend(['-c:v', 'copy'])
else: else:
# Se for VAAPI Híbrido (leitura CPU), precisamos subir pra GPU # Aplica filtros acumulados
if 'vaapi' in p.video_codec and not can_hw_decode:
video_filters.append('format=nv12,hwupload')
if video_filters: if video_filters:
cmd.extend(['-vf', ",".join(video_filters)]) cmd.extend(['-vf', ",".join(video_filters)])
cmd.extend(['-c:v', p.video_codec]) cmd.extend(['-c:v', p.video_codec])
# Qualidade baseada no encoder
if 'vaapi' in p.video_codec: 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: elif 'nvenc' in p.video_codec:
cmd.extend(['-cq', str(p.crf), '-preset', p.preset]) 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]) 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(',')] 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'] 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']) cmd.extend([f'-c:s:{scount}', 'copy'])
scount += 1 scount += 1
# Metadados
clean_title = os.path.splitext(os.path.basename(output_file))[0] clean_title = os.path.splitext(os.path.basename(output_file))[0]
cmd.extend(['-metadata', f'title={clean_title}']) cmd.extend(['-metadata', f'title={clean_title}'])
cmd.append(output_file) cmd.append(output_file)

View File

@@ -36,20 +36,27 @@ class Category(BaseModel):
class FFmpegProfile(BaseModel): class FFmpegProfile(BaseModel):
name = CharField() name = CharField()
# Configuração de Codec
video_codec = CharField(default='h264_vaapi') video_codec = CharField(default='h264_vaapi')
preset = CharField(default='medium') preset = CharField(default='medium')
crf = IntegerField(default=23) 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') audio_langs = CharField(default='por,eng,jpn')
subtitle_langs = CharField(default='por') subtitle_langs = CharField(default='por')
is_active = BooleanField(default=False) is_active = BooleanField(default=False)
def init_db(): def init_db():
# --- CORREÇÃO AQUI: Verifica se já está conectado ---
if db.is_closed(): if db.is_closed():
db.connect() db.connect()
db.create_tables([AppConfig, Category, FFmpegProfile], safe=True) 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"') try: db.execute_sql('ALTER TABLE category ADD COLUMN content_type VARCHAR DEFAULT "mixed"')
except: pass except: pass
try: db.execute_sql('ALTER TABLE category ADD COLUMN genre_filters VARCHAR DEFAULT ""') 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 ""') try: db.execute_sql('ALTER TABLE category ADD COLUMN country_filters VARCHAR DEFAULT ""')
except: pass except: pass
if FFmpegProfile.select().count() == 0: # Migração FFmpeg (Hardware Type)
FFmpegProfile.create(name="Padrão VAAPI (Intel)", video_codec="h264_vaapi", is_active=True) 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
# Não fechamos a conexão aqui para manter o pool ativo no container # Perfil padrão se estiver vazio
if FFmpegProfile.select().count() == 0:
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 os
import shutil import shutil
import asyncio import asyncio
import datetime import datetime
import subprocess import subprocess
import json 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" 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): async def get_human_size(size):
for unit in ['B', 'KB', 'MB', 'GB']: for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024: return f"{size:.2f} {unit}" if size < 1024: return f"{size:.2f} {unit}"
size /= 1024 size /= 1024
return f"{size:.2f} TB" 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): async def get_media_info_async(filepath):
def _probe(): def _probe():
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath] 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) data = await run.io_bound(_probe)
if not data: return None if not data: return None
# Tratamento de erros se chaves não existirem
fmt = data.get('format', {}) fmt = data.get('format', {})
info = { info = {
"filename": os.path.basename(filepath), "filename": os.path.basename(filepath),
"size": int(fmt.get('size', 0)), "size": int(fmt.get('size', 0)),
@@ -37,28 +114,16 @@ async def get_media_info_async(filepath):
"bitrate": int(fmt.get('bit_rate', 0)), "bitrate": int(fmt.get('bit_rate', 0)),
"video": [], "audio": [], "subtitle": [] "video": [], "audio": [], "subtitle": []
} }
for s in data.get('streams', []): for s in data.get('streams', []):
stype = s.get('codec_type') stype = s.get('codec_type')
lang = s.get('tags', {}).get('language', 'und').upper() lang = s.get('tags', {}).get('language', 'und').upper()
codec = s.get('codec_name', 'unknown').upper() codec = s.get('codec_name', 'unknown').upper()
if stype == 'video': if stype == 'video':
info['video'].append({ info['video'].append({"codec": codec, "res": f"{s.get('width','?')}x{s.get('height','?')}", "fps": s.get('r_frame_rate', '')})
"codec": codec,
"res": f"{s.get('width','?')}x{s.get('height','?')}",
"fps": s.get('r_frame_rate', '')
})
elif stype == 'audio': elif stype == 'audio':
info['audio'].append({ info['audio'].append({"lang": lang, "codec": codec, "ch": s.get('channels', 0), "title": s.get('tags', {}).get('title', '')})
"lang": lang,
"codec": codec,
"ch": s.get('channels', 0),
"title": s.get('tags', {}).get('title', '')
})
elif stype == 'subtitle': elif stype == 'subtitle':
info['subtitle'].append({"lang": lang, "codec": codec}) info['subtitle'].append({"lang": lang, "codec": codec})
return info return info
# --- CLASSE GERENCIADORA --- # --- CLASSE GERENCIADORA ---
@@ -68,7 +133,7 @@ class FileManager:
self.view_mode = 'grid' self.view_mode = 'grid'
self.is_selecting = False self.is_selecting = False
self.selected_items = set() self.selected_items = set()
self.search_term = "" self.cached_entries = []
self.refreshing = False self.refreshing = False
# Elementos UI # Elementos UI
@@ -78,35 +143,53 @@ class FileManager:
self.btn_select_mode = None self.btn_select_mode = None
self.header_row = 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 --- # --- NAVEGAÇÃO ---
async def navigate(self, path): async def navigate(self, path):
if os.path.exists(path) and os.path.isdir(path): if os.path.exists(path) and os.path.isdir(path):
self.path = path self.path = path
self.selected_items.clear() self.selected_items.clear()
self.is_selecting = False self.is_selecting = False
self.search_term = ""
self.update_footer_state() self.update_footer_state()
if self.header_row: if self.header_row:
self.header_row.clear() self.header_row.clear()
with self.header_row: with self.header_row: self.build_header_content()
self.build_header_content()
await self.refresh() await self.refresh()
else: else:
ui.notify('Caminho inválido.', type='negative') ui.notify('Caminho inválido.', type='negative')
async def navigate_up(self): async def navigate_up(self):
parent = os.path.dirname(self.path) parent = os.path.dirname(self.path)
# Permite subir até a raiz do sistema se necessário, mas idealmente trava no ROOT if parent and os.path.exists(parent): await self.navigate(parent)
# Se quiser travar: if self.path != ROOT_DIR: await self.navigate(parent)
if parent and os.path.exists(parent):
await self.navigate(parent)
# --- UPLOAD --- # --- UPLOAD ---
def open_upload_dialog(self): def open_upload_dialog(self):
with ui.dialog() as dialog, ui.card(): with ui.dialog() as dialog, ui.card():
ui.label(f'Upload para: {os.path.basename(self.path)}').classes('font-bold') ui.label(f'Upload para: {os.path.basename(self.path)}').classes('font-bold')
async def handle(e): async def handle(e):
try: try:
name = None name = None
@@ -115,66 +198,65 @@ class FileManager:
if not name: name = getattr(e, 'name', 'arquivo_sem_nome') if not name: name = getattr(e, 'name', 'arquivo_sem_nome')
content = b'' 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'): elif hasattr(e, 'file'):
if hasattr(e.file, 'seek'): await e.file.seek(0) if hasattr(e.file, 'seek'): await e.file.seek(0)
if hasattr(e.file, 'read'): content = await e.file.read() if hasattr(e.file, 'read'): content = await e.file.read()
if not content: if not content: ui.notify('Erro: Arquivo vazio', type='warning'); return
ui.notify('Erro: Arquivo vazio', type='warning'); return
target = os.path.join(self.path, name) 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') ui.notify(f'Sucesso: {name}', type='positive')
except Exception as ex: except Exception as ex: ui.notify(f'Erro: {ex}', type='negative')
ui.notify(f'Erro: {ex}', type='negative')
ui.upload(on_upload=handle, auto_upload=True, multiple=True).props('accept=*').classes('w-full') ui.upload(on_upload=handle, auto_upload=True, multiple=True).props('accept=*').classes('w-full')
ui.button('Fechar', on_click=lambda: (dialog.close(), self.safe_nav(self.path))).props('flat 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() dialog.open()
def _save_file_bytes(self, target, content_bytes): def _save_file(self, target, content):
with open(target, 'wb') as f: f.write(content_bytes) with open(target, 'wb') as f: f.write(content)
# --- SELEÇÃO --- # --- EDITOR DE TEXTO ---
def toggle_select_mode(self): async def open_text_editor(self, filepath):
self.is_selecting = not self.is_selecting if os.path.getsize(filepath) > 1024 * 1024:
if not self.is_selecting: self.selected_items.clear() ui.notify('Arquivo muito grande.', type='warning'); return
self.update_footer_state() try:
self.refresh_ui_only() 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 toggle_selection(self, item_path): with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl h-[80vh] flex flex-col'):
if item_path in self.selected_items: self.selected_items.remove(item_path) ui.label(f'Editando: {os.path.basename(filepath)}').classes('font-bold')
else: self.selected_items.add(item_path) editor = ui.textarea(value=content).classes('w-full flex-grow font-mono text-sm').props('outlined')
self.update_footer_state() async def save():
self.refresh_ui_only() # Refresh leve 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 select_all(self, entries): # --- BOT INTEGRATION ---
current = {e.path for e in entries} async def send_to_cleiflow(self, filepath):
if self.selected_items.issuperset(current): self.selected_items.difference_update(current) if not state.watcher: ui.notify('Watcher offline.', type='warning'); return
else: self.selected_items.update(current) ui.notify(f'Processando {os.path.basename(filepath)}...', type='info')
self.update_footer_state() asyncio.create_task(state.watcher.process_pipeline(Path(filepath)))
self.refresh_ui_only()
def update_footer_state(self): # --- EXCLUSÃO SEGURA ---
if self.footer: async def confirm_delete(self, items_to_delete):
self.footer.set_visibility(self.is_selecting) if not items_to_delete: return
if self.lbl_selection_count: is_single = len(items_to_delete) == 1
self.lbl_selection_count.text = f'{len(self.selected_items)} item(s)' 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(): with ui.dialog() as dialog, ui.card():
ui.label(f'Excluir {len(self.selected_items)} itens?').classes('font-bold text-red') ui.label(title).classes('text-xl font-bold text-red-600')
async def confirm(): ui.label(msg).classes('text-gray-700 whitespace-pre-wrap')
async def execute():
dialog.close() dialog.close()
count = 0 count = 0
items_copy = list(self.selected_items) for item in items_to_delete:
for item in items_copy:
try: try:
if os.path.isdir(item): await run.io_bound(shutil.rmtree, item) if os.path.isdir(item): await run.io_bound(shutil.rmtree, item)
else: await run.io_bound(os.remove, item) else: await run.io_bound(os.remove, item)
@@ -184,19 +266,21 @@ class FileManager:
self.selected_items.clear() self.selected_items.clear()
self.update_footer_state() self.update_footer_state()
await self.refresh() 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('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() dialog.open()
async def open_move_dialog(self, target_items=None): async def confirm_delete_selected(self):
items_to_move = target_items if target_items else list(self.selected_items) await self.confirm_delete(list(self.selected_items))
if not items_to_move:
ui.notify('Nada para mover.', type='warning'); return
# --- 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 browser_path = ROOT_DIR
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col p-4'): 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') 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') 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) ui.timer(0, lambda: load_folders(ROOT_DIR), once=True)
async def execute_move(): async def execute():
dialog.close(); ui.notify('Movendo...', type='info') dialog.close(); ui.notify('Movendo...', type='info')
count = 0 count = 0
for item in items_to_move: for item in items:
try: try:
tgt = os.path.join(browser_path, os.path.basename(item)) tgt = os.path.join(browser_path, os.path.basename(item))
if item != tgt: if item != tgt:
await run.io_bound(shutil.move, item, tgt) await run.io_bound(shutil.move, item, tgt)
count += 1 count += 1
except Exception as e: ui.notify(f"Erro: {e}", type='negative') except Exception as e: ui.notify(f"Erro: {e}", type='negative')
ui.notify(f'{count} movidos!', type='positive') ui.notify(f'{count} movidos!', type='positive')
if not target_items: self.selected_items.clear() if not target_items: self.selected_items.clear()
self.update_footer_state() self.update_footer_state()
await self.refresh() await self.refresh()
with ui.row().classes('w-full justify-between mt-auto'): with ui.row().classes('w-full justify-between mt-auto'):
ui.button('Cancelar', on_click=dialog.close).props('flat') 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() dialog.open()
# --- UI HELPERS ---
def open_rename_dialog(self, path): def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card(): with ui.dialog() as dialog, ui.card():
ui.label('Renomear'); name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full') 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) await run.io_bound(os.rename, path, new_path)
dialog.close(); await self.refresh() dialog.close(); await self.refresh()
except Exception as e: ui.notify(str(e), type='negative') 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() 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): async def refresh(self):
if self.refreshing: return if self.refreshing: return
self.refreshing = True self.refreshing = True
# Recarrega arquivos do disco
try: try:
entries = await run.io_bound(os.scandir, self.path) entries = await run.io_bound(os.scandir, self.path)
self.cached_entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower())) self.cached_entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
except Exception as e: except Exception as e:
self.cached_entries = [] self.cached_entries = []
ui.notify(f"Erro leitura: {e}", type='negative') ui.notify(f"Erro: {e}", type='negative')
self.refresh_ui_only() self.refresh_ui_only()
self.refreshing = False self.refreshing = False
def refresh_ui_only(self): def refresh_ui_only(self):
if self.container_content: if self.container_content:
self.container_content.clear() self.container_content.clear()
color = 'green' if self.is_selecting else 'grey' color = 'green' if self.is_selecting else 'grey'
if self.btn_select_mode: self.btn_select_mode.props(f'text-color={color}') if self.btn_select_mode: self.btn_select_mode.props(f'text-color={color}')
# Select All Bar
if self.is_selecting: if self.is_selecting:
with self.container_content: 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'): 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') 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: with self.container_content:
if not self.cached_entries: if not self.cached_entries:
ui.label('Pasta Vazia').classes('w-full text-center text-gray-400 mt-10') 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): def render_card(self, entry):
is_sel = entry.path in self.selected_items is_sel = entry.path in self.selected_items
is_dir = entry.is_dir() is_dir = entry.is_dir()
icon = 'folder' if is_dir else 'description' ftype = 'folder' if is_dir else get_file_type(entry.name)
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie' 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' 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: 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) 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: if self.is_selecting:
card.on('click', lambda: self.toggle_selection(entry.path)) 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()) 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: else:
if is_dir: card.on('click', lambda e, p=entry.path: self.navigate(p)) # CORREÇÃO: Usando safe_nav aqui também
elif icon == 'movie': card.on('click', lambda e, p=entry.path: self.open_inspector(p)) 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-auto z-10 bg-white/80 rounded px-1')
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): def render_list_item(self, entry):
is_sel = entry.path in self.selected_items 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' 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: 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: if self.is_selecting:
ui.checkbox(value=is_sel, on_change=lambda: self.toggle_selection(entry.path)).props('dense') ui.checkbox(value=is_sel, on_change=lambda: self.toggle_selection(entry.path)).props('dense')
row.on('click', lambda: self.toggle_selection(entry.path)) row.on('click', lambda: self.toggle_selection(entry.path))
else: 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) self.bind_context_menu(row, entry)
ui.icon(icon, color='amber' if entry.is_dir() else 'grey') ui.icon(icon, color=color.split('-')[0])
ui.label(entry.name).classes('text-sm font-medium flex-grow') ui.label(entry.name).classes('text-sm font-medium flex-grow truncate')
def bind_context_menu(self, element, entry): def bind_context_menu(self, element, entry):
ftype = 'folder' if entry.is_dir() else get_file_type(entry.name)
with ui.menu() as m: 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('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('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.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog([entry.path]))
ui.menu_item('Excluir', on_click=lambda: self.confirm_delete([entry.path])).props('text-color=red')
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')
element.on('contextmenu.prevent', lambda: m.open()) element.on('contextmenu.prevent', lambda: m.open())
# --- INSPECTOR --- # --- INSPECTOR ---
@@ -407,28 +513,22 @@ class FileManager:
with ui.row().classes('w-full justify-between items-start'): 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.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.button(icon='close', on_click=dialog.close).props('flat dense round')
content = ui.column().classes('w-full') content = ui.column().classes('w-full')
with content: ui.spinner('dots').classes('self-center') with content: ui.spinner('dots').classes('self-center')
dialog.open() dialog.open()
info = await get_media_info_async(path) info = await get_media_info_async(path)
content.clear() content.clear()
if not info: if not info:
with content: ui.label('Erro ao ler metadados.').classes('text-red'); return with content: ui.label('Erro ao ler metadados.').classes('text-red'); return
with content: with content:
with ui.row().classes('w-full bg-blue-50 p-2 rounded mb-4 gap-4'): 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"⏱️ {datetime.timedelta(seconds=int(info['duration']))}")
ui.label(f"📦 {await get_human_size(info['size'])}") ui.label(f"📦 {await get_human_size(info['size'])}")
ui.label(f"🚀 {int(info['bitrate']/1000)} kbps") ui.label(f"🚀 {int(info['bitrate']/1000)} kbps")
ui.label('Vídeo').classes('text-xs font-bold text-gray-500 uppercase mt-2') ui.label('Vídeo').classes('text-xs font-bold text-gray-500 uppercase mt-2')
for v in info['video']: for v in info['video']:
with ui.card().classes('w-full p-2 bg-gray-50 border-l-4 border-blue-500'): 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(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') ui.label('Áudio').classes('text-xs font-bold text-gray-500 uppercase mt-4')
if info['audio']: if info['audio']:
with ui.grid().classes('grid-cols-[auto_1fr_auto] w-full gap-2 text-sm'): 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(a['lang']).classes('font-bold bg-gray-200 px-2 rounded text-center')
ui.label(f"{a['codec']} - {a['title']}") ui.label(f"{a['codec']} - {a['title']}")
ui.label(str(a['ch'])).classes('text-gray-500') ui.label(str(a['ch'])).classes('text-gray-500')
ui.label('Legendas').classes('text-xs font-bold text-gray-500 uppercase mt-4') ui.label('Legendas').classes('text-xs font-bold text-gray-500 uppercase mt-4')
if info['subtitle']: if info['subtitle']:
with ui.row().classes('w-full gap-2'): with ui.row().classes('w-full gap-2'):
@@ -447,4 +546,5 @@ class FileManager:
def show(): def show():
fm = FileManager() fm = FileManager()
fm.create_layout() 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 os
import asyncio import asyncio
from database import Category, FFmpegProfile, AppConfig from database import Category, FFmpegProfile, AppConfig
from core.state import state # <--- ACESSAMOS O BOT POR AQUI AGORA from core.state import state
# --- CONSTANTES --- # --- CONSTANTES ---
TMDB_GENRES = { TMDB_GENRES = {
@@ -26,315 +26,261 @@ ISO_LANGS = {
'zho': 'Chinês (zho)', 'und': 'Indefinido (und)' 'zho': 'Chinês (zho)', 'und': 'Indefinido (und)'
} }
# --- SELETOR DE PASTAS FLEXÍVEL --- # 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='/'): 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 if not start_path: start_path = allowed_root
if allowed_root != '/' and not start_path.startswith(allowed_root): 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
result = {'path': None} result = {'path': None}
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col'): 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') 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') 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') scroll = ui.scroll_area().classes('flex-grow border rounded p-1 mt-2 bg-white')
async def load_dir(path): 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
if allowed_root != '/' and not path.startswith(allowed_root): path_label.text = path; scroll.clear()
path = allowed_root
path_label.text = path
scroll.clear()
try: try:
# Botão Voltar (..) if path != '/' and path != allowed_root:
# 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:
parent = os.path.dirname(path) parent = os.path.dirname(path)
with scroll: with scroll: ui.button('.. (Voltar)', on_click=lambda: load_dir(parent)).props('flat dense icon=arrow_upward align=left w-full')
ui.button('.. (Voltar)', on_click=lambda: load_dir(parent)).props('flat dense icon=arrow_upward align=left w-full')
# Lista Pastas
with scroll: with scroll:
if os.path.exists(path): 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 sorted([e for e in os.scandir(path) if e.is_dir()], key=lambda x: x.name.lower()):
for entry in entries:
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') 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: except Exception as e:
with scroll: ui.label(f'Acesso negado: {e}').classes('text-red text-xs') with scroll: ui.label(f'Erro: {e}').classes('text-red text-xs')
def select_this(): result['path'] = path_label.text; dialog.close()
def select_this():
result['path'] = path_label.text
dialog.close()
with ui.row().classes('w-full justify-between mt-auto pt-2'): 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('Cancelar', on_click=dialog.close).props('flat color=grey')
ui.button('Selecionar Esta', on_click=select_this).props('flat icon=check color=green') ui.button('Selecionar', on_click=select_this).props('flat icon=check color=green')
await load_dir(start_path); await dialog
await load_dir(start_path)
await dialog
return result['path'] return result['path']
# --- TELA PRINCIPAL --- # --- TELA PRINCIPAL ---
def show(): def show():
# Helper para pegar o bot de forma segura via State def get_bot(): return state.watcher.bot if state.watcher else None
def get_bot():
if state.watcher and state.watcher.bot:
return state.watcher.bot
return None
with ui.column().classes('w-full p-6'): with ui.column().classes('w-full p-6'):
ui.label('Configurações').classes('text-3xl font-light text-gray-800 mb-4') ui.label('Configurações').classes('text-3xl font-light text-gray-800 mb-4')
with ui.tabs().classes('w-full') as tabs: with ui.tabs().classes('w-full') as tabs:
tab_ident = ui.tab('Identificação', icon='search') tab_ident = ui.tab('Identificação', icon='search')
tab_cats = ui.tab('Categorias', icon='category') 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_ffmpeg = ui.tab('Motor (FFmpeg)', icon='movie')
tab_telegram = ui.tab('Telegram', icon='send') tab_telegram = ui.tab('Telegram', icon='send')
with ui.tab_panels(tabs).classes('w-full bg-gray-50 p-4 rounded border'): 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.tab_panel(tab_ident):
with ui.card().classes('w-full max-w-2xl mx-auto p-6'): 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') ui.label('🔍 Configuração do Identificador').classes('text-2xl font-bold mb-4 text-indigo-600')
tmdb_key = AppConfig.get_val('tmdb_api_key', '') 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') 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'): 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.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)))) 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): with ui.tab_panel(tab_cats):
ui.label('Regras de Organização').classes('text-xl text-gray-700 mb-2') 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') cats_container = ui.column().classes('w-full gap-2')
form_card = ui.card().classes('w-full mb-4 bg-gray-100 p-4') form_card = ui.card().classes('w-full mb-4 bg-gray-100 p-4')
editing_id = {'val': None}
def load_cats(): def load_cats():
cats_container.clear() cats_container.clear()
cats = list(Category.select()) for cat in list(Category.select()):
for cat in cats:
g_ids = cat.genre_filters.split(',') if cat.genre_filters else [] g_ids = cat.genre_filters.split(',') if cat.genre_filters else []
c_codes = cat.country_filters.split(',') if cat.country_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] 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] c_names = [COMMON_COUNTRIES.get(cc, cc) for cc in c_codes if cc]
desc = f"{cat.content_type.upper()}" desc = f"{cat.content_type.upper()}"
if g_names: desc += f" | {', '.join(g_names)}" if g_names: desc += f" | {', '.join(g_names)}"
if c_names: desc += f" | {', '.join(c_names)}" if c_names: desc += f" | {', '.join(c_names)}"
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'
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'
with cats_container, ui.card().classes(f'w-full flex-row items-center justify-between p-3 border {color_cls}'): 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'): 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('movie' if cat.content_type == 'movie' else 'tv').classes('text-gray-600')
ui.icon(icon).classes('text-gray-600')
with ui.column().classes('gap-0'): with ui.column().classes('gap-0'):
ui.label(cat.name).classes('font-bold text-lg') ui.label(cat.name).classes('font-bold text-lg')
ui.label(desc).classes('text-xs text-gray-500') ui.label(desc).classes('text-xs text-gray-500')
with ui.row(): with ui.row():
ui.button(icon='edit', on_click=lambda c=cat: start_edit(c)).props('flat dense color=blue') 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): def start_edit(cat):
editing_id['val'] = cat.id editing_id['val'] = cat.id
name_input.value = cat.name name_input.value = cat.name; type_select.value = cat.content_type
type_select.value = cat.content_type
genre_select.value = cat.genre_filters.split(',') if cat.genre_filters else [] genre_select.value = cat.genre_filters.split(',') if cat.genre_filters else []
country_select.value = cat.country_filters.split(',') if cat.country_filters else [] country_select.value = cat.country_filters.split(',') if cat.country_filters else []
btn_save.text = "Atualizar"; btn_cancel.classes(remove='hidden')
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')
def cancel_edit(): def cancel_edit():
editing_id['val'] = None editing_id['val'] = None; name_input.value = ''; genre_select.value = []; country_select.value = []
name_input.value = '' btn_save.text = "Adicionar"; btn_cancel.classes(add='hidden')
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')
def save_cat(): def save_cat():
if not name_input.value: return if not name_input.value: return
g_str = ",".join(genre_select.value) if genre_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)}
c_str = ",".join(country_select.value) if country_select.value else "" if not editing_id['val']: data['target_path'] = f"/media/{name_input.value}"
try: if editing_id['val']: Category.update(**data).where(Category.id == editing_id['val']).execute()
if editing_id['val']: else: Category.create(**data)
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): # Correção: Notifica ANTES de recarregar a lista (para não perder o contexto do botão)
cat.delete_instance() ui.notify('Salvo!', type='positive')
if editing_id['val'] == cat.id: cancel_edit() cancel_edit(); load_cats();
load_cats()
with form_card: with form_card:
with ui.row().classes('w-full justify-between items-center'): with ui.row().classes('w-full justify-between'):
form_label = ui.label('Nova Biblioteca').classes('font-bold text-gray-700') ui.label('Editor').classes('font-bold'); btn_cancel = ui.button('Cancelar', on_click=cancel_edit).props('flat dense color=red').classes('hidden')
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'): with ui.grid(columns=4).classes('w-full gap-2'):
name_input = ui.input('Nome') name_input = ui.input('Nome')
type_select = ui.select({'mixed': 'Misto', 'movie': 'Só Filmes', 'series': 'Só Séries'}, value='mixed', label='Tipo') 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') 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') country_select = ui.select(COMMON_COUNTRIES, multiple=True, label='Países').props('use-chips')
btn_save = ui.button('Adicionar', on_click=save_cat).props('icon=add color=green').classes('mt-2 w-full')
btn_save = ui.button('Adicionar Regra', on_click=save_cat).props('icon=add color=green').classes('mt-2 w-full')
load_cats() load_cats()
# --- ABA 3: DEPLOY (DESTINOS VS ORIGEM) --- # --- DEPLOY ---
with ui.tab_panel(tab_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'): 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') monitor_path = AppConfig.get_val('monitor_path', '/downloads')
async def pick_source(): async def pick_source():
# AQUI: allowed_root='/' permite navegar em tudo (incluindo /downloads)
p = await pick_folder_dialog(mon_input.value, allowed_root='/') p = await pick_folder_dialog(mon_input.value, allowed_root='/')
if p: mon_input.value = p if p: mon_input.value = p
with ui.row().classes('w-full items-center gap-2'): 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') 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(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') 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('📂 Destinos (/media)').classes('text-xl text-gray-700 mt-4')
ui.label('📂 Mapeamento de Destinos (/media)').classes('text-xl text-gray-700 mt-4')
paths_container = ui.column().classes('w-full gap-2') paths_container = ui.column().classes('w-full gap-2')
def load_deploy_paths(): def load_deploy_paths():
paths_container.clear() paths_container.clear()
for cat in list(Category.select()): for cat in list(Category.select()):
with paths_container, ui.card().classes('w-full p-4 flex-row items-center gap-4'): 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') ui.label(cat.name).classes('font-bold w-32')
path_input = ui.input(value=cat.target_path).classes('flex-grow font-mono') path_input = ui.input(value=cat.target_path).classes('flex-grow font-mono')
async def pick_dest(i=path_input): 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') p = await pick_folder_dialog(i.value, allowed_root='/media')
if p: i.value = p if p: i.value = p
ui.button(icon='folder', on_click=pick_dest).props('flat dense color=amber-8') 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') 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() load_deploy_paths()
# --- ABA 4: FFMPEG --- # --- MOTOR (FFMPEG MODULAR) ---
with ui.tab_panel(tab_ffmpeg): with ui.tab_panel(tab_ffmpeg):
ui.label('Perfis de Conversão').classes('text-xl text-gray-700') ui.label('Gerenciador de Perfis').classes('text-xl text-gray-700')
profiles_query = list(FFmpegProfile.select()) profiles_container = ui.column().classes('w-full gap-4')
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): def load_ffmpeg_profiles():
if not e.value: return profiles_container.clear()
FFmpegProfile.update(is_active=False).execute() profiles = list(FFmpegProfile.select())
FFmpegProfile.update(is_active=True).where(FFmpegProfile.id == int(e.value)).execute() active_p = next((p for p in profiles if p.is_active), None)
ui.notify(f'Perfil Ativado!', type='positive')
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') 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')
for p in profiles_query: with profiles_container:
with ui.expansion(f"{p.name}", icon='tune').classes('w-full bg-white mb-2 border rounded'): 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')
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 save_profile(prof=p, n=c_name, c=c_codec, pr=c_preset, cr=c_crf, a=c_audio, s=c_sub): for p in profiles:
prof.name = n.value; prof.video_codec = c.value; prof.preset = pr.value with ui.expansion(f"{p.name} ({p.hardware_type.upper()})", icon='tune').classes('w-full bg-white mb-2 border rounded'):
prof.crf = int(cr.value); prof.audio_langs = ",".join(a.value); prof.subtitle_langs = ",".join(s.value) with ui.column().classes('p-4 w-full'):
prof.save() with ui.grid(columns=2).classes('w-full gap-4'):
profiles_dict[prof.id] = prof.name c_name = ui.input('Nome do Perfil', value=p.name)
select_profile.options = profiles_dict c_hw = ui.select(HARDWARE_OPTS, value=p.hardware_type, label='Tipo de Hardware')
select_profile.update()
ui.notify('Salvo!')
ui.button('Salvar', on_click=save_profile).classes('mt-2')
# --- ABA 5: TELEGRAM --- 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)
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
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.tab_panel(tab_telegram):
with ui.card().classes('w-full max-w-lg mx-auto p-6'): 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') ui.label('🤖 Integração Telegram').classes('text-2xl font-bold mb-4 text-blue-600')
t_tok = ui.input('Bot Token', value=AppConfig.get_val('telegram_token', '')).props('password').classes('w-full mb-2')
token_input = 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')
chat_input = 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)
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')
bot = get_bot() bot = get_bot()
if bot: if bot: await bot.restart()
await bot.restart() ui.notify('Salvo!', type='positive')
ui.notify('Bot Reiniciado!', type='positive') async def test_tg():
else:
ui.notify('Salvo, mas Bot não está rodando.', type='warning')
async def test_connection():
bot = get_bot() bot = get_bot()
if bot: if bot and await bot.send_test_msg(): ui.notify('Sucesso!', type='positive')
ui.notify('Enviando mensagem...') else: ui.notify('Falha.', type='negative')
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')
with ui.row().classes('w-full gap-2'): 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('Salvar', on_click=save_tg).classes('flex-grow'); ui.button('Testar', on_click=test_tg).props('color=green')
ui.button('Testar', on_click=test_connection).props('icon=send color=green')