melhorias gerais

This commit is contained in:
2026-02-06 00:17:26 +00:00
parent acade68a0b
commit 53a57232a2
5 changed files with 483 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
from nicegui import ui, app
from modules import file_manager, renamer, encoder, downloader, deployer
from modules import file_manager, renamer, encoder, downloader, deployer, automator
# --- CONFIGURAÇÃO DE ARQUIVOS ESTÁTICOS ---
app.add_static_files('/files', '/downloads')
@@ -18,6 +18,8 @@ with ui.tabs().classes('w-full sticky top-0 z-10 bg-white shadow-sm') as tabs:
t_encode = ui.tab('Encoder', icon='movie')
t_down = ui.tab('Downloader', icon='download')
t_deploy = ui.tab('Mover Final', icon='publish')
# NOVA ABA AQUI
t_auto = ui.tab('Automação', icon='auto_mode')
# --- PAINÉIS DE CONTEÚDO ---
with ui.tab_panels(tabs, value=t_files).classes('w-full p-0 pb-12'): # pb-12 dá espaço para o footer não cobrir o conteúdo
@@ -36,6 +38,9 @@ with ui.tab_panels(tabs, value=t_files).classes('w-full p-0 pb-12'): # pb-12 dá
with ui.tab_panel(t_deploy):
deployer.create_ui()
# NOVO PAINEL AQUI
with ui.tab_panel(t_auto):
automator.create_ui()
# --- RODAPÉ (FOOTER) ---
# Fixo na parte inferior, mesma cor do header, texto centralizado

Binary file not shown.

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

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