adicionado explorer e quse tudo funcionando coretamente

This commit is contained in:
2026-02-10 23:31:27 +00:00
parent bd59ba234c
commit c8f2dc32ff
7 changed files with 682 additions and 164 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ app/core/__pycache__/renamer.cpython-311.pyc
app/core/__pycache__/state.cpython-311.pyc app/core/__pycache__/state.cpython-311.pyc
app/core/__pycache__/watcher.cpython-311.pyc app/core/__pycache__/watcher.cpython-311.pyc
app/data/clei.db 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

View File

@@ -1,23 +1,21 @@
import asyncio import asyncio
import httpx # Usamos para testar a rede antes import httpx
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ApplicationBuilder, ContextTypes, CallbackQueryHandler, CommandHandler from telegram.ext import ApplicationBuilder, ContextTypes, CallbackQueryHandler, CommandHandler
from database import AppConfig from database import AppConfig
import logging import logging
# Configuração de Log
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("telegram").setLevel(logging.INFO) logging.getLogger("telegram").setLevel(logging.INFO)
class TelegramManager: class TelegramManager:
def __init__(self): def __init__(self):
# NÃO carregamos o token aqui. Carregamos na hora de iniciar.
self.app = None self.app = None
self.active_requests = {} self.active_requests = {}
self.is_connected = False self.is_connected = False
async def check_internet(self): async def check_internet(self):
"""Testa se o container tem internet antes de tentar o Telegram""" """Testa se o container tem internet"""
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
await client.get("https://www.google.com") await client.get("https://www.google.com")
@@ -27,85 +25,87 @@ class TelegramManager:
async def start(self): async def start(self):
"""Inicia o Bot""" """Inicia o Bot"""
# 1. Pega o Token Fresquinho do Banco
token = AppConfig.get_val('telegram_token') token = AppConfig.get_val('telegram_token')
chat_id = AppConfig.get_val('telegram_chat_id') chat_id = AppConfig.get_val('telegram_chat_id')
if not token: if not token:
print("🟡 Bot: Token não configurado. Aguardando você salvar no Painel...") print("🟡 Bot: Token não configurado.")
return return
# 2. Teste de Rede Prévio if self.is_connected: return
print("🤖 Bot: Verificando conectividade...")
print("🤖 Bot: Iniciando conexão...")
if not await self.check_internet(): if not await self.check_internet():
print("❌ Bot: ERRO DE REDE! O container não consegue acessar a internet.") print("❌ Bot: Sem internet.")
print(" -> Verifique DNS ou Firewall.")
return return
print(f"🤖 Bot: Conectando com token termina em ...{token[-5:]}")
try: try:
# 3. Constroi a Aplicação
self.app = ApplicationBuilder().token(token).build() self.app = ApplicationBuilder().token(token).build()
# Handlers
self.app.add_handler(CommandHandler("start", self.cmd_start)) self.app.add_handler(CommandHandler("start", self.cmd_start))
self.app.add_handler(CommandHandler("id", self.cmd_id)) self.app.add_handler(CommandHandler("id", self.cmd_id))
self.app.add_handler(CallbackQueryHandler(self.handle_selection)) self.app.add_handler(CallbackQueryHandler(self.handle_selection))
# Inicializa
await self.app.initialize() await self.app.initialize()
await self.app.start() await self.app.start()
# Inicia Polling (Limpa mensagens velhas acumuladas para não travar)
await self.app.updater.start_polling(drop_pending_updates=True) await self.app.updater.start_polling(drop_pending_updates=True)
self.is_connected = True self.is_connected = True
print("✅ Bot Online e Rodando!") print("✅ Bot Online!")
# Tenta mandar um oi se tiver chat_id
if chat_id:
try:
await self.app.bot.send_message(chat_id=chat_id, text="🚀 Clei-Flow: Conexão restabelecida!")
except Exception as e:
print(f"⚠️ Bot online, mas falhou ao enviar msg (Chat ID errado?): {e}")
except Exception as e: except Exception as e:
print(f"❌ Falha Crítica no Bot: {e}") print(f"❌ Falha no Bot: {e}")
self.is_connected = False self.is_connected = False
async def stop(self): async def stop(self):
"""Para o Bot"""
if self.app: if self.app:
try: try:
if self.app.updater.running:
await self.app.updater.stop() await self.app.updater.stop()
if self.app.running:
await self.app.stop() await self.app.stop()
await self.app.shutdown() await self.app.shutdown()
except Exception as e:
print(f"Erro ao parar bot: {e}")
finally:
self.is_connected = False self.is_connected = False
except: pass self.app = None
async def restart(self):
"""Reinicia a conexão (Útil após mudar token)"""
print("🔄 Bot: Reiniciando serviço...")
await self.stop()
await asyncio.sleep(1)
await self.start()
# --- COMANDOS --- # --- COMANDOS ---
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
await update.message.reply_text(f"Olá! Configurado.\nSeu Chat ID é: `{chat_id}`", parse_mode='Markdown') await update.message.reply_text(f"Olá! Configurado.\nSeu Chat ID é: `{chat_id}`", parse_mode='Markdown')
# Opcional: Salvar o Chat ID automaticamente se o usuário mandar /start
# AppConfig.set_val('telegram_chat_id', str(chat_id))
async def cmd_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_id(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(f"`{update.effective_chat.id}`", parse_mode='Markdown') await update.message.reply_text(f"`{update.effective_chat.id}`", parse_mode='Markdown')
# --- INTERAÇÃO (Renomeação) --- async def send_test_msg(self):
"""Envia mensagem de teste"""
if not self.is_connected: return False
chat_id = AppConfig.get_val('telegram_chat_id')
if not chat_id: return False
try:
await self.app.bot.send_message(chat_id=chat_id, text="🚀 Clei-Flow: Teste de conexão bem sucedido!")
return True
except:
return False
# --- INTERAÇÃO ---
async def ask_user_choice(self, filename, candidates): async def ask_user_choice(self, filename, candidates):
chat_id = AppConfig.get_val('telegram_chat_id') # Pega sempre o atual chat_id = AppConfig.get_val('telegram_chat_id')
if not chat_id or not self.is_connected: if not chat_id or not self.is_connected: return None
print("❌ Bot não pode perguntar (Sem Chat ID ou Desconectado)")
return None
request_id = f"req_{filename}" request_id = f"req_{filename}"
keyboard = [] keyboard = []
for cand in candidates: for cand in candidates:
# Texto do botão
text = f"{cand['title']} ({cand['year']})" text = f"{cand['title']} ({cand['year']})"
# Dados (ID|Tipo)
callback_data = f"{request_id}|{cand['tmdb_id']}|{cand['type']}" callback_data = f"{request_id}|{cand['tmdb_id']}|{cand['type']}"
keyboard.append([InlineKeyboardButton(text, callback_data=callback_data)]) keyboard.append([InlineKeyboardButton(text, callback_data=callback_data)])
@@ -115,20 +115,17 @@ class TelegramManager:
try: try:
await self.app.bot.send_message( await self.app.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text=f"🤔 <b>Clei-Flow Precisa de Ajuda:</b>\nArquivo: <code>{filename}</code>", text=f"🤔 <b>Clei-Flow:</b>\nArquivo: <code>{filename}</code>",
reply_markup=reply_markup, reply_markup=reply_markup,
parse_mode='HTML' parse_mode='HTML'
) )
except Exception as e: except: return None
print(f"Erro ao enviar pergunta: {e}")
return None
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
future = loop.create_future() future = loop.create_future()
self.active_requests[request_id] = future self.active_requests[request_id] = future
try: try:
# Espera 12 horas
result = await asyncio.wait_for(future, timeout=43200) result = await asyncio.wait_for(future, timeout=43200)
return result return result
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -156,7 +153,7 @@ class TelegramManager:
future.set_result({'tmdb_id': int(tmdb_id), 'type': media_type}) future.set_result({'tmdb_id': int(tmdb_id), 'type': media_type})
del self.active_requests[req_id] del self.active_requests[req_id]
else: else:
await query.edit_message_text(text="⚠️ Solicitação expirada.") await query.edit_message_text(text="⚠️ Expirado.")
async def send_notification(self, message): async def send_notification(self, message):
chat_id = AppConfig.get_val('telegram_chat_id') chat_id = AppConfig.get_val('telegram_chat_id')

