melhorias gerais
This commit is contained in:
@@ -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
|
||||
|
||||
BIN
app/modules/__pycache__/automator.cpython-310.pyc
Normal file
BIN
app/modules/__pycache__/automator.cpython-310.pyc
Normal file
Binary file not shown.
459
app/modules/automator.py
Normal file
459
app/modules/automator.py
Normal 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()
|
||||
12
data/automation.json
Normal file
12
data/automation.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"telegram_token": "8543089985:AAFgDMOg9VlikAsPTi35FcF395W2uAtSzuY",
|
||||
"telegram_chat_id": "8392926121",
|
||||
"monitor_folder": "/downloads/completos",
|
||||
"destinations": {
|
||||
"Filmes": "/media/Jellyfin/onedrive/Jellyfin/Filmes",
|
||||
"S\u00e9ries": "/media/Jellyfin/onedrive/Jellyfin/Series",
|
||||
"Animes": "/media/Jellyfin/onedrive/Jellyfin/Animes",
|
||||
"Desenhos": "/media/Jellyfin/onedrive/Jellyfin/Desenhos"
|
||||
},
|
||||
"tmdb_api_key_view": "12856f632876dc743b6f6775f4e5bd7d"
|
||||
}
|
||||
@@ -50,3 +50,9 @@ Traceback (most recent call last):
|
||||
OSError: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv'
|
||||
|
||||
[2026-02-04 22:50:37] ✅ Movido: Se Esse Amor Desaparecesse Hoje (2025).mkv
|
||||
[2026-02-05 13:23:06] ✅ Movido: Episódio 17.mp4
|
||||
[2026-02-05 13:23:09] ✅ Movido: Episódio 03.mp4
|
||||
[2026-02-05 13:23:11] ✅ Movido: Episódio 06.mp4
|
||||
[2026-02-05 13:23:13] ✅ Movido: Episódio 07.mp4
|
||||
[2026-02-05 13:23:13] ⚠️ Pendência: Episódio 14.mp4
|
||||
[2026-02-05 13:23:27] 🔄 Substituído: Episódio 14.mp4
|
||||
|
||||
Reference in New Issue
Block a user