View File

@@ -29,12 +29,10 @@ class AppConfig(BaseModel):
class Category(BaseModel): class Category(BaseModel):
name = CharField(unique=True) name = CharField(unique=True)
target_path = CharField() target_path = CharField()
match_keywords = CharField(null=True) # Mantido para legado, mas vamos priorizar os filtros abaixo match_keywords = CharField(null=True)
content_type = CharField(default='mixed') # movie, series, mixed content_type = CharField(default='mixed')
genre_filters = CharField(null=True)
# NOVOS CAMPOS DE FILTRAGEM country_filters = CharField(null=True)
genre_filters = CharField(null=True) # Ex: "16,28,35" (IDs do TMDb)
country_filters = CharField(null=True) # Ex: "JP,US,BR" (Siglas ISO)
class FFmpegProfile(BaseModel): class FFmpegProfile(BaseModel):
name = CharField() name = CharField()
@@ -46,21 +44,20 @@ class FFmpegProfile(BaseModel):
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():
db.connect() db.connect()
db.create_tables([AppConfig, Category, FFmpegProfile], safe=True) db.create_tables([AppConfig, Category, FFmpegProfile], safe=True)
# Migrações Seguras (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 ""')
except: pass except: pass
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
# Perfil padrão
if FFmpegProfile.select().count() == 0: if FFmpegProfile.select().count() == 0:
FFmpegProfile.create(name="Padrão VAAPI (Intel)", video_codec="h264_vaapi", is_active=True) FFmpegProfile.create(name="Padrão VAAPI (Intel)", video_codec="h264_vaapi", is_active=True)
db.close() # Não fechamos a conexão aqui para manter o pool ativo no container

View File

@@ -1,2 +1,450 @@
from nicegui import ui from nicegui import ui, run
# Abas antigas (Renomeador Manual, Encoder Manual) viram ferramentas aqui import os
import shutil
import asyncio
import datetime
import subprocess
import json
# Define a raiz como /downloads, mas permite navegar se o container tiver permissão
ROOT_DIR = "/downloads"
# --- UTILITÁRIOS ASSÍNCRONOS ---
async def get_human_size(size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024: return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TB"
async def get_media_info_async(filepath):
def _probe():
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return json.loads(res.stdout)
except: return None
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)),
"duration": float(fmt.get('duration', 0)),
"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', '')
})
elif stype == 'audio':
info['audio'].append({
"lang": lang,
"codec": codec,
"ch": s.get('channels', 0),
"title": s.get('tags', {}).get('title', '')
})
elif stype == 'subtitle':
info['subtitle'].append({"lang": lang, "codec": codec})
return info
# --- CLASSE GERENCIADORA ---
class FileManager:
def __init__(self):
self.path = ROOT_DIR
self.view_mode = 'grid'
self.is_selecting = False
self.selected_items = set()
self.search_term = ""
self.refreshing = False
# Elementos UI
self.container_content = None
self.footer = None
self.lbl_selection_count = None
self.btn_select_mode = None
self.header_row = None
# --- 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()
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)
# --- 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
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
target = os.path.join(self.path, name)
await run.io_bound(self._save_file_bytes, target, content)
ui.notify(f'Sucesso: {name}', type='positive')
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')
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 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
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)'
# --- 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():
dialog.close()
count = 0
items_copy = list(self.selected_items)
for item in items_copy:
try:
if os.path.isdir(item): await run.io_bound(shutil.rmtree, item)
else: await run.io_bound(os.remove, item)
count += 1
except: pass
ui.notify(f'{count} excluídos.', type='positive')
self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-end'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Confirmar', on_click=confirm).props('color=red')
dialog.open()
async def open_move_dialog(self, target_items=None):
items_to_move = target_items if target_items else list(self.selected_items)
if not items_to_move:
ui.notify('Nada para mover.', type='warning'); return
browser_path = ROOT_DIR
with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col p-4'):
ui.label(f'Mover {len(items_to_move)} item(s) para...').classes('font-bold')
lbl_path = ui.label(ROOT_DIR).classes('text-xs bg-gray-100 p-2 w-full truncate border rounded')
scroll = ui.scroll_area().classes('flex-grow border rounded p-1 bg-white')
async def load_folders(p):
nonlocal browser_path; browser_path = p
lbl_path.text = p; scroll.clear()
try:
entries = await run.io_bound(os.scandir, p)
sorted_e = sorted([e for e in entries if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower())
with scroll:
parent = os.path.dirname(p)
if parent and os.path.exists(parent):
ui.button('..', on_click=lambda: load_folders(parent)).props('flat dense icon=arrow_upward w-full align=left')
for e in sorted_e:
ui.button(e.name, on_click=lambda path=e.path: load_folders(path)).props('flat dense w-full align=left icon=folder color=amber')
except: pass
ui.timer(0, lambda: load_folders(ROOT_DIR), once=True)
async def execute_move():
dialog.close(); ui.notify('Movendo...', type='info')
count = 0
for item in items_to_move:
try:
tgt = os.path.join(browser_path, os.path.basename(item))
if item != tgt:
await run.io_bound(shutil.move, item, tgt)
count += 1
except Exception as e: ui.notify(f"Erro: {e}", type='negative')
ui.notify(f'{count} movidos!', type='positive')
if not target_items: self.selected_items.clear()
self.update_footer_state()
await self.refresh()
with ui.row().classes('w-full justify-between mt-auto'):
ui.button('Cancelar', on_click=dialog.close).props('flat')
ui.button('Mover Aqui', on_click=execute_move).props('color=green icon=drive_file_move')
dialog.open()
# --- 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()
def open_rename_dialog(self, path):
with ui.dialog() as dialog, ui.card():
ui.label('Renomear'); name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full')
async def save():
try:
new_path = os.path.join(os.path.dirname(path), name.value)
await run.io_bound(os.rename, path, new_path)
dialog.close(); await self.refresh()
except Exception as e: ui.notify(str(e), type='negative')
ui.button('Salvar', on_click=save)
dialog.open()
# --- RENDERIZAÇÃO ---
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')
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')
elif self.view_mode == 'grid':
with ui.grid().classes('w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3'):
for entry in self.cached_entries: self.render_card(entry)
else:
with ui.column().classes('w-full gap-0'):
for entry in self.cached_entries: self.render_list_item(entry)
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'
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 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))
ui.icon(icon, size='3rem', color=color)
ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all mt-2')
def render_list_item(self, entry):
is_sel = entry.path in self.selected_items
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))
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')
def bind_context_menu(self, element, entry):
with ui.menu() as m:
if not entry.is_dir() and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')):
ui.menu_item('Media Info', on_click=lambda: self.open_inspector(entry.path))
ui.menu_item('Renomear', on_click=lambda: self.open_rename_dialog(entry.path))
ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog([entry.path]))
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())
# --- INSPECTOR ---
async def open_inspector(self, path):
dialog = ui.dialog()
with dialog, ui.card().classes('w-full max-w-3xl'):
with ui.row().classes('w-full justify-between items-start'):
ui.label(os.path.basename(path)).classes('text-lg font-bold break-all w-10/12 text-blue-800')
ui.button(icon='close', on_click=dialog.close).props('flat dense round')
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'):
for a in info['audio']:
ui.label(a['lang']).classes('font-bold bg-gray-200 px-2 rounded text-center')
ui.label(f"{a['codec']} - {a['title']}")
ui.label(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'):
for s in info['subtitle']:
color = 'green' if s['codec'] in ['subrip', 'ass'] else 'grey'
ui.chip(f"{s['lang']} ({s['codec']})", color=color).props('dense icon=subtitles')
def show():
fm = FileManager()
fm.create_layout()
ui.timer(0, fm.refresh, once=True)

View File

@@ -1,10 +1,10 @@
from nicegui import ui from nicegui import ui, app
import os import os
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
# --- CONSTANTES DE DADOS --- # --- CONSTANTES ---
# 1. Gêneros TMDb (Para as Categorias)
TMDB_GENRES = { TMDB_GENRES = {
'28': 'Ação (Action)', '12': 'Aventura (Adventure)', '16': 'Animação (Animation)', '28': 'Ação (Action)', '12': 'Aventura (Adventure)', '16': 'Animação (Animation)',
'35': 'Comédia (Comedy)', '80': 'Crime', '99': 'Documentário', '18': 'Drama', '35': 'Comédia (Comedy)', '80': 'Crime', '99': 'Documentário', '18': 'Drama',
@@ -14,15 +14,11 @@ TMDB_GENRES = {
'53': 'Thriller', '10752': 'Guerra (War)', '37': 'Faroeste (Western)', '53': 'Thriller', '10752': 'Guerra (War)', '37': 'Faroeste (Western)',
'10759': 'Action & Adventure (TV)', '10762': 'Kids (TV)', '10765': 'Sci-Fi & Fantasy (TV)' '10759': 'Action & Adventure (TV)', '10762': 'Kids (TV)', '10765': 'Sci-Fi & Fantasy (TV)'
} }
# 2. Países Comuns (Para as Categorias)
COMMON_COUNTRIES = { COMMON_COUNTRIES = {
'JP': '🇯🇵 Japão (Anime)', 'US': '🇺🇸 EUA (Hollywood)', 'BR': '🇧🇷 Brasil', 'JP': '🇯🇵 Japão (Anime)', 'US': '🇺🇸 EUA (Hollywood)', 'BR': '🇧🇷 Brasil',
'KR': '🇰🇷 Coreia do Sul (Dorama)', 'GB': '🇬🇧 Reino Unido', 'ES': '🇪🇸 Espanha', 'KR': '🇰🇷 Coreia do Sul (Dorama)', 'GB': '🇬🇧 Reino Unido', 'ES': '🇪🇸 Espanha',
'FR': '🇫🇷 França', 'CN': '🇨🇳 China', 'IN': '🇮🇳 Índia' 'FR': '🇫🇷 França', 'CN': '🇨🇳 China', 'IN': '🇮🇳 Índia'
} }
# 3. Idiomas ISO-639-2 (Para o FFmpeg - ESTAVA FALTANDO ISSO)
ISO_LANGS = { ISO_LANGS = {
'por': 'Português (por)', 'eng': 'Inglês (eng)', 'jpn': 'Japonês (jpn)', 'por': 'Português (por)', 'eng': 'Inglês (eng)', 'jpn': 'Japonês (jpn)',
'spa': 'Espanhol (spa)', 'fra': 'Francês (fra)', 'ger': 'Alemão (ger)', 'spa': 'Espanhol (spa)', 'fra': 'Francês (fra)', 'ger': 'Alemão (ger)',
@@ -30,41 +26,78 @@ ISO_LANGS = {
'zho': 'Chinês (zho)', 'und': 'Indefinido (und)' 'zho': 'Chinês (zho)', 'und': 'Indefinido (und)'
} }
# --- SELETOR DE PASTAS --- # --- SELETOR DE PASTAS FLEXÍVEL ---
async def pick_folder_dialog(start_path='/media'): async def pick_folder_dialog(start_path='/', allowed_root='/'):
ALLOWED_ROOT = '/media' """
if not start_path or not start_path.startswith(ALLOWED_ROOT): 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
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('Selecionar Pasta (/media)').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):
if not path.startswith(ALLOWED_ROOT): path = ALLOWED_ROOT # 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 path_label.text = path
scroll.clear() scroll.clear()
try: try:
if path != ALLOWED_ROOT: # 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:
parent = os.path.dirname(path) parent = os.path.dirname(path)
if not parent.startswith(ALLOWED_ROOT): parent = ALLOWED_ROOT 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):
for entry in sorted([e for e in os.scandir(path) if e.is_dir()], key=lambda x: x.name.lower()): entries = 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('Pasta nova').classes('text-gray-400 italic p-2') 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'Erro: {e}').classes('text-red text-xs') with scroll: ui.label(f'Acesso negado: {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 Esta', on_click=select_this).props('flat icon=check color=green')
await load_dir(start_path) await load_dir(start_path)
await dialog 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():
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')
@@ -82,41 +115,36 @@ def show():
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', '')
lang = AppConfig.get_val('tmdb_language', 'pt-BR')
confidence = AppConfig.get_val('min_confidence', '90')
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=lang, 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(confidence), 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))))
ui.notify('As alterações são salvas automaticamente.', type='info', position='bottom')
# --- ABA 2: CATEGORIAS --- # --- ABA 2: CATEGORIAS (COM EDIÇÃO) ---
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')
def load_cats(): def load_cats():
cats_container.clear() cats_container.clear()
cats = list(Category.select()) cats = list(Category.select())
if not cats: ui.label('Nenhuma categoria criada.').classes('text-gray-400')
for cat in cats: for cat in cats:
# Processa filtros para exibição
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_text = f"Tipo: {cat.content_type.upper()}" desc = f"{cat.content_type.upper()}"
if g_names: desc_text += f" | Gêneros: {', '.join(g_names)}" if g_names: desc += f" | {', '.join(g_names)}"
if c_names: desc_text += f" | Países: {', '.join(c_names)}" if c_names: desc += f" | {', '.join(c_names)}"
if not g_names and not c_names: desc_text += " | (Genérico/Padrão)"
color_cls = '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' # Anime visual if 'Animação' in str(g_names) and 'Japão' in str(c_names): color_cls = 'bg-purple-50 border-purple-200'
elif 'Animação' in str(g_names): color_cls = 'bg-yellow-50 border-yellow-200' # Desenho visual
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'):
@@ -124,16 +152,51 @@ def show():
ui.icon(icon).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_text).classes('text-xs text-gray-500') ui.label(desc).classes('text-xs text-gray-500')
ui.button(icon='delete', color='red', on_click=lambda c=cat: delete_cat(c)).props('flat dense')
def add_cat(): 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')
def start_edit(cat):
editing_id['val'] = cat.id
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')
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')
def save_cat():
if not name_input.value: return if not name_input.value: return
# Converte listas para string CSV
g_str = ",".join(genre_select.value) if genre_select.value else "" g_str = ",".join(genre_select.value) if genre_select.value else ""
c_str = ",".join(country_select.value) if country_select.value else "" c_str = ",".join(country_select.value) if country_select.value else ""
try: 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( Category.create(
name=name_input.value, name=name_input.value,
target_path=f"/media/{name_input.value}", target_path=f"/media/{name_input.value}",
@@ -141,50 +204,51 @@ def show():
genre_filters=g_str, genre_filters=g_str,
country_filters=c_str country_filters=c_str
) )
name_input.value = ''; genre_select.value = []; country_select.value = []
ui.notify('Categoria criada!', type='positive') ui.notify('Categoria criada!', type='positive')
cancel_edit()
load_cats() load_cats()
except Exception as e: ui.notify(f'Erro: {e}', type='negative') except Exception as e: ui.notify(f'Erro: {e}', type='negative')
def delete_cat(cat): def delete_cat(cat):
cat.delete_instance() cat.delete_instance()
if editing_id['val'] == cat.id: cancel_edit()
load_cats() load_cats()
# Formulário de Criação Inteligente with form_card:
with ui.card().classes('w-full mb-4 bg-gray-100 p-4'): with ui.row().classes('w-full justify-between items-center'):
ui.label('Nova Biblioteca').classes('font-bold text-gray-700') 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.grid(columns=4).classes('w-full gap-2'): with ui.grid(columns=4).classes('w-full gap-2'):
name_input = ui.input('Nome (ex: Animes)') 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')
type_select = ui.select({ btn_save = ui.button('Adicionar Regra', on_click=save_cat).props('icon=add color=green').classes('mt-2 w-full')
'mixed': 'Misto (Filmes e Séries)',
'movie': 'Apenas Filmes',
'series': 'Apenas Séries'
}, value='mixed', label='Tipo de Conteúdo')
# Novos Seletores Múltiplos
genre_select = ui.select(TMDB_GENRES, multiple=True, label='Filtrar Gêneros (Opcional)').props('use-chips')
country_select = ui.select(COMMON_COUNTRIES, multiple=True, label='Filtrar País (Opcional)').props('use-chips')
ui.button('Adicionar Regra', on_click=add_cat).props('icon=add color=green').classes('mt-2 w-full')
load_cats() load_cats()
# --- ABA 3: DEPLOY --- # --- ABA 3: DEPLOY (DESTINOS VS ORIGEM) ---
with ui.tab_panel(tab_deploy): with ui.tab_panel(tab_deploy):
async def open_picker(input_element):
start = input_element.value if input_element.value else '/'
selected = await pick_folder_dialog(start)
if selected: input_element.value = selected
# 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 dos Arquivos').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():
# 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'): 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=lambda i=mon_input: open_picker(i)).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('📂 Mapeamento de 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')
@@ -194,52 +258,42 @@ def show():
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')
ui.button(icon='folder', on_click=lambda i=path_input: open_picker(i)).props('flat dense color=amber-8')
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') 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 (AGORA COM ISO_LANGS CORRIGIDO) --- # --- ABA 4: FFMPEG ---
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('Perfis de Conversão').classes('text-xl text-gray-700')
profiles_query = list(FFmpegProfile.select()) profiles_query = list(FFmpegProfile.select())
profiles_dict = {p.id: p.name for p in profiles_query} 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) active_profile = next((p for p in profiles_query if p.is_active), None)
initial_val = active_profile.id if active_profile else None
def set_active_profile(e): def set_active_profile(e):
if not e.value: return if not e.value: return
FFmpegProfile.update(is_active=False).execute() FFmpegProfile.update(is_active=False).execute()
FFmpegProfile.update(is_active=True).where(FFmpegProfile.id == int(e.value)).execute() FFmpegProfile.update(is_active=True).where(FFmpegProfile.id == int(e.value)).execute()
ui.notify(f'Perfil "{profiles_dict[e.value]}" ativado!', type='positive') ui.notify(f'Perfil Ativado!', type='positive')
select_profile = ui.select(profiles_dict, value=initial_val, label='Perfil Ativo', on_change=set_active_profile).classes('w-64 mb-6') 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')
ui.separator()
for p in profiles_query: for p in profiles_query:
with ui.expansion(f"{p.name}", icon='tune').classes('w-full bg-white mb-2 border rounded'): 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.column().classes('p-4 w-full'):
with ui.grid(columns=2).classes('w-full gap-4'): with ui.grid(columns=2).classes('w-full gap-4'):
c_name = ui.input('Nome', value=p.name) c_name = ui.input('Nome', value=p.name)
c_codec = ui.select({ c_codec = ui.select({'h264_vaapi':'VAAPI','libx264':'CPU','copy':'Copy'}, value=p.video_codec, label='Codec')
'h264_vaapi': 'Intel VAAPI (Linux/Docker)',
'h264_qsv': 'Intel QuickSync',
'h264_nvenc': 'Nvidia NVENC',
'libx264': 'CPU',
'copy': 'Copy'
}, value=p.video_codec, label='Codec')
c_preset = ui.select(['fast', 'medium', 'slow'], value=p.preset, label='Preset') c_preset = ui.select(['fast', 'medium', 'slow'], value=p.preset, label='Preset')
c_crf = ui.number('CRF/QP', value=p.crf, min=0, max=51) 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')
audios = p.audio_langs.split(',') if p.audio_langs else [] c_sub = ui.select(ISO_LANGS, value=p.subtitle_langs.split(','), multiple=True, label='Legendas').props('use-chips')
subs = p.subtitle_langs.split(',') if p.subtitle_langs else []
# AQUI ESTAVA O ERRO (ISO_LANGS faltando)
c_audio = ui.select(ISO_LANGS, value=audios, multiple=True, label='Áudios').props('use-chips')
c_sub = ui.select(ISO_LANGS, value=subs, 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): 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.name = n.value; prof.video_codec = c.value; prof.preset = pr.value
@@ -249,19 +303,38 @@ def show():
select_profile.options = profiles_dict select_profile.options = profiles_dict
select_profile.update() select_profile.update()
ui.notify('Salvo!') ui.notify('Salvo!')
ui.button('Salvar', on_click=save_profile).classes('mt-2') ui.button('Salvar', on_click=save_profile).classes('mt-2')
# --- ABA 5: TELEGRAM --- # --- ABA 5: 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_token = AppConfig.get_val('telegram_token', '')
t_chat = AppConfig.get_val('telegram_chat_id', '') token_input = ui.input('Bot Token', value=AppConfig.get_val('telegram_token', '')).props('password').classes('w-full mb-2')
token_input = ui.input('Bot Token', value=t_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')
chat_input = ui.input('Chat ID', value=t_chat).classes('w-full mb-6')
def save_telegram(): async def save_and_restart():
AppConfig.set_val('telegram_token', token_input.value) AppConfig.set_val('telegram_token', token_input.value)
AppConfig.set_val('telegram_chat_id', chat_input.value) AppConfig.set_val('telegram_chat_id', chat_input.value)
ui.notify('Salvo!', type='positive') ui.notify('Salvando...', type='warning')
ui.button('Salvar', on_click=save_telegram).props('icon=save color=blue').classes('w-full')
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():
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')
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')