Compare commits
11 Commits
74d19146d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d77959d1d | |||
| 53a57232a2 | |||
| acade68a0b | |||
| 833378b307 | |||
| 935b15980c | |||
| 3ebe723edb | |||
| 832fdfd35a | |||
| 48d0dbf7d3 | |||
| 91979b9fd4 | |||
| da65b8d99f | |||
| 36a2b465d3 |
1
app/config.json
Normal file
1
app/config.json
Normal file
@@ -0,0 +1 @@
|
||||
{"tmdb_api_key": "12856f632876dc743b6f6775f4e5bd7d"}
|
||||
BIN
app/img/icone.ico
Normal file
BIN
app/img/icone.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
app/img/logo.png
Normal file
BIN
app/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
app/img/logotexto.png
Normal file
BIN
app/img/logotexto.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
BIN
app/img/logotextofundo.png
Normal file
BIN
app/img/logotextofundo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
47
app/main.py
47
app/main.py
@@ -1,18 +1,28 @@
|
||||
from nicegui import ui, app
|
||||
# ADICIONE 'downloader' AQUI:
|
||||
from modules import file_manager, renamer, encoder, downloader, deployer
|
||||
app.add_static_files('/files', '/downloads')
|
||||
from modules import file_manager, renamer, encoder, downloader, deployer, automator
|
||||
|
||||
# ATUALIZE AS ABAS:
|
||||
with ui.tabs().classes('w-full') as tabs:
|
||||
# --- CONFIGURAÇÃO DE ARQUIVOS ESTÁTICOS ---
|
||||
app.add_static_files('/files', '/downloads')
|
||||
app.add_static_files('/img', '/app/img')
|
||||
|
||||
# --- CABEÇALHO (HEADER) ---
|
||||
# Cor alterada para 'bg-blue-700'
|
||||
with ui.header(elevated=True).classes('items-center justify-center bg-slate-700 p-2'):
|
||||
# Tamanho específico solicitado: w-1/2 com máximo de 100px
|
||||
ui.image('/img/logotextofundo.png').classes('w-1/2 max-w-[200px] object-contain')
|
||||
|
||||
# --- NAVEGAÇÃO (TABS) ---
|
||||
with ui.tabs().classes('w-full sticky top-0 z-10 bg-white shadow-sm') as tabs:
|
||||
t_files = ui.tab('Gerenciador', icon='folder')
|
||||
t_rename = ui.tab('Renomeador', icon='edit')
|
||||
t_encode = ui.tab('Encoder', icon='movie')
|
||||
t_down = ui.tab('Downloader', icon='download') # NOVA ABA
|
||||
t_deploy = ui.tab('Mover Final', icon='publish') # NOVA ABA
|
||||
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')
|
||||
|
||||
# ATUALIZE OS PAINÉIS:
|
||||
with ui.tab_panels(tabs, value=t_files).classes('w-full p-0'):
|
||||
# --- 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
|
||||
|
||||
with ui.tab_panel(t_files).classes('p-0'):
|
||||
file_manager.create_ui()
|
||||
@@ -23,11 +33,26 @@ with ui.tab_panels(tabs, value=t_files).classes('w-full p-0'):
|
||||
with ui.tab_panel(t_encode):
|
||||
encoder.create_ui()
|
||||
|
||||
# NOVO PAINEL:
|
||||
with ui.tab_panel(t_down):
|
||||
downloader.create_ui()
|
||||
|
||||
with ui.tab_panel(t_deploy):
|
||||
deployer.create_ui()
|
||||
# NOVO PAINEL AQUI
|
||||
with ui.tab_panel(t_auto):
|
||||
automator.create_ui()
|
||||
|
||||
ui.run(title='PyMedia Manager', port=8080, reload=True, storage_secret='secret')
|
||||
# --- RODAPÉ (FOOTER) ---
|
||||
# Fixo na parte inferior, mesma cor do header, texto centralizado
|
||||
with ui.footer().classes('bg-slate-700 justify-center items-center py-1'):
|
||||
# Texto com estilo levemente menor e fonte monoespaçada para dar o ar de "sistema/server"
|
||||
ui.label('Criado por Creidsu. Clei-Server').classes('text-xs text-white opacity-90 font-mono tracking-wide')
|
||||
|
||||
# --- INICIALIZAÇÃO ---
|
||||
ui.run(
|
||||
title='PyMedia Manager',
|
||||
port=8080,
|
||||
reload=True,
|
||||
storage_secret='secret',
|
||||
favicon='/app/img/icone.ico' # Caminho absoluto
|
||||
)
|
||||
BIN
app/modules/__pycache__/automator.cpython-310.pyc
Normal file
BIN
app/modules/__pycache__/automator.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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()
|
||||
@@ -1,222 +1,296 @@
|
||||
from nicegui import ui
|
||||
from nicegui import ui, run
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import asyncio
|
||||
import datetime
|
||||
from collections import deque
|
||||
|
||||
# Configurações de Raiz
|
||||
# --- CONFIGURAÇÕES DE DIRETÓRIOS ---
|
||||
SRC_ROOT = "/downloads"
|
||||
DST_ROOT = "/media"
|
||||
CONFIG_PATH = "/app/data/presets.json"
|
||||
LOG_PATH = "/app/data/history.log"
|
||||
|
||||
class DeployManager:
|
||||
def __init__(self):
|
||||
self.src_path = SRC_ROOT
|
||||
self.dst_path = DST_ROOT
|
||||
self.selected_items = [] # Lista de caminhos selecionados
|
||||
self.selected_items = []
|
||||
self.container = None
|
||||
self.presets = self.load_presets()
|
||||
self.pendencies = []
|
||||
self.logs = self.load_logs_from_file()
|
||||
|
||||
# --- NAVEGAÇÃO ---
|
||||
def navigate_src(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.src_path = path
|
||||
# Nota: Não limpamos a seleção ao navegar para permitir selecionar coisas de pastas diferentes se quiser
|
||||
# self.selected_items = []
|
||||
self.refresh()
|
||||
# --- GERENCIAMENTO DE LOGS ---
|
||||
def load_logs_from_file(self):
|
||||
if not os.path.exists(LOG_PATH):
|
||||
return []
|
||||
try:
|
||||
with open(LOG_PATH, 'r', encoding='utf-8') as f:
|
||||
last_lines = list(deque(f, maxlen=50))
|
||||
return [line.strip() for line in reversed(last_lines)]
|
||||
except:
|
||||
return []
|
||||
|
||||
def navigate_dst(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.dst_path = path
|
||||
self.refresh()
|
||||
def add_log(self, message, type="info"):
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
full_msg = f"[{timestamp}] {message}"
|
||||
self.logs.insert(0, full_msg)
|
||||
if len(self.logs) > 50: self.logs.pop()
|
||||
try:
|
||||
with open(LOG_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(full_msg + "\n")
|
||||
except: pass
|
||||
|
||||
def refresh(self):
|
||||
if self.container:
|
||||
self.container.clear()
|
||||
with self.container:
|
||||
self.render_layout()
|
||||
# --- 1. PERSISTÊNCIA ---
|
||||
def load_presets(self):
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r') as f: return json.load(f)
|
||||
except: return {}
|
||||
return {}
|
||||
|
||||
# --- LÓGICA DE SELEÇÃO ---
|
||||
def toggle_selection(self, path):
|
||||
if path in self.selected_items:
|
||||
self.selected_items.remove(path)
|
||||
else:
|
||||
self.selected_items.append(path)
|
||||
# Recarrega para mostrar o checkbox marcado/desmarcado e a cor de fundo
|
||||
def save_preset(self, name):
|
||||
if not name: return
|
||||
self.presets[name] = {'src': self.src_path, 'dst': self.dst_path}
|
||||
with open(CONFIG_PATH, 'w') as f: json.dump(self.presets, f)
|
||||
ui.notify(f'Preset "{name}" salvo!')
|
||||
self.refresh()
|
||||
|
||||
# --- AÇÃO DE MOVER ---
|
||||
def execute_move(self):
|
||||
if not self.selected_items:
|
||||
ui.notify('Selecione itens na esquerda para mover.', type='warning')
|
||||
def delete_preset(self, name):
|
||||
if name in self.presets:
|
||||
del self.presets[name]
|
||||
with open(CONFIG_PATH, 'w') as f: json.dump(self.presets, f)
|
||||
self.refresh()
|
||||
|
||||
# --- 2. DIÁLOGO ---
|
||||
def confirm_preset_execution(self, name, paths):
|
||||
src, dst = paths['src'], paths['dst']
|
||||
if not os.path.exists(src):
|
||||
ui.notify(f'Erro: Origem não existe: {src}', type='negative')
|
||||
return
|
||||
|
||||
if self.src_path == self.dst_path:
|
||||
ui.notify('Origem e Destino são iguais!', type='warning')
|
||||
return
|
||||
try: count = len([f for f in os.listdir(src) if not f.startswith('.')])
|
||||
except: count = 0
|
||||
|
||||
count = 0
|
||||
errors = 0
|
||||
|
||||
with ui.dialog() as dialog, ui.card():
|
||||
ui.label('Confirmar Movimentação Definitiva').classes('text-lg font-bold')
|
||||
ui.label(f'Destino: {self.dst_path}')
|
||||
ui.label(f'Itens selecionados: {len(self.selected_items)}')
|
||||
|
||||
# Lista itens no dialog para conferência
|
||||
with ui.scroll_area().classes('h-32 w-full border p-2 bg-gray-50'):
|
||||
for item in self.selected_items:
|
||||
ui.label(os.path.basename(item)).classes('text-xs')
|
||||
ui.label(f'Executar: {name}?').classes('text-xl font-bold text-blue-900')
|
||||
ui.label(f'Origem: {src}').classes('text-xs bg-gray-100 p-1 w-full break-all')
|
||||
ui.label(f'Destino: {dst}').classes('text-xs bg-gray-100 p-1 w-full break-all')
|
||||
ui.label(f'{count} itens encontrados.').classes('font-bold text-green-700 mt-2') if count > 0 else None
|
||||
|
||||
def confirm():
|
||||
nonlocal count, errors
|
||||
async def execute_action():
|
||||
dialog.close()
|
||||
ui.notify('Iniciando movimentação...', type='info')
|
||||
|
||||
for item_path in self.selected_items:
|
||||
if not os.path.exists(item_path): continue # Já foi movido ou deletado
|
||||
|
||||
item_name = os.path.basename(item_path)
|
||||
target = os.path.join(self.dst_path, item_name)
|
||||
|
||||
try:
|
||||
if os.path.exists(target):
|
||||
ui.notify(f'Erro: {item_name} já existe no destino!', type='negative')
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
shutil.move(item_path, target)
|
||||
# Tenta ajustar permissões após mover para garantir que o Jellyfin leia
|
||||
try:
|
||||
if os.path.isdir(target):
|
||||
os.system(f'chmod -R 777 "{target}"')
|
||||
else:
|
||||
os.chmod(target, 0o777)
|
||||
except: pass
|
||||
|
||||
count += 1
|
||||
except Exception as e:
|
||||
ui.notify(f'Erro ao mover {item_name}: {e}', type='negative')
|
||||
errors += 1
|
||||
|
||||
if count > 0:
|
||||
ui.notify(f'{count} itens movidos com sucesso!', type='positive')
|
||||
|
||||
self.selected_items = [] # Limpa seleção após sucesso
|
||||
self.refresh()
|
||||
await asyncio.sleep(0.1)
|
||||
await self.move_process_from_preset(paths)
|
||||
|
||||
with ui.row().classes('w-full justify-end'):
|
||||
with ui.row().classes('w-full justify-end mt-4'):
|
||||
ui.button('Cancelar', on_click=dialog.close).props('flat')
|
||||
ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox')
|
||||
|
||||
ui.button('CONFIRMAR', on_click=execute_action).props('color=green')
|
||||
dialog.open()
|
||||
|
||||
# --- RENDERIZADORES AUXILIARES ---
|
||||
def render_breadcrumbs(self, current_path, root_dir, nav_callback):
|
||||
with ui.row().classes('items-center gap-1 bg-gray-100 p-1 rounded w-full'):
|
||||
ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm')
|
||||
# --- 3. MOVIMENTAÇÃO BLINDADA (SAFE MERGE) ---
|
||||
async def move_process(self, items_to_move, target_folder):
|
||||
"""
|
||||
Nova lógica: Nunca move pastas inteiras.
|
||||
Sempre cria a estrutura no destino e move apenas arquivos.
|
||||
"""
|
||||
for item_path in items_to_move:
|
||||
if not os.path.exists(item_path): continue
|
||||
|
||||
name = os.path.basename(item_path)
|
||||
destination = os.path.join(target_folder, name)
|
||||
|
||||
# --- CASO 1: É UMA PASTA? ---
|
||||
if os.path.isdir(item_path):
|
||||
# Não verifica se existe ou não. Simplesmente garante que a pasta
|
||||
# exista no destino (mkdir -p) e entra nela.
|
||||
try:
|
||||
if not os.path.exists(destination):
|
||||
os.makedirs(destination, exist_ok=True)
|
||||
self.apply_permissions(destination) # Garante permissão na nova pasta
|
||||
|
||||
# Pega conteúdo e RECURSIVIDADE (mergulha na pasta)
|
||||
sub_items = [os.path.join(item_path, f) for f in os.listdir(item_path)]
|
||||
await self.move_process(sub_items, destination)
|
||||
|
||||
# Limpeza: Se a pasta de origem ficou vazia, remove ela
|
||||
if not os.listdir(item_path):
|
||||
os.rmdir(item_path)
|
||||
|
||||
except Exception as e:
|
||||
self.add_log(f"❌ Erro na pasta {name}: {e}", "negative")
|
||||
continue
|
||||
|
||||
# --- CASO 2: É UM ARQUIVO? ---
|
||||
# Se já existe o arquivo exato no destino -> Pendência
|
||||
if os.path.exists(destination):
|
||||
self.add_log(f"⚠️ Conflito de arquivo: {name}", "warning")
|
||||
self.pendencies.append({'name': name, 'src': item_path, 'dst': destination})
|
||||
self.refresh()
|
||||
continue
|
||||
|
||||
# Se não existe -> Move o arquivo
|
||||
try:
|
||||
await run.cpu_bound(shutil.move, item_path, destination)
|
||||
self.apply_permissions(destination)
|
||||
self.add_log(f"✅ Arquivo movido: {name}", "positive")
|
||||
except Exception as e:
|
||||
self.add_log(f"❌ Erro arquivo {name}: {e}", "negative")
|
||||
|
||||
self.selected_items = []
|
||||
self.refresh()
|
||||
|
||||
async def move_process_from_preset(self, paths):
|
||||
src, dst = paths['src'], paths['dst']
|
||||
if os.path.exists(src):
|
||||
items = [os.path.join(src, f) for f in os.listdir(src)]
|
||||
await self.move_process(items, dst)
|
||||
else: ui.notify('Origem não encontrada!', type='negative')
|
||||
|
||||
def apply_permissions(self, path):
|
||||
try:
|
||||
if os.path.isdir(path): os.system(f'chmod -R 777 "{path}"')
|
||||
else: os.chmod(path, 0o777)
|
||||
except: pass
|
||||
|
||||
# --- 4. AÇÕES PENDÊNCIAS ---
|
||||
async def handle_all_pendencies(self, action):
|
||||
temp_list = list(self.pendencies)
|
||||
for i in range(len(temp_list)):
|
||||
await self.handle_pendency(0, action, refresh=False)
|
||||
self.refresh()
|
||||
|
||||
async def handle_pendency(self, index, action, refresh=True):
|
||||
if index >= len(self.pendencies): return
|
||||
item = self.pendencies.pop(index)
|
||||
|
||||
if action == 'replace':
|
||||
try:
|
||||
# Como agora só arquivos caem aqui, remove e substitui
|
||||
if os.path.isdir(item['dst']):
|
||||
await run.cpu_bound(shutil.rmtree, item['dst'])
|
||||
else:
|
||||
await run.cpu_bound(os.remove, item['dst'])
|
||||
|
||||
await run.cpu_bound(shutil.move, item['src'], item['dst'])
|
||||
self.apply_permissions(item['dst'])
|
||||
self.add_log(f"🔄 Substituído: {item['name']}")
|
||||
except Exception as e:
|
||||
self.add_log(f"❌ Erro ao substituir {item['name']}: {e}", "negative")
|
||||
|
||||
if refresh: self.refresh()
|
||||
|
||||
# --- 5. NAVEGAÇÃO ---
|
||||
def render_breadcrumbs(self, current_path, root_dir, nav_callback):
|
||||
with ui.row().classes('items-center gap-1 bg-gray-100 p-1 rounded w-full mb-2'):
|
||||
ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm')
|
||||
rel = os.path.relpath(current_path, root_dir)
|
||||
if rel != '.':
|
||||
acc = root_dir
|
||||
parts = rel.split(os.sep)
|
||||
for part in parts:
|
||||
for part in rel.split(os.sep):
|
||||
ui.label('/')
|
||||
acc = os.path.join(acc, part)
|
||||
ui.button(part, on_click=lambda p=acc: nav_callback(p)).props('flat dense no-caps size=sm')
|
||||
|
||||
if current_path != root_dir:
|
||||
ui.space()
|
||||
parent = os.path.dirname(current_path)
|
||||
ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm')
|
||||
ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm color=primary')
|
||||
|
||||
def navigate_src(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.src_path = path; self.refresh()
|
||||
|
||||
def navigate_dst(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.dst_path = path; self.refresh()
|
||||
|
||||
# --- 6. UI ---
|
||||
def refresh(self):
|
||||
if self.container:
|
||||
self.container.clear()
|
||||
with self.container: self.render_layout()
|
||||
|
||||
def render_layout(self):
|
||||
with ui.row().classes('w-full bg-blue-50 p-3 rounded-lg items-center shadow-sm'):
|
||||
ui.icon('bolt', color='blue').classes('text-2xl')
|
||||
ui.label('SMART DEPLOYS:').classes('font-bold text-blue-900 mr-4')
|
||||
for name, paths in self.presets.items():
|
||||
with ui.button_group().props('rounded'):
|
||||
ui.button(name, on_click=lambda n=name, p=paths: self.confirm_preset_execution(n, p)).props('color=blue-6')
|
||||
ui.button(on_click=lambda n=name: self.delete_preset(n)).props('icon=delete color=red-4')
|
||||
ui.button('Salvar Favorito', on_click=self.prompt_save_preset).props('flat icon=add_circle color=green-7').classes('ml-auto')
|
||||
|
||||
with ui.row().classes('w-full gap-6 mt-4'):
|
||||
with ui.column().classes('flex-grow w-1/2'):
|
||||
ui.label('📂 ORIGEM').classes('text-lg font-bold text-blue-700')
|
||||
self.render_breadcrumbs(self.src_path, SRC_ROOT, self.navigate_src)
|
||||
self.render_file_list(self.src_path, is_source=True)
|
||||
with ui.column().classes('flex-grow w-1/2'):
|
||||
ui.label('🎯 DESTINO').classes('text-lg font-bold text-green-700')
|
||||
self.render_breadcrumbs(self.dst_path, DST_ROOT, self.navigate_dst)
|
||||
self.render_file_list(self.dst_path, is_source=False)
|
||||
|
||||
with ui.row().classes('w-full gap-6 mt-6'):
|
||||
with ui.card().classes('flex-grow h-64 bg-orange-50 border-orange-200 shadow-none'):
|
||||
with ui.row().classes('w-full items-center border-b pb-2'):
|
||||
ui.label(f'⚠️ Pendências ({len(self.pendencies)})').classes('font-bold text-orange-900 text-lg')
|
||||
if self.pendencies:
|
||||
ui.button('SUBSTITUIR TODOS', on_click=lambda: self.handle_all_pendencies('replace')).props('color=green-8 size=sm icon=done_all')
|
||||
ui.button('IGNORAR TODOS', on_click=lambda: self.handle_all_pendencies('ignore')).props('color=grey-7 size=sm icon=clear_all')
|
||||
with ui.scroll_area().classes('w-full h-full'):
|
||||
for i, p in enumerate(self.pendencies):
|
||||
with ui.row().classes('w-full items-center p-2 border-b bg-white rounded mb-1'):
|
||||
ui.label(p['name']).classes('flex-grow text-xs font-medium')
|
||||
ui.button(icon='swap_horiz', on_click=lambda idx=i: self.handle_pendency(idx, 'replace')).props('flat dense color=green')
|
||||
ui.button(icon='close', on_click=lambda idx=i: self.handle_pendency(idx, 'ignore')).props('flat dense color=red')
|
||||
|
||||
with ui.card().classes('flex-grow h-64 bg-slate-900 text-slate-200 shadow-none'):
|
||||
ui.label('📜 Logs').classes('font-bold border-b border-slate-700 w-full pb-2')
|
||||
with ui.scroll_area().classes('w-full h-full'):
|
||||
for log in self.logs:
|
||||
color = 'text-red-400' if '❌' in log else 'text-green-400' if '✅' in log else 'text-slate-300'
|
||||
ui.label(f"> {log}").classes(f'text-[10px] font-mono leading-tight {color}')
|
||||
|
||||
ui.button('INICIAR MOVIMENTAÇÃO', on_click=lambda: self.move_process(self.selected_items, self.dst_path))\
|
||||
.classes('w-full py-6 mt-4 text-xl font-black shadow-lg')\
|
||||
.props('color=green-7 icon=forward')\
|
||||
.bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0)
|
||||
|
||||
def render_file_list(self, path, is_source):
|
||||
try:
|
||||
entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||
except:
|
||||
ui.label('Erro ao ler pasta').classes('text-red')
|
||||
return
|
||||
with ui.scroll_area().classes('h-[400px] border-2 rounded-lg bg-white w-full shadow-inner'):
|
||||
if not entries: ui.label('Pasta vazia').classes('p-4 text-gray-400 italic')
|
||||
for entry in entries:
|
||||
is_selected = entry.path in self.selected_items
|
||||
bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100"
|
||||
with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r:
|
||||
if is_source: ui.checkbox(value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense')
|
||||
icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description'
|
||||
ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400')
|
||||
ui.label(entry.name).classes('text-sm flex-grow truncate select-none')
|
||||
if entry.is_dir(): r.on('click', lambda p=entry.path: self.navigate_src(p) if is_source else self.navigate_dst(p))
|
||||
elif is_source: r.on('click', lambda p=entry.path: self.toggle_selection(p))
|
||||
except: ui.label('Erro ao acessar diretório.').classes('text-red-500 p-4 font-bold')
|
||||
|
||||
with ui.scroll_area().classes('h-96 border rounded bg-white'):
|
||||
if not entries:
|
||||
ui.label('Pasta Vazia').classes('p-4 text-gray-400 italic')
|
||||
|
||||
for entry in entries:
|
||||
is_dir = entry.is_dir()
|
||||
icon = 'folder' if is_dir else 'description'
|
||||
if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4')): icon = 'movie'
|
||||
color = 'amber' if is_dir else 'grey'
|
||||
|
||||
# Verifica se está selecionado
|
||||
is_selected = entry.path in self.selected_items
|
||||
bg_color = 'bg-blue-100' if is_selected else 'hover:bg-gray-50'
|
||||
|
||||
# Linha do Arquivo/Pasta
|
||||
with ui.row().classes(f'w-full items-center p-1 cursor-pointer border-b {bg_color}') as row:
|
||||
|
||||
# Lógica de Clique na Linha (Texto)
|
||||
if is_source:
|
||||
if is_dir:
|
||||
# Se for pasta na origem: Clique entra na pasta
|
||||
row.on('click', lambda p=entry.path: self.navigate_src(p))
|
||||
else:
|
||||
# Se for arquivo na origem: Clique seleciona
|
||||
row.on('click', lambda p=entry.path: self.toggle_selection(p))
|
||||
else:
|
||||
# No destino: Clique sempre navega (se for pasta)
|
||||
if is_dir:
|
||||
row.on('click', lambda p=entry.path: self.navigate_dst(p))
|
||||
def prompt_save_preset(self):
|
||||
with ui.dialog() as d, ui.card().classes('p-6'):
|
||||
ui.label('Criar Preset').classes('text-lg font-bold')
|
||||
name_input = ui.input('Nome')
|
||||
with ui.row().classes('w-full justify-end mt-4'):
|
||||
ui.button('Cancelar', on_click=d.close).props('flat')
|
||||
ui.button('SALVAR', on_click=lambda: [self.save_preset(name_input.value), d.close()]).props('color=green')
|
||||
d.open()
|
||||
|
||||
# COLUNA 1: Checkbox (Apenas na Origem)
|
||||
if is_source:
|
||||
# O checkbox permite selecionar pastas sem entrar nelas
|
||||
# stop_propagation impede que o clique no checkbox acione o clique da linha (entrar na pasta)
|
||||
ui.checkbox('', value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense').on('click', lambda e: e.stop_propagation())
|
||||
|
||||
# COLUNA 2: Ícone
|
||||
ui.icon(icon, color=color).classes('mx-2')
|
||||
def toggle_selection(self, path):
|
||||
if path in self.selected_items: self.selected_items.remove(path)
|
||||
else: self.selected_items.append(path)
|
||||
self.refresh()
|
||||
|
||||
# COLUNA 3: Nome
|
||||
ui.label(entry.name).classes('text-sm truncate flex-grow select-none')
|
||||
|
||||
# --- LAYOUT PRINCIPAL ---
|
||||
def render_layout(self):
|
||||
with ui.row().classes('w-full h-full gap-4'):
|
||||
|
||||
# ESQUERDA (ORIGEM)
|
||||
with ui.column().classes('w-1/2 h-full'):
|
||||
ui.label('📂 Origem (Downloads)').classes('text-lg font-bold text-blue-600')
|
||||
self.render_breadcrumbs(self.src_path, SRC_ROOT, self.navigate_src)
|
||||
|
||||
# Contador
|
||||
if self.selected_items:
|
||||
ui.label(f'{len(self.selected_items)} itens selecionados').classes('text-sm font-bold text-blue-800')
|
||||
else:
|
||||
ui.label('Selecione arquivos ou pastas').classes('text-xs text-gray-400')
|
||||
|
||||
self.render_file_list(self.src_path, is_source=True)
|
||||
|
||||
# DIREITA (DESTINO)
|
||||
with ui.column().classes('w-1/2 h-full'):
|
||||
ui.label('🏁 Destino (Mídia Final)').classes('text-lg font-bold text-green-600')
|
||||
self.render_breadcrumbs(self.dst_path, DST_ROOT, self.navigate_dst)
|
||||
|
||||
# Espaçador visual
|
||||
ui.label('Navegue até a pasta de destino').classes('text-xs text-gray-400')
|
||||
|
||||
self.render_file_list(self.dst_path, is_source=False)
|
||||
|
||||
# Botão de Ação Principal
|
||||
with ui.row().classes('w-full justify-end mt-4'):
|
||||
ui.button('Mover Selecionados >>>', on_click=self.execute_move)\
|
||||
.props('icon=arrow_forward color=green')\
|
||||
.bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0)
|
||||
|
||||
# --- INICIALIZADOR ---
|
||||
def create_ui():
|
||||
os.makedirs("/app/data", exist_ok=True)
|
||||
dm = DeployManager()
|
||||
# Garante pastas
|
||||
for d in [SRC_ROOT, DST_ROOT]:
|
||||
if not os.path.exists(d):
|
||||
try: os.makedirs(d)
|
||||
except: pass
|
||||
|
||||
dm.container = ui.column().classes('w-full h-full p-4')
|
||||
dm.container = ui.column().classes('w-full h-full p-4 max-w-7xl mx-auto')
|
||||
dm.refresh()
|
||||
@@ -1,32 +1,19 @@
|
||||
from nicegui import ui
|
||||
from nicegui import ui, run
|
||||
import os
|
||||
import threading
|
||||
import json
|
||||
import time
|
||||
import yt_dlp
|
||||
|
||||
# --- CONFIGURAÇÕES ---
|
||||
DOWNLOAD_DIR = "/downloads/Youtube"
|
||||
STATUS_FILE = "/app/data/dl_status.json"
|
||||
|
||||
# --- UTILITÁRIOS ---
|
||||
def save_status(data):
|
||||
try:
|
||||
with open(STATUS_FILE, 'w') as f: json.dump(data, f)
|
||||
except: pass
|
||||
|
||||
def load_status():
|
||||
if not os.path.exists(STATUS_FILE): return None
|
||||
try:
|
||||
with open(STATUS_FILE, 'r') as f: return json.load(f)
|
||||
except: return None
|
||||
DOWNLOAD_DIR = "/downloads/ytdlp"
|
||||
|
||||
# --- WORKER (BACKEND) ---
|
||||
class DownloadWorker(threading.Thread):
|
||||
def __init__(self, url, format_type):
|
||||
def __init__(self, url, format_type, status_callback):
|
||||
super().__init__()
|
||||
self.url = url
|
||||
self.format_type = format_type
|
||||
self.callback = status_callback # Função para atualizar o estado na Interface
|
||||
self.daemon = True
|
||||
self.stop_requested = False
|
||||
|
||||
@@ -42,33 +29,38 @@ class DownloadWorker(threading.Thread):
|
||||
speed = d.get('speed', 0) or 0
|
||||
speed_str = f"{speed / 1024 / 1024:.2f} MiB/s"
|
||||
filename = os.path.basename(d.get('filename', 'Baixando...'))
|
||||
eta = d.get('_eta_str', '?')
|
||||
|
||||
save_status({
|
||||
# Atualiza estado em memória via callback
|
||||
self.callback({
|
||||
"running": True,
|
||||
"file": filename,
|
||||
"progress": pct,
|
||||
"log": f"Baixando: {speed_str} | {d.get('_eta_str', '?')} restantes",
|
||||
"stop_requested": False
|
||||
"log": f"Baixando: {speed_str} | ETA: {eta}",
|
||||
"status": "downloading"
|
||||
})
|
||||
|
||||
elif d['status'] == 'finished':
|
||||
save_status({
|
||||
self.callback({
|
||||
"running": True,
|
||||
"file": "Processando...",
|
||||
"progress": 99,
|
||||
"log": "Convertendo/Juntando arquivos...",
|
||||
"stop_requested": False
|
||||
"log": "Convertendo/Juntando arquivos (ffmpeg)...",
|
||||
"status": "processing"
|
||||
})
|
||||
|
||||
def run(self):
|
||||
if not os.path.exists(DOWNLOAD_DIR): os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||
if not os.path.exists(DOWNLOAD_DIR):
|
||||
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||
|
||||
ydl_opts = {
|
||||
'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s',
|
||||
'progress_hooks': [self.progress_hook],
|
||||
'nocheckcertificate': True,
|
||||
'ignoreerrors': True,
|
||||
'ffmpeg_location': '/usr/bin/ffmpeg'
|
||||
'ignoreerrors': False, # Mudado para False para pegarmos os erros reais
|
||||
'ffmpeg_location': '/usr/bin/ffmpeg',
|
||||
'writethumbnail': True, # Garante metadados no arquivo final
|
||||
'addmetadata': True,
|
||||
}
|
||||
|
||||
if self.format_type == 'best':
|
||||
@@ -76,111 +68,214 @@ class DownloadWorker(threading.Thread):
|
||||
ydl_opts['merge_output_format'] = 'mkv'
|
||||
elif self.format_type == 'audio':
|
||||
ydl_opts['format'] = 'bestaudio/best'
|
||||
ydl_opts['postprocessors'] = [{'key': 'FFmpegExtractAudio','preferredcodec': 'mp3','preferredquality': '192'}]
|
||||
ydl_opts['postprocessors'] = [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '192'
|
||||
}]
|
||||
elif self.format_type == '1080p':
|
||||
ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]'
|
||||
ydl_opts['merge_output_format'] = 'mkv'
|
||||
|
||||
try:
|
||||
save_status({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando..."})
|
||||
self.callback({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando...", "status": "starting"})
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([self.url])
|
||||
save_status({"running": False, "file": "Concluído!", "progress": 100, "log": "Sucesso."})
|
||||
|
||||
self.callback({"running": False, "file": "Concluído!", "progress": 100, "log": "Download finalizado com sucesso.", "status": "success"})
|
||||
|
||||
except Exception as e:
|
||||
msg = "Cancelado." if "Cancelado" in str(e) else str(e)
|
||||
save_status({"running": False, "file": "Parado", "progress": 0, "log": msg})
|
||||
msg = str(e)
|
||||
if "Cancelado" in msg:
|
||||
log_msg = "Download cancelado pelo usuário."
|
||||
else:
|
||||
log_msg = f"Erro: {msg}"
|
||||
|
||||
self.callback({"running": False, "file": "Erro/Parado", "progress": 0, "log": log_msg, "status": "error"})
|
||||
|
||||
# --- FUNÇÃO AUXILIAR DE METADADOS (IO BOUND) ---
|
||||
def fetch_meta(url):
|
||||
try:
|
||||
ydl_opts = {'quiet': True, 'nocheckcertificate': True, 'ignoreerrors': True}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
return ydl.extract_info(url, download=False)
|
||||
except:
|
||||
return None
|
||||
|
||||
# --- INTERFACE (FRONTEND) ---
|
||||
class DownloaderInterface:
|
||||
def __init__(self):
|
||||
self.container = None
|
||||
self.timer = None
|
||||
self.btn_download = None
|
||||
self.card_status = None
|
||||
self.worker = None
|
||||
|
||||
# Elementos dinâmicos
|
||||
self.lbl_file = None
|
||||
self.progress = None
|
||||
self.lbl_log = None
|
||||
self.btn_stop = None
|
||||
# Estado Local (Em memória)
|
||||
self.state = {
|
||||
"running": False,
|
||||
"file": "Aguardando...",
|
||||
"progress": 0,
|
||||
"log": "---",
|
||||
"status": "idle"
|
||||
}
|
||||
|
||||
def start_download(self, url, fmt):
|
||||
if not url:
|
||||
ui.notify('Cole uma URL!', type='warning')
|
||||
return
|
||||
# Elementos UI
|
||||
self.url_input = None
|
||||
self.fmt_select = None
|
||||
self.btn_check = None
|
||||
self.btn_download = None
|
||||
self.btn_stop = None
|
||||
self.btn_reset = None
|
||||
|
||||
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE)
|
||||
t = DownloadWorker(url, fmt)
|
||||
t.start()
|
||||
ui.notify('Iniciando...')
|
||||
self.render_update()
|
||||
self.preview_card = None
|
||||
self.preview_img = None
|
||||
self.preview_title = None
|
||||
|
||||
self.status_card = None
|
||||
self.lbl_file = None
|
||||
self.progress_bar = None
|
||||
self.lbl_log = None
|
||||
|
||||
def update_state(self, new_data):
|
||||
"""Callback chamada pelo Worker (thread) para atualizar o dict de estado."""
|
||||
self.state.update(new_data)
|
||||
|
||||
async def check_url(self):
|
||||
url = self.url_input.value
|
||||
if not url:
|
||||
ui.notify('Insira uma URL primeiro!', type='warning')
|
||||
return
|
||||
|
||||
self.btn_check.props('loading')
|
||||
self.lbl_log.text = "Buscando informações do vídeo..."
|
||||
|
||||
# Roda em thread separada para não travar a UI
|
||||
info = await run.io_bound(fetch_meta, url)
|
||||
|
||||
self.btn_check.props(remove='loading')
|
||||
|
||||
if info and 'title' in info:
|
||||
self.preview_card.visible = True
|
||||
self.preview_title.text = info.get('title', 'Sem título')
|
||||
self.preview_img.set_source(info.get('thumbnail', ''))
|
||||
self.btn_download.enable()
|
||||
self.status_card.visible = True
|
||||
self.lbl_log.text = "Vídeo encontrado. Pronto para baixar."
|
||||
else:
|
||||
ui.notify('Não foi possível obter dados do vídeo. Verifique o link.', type='negative')
|
||||
self.lbl_log.text = "Erro ao buscar metadados."
|
||||
|
||||
def start_download(self):
|
||||
url = self.url_input.value
|
||||
fmt = self.fmt_select.value
|
||||
|
||||
# Reset visual
|
||||
self.state['progress'] = 0
|
||||
self.btn_download.disable()
|
||||
self.btn_check.disable()
|
||||
self.url_input.disable()
|
||||
self.btn_reset.visible = False
|
||||
|
||||
# Inicia Worker
|
||||
self.worker = DownloadWorker(url, fmt, self.update_state)
|
||||
self.worker.start()
|
||||
|
||||
ui.notify('Download iniciado!')
|
||||
|
||||
def stop_download(self):
|
||||
data = load_status()
|
||||
if data:
|
||||
data['stop_requested'] = True
|
||||
save_status(data)
|
||||
ui.notify('Parando...')
|
||||
if self.worker and self.worker.is_alive():
|
||||
self.worker.stop_requested = True
|
||||
self.worker.join(timeout=1.0)
|
||||
ui.notify('Solicitação de cancelamento enviada.')
|
||||
|
||||
def reset_ui(self):
|
||||
"""Reseta a interface para um novo download"""
|
||||
self.url_input.value = ''
|
||||
self.url_input.enable()
|
||||
self.btn_check.enable()
|
||||
self.btn_download.disable()
|
||||
self.preview_card.visible = False
|
||||
self.status_card.visible = False
|
||||
self.btn_reset.visible = False
|
||||
self.lbl_log.text = '---'
|
||||
self.state = {"running": False, "file": "Aguardando...", "progress": 0, "log": "---", "status": "idle"}
|
||||
|
||||
def ui_update_loop(self):
|
||||
"""Timer que atualiza os elementos visuais com base no self.state"""
|
||||
# Sincroniza dados da memória com os componentes
|
||||
self.lbl_file.text = f"Arquivo: {self.state.get('file')}"
|
||||
self.progress_bar.value = self.state.get('progress', 0) / 100
|
||||
self.lbl_log.text = self.state.get('log')
|
||||
|
||||
status = self.state.get('status')
|
||||
is_running = self.state.get('running', False)
|
||||
|
||||
# Controle de visibilidade do botão Cancelar
|
||||
if self.btn_stop:
|
||||
self.btn_stop.visible = is_running
|
||||
|
||||
# Tratamento de finalização/erro para mostrar botão de "Novo"
|
||||
if status in ['success', 'error'] and not is_running:
|
||||
self.btn_reset.visible = True
|
||||
if status == 'error':
|
||||
self.lbl_log.classes('text-red-500', remove='text-gray-500')
|
||||
else:
|
||||
self.lbl_log.classes('text-green-600', remove='text-gray-500')
|
||||
else:
|
||||
self.lbl_log.classes('text-gray-500', remove='text-red-500 text-green-600')
|
||||
|
||||
def render(self):
|
||||
ui.label('📺 YouTube Downloader').classes('text-xl font-bold mb-2')
|
||||
ui.label('📺 YouTube Downloader (Docker)').classes('text-xl font-bold mb-2')
|
||||
|
||||
# --- INPUT ---
|
||||
# --- ÁREA DE INPUT ---
|
||||
with ui.card().classes('w-full p-4 mb-4'):
|
||||
url_input = ui.input('URL do Vídeo').classes('w-full').props('clearable placeholder="https://youtube.com/..."')
|
||||
|
||||
with ui.row().classes('items-center mt-2'):
|
||||
fmt_select = ui.select(
|
||||
with ui.row().classes('w-full items-center gap-2'):
|
||||
self.url_input = ui.input('URL do Vídeo').classes('flex-grow').props('clearable placeholder="https://..."')
|
||||
self.btn_check = ui.button('Verificar', on_click=self.check_url).props('icon=search color=secondary')
|
||||
|
||||
with ui.row().classes('items-center mt-2 gap-4'):
|
||||
self.fmt_select = ui.select(
|
||||
{'best': 'Melhor Qualidade (MKV)', '1080p': 'Limitado a 1080p (MKV)', 'audio': 'Apenas Áudio (MP3)'},
|
||||
value='best', label='Formato'
|
||||
).classes('w-64')
|
||||
|
||||
self.btn_download = ui.button('Baixar', on_click=lambda: self.start_download(url_input.value, fmt_select.value))\
|
||||
.props('icon=download color=primary')
|
||||
self.btn_download = ui.button('Baixar Agora', on_click=self.start_download)\
|
||||
.props('icon=download color=primary').classes('w-40')
|
||||
self.btn_download.disable() # Começa desabilitado até verificar
|
||||
|
||||
# --- MONITORAMENTO ---
|
||||
# CORREÇÃO AQUI: Criamos o card primeiro, depois definimos visibilidade
|
||||
self.card_status = ui.card().classes('w-full p-4')
|
||||
self.card_status.visible = False # Esconde inicialmente
|
||||
# --- PREVIEW (Melhoria 7) ---
|
||||
self.preview_card = ui.card().classes('w-full p-2 mb-4 bg-gray-100 flex-row gap-4 items-center')
|
||||
self.preview_card.visible = False
|
||||
with self.preview_card:
|
||||
self.preview_img = ui.image().classes('w-32 h-24 rounded object-cover')
|
||||
with ui.column():
|
||||
ui.label('Vídeo Detectado:').classes('text-xs text-gray-600 uppercase font-bold')
|
||||
self.preview_title = ui.label('').classes('font-bold text-md leading-tight')
|
||||
|
||||
# --- STATUS E MONITORAMENTO ---
|
||||
self.status_card = ui.card().classes('w-full p-4')
|
||||
self.status_card.visible = False
|
||||
|
||||
with self.card_status:
|
||||
ui.label('Progresso').classes('font-bold')
|
||||
with self.status_card:
|
||||
with ui.row().classes('w-full justify-between items-center'):
|
||||
ui.label('Status do Processo').classes('font-bold')
|
||||
self.btn_reset = ui.button('Baixar Outro', on_click=self.reset_ui)\
|
||||
.props('icon=refresh flat color=primary').classes('text-sm')
|
||||
self.btn_reset.visible = False
|
||||
|
||||
self.lbl_file = ui.label('Aguardando...')
|
||||
self.progress = ui.linear_progress(value=0).classes('w-full')
|
||||
self.progress_bar = ui.linear_progress(value=0).classes('w-full my-2')
|
||||
self.lbl_log = ui.label('---').classes('text-sm text-gray-500 font-mono')
|
||||
|
||||
with ui.row().classes('w-full justify-end mt-2'):
|
||||
self.btn_stop = ui.button('🛑 Cancelar', on_click=self.stop_download).props('color=red flat')
|
||||
self.btn_stop = ui.button('🛑 Cancelar Download', on_click=self.stop_download).props('color=red flat')
|
||||
|
||||
self.timer = ui.timer(1.0, self.render_update)
|
||||
|
||||
def render_update(self):
|
||||
data = load_status()
|
||||
|
||||
if not data:
|
||||
if self.card_status: self.card_status.visible = False
|
||||
if self.btn_download: self.btn_download.enable()
|
||||
return
|
||||
|
||||
# Atualiza UI
|
||||
is_running = data.get('running', False)
|
||||
|
||||
if self.btn_download:
|
||||
if is_running: self.btn_download.disable()
|
||||
else: self.btn_download.enable()
|
||||
|
||||
if self.card_status: self.card_status.visible = True
|
||||
|
||||
if self.lbl_file: self.lbl_file.text = f"Arquivo: {data.get('file', '?')}"
|
||||
if self.progress: self.progress.value = data.get('progress', 0) / 100
|
||||
if self.lbl_log: self.lbl_log.text = data.get('log', '')
|
||||
|
||||
if self.btn_stop: self.btn_stop.visible = is_running
|
||||
# Timer para atualizar UI a partir do estado em memória
|
||||
self.timer = ui.timer(0.5, self.ui_update_loop)
|
||||
|
||||
# --- INICIALIZADOR ---
|
||||
def create_ui():
|
||||
dl = DownloaderInterface()
|
||||
dl.container = ui.column().classes('w-full h-full p-4 gap-4')
|
||||
dl.container = ui.column().classes('w-full h-full p-4 max-w-4xl mx-auto')
|
||||
with dl.container:
|
||||
dl.render()
|
||||
@@ -1,200 +1,374 @@
|
||||
from nicegui import ui, app
|
||||
from nicegui import ui
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import math
|
||||
import shutil
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
# ==============================================================================
|
||||
# --- SEÇÃO 1: CONFIGURAÇÕES GLOBAIS E CONSTANTES ---
|
||||
# ==============================================================================
|
||||
|
||||
ROOT_DIR = "/downloads"
|
||||
OUTPUT_BASE = "/downloads/finalizados"
|
||||
STATUS_FILE = "/app/data/status.json"
|
||||
|
||||
# --- BACKEND: PREPARAÇÃO DE DRIVERS ---
|
||||
# Caminhos dos drivers Intel problemáticos (NÃO ALTERAR)
|
||||
BAD_DRIVERS = [
|
||||
"/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so",
|
||||
"/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"
|
||||
]
|
||||
|
||||
# Extensões de legenda que vamos procurar
|
||||
SUBTITLE_EXTS = ('.srt', '.sub', '.sbv', '.ass', '.vtt', '.ssa')
|
||||
|
||||
# VARIÁVEIS DE ESTADO (MEMÓRIA RAM)
|
||||
CURRENT_STATUS = {
|
||||
"running": False,
|
||||
"stop_requested": False,
|
||||
"file": "",
|
||||
"pct_file": 0.0,
|
||||
"pct_total": 0.0,
|
||||
"current_index": 0,
|
||||
"total_files": 0,
|
||||
"log": "Aguardando...",
|
||||
"speed": "N/A"
|
||||
}
|
||||
|
||||
# Histórico dos últimos 50 processamentos
|
||||
HISTORY_LOG = deque(maxlen=50)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# --- SEÇÃO 2: UTILITÁRIOS (Backend) ---
|
||||
# ==============================================================================
|
||||
|
||||
def prepare_driver_environment():
|
||||
"""Configura o ambiente para usar o driver i965 e remove os problemáticos."""
|
||||
os.environ["LIBVA_DRIVER_NAME"] = "i965"
|
||||
drivers_ruins = ["/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so", "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"]
|
||||
for driver in drivers_ruins:
|
||||
for driver in BAD_DRIVERS:
|
||||
if os.path.exists(driver):
|
||||
try: os.remove(driver)
|
||||
try:
|
||||
os.remove(driver)
|
||||
except: pass
|
||||
|
||||
# --- BACKEND: UTILS FFMPEG ---
|
||||
def get_video_duration(filepath):
|
||||
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filepath]
|
||||
try: return float(subprocess.check_output(cmd).decode().strip())
|
||||
except: return None
|
||||
"""Usa ffprobe para descobrir a duração total do vídeo em segundos."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", filepath
|
||||
]
|
||||
try:
|
||||
output = subprocess.check_output(cmd).decode().strip()
|
||||
return float(output)
|
||||
except:
|
||||
return 1.0
|
||||
|
||||
def parse_time_to_seconds(time_str):
|
||||
h, m, s = time_str.split(':')
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
|
||||
def get_streams_map(filepath):
|
||||
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath]
|
||||
"""Converte o timecode do FFmpeg (HH:MM:SS.ms) para segundos float."""
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, env=os.environ)
|
||||
data = json.loads(res.stdout)
|
||||
except: return ["-map", "0"]
|
||||
|
||||
map_args = ["-map", "0:v"]
|
||||
audio_found = False
|
||||
for s in data.get('streams', []):
|
||||
if s['codec_type'] == 'audio':
|
||||
lang = s.get('tags', {}).get('language', 'und').lower()
|
||||
if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']:
|
||||
map_args.extend(["-map", f"0:{s['index']}"])
|
||||
audio_found = True
|
||||
if not audio_found: map_args.extend(["-map", "0:a"])
|
||||
|
||||
for s in data.get('streams', []):
|
||||
if s['codec_type'] == 'subtitle':
|
||||
lang = s.get('tags', {}).get('language', 'und').lower()
|
||||
if lang in ['por', 'pt', 'pob', 'pt-br']:
|
||||
map_args.extend(["-map", f"0:{s['index']}"])
|
||||
return map_args
|
||||
parts = time_str.split(':')
|
||||
h = int(parts[0])
|
||||
m = int(parts[1])
|
||||
s = float(parts[2])
|
||||
return h * 3600 + m * 60 + s
|
||||
except:
|
||||
return 0.0
|
||||
|
||||
def format_size(size_bytes):
|
||||
"""Formata bytes para leitura humana (MB, GB)."""
|
||||
if size_bytes == 0: return "0B"
|
||||
size_name = ("B", "KB", "MB", "GB", "TB")
|
||||
try:
|
||||
i = int(math.log(size_bytes, 1024) // 1)
|
||||
p = math.pow(1024, i)
|
||||
s = round(size_bytes / p, 2)
|
||||
return f"{s} {size_name[i]}"
|
||||
except:
|
||||
return f"{size_bytes} B"
|
||||
|
||||
def clean_metadata_title(title):
|
||||
"""Limpa o título das faixas de áudio/legenda usando Regex."""
|
||||
if not title: return ""
|
||||
junk_terms = [
|
||||
r'\b5\.1\b', r'\b7\.1\b', r'\b2\.0\b',
|
||||
r'\baac\b', r'\bac3\b', r'\beac3\b', r'\batmos\b', r'\bdts\b', r'\btruehd\b',
|
||||
r'\bh264\b', r'\bx264\b', r'\bx265\b', r'\bhevc\b', r'\b1080p\b', r'\b720p\b', r'\b4k\b',
|
||||
r'\bbludv\b', r'\bcomandotorrents\b', r'\brarbg\b', r'\bwww\..+\.com\b',
|
||||
r'\bcópia\b', r'\boriginal\b'
|
||||
]
|
||||
clean_title = title
|
||||
for pattern in junk_terms:
|
||||
clean_title = re.sub(pattern, '', clean_title, flags=re.IGNORECASE)
|
||||
clean_title = re.sub(r'\s+', ' ', clean_title).strip()
|
||||
return clean_title.strip('-.|[]()').strip()
|
||||
|
||||
def handle_external_subtitles(src_video_path, dest_folder, delete_original=False):
|
||||
"""
|
||||
Procura legendas externas com o mesmo nome base do vídeo e as copia/move.
|
||||
"""
|
||||
try:
|
||||
src_folder = os.path.dirname(src_video_path)
|
||||
video_name = os.path.basename(src_video_path)
|
||||
video_stem = os.path.splitext(video_name)[0]
|
||||
|
||||
for file in os.listdir(src_folder):
|
||||
if file.lower().endswith(SUBTITLE_EXTS):
|
||||
if file.startswith(video_stem):
|
||||
remaining = file[len(video_stem):]
|
||||
if not remaining or remaining.startswith('.'):
|
||||
src_sub = os.path.join(src_folder, file)
|
||||
dest_sub = os.path.join(dest_folder, file)
|
||||
shutil.copy2(src_sub, dest_sub)
|
||||
if delete_original:
|
||||
os.remove(src_sub)
|
||||
except Exception as e:
|
||||
print(f"Erro legendas: {e}")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# --- SEÇÃO 3: LÓGICA DO FFMPEG ---
|
||||
# ==============================================================================
|
||||
|
||||
def build_ffmpeg_command(input_file, output_file):
|
||||
"""Constrói o comando FFmpeg inteligente."""
|
||||
cmd_probe = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", input_file]
|
||||
try:
|
||||
res = subprocess.run(cmd_probe, capture_output=True, text=True, env=os.environ)
|
||||
data = json.loads(res.stdout)
|
||||
except:
|
||||
return [
|
||||
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
|
||||
"-hwaccel_output_format", "vaapi", "-i", input_file, "-map", "0",
|
||||
"-c:v", "h264_vaapi", "-qp", "25", "-c:a", "copy", "-c:s", "copy", output_file
|
||||
]
|
||||
|
||||
input_streams = data.get('streams', [])
|
||||
map_args = ["-map", "0:v:0"]
|
||||
metadata_args = []
|
||||
found_pt_audio = False
|
||||
|
||||
# ÁUDIO
|
||||
audio_idx = 0
|
||||
for stream in input_streams:
|
||||
if stream['codec_type'] == 'audio':
|
||||
tags = stream.get('tags', {})
|
||||
lang = tags.get('language', 'und').lower()
|
||||
title = tags.get('title', '')
|
||||
|
||||
if lang in ['por', 'pt', 'pob', 'pt-br', 'eng', 'en', 'jpn', 'ja', 'und']:
|
||||
map_args.extend(["-map", f"0:{stream['index']}"])
|
||||
|
||||
new_title = clean_metadata_title(title)
|
||||
if not new_title:
|
||||
if lang in ['por', 'pt', 'pob', 'pt-br']: new_title = "Português"
|
||||
elif lang in ['eng', 'en']: new_title = "Inglês"
|
||||
elif lang in ['jpn', 'ja']: new_title = "Japonês"
|
||||
|
||||
metadata_args.extend([f"-metadata:s:a:{audio_idx}", f"title={new_title}"])
|
||||
|
||||
if lang in ['por', 'pt', 'pob', 'pt-br'] and not found_pt_audio:
|
||||
metadata_args.extend([f"-disposition:a:{audio_idx}", "default"])
|
||||
found_pt_audio = True
|
||||
else:
|
||||
metadata_args.extend([f"-disposition:a:{audio_idx}", "0"])
|
||||
audio_idx += 1
|
||||
|
||||
if audio_idx == 0: map_args.extend(["-map", "0:a"])
|
||||
|
||||
# LEGENDAS INTERNAS
|
||||
sub_idx = 0
|
||||
for stream in input_streams:
|
||||
if stream['codec_type'] == 'subtitle':
|
||||
tags = stream.get('tags', {})
|
||||
lang = tags.get('language', 'und').lower()
|
||||
title = tags.get('title', '')
|
||||
is_forced = 'forced' in stream.get('disposition', {})
|
||||
|
||||
if lang in ['por', 'pt', 'pob', 'pt-br']:
|
||||
map_args.extend(["-map", f"0:{stream['index']}"])
|
||||
new_title = clean_metadata_title(title)
|
||||
metadata_args.extend([f"-metadata:s:s:{sub_idx}", f"title={new_title}"])
|
||||
|
||||
if is_forced or "forç" in (title or "").lower():
|
||||
metadata_args.extend([f"-disposition:s:{sub_idx}", "forced"])
|
||||
else:
|
||||
metadata_args.extend([f"-disposition:s:{sub_idx}", "0"])
|
||||
sub_idx += 1
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
|
||||
"-hwaccel_output_format", "vaapi", "-i", input_file
|
||||
]
|
||||
cmd += map_args
|
||||
cmd += ["-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0", "-c:a", "copy", "-c:s", "copy"]
|
||||
cmd += metadata_args
|
||||
cmd.append(output_file)
|
||||
return cmd
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# --- SEÇÃO 4: WORKER THREAD ---
|
||||
# ==============================================================================
|
||||
|
||||
# --- BACKEND: WORKER THREAD ---
|
||||
class EncoderWorker(threading.Thread):
|
||||
def __init__(self, input_folder):
|
||||
def __init__(self, input_folder, delete_original=False):
|
||||
super().__init__()
|
||||
self.input_folder = input_folder
|
||||
self.delete_original = delete_original
|
||||
self.daemon = True
|
||||
|
||||
def run(self):
|
||||
global CURRENT_STATUS, HISTORY_LOG
|
||||
prepare_driver_environment()
|
||||
|
||||
files = []
|
||||
CURRENT_STATUS["running"] = True
|
||||
CURRENT_STATUS["stop_requested"] = False
|
||||
CURRENT_STATUS["log"] = "Escaneando arquivos..."
|
||||
|
||||
files_to_process = []
|
||||
for r, d, f in os.walk(self.input_folder):
|
||||
if "finalizados" in r or "temp" in r: continue
|
||||
for file in f:
|
||||
if file.lower().endswith(('.mkv', '.mp4', '.avi')):
|
||||
files.append(os.path.join(r, file))
|
||||
files_to_process.append(os.path.join(r, file))
|
||||
|
||||
total_files = len(files)
|
||||
stop_signal = False
|
||||
CURRENT_STATUS["total_files"] = len(files_to_process)
|
||||
|
||||
for i, fpath in enumerate(files):
|
||||
# Verifica Parada antes de começar o próximo
|
||||
if os.path.exists(STATUS_FILE):
|
||||
with open(STATUS_FILE, 'r') as f:
|
||||
if json.load(f).get('stop_requested'):
|
||||
stop_signal = True
|
||||
break
|
||||
|
||||
for i, fpath in enumerate(files_to_process):
|
||||
if CURRENT_STATUS["stop_requested"]: break
|
||||
|
||||
fname = os.path.basename(fpath)
|
||||
CURRENT_STATUS["file"] = fname
|
||||
CURRENT_STATUS["current_index"] = i + 1
|
||||
CURRENT_STATUS["pct_file"] = 0
|
||||
CURRENT_STATUS["pct_total"] = int((i / len(files_to_process)) * 100)
|
||||
|
||||
# Status Inicial
|
||||
status = {
|
||||
"running": True,
|
||||
"stop_requested": False,
|
||||
"file": fname,
|
||||
"pct_file": 0,
|
||||
"pct_total": int((i / total_files) * 100),
|
||||
"current_index": i + 1,
|
||||
"total_files": total_files,
|
||||
"log": "Iniciando..."
|
||||
}
|
||||
with open(STATUS_FILE, 'w') as f: json.dump(status, f)
|
||||
|
||||
rel = os.path.relpath(fpath, self.input_folder)
|
||||
out = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
|
||||
map_args = get_streams_map(fpath)
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128",
|
||||
"-hwaccel_output_format", "vaapi", "-i", fpath
|
||||
]
|
||||
cmd += map_args
|
||||
cmd += [
|
||||
"-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0",
|
||||
"-c:a", "copy", "-c:s", "copy", out
|
||||
]
|
||||
out_file = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel)
|
||||
out_file = os.path.splitext(out_file)[0] + ".mkv"
|
||||
dest_folder = os.path.dirname(out_file)
|
||||
|
||||
os.makedirs(dest_folder, exist_ok=True)
|
||||
|
||||
size_before = os.path.getsize(fpath)
|
||||
cmd = build_ffmpeg_command(fpath, out_file)
|
||||
total_sec = get_video_duration(fpath)
|
||||
|
||||
total_sec = get_video_duration(fpath) or 1
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ)
|
||||
|
||||
for line in proc.stdout:
|
||||
# Verifica Parada DURANTE a conversão
|
||||
if "time=" in line: # Checa a cada atualização de tempo
|
||||
if os.path.exists(STATUS_FILE):
|
||||
with open(STATUS_FILE, 'r') as f:
|
||||
if json.load(f).get('stop_requested'):
|
||||
proc.terminate() # Mata o FFmpeg
|
||||
stop_signal = True
|
||||
break
|
||||
|
||||
if CURRENT_STATUS["stop_requested"]:
|
||||
proc.terminate()
|
||||
break
|
||||
if "time=" in line:
|
||||
match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line)
|
||||
if match:
|
||||
sec = parse_time_to_seconds(match.group(1))
|
||||
pct = min(int((sec/total_sec)*100), 100)
|
||||
status["pct_file"] = pct
|
||||
speed = re.search(r"speed=\s*(\S+)", line)
|
||||
if speed: status["log"] = f"Velocidade: {speed.group(1)}"
|
||||
with open(STATUS_FILE, 'w') as f: json.dump(status, f)
|
||||
|
||||
CURRENT_STATUS["pct_file"] = pct
|
||||
speed = re.search(r"speed=\s*(\S+)", line)
|
||||
if speed: CURRENT_STATUS["log"] = f"Vel: {speed.group(1)}"
|
||||
|
||||
proc.wait()
|
||||
|
||||
if stop_signal:
|
||||
# Limpa arquivo incompleto se foi cancelado
|
||||
if os.path.exists(out): os.remove(out)
|
||||
break
|
||||
final_status = "Erro"
|
||||
if CURRENT_STATUS["stop_requested"]:
|
||||
if os.path.exists(out_file): os.remove(out_file)
|
||||
final_status = "Cancelado"
|
||||
elif proc.returncode == 0:
|
||||
final_status = "✅ Sucesso"
|
||||
size_after = os.path.getsize(out_file) if os.path.exists(out_file) else 0
|
||||
diff = size_after - size_before
|
||||
|
||||
HISTORY_LOG.appendleft({
|
||||
"time": datetime.now().strftime("%H:%M:%S"),
|
||||
"file": fname,
|
||||
"status": final_status,
|
||||
"orig_size": format_size(size_before),
|
||||
"final_size": format_size(size_after),
|
||||
"diff": ("+" if diff > 0 else "") + format_size(diff)
|
||||
})
|
||||
|
||||
handle_external_subtitles(fpath, dest_folder, self.delete_original)
|
||||
|
||||
if self.delete_original:
|
||||
try:
|
||||
os.remove(fpath)
|
||||
CURRENT_STATUS["log"] = "Original excluído."
|
||||
except: pass
|
||||
else:
|
||||
HISTORY_LOG.appendleft({
|
||||
"time": datetime.now().strftime("%H:%M:%S"),
|
||||
"file": fname,
|
||||
"status": "❌ Falha",
|
||||
"orig_size": format_size(size_before),
|
||||
"final_size": "-",
|
||||
"diff": "-"
|
||||
})
|
||||
|
||||
# Status Final
|
||||
final_msg = "Cancelado pelo usuário 🛑" if stop_signal else "Finalizado ✅"
|
||||
with open(STATUS_FILE, 'w') as f:
|
||||
json.dump({"running": False, "file": final_msg, "pct_file": 0 if stop_signal else 100, "pct_total": 100, "log": final_msg}, f)
|
||||
CURRENT_STATUS["running"] = False
|
||||
CURRENT_STATUS["log"] = "Parado" if CURRENT_STATUS["stop_requested"] else "Finalizado"
|
||||
CURRENT_STATUS["pct_file"] = 100
|
||||
CURRENT_STATUS["pct_total"] = 100
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# --- SEÇÃO 5: FRONTEND ---
|
||||
# ==============================================================================
|
||||
|
||||
# --- FRONTEND: UI ---
|
||||
class EncoderInterface:
|
||||
def __init__(self):
|
||||
self.path = ROOT_DIR
|
||||
self.container = None
|
||||
self.view_mode = 'explorer'
|
||||
self.timer = None
|
||||
self.delete_switch = None
|
||||
self.main_container = None
|
||||
|
||||
if CURRENT_STATUS["running"]:
|
||||
self.view_mode = 'monitor'
|
||||
else:
|
||||
self.view_mode = 'explorer'
|
||||
|
||||
self.main_container = ui.column().classes('w-full h-full gap-4')
|
||||
self.refresh_ui()
|
||||
|
||||
def refresh_ui(self):
|
||||
self.main_container.clear()
|
||||
with self.main_container:
|
||||
if self.view_mode == 'explorer':
|
||||
self.render_breadcrumbs()
|
||||
self.render_options()
|
||||
self.render_folder_list()
|
||||
self.render_history_btn()
|
||||
elif self.view_mode == 'monitor':
|
||||
self.render_monitor()
|
||||
elif self.view_mode == 'history':
|
||||
self.render_history_table()
|
||||
|
||||
def navigate(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.path = path
|
||||
self.refresh()
|
||||
self.refresh_ui()
|
||||
else:
|
||||
ui.notify('Erro ao acessar pasta', type='negative')
|
||||
|
||||
def refresh(self):
|
||||
if self.container:
|
||||
self.container.clear()
|
||||
with self.container:
|
||||
if self.view_mode == 'explorer':
|
||||
self.render_breadcrumbs()
|
||||
self.render_folder_list()
|
||||
else:
|
||||
self.render_monitor()
|
||||
|
||||
def start_encoding(self):
|
||||
if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE)
|
||||
t = EncoderWorker(self.path)
|
||||
should_delete = self.delete_switch.value if self.delete_switch else False
|
||||
CURRENT_STATUS["pct_file"] = 0
|
||||
CURRENT_STATUS["pct_total"] = 0
|
||||
t = EncoderWorker(self.path, delete_original=should_delete)
|
||||
t.start()
|
||||
ui.notify('Iniciado!', type='positive')
|
||||
ui.notify('Iniciando Conversão...', type='positive')
|
||||
self.view_mode = 'monitor'
|
||||
self.refresh()
|
||||
self.refresh_ui()
|
||||
|
||||
def stop_encoding(self):
|
||||
# Escreve o sinal de parada no arquivo JSON
|
||||
if os.path.exists(STATUS_FILE):
|
||||
try:
|
||||
with open(STATUS_FILE, 'r+') as f:
|
||||
data = json.load(f)
|
||||
data['stop_requested'] = True
|
||||
f.seek(0)
|
||||
json.dump(data, f)
|
||||
f.truncate()
|
||||
ui.notify('Parando processo... aguarde.', type='warning')
|
||||
except: pass
|
||||
|
||||
def back_to_explorer(self):
|
||||
self.view_mode = 'explorer'
|
||||
self.refresh()
|
||||
CURRENT_STATUS["stop_requested"] = True
|
||||
ui.notify('Solicitando parada...', type='warning')
|
||||
|
||||
def render_breadcrumbs(self):
|
||||
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'):
|
||||
@@ -207,8 +381,12 @@ class EncoderInterface:
|
||||
ui.icon('chevron_right', color='grey')
|
||||
acc = os.path.join(acc, part)
|
||||
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary')
|
||||
ui.space()
|
||||
ui.button("🚀 Converter Esta Pasta", on_click=self.start_encoding).props('push color=primary')
|
||||
|
||||
def render_options(self):
|
||||
with ui.card().classes('w-full mt-2 p-2 bg-blue-50'):
|
||||
with ui.row().classes('items-center w-full justify-between'):
|
||||
self.delete_switch = ui.switch('Excluir original ao finalizar com sucesso?').props('color=red')
|
||||
ui.button("🚀 Iniciar Conversão", on_click=self.start_encoding).props('push color=primary')
|
||||
|
||||
def render_folder_list(self):
|
||||
try:
|
||||
@@ -216,72 +394,69 @@ class EncoderInterface:
|
||||
except: return
|
||||
with ui.column().classes('w-full gap-1 mt-2'):
|
||||
if self.path != ROOT_DIR:
|
||||
with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-blue-50 hover:bg-blue-100 cursor-pointer rounded'):
|
||||
with ui.item_section().props('avatar'): ui.icon('arrow_upward', color='grey')
|
||||
with ui.item_section(): ui.item_label('Voltar / Subir Nível')
|
||||
with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-gray-200 hover:bg-gray-300 cursor-pointer rounded'):
|
||||
# Sintaxe correta: usando context manager (with)
|
||||
with ui.item_section().props('avatar'):
|
||||
ui.icon('arrow_upward', color='grey')
|
||||
with ui.item_section():
|
||||
ui.item_label('.. (Subir nível)')
|
||||
for entry in entries:
|
||||
with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-gray-100 cursor-pointer rounded'):
|
||||
with ui.item_section().props('avatar'): ui.icon('folder', color='amber')
|
||||
with ui.item_section(): ui.item_label(entry.name).classes('font-medium')
|
||||
with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-blue-50 cursor-pointer rounded border-b border-gray-100'):
|
||||
with ui.item_section().props('avatar'):
|
||||
ui.icon('folder', color='amber')
|
||||
with ui.item_section():
|
||||
ui.item_label(entry.name).classes('font-medium')
|
||||
|
||||
def render_history_btn(self):
|
||||
ui.separator().classes('mt-4')
|
||||
ui.button('Ver Histórico (Últimos 50)', on_click=lambda: self.set_view('history')).props('outline w-full')
|
||||
|
||||
def set_view(self, mode):
|
||||
self.view_mode = mode
|
||||
self.refresh_ui()
|
||||
|
||||
def render_history_table(self):
|
||||
ui.label('Histórico de Conversões').classes('text-xl font-bold mb-4')
|
||||
columns = [
|
||||
{'name': 'time', 'label': 'Hora', 'field': 'time', 'align': 'left'},
|
||||
{'name': 'file', 'label': 'Arquivo', 'field': 'file', 'align': 'left'},
|
||||
{'name': 'status', 'label': 'Status', 'field': 'status', 'align': 'center'},
|
||||
{'name': 'orig', 'label': 'Tam. Orig.', 'field': 'orig_size'},
|
||||
{'name': 'final', 'label': 'Tam. Final', 'field': 'final_size'},
|
||||
{'name': 'diff', 'label': 'Diferença', 'field': 'diff'},
|
||||
]
|
||||
ui.table(columns=columns, rows=list(HISTORY_LOG), row_key='file').classes('w-full')
|
||||
ui.button('Voltar', on_click=lambda: self.set_view('explorer')).props('outline mt-4')
|
||||
|
||||
def render_monitor(self):
|
||||
ui.label('Monitor de Conversão').classes('text-xl font-bold mb-4')
|
||||
|
||||
lbl_file = ui.label('Inicializando...')
|
||||
progress_file = ui.linear_progress(value=0).classes('w-full')
|
||||
lbl_status = ui.label('---')
|
||||
lbl_log = ui.label('---').classes('text-caption text-grey')
|
||||
|
||||
ui.separator().classes('my-4')
|
||||
|
||||
lbl_total = ui.label('Total: 0/0')
|
||||
progress_total = ui.linear_progress(value=0).classes('w-full')
|
||||
|
||||
# Botões de Controle
|
||||
row_btns = ui.row().classes('mt-4 gap-2')
|
||||
|
||||
# Botão de Parar (Só aparece se estiver rodando)
|
||||
btn_stop = ui.button('🛑 Parar Processo', on_click=self.stop_encoding).props('color=red')
|
||||
# Botão Voltar (Só aparece se acabou)
|
||||
btn_back = ui.button('Voltar para Pastas', on_click=self.back_to_explorer).props('outline')
|
||||
btn_back.set_visibility(False)
|
||||
row_btns = ui.row().classes('mt-6 gap-4')
|
||||
with row_btns:
|
||||
btn_stop = ui.button('🛑 Parar Tudo', on_click=self.stop_encoding).props('color=red')
|
||||
btn_back = ui.button('Voltar / Novo', on_click=lambda: self.set_view('explorer')).props('outline')
|
||||
btn_back.set_visibility(False)
|
||||
|
||||
def update_loop():
|
||||
if not os.path.exists(STATUS_FILE): return
|
||||
try:
|
||||
with open(STATUS_FILE, 'r') as f: data = json.load(f)
|
||||
|
||||
is_running = data.get('running', False)
|
||||
|
||||
lbl_file.text = f"Arquivo: {data.get('file', '?')}"
|
||||
val_file = data.get('pct_file', 0) / 100
|
||||
progress_file.value = val_file
|
||||
lbl_status.text = f"Status: {int(val_file*100)}% | {data.get('log', '')}"
|
||||
|
||||
if 'total_files' in data:
|
||||
curr = data.get('current_index', 0)
|
||||
tot = data.get('total_files', 0)
|
||||
lbl_total.text = f"Fila: {curr} de {tot} arquivos"
|
||||
val_total = data.get('pct_total', 0) / 100
|
||||
progress_total.value = val_total
|
||||
|
||||
# Controle de Visibilidade dos Botões
|
||||
if is_running:
|
||||
btn_stop.set_visibility(True)
|
||||
btn_back.set_visibility(False)
|
||||
else:
|
||||
btn_stop.set_visibility(False)
|
||||
btn_back.set_visibility(True)
|
||||
|
||||
except: pass
|
||||
def update_monitor():
|
||||
if not CURRENT_STATUS["running"] and CURRENT_STATUS["pct_total"] >= 100:
|
||||
btn_stop.set_visibility(False)
|
||||
btn_back.set_visibility(True)
|
||||
lbl_file.text = "Todos os processos finalizados."
|
||||
|
||||
lbl_file.text = f"Arquivo: {CURRENT_STATUS['file']}"
|
||||
progress_file.value = CURRENT_STATUS['pct_file'] / 100
|
||||
lbl_log.text = f"{int(CURRENT_STATUS['pct_file'])}% | {CURRENT_STATUS['log']}"
|
||||
lbl_total.text = f"Fila: {CURRENT_STATUS['current_index']} de {CURRENT_STATUS['total_files']}"
|
||||
progress_total.value = CURRENT_STATUS['pct_total'] / 100
|
||||
|
||||
self.timer = ui.timer(1.0, update_loop)
|
||||
self.timer = ui.timer(0.5, update_monitor)
|
||||
|
||||
def create_ui():
|
||||
enc = EncoderInterface()
|
||||
if os.path.exists(STATUS_FILE):
|
||||
try:
|
||||
with open(STATUS_FILE, 'r') as f:
|
||||
if json.load(f).get('running'): enc.view_mode = 'monitor'
|
||||
except: pass
|
||||
enc.container = ui.column().classes('w-full h-full p-4 gap-4')
|
||||
enc.refresh()
|
||||
return EncoderInterface()
|
||||
@@ -1,327 +1,514 @@
|
||||
from nicegui import ui, app
|
||||
from nicegui import ui, run
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
import datetime
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
ROOT_DIR = "/downloads"
|
||||
|
||||
# --- UTILITÁRIOS ---
|
||||
def get_human_size(size):
|
||||
# --- 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"
|
||||
|
||||
def get_subfolders(root):
|
||||
folders = [root]
|
||||
try:
|
||||
for r, d, f in os.walk(root):
|
||||
if "finalizados" in r or "temp" in r: continue
|
||||
for folder in d:
|
||||
if not folder.startswith('.'): folders.append(os.path.join(r, folder))
|
||||
except: pass
|
||||
return sorted(folders)
|
||||
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
|
||||
|
||||
# --- LEITOR DE METADADOS (FFPROBE) ---
|
||||
def get_media_info(filepath):
|
||||
"""Lê as faixas de áudio e legenda do arquivo"""
|
||||
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath]
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
data = json.loads(res.stdout)
|
||||
|
||||
info = {
|
||||
"duration": float(data['format'].get('duration', 0)),
|
||||
"bitrate": int(data['format'].get('bit_rate', 0)),
|
||||
"video": [],
|
||||
"audio": [],
|
||||
"subtitle": []
|
||||
}
|
||||
data = await run.io_bound(_probe)
|
||||
if not data: return None
|
||||
|
||||
info = {
|
||||
"filename": os.path.basename(filepath),
|
||||
"size": int(data['format'].get('size', 0)),
|
||||
"duration": float(data['format'].get('duration', 0)),
|
||||
"bitrate": int(data['format'].get('bit_rate', 0)),
|
||||
"video": [], "audio": [], "subtitle": []
|
||||
}
|
||||
|
||||
for s in data.get('streams', []):
|
||||
type = s['codec_type']
|
||||
lang = s.get('tags', {}).get('language', 'und')
|
||||
title = s.get('tags', {}).get('title', '')
|
||||
codec = s.get('codec_name', 'unknown')
|
||||
for s in data.get('streams', []):
|
||||
stype = s['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})
|
||||
|
||||
desc = f"[{lang.upper()}] {codec}"
|
||||
if title: desc += f" - {title}"
|
||||
|
||||
if type == 'video':
|
||||
w = s.get('width', 0)
|
||||
h = s.get('height', 0)
|
||||
info['video'].append(f"{codec.upper()} ({w}x{h})")
|
||||
elif type == 'audio':
|
||||
ch = s.get('channels', 0)
|
||||
info['audio'].append(f"{desc} ({ch}ch)")
|
||||
elif type == 'subtitle':
|
||||
info['subtitle'].append(desc)
|
||||
|
||||
return info
|
||||
except:
|
||||
return None
|
||||
return info
|
||||
|
||||
# --- CLASSE GERENCIADORA ---
|
||||
class FileManager:
|
||||
def __init__(self):
|
||||
self.path = ROOT_DIR
|
||||
self.view_mode = 'grid'
|
||||
self.container = None
|
||||
self.is_selecting = False
|
||||
self.selected_items = set()
|
||||
self.search_term = ""
|
||||
self.refreshing = False
|
||||
|
||||
def navigate(self, path):
|
||||
# Elementos UI
|
||||
self.container_content = None
|
||||
self.footer = None
|
||||
self.lbl_selection_count = None
|
||||
self.btn_select_mode = None
|
||||
self.header_row = None # Para atualizar breadcrumbs
|
||||
|
||||
# --- NAVEGAÇÃO ---
|
||||
async def navigate(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.path = path
|
||||
self.refresh()
|
||||
self.selected_items.clear()
|
||||
self.is_selecting = False
|
||||
self.search_term = ""
|
||||
self.update_footer_state()
|
||||
# Atualiza o header completamente para refazer breadcrumbs
|
||||
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')
|
||||
ui.notify('Caminho inválido.', type='negative')
|
||||
|
||||
def navigate_up(self):
|
||||
async def navigate_up(self):
|
||||
parent = os.path.dirname(self.path)
|
||||
if self.path != ROOT_DIR: await self.navigate(parent)
|
||||
|
||||
# --- UPLOAD CORRIGIDO FINAL (NOME E CONTEÚDO) ---
|
||||
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:
|
||||
# 1. Recuperação do Nome (Prioridade para .filename do Starlette)
|
||||
name = None
|
||||
|
||||
# Verifica se existe o objeto interno 'file' (detectado nos logs anteriores)
|
||||
if hasattr(e, 'file'):
|
||||
# .filename é onde fica o nome original do arquivo no upload web
|
||||
name = getattr(e.file, 'filename', None)
|
||||
|
||||
# Se não achar, tenta .name
|
||||
if not name:
|
||||
name = getattr(e.file, 'name', None)
|
||||
|
||||
# Se ainda não achou, tenta direto no evento (fallback)
|
||||
if not name:
|
||||
name = getattr(e, 'name', 'arquivo_sem_nome')
|
||||
|
||||
# 2. Leitura do Conteúdo (Assíncrona)
|
||||
content = b''
|
||||
if hasattr(e, 'file'):
|
||||
# Tenta resetar o ponteiro de leitura para o início
|
||||
if hasattr(e.file, 'seek'):
|
||||
await e.file.seek(0)
|
||||
|
||||
# Lê os bytes (await é necessário aqui)
|
||||
if hasattr(e.file, 'read'):
|
||||
content = await e.file.read()
|
||||
|
||||
if not content:
|
||||
ui.notify('Erro: Arquivo vazio ou ilegível', type='warning')
|
||||
return
|
||||
|
||||
# 3. Salva no disco
|
||||
target = os.path.join(self.path, name)
|
||||
|
||||
# Executa a gravação em thread separada para não travar o servidor
|
||||
await run.io_bound(self._save_file_bytes, target, content)
|
||||
|
||||
ui.notify(f'Sucesso: {name}', type='positive')
|
||||
|
||||
except Exception as ex:
|
||||
# Imprime no log do container para diagnóstico se falhar
|
||||
print(f"ERRO CRITICO UPLOAD: {ex}")
|
||||
ui.notify(f'Erro: {ex}', type='negative')
|
||||
|
||||
# Componente UI
|
||||
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()
|
||||
|
||||
|
||||
# Função auxiliar simples para salvar bytes
|
||||
def _save_file_bytes(self, target, content_bytes):
|
||||
with open(target, 'wb') as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
|
||||
# --- LÓGICA DE 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()
|
||||
ui.timer(0, self.refresh, once=True)
|
||||
|
||||
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()
|
||||
ui.timer(0, self.refresh, once=True)
|
||||
|
||||
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()
|
||||
ui.timer(0, self.refresh, once=True)
|
||||
|
||||
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) selecionados'
|
||||
|
||||
# --- AÇÕES EM LOTE ---
|
||||
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:
|
||||
if p != ROOT_DIR:
|
||||
ui.button('.. (Voltar)', on_click=lambda: load_folders(os.path.dirname(p))).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()
|
||||
|
||||
# --- INTERFACE PRINCIPAL ---
|
||||
def create_layout(self):
|
||||
# 1. Header (Fixo)
|
||||
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()
|
||||
|
||||
# 2. Área de Conteúdo
|
||||
self.container_content = ui.column().classes('w-full gap-4 pb-24')
|
||||
|
||||
# 3. Footer (SIMULADO com ROW FIXED)
|
||||
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 Seleção', on_click=lambda: self.open_move_dialog(None)).props('color=amber icon=drive_file_move dense')
|
||||
ui.button('Excluir Seleção', 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').tooltip('Sair da Seleção')
|
||||
|
||||
self.footer.visible = False
|
||||
|
||||
def build_header_content(self):
|
||||
# Botão Subir Nível
|
||||
if self.path != ROOT_DIR:
|
||||
self.navigate(parent)
|
||||
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir Nível')
|
||||
else:
|
||||
ui.button(icon='home').props('flat round dense disabled text-color=grey')
|
||||
|
||||
# BREADCRUMBS (Barra de Endereço Restaurada)
|
||||
rel = os.path.relpath(self.path, ROOT_DIR)
|
||||
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')
|
||||
# Precisamos capturar o valor de acc no lambda
|
||||
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-1 text-xs')
|
||||
|
||||
# Botões de Ação
|
||||
self.btn_select_mode = ui.button(icon='check_box', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Modo Seleção')
|
||||
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense').tooltip('Nova Pasta')
|
||||
ui.button(icon='cloud_upload', on_click=self.open_upload_dialog).props('flat round dense').tooltip('Upload')
|
||||
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()
|
||||
|
||||
def refresh(self):
|
||||
if self.container:
|
||||
self.container.clear()
|
||||
with self.container:
|
||||
self.render_header()
|
||||
self.render_content()
|
||||
|
||||
# --- PLAYER DE VÍDEO ---
|
||||
def open_player(self, path):
|
||||
filename = os.path.basename(path)
|
||||
|
||||
# Converte caminho local (/downloads/pasta/video.mkv) para URL (/files/pasta/video.mkv)
|
||||
# O prefixo /files foi configurado no main.py
|
||||
rel_path = os.path.relpath(path, ROOT_DIR)
|
||||
video_url = f"/files/{rel_path}"
|
||||
|
||||
# Pega dados técnicos
|
||||
info = get_media_info(path)
|
||||
|
||||
with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl h-[80vh] p-0 gap-0'):
|
||||
# Header
|
||||
with ui.row().classes('w-full bg-gray-100 p-2 justify-between items-center'):
|
||||
ui.label(filename).classes('font-bold text-lg truncate')
|
||||
ui.button(icon='close', on_click=dialog.close).props('flat round dense')
|
||||
|
||||
with ui.row().classes('w-full h-full'):
|
||||
# Coluna Esquerda: Player
|
||||
with ui.column().classes('w-2/3 h-full bg-black justify-center'):
|
||||
# Player HTML5 Nativo
|
||||
ui.video(video_url).classes('w-full max-h-full')
|
||||
ui.label('Nota: Áudios AC3/DTS podem ficar mudos no navegador.').classes('text-gray-500 text-xs text-center w-full')
|
||||
|
||||
# Coluna Direita: Informações
|
||||
with ui.column().classes('w-1/3 h-full p-4 overflow-y-auto bg-white border-l'):
|
||||
ui.label('📋 Detalhes do Arquivo').classes('text-lg font-bold mb-4 text-blue-600')
|
||||
|
||||
if info:
|
||||
# Vídeo
|
||||
ui.label('Vídeo').classes('font-bold text-xs text-gray-500 uppercase')
|
||||
for v in info['video']:
|
||||
ui.label(f"📺 {v}").classes('ml-2 text-sm')
|
||||
|
||||
ui.separator().classes('my-2')
|
||||
|
||||
# Áudio
|
||||
ui.label('Áudio').classes('font-bold text-xs text-gray-500 uppercase')
|
||||
if info['audio']:
|
||||
for a in info['audio']:
|
||||
ui.label(f"🔊 {a}").classes('ml-2 text-sm')
|
||||
else:
|
||||
ui.label("Sem áudio").classes('ml-2 text-sm text-gray-400')
|
||||
|
||||
ui.separator().classes('my-2')
|
||||
|
||||
# Legenda
|
||||
ui.label('Legendas').classes('font-bold text-xs text-gray-500 uppercase')
|
||||
if info['subtitle']:
|
||||
for s in info['subtitle']:
|
||||
ui.label(f"💬 {s}").classes('ml-2 text-sm')
|
||||
else:
|
||||
ui.label("Sem legendas").classes('ml-2 text-sm text-gray-400')
|
||||
else:
|
||||
ui.label('Não foi possível ler os metadados.').classes('text-red')
|
||||
|
||||
dialog.open()
|
||||
|
||||
# --- DIÁLOGOS DE AÇÃO ---
|
||||
def open_delete_dialog(self, path):
|
||||
with ui.dialog() as dialog, ui.card():
|
||||
ui.label('Excluir item?').classes('font-bold')
|
||||
ui.label(os.path.basename(path))
|
||||
with ui.row().classes('w-full justify-end'):
|
||||
ui.button('Cancelar', on_click=dialog.close).props('flat')
|
||||
def confirm():
|
||||
try:
|
||||
if os.path.isdir(path): shutil.rmtree(path)
|
||||
else: os.remove(path)
|
||||
dialog.close()
|
||||
self.refresh()
|
||||
ui.notify('Excluído!')
|
||||
except Exception as e: ui.notify(str(e), type='negative')
|
||||
ui.button('Excluir', on_click=confirm).props('color=red')
|
||||
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')
|
||||
def save():
|
||||
try:
|
||||
os.rename(path, os.path.join(os.path.dirname(path), name.value))
|
||||
dialog.close()
|
||||
self.refresh()
|
||||
ui.notify('Renomeado!')
|
||||
except Exception as e: ui.notify(str(e), type='negative')
|
||||
ui.button('Salvar', on_click=save)
|
||||
dialog.open()
|
||||
|
||||
def open_move_dialog(self, path):
|
||||
folders = get_subfolders(ROOT_DIR)
|
||||
if os.path.isdir(path) and path in folders: folders.remove(path)
|
||||
opts = {f: f.replace(ROOT_DIR, "Raiz") if f != ROOT_DIR else "Raiz" for f in folders}
|
||||
with ui.dialog() as dialog, ui.card().classes('w-96'):
|
||||
ui.label('Mover Para')
|
||||
target = ui.select(opts, value=ROOT_DIR, with_input=True).classes('w-full')
|
||||
def confirm():
|
||||
try:
|
||||
shutil.move(path, target.value)
|
||||
dialog.close()
|
||||
self.refresh()
|
||||
ui.notify('Movido!')
|
||||
except Exception as e: ui.notify(str(e), type='negative')
|
||||
ui.button('Mover', on_click=confirm)
|
||||
dialog.open()
|
||||
ui.timer(0, self.refresh, once=True)
|
||||
|
||||
def open_create_folder(self):
|
||||
with ui.dialog() as dialog, ui.card():
|
||||
ui.label('Nova Pasta')
|
||||
name = ui.input('Nome')
|
||||
def create():
|
||||
async def create():
|
||||
try:
|
||||
os.makedirs(os.path.join(self.path, name.value))
|
||||
await run.io_bound(os.makedirs, os.path.join(self.path, name.value))
|
||||
dialog.close()
|
||||
self.refresh()
|
||||
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()
|
||||
|
||||
# --- MENU DE CONTEXTO ---
|
||||
# --- RENDERIZAÇÃO DE CONTEÚDO ---
|
||||
async def refresh(self):
|
||||
if self.refreshing: return
|
||||
self.refreshing = True
|
||||
|
||||
color = 'green' if self.is_selecting else 'grey'
|
||||
if self.btn_select_mode:
|
||||
self.btn_select_mode.props(f'text-color={color}')
|
||||
|
||||
if self.container_content:
|
||||
self.container_content.clear()
|
||||
|
||||
try:
|
||||
entries = await run.io_bound(os.scandir, self.path)
|
||||
entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||
|
||||
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'Selecionar Todos ({len(entries)})', on_change=lambda: self.select_all(entries)).props('dense size=xs')
|
||||
|
||||
with self.container_content:
|
||||
if not 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 entries:
|
||||
self.render_card(entry)
|
||||
else:
|
||||
with ui.column().classes('w-full gap-0'):
|
||||
for entry in entries:
|
||||
self.render_list_item(entry)
|
||||
|
||||
except Exception as e:
|
||||
with self.container_content:
|
||||
ui.label(f'Erro ao ler pasta: {e}').classes('text-red')
|
||||
|
||||
self.refreshing = False
|
||||
|
||||
def render_card(self, entry):
|
||||
is_selected = 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 'blue-grey'
|
||||
if icon == 'movie': color = 'purple-6'
|
||||
|
||||
bg_cls = 'bg-green-100 ring-2 ring-green-500' if is_selected else 'bg-white hover:shadow-md'
|
||||
if self.is_selecting and not is_selected: bg_cls += ' border-dashed border-2 border-gray-300'
|
||||
|
||||
with ui.card().classes(f'w-full aspect-square p-2 items-center justify-center relative group cursor-pointer select-none {bg_cls}') as card:
|
||||
self.bind_context_menu(card, entry)
|
||||
|
||||
if self.is_selecting:
|
||||
card.on('click', lambda: self.toggle_selection(entry.path))
|
||||
with ui.column().classes('absolute top-1 left-1 z-10'):
|
||||
ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense').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_selected = entry.path in self.selected_items
|
||||
bg_cls = 'bg-green-100' if is_selected 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_cls}') as row:
|
||||
if self.is_selecting:
|
||||
ui.checkbox(value=is_selected, 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')
|
||||
|
||||
# --- MENU DE CONTEXTO (CORRIGIDO) ---
|
||||
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('▶️ Reproduzir / Detalhes', on_click=lambda: self.open_player(entry.path))
|
||||
ui.separator()
|
||||
# Opções de mídia apenas para vídeos
|
||||
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))
|
||||
ui.separator()
|
||||
ui.menu_item('Excluir', on_click=lambda: self.open_delete_dialog(entry.path)).props('text-color=red')
|
||||
ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog([entry.path]))
|
||||
|
||||
async def delete_single():
|
||||
try:
|
||||
ui.notify(f'Excluindo {entry.name}...')
|
||||
|
||||
# CORREÇÃO AQUI: Adicionados parênteses () em is_dir()
|
||||
# Sem eles, o Python acha que sempre é True (porque o método existe)
|
||||
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('Item excluído.', type='positive')
|
||||
except Exception as e:
|
||||
ui.notify(f"Erro ao excluir: {e}", type='negative')
|
||||
print(f"DEBUG DELETE ERROR: {e}")
|
||||
|
||||
ui.menu_item('Excluir', on_click=delete_single).props('text-color=red')
|
||||
|
||||
element.on('contextmenu.prevent', lambda: m.open())
|
||||
|
||||
# --- RENDERIZADORES ---
|
||||
def render_header(self):
|
||||
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2'):
|
||||
if self.path != ROOT_DIR:
|
||||
ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir')
|
||||
else:
|
||||
ui.button(icon='home').props('flat round dense disabled text-color=grey')
|
||||
|
||||
rel = os.path.relpath(self.path, ROOT_DIR)
|
||||
parts = rel.split(os.sep) if rel != '.' else []
|
||||
with ui.row().classes('items-center gap-0'):
|
||||
ui.button('/', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-2')
|
||||
acc = ROOT_DIR
|
||||
for part in parts:
|
||||
acc = os.path.join(acc, part)
|
||||
ui.label('/')
|
||||
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-2')
|
||||
# --- INSPECTOR (RESTAURADO E RICO) ---
|
||||
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')
|
||||
|
||||
ui.space()
|
||||
ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense')
|
||||
ui.button(icon='view_list' if self.view_mode == 'grid' else 'grid_view', on_click=self.toggle_view).props('flat round dense')
|
||||
ui.button(icon='refresh', on_click=self.refresh).props('flat round dense')
|
||||
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('Não foi possível ler os metadados.').classes('text-red font-bold')
|
||||
return
|
||||
|
||||
def render_content(self):
|
||||
try:
|
||||
entries = sorted(os.scandir(self.path), key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||
except: return
|
||||
with content:
|
||||
# 1. Resumo Geral (Stats)
|
||||
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']))}").tooltip('Duração')
|
||||
ui.label(f"📦 {await get_human_size(info['size'])}").tooltip('Tamanho')
|
||||
ui.label(f"🚀 {int(info['bitrate']/1000)} kbps").tooltip('Bitrate Total')
|
||||
|
||||
if not entries:
|
||||
ui.label('Pasta vazia').classes('w-full text-center text-gray-400 mt-10')
|
||||
return
|
||||
# 2. Vídeo
|
||||
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')
|
||||
|
||||
if 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 entries:
|
||||
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 'blue-grey'
|
||||
if icon == 'movie': color = 'purple-6'
|
||||
# 3. Áudio
|
||||
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(a['ch']).classes('text-gray-500')
|
||||
else:
|
||||
ui.label('Sem faixas de áudio').classes('italic text-gray-400')
|
||||
|
||||
with ui.card().classes('w-full aspect-square p-2 items-center justify-center relative group hover:shadow-md cursor-pointer select-none') as card:
|
||||
if is_dir:
|
||||
card.on('click', lambda p=entry.path: self.navigate(p))
|
||||
elif icon == 'movie':
|
||||
# Duplo clique no vídeo abre o player
|
||||
card.on('dblclick', lambda p=entry.path: self.open_player(p))
|
||||
|
||||
self.bind_context_menu(card, entry)
|
||||
|
||||
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')
|
||||
if not is_dir: ui.label(get_human_size(entry.stat().st_size)).classes('text-[10px] text-gray-400')
|
||||
|
||||
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90'):
|
||||
with ui.menu():
|
||||
if icon == 'movie':
|
||||
ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p))
|
||||
ui.separator()
|
||||
ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p))
|
||||
ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p))
|
||||
ui.separator()
|
||||
ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p)).props('text-color=red')
|
||||
|
||||
else:
|
||||
with ui.column().classes('w-full gap-0'):
|
||||
for entry in entries:
|
||||
is_dir = entry.is_dir()
|
||||
icon = 'folder' if is_dir else 'description'
|
||||
color = 'amber-8' if is_dir else 'blue-grey'
|
||||
|
||||
with ui.row().classes('w-full items-center px-2 py-2 border-b hover:bg-blue-50 cursor-pointer group') as row:
|
||||
if is_dir: row.on('click', lambda p=entry.path: self.navigate(p))
|
||||
elif entry.name.lower().endswith(('.mkv', '.mp4')):
|
||||
row.on('dblclick', lambda p=entry.path: self.open_player(p))
|
||||
|
||||
self.bind_context_menu(row, entry)
|
||||
|
||||
ui.icon(icon, color=color).classes('mr-2')
|
||||
with ui.column().classes('flex-grow gap-0'):
|
||||
ui.label(entry.name).classes('text-sm font-medium break-all')
|
||||
|
||||
if not is_dir:
|
||||
ui.label(get_human_size(entry.stat().st_size)).classes('text-xs text-gray-500 mr-4')
|
||||
|
||||
with ui.button(icon='more_vert').props('flat round dense size=sm').classes('sm:opacity-0 group-hover:opacity-100'):
|
||||
with ui.menu():
|
||||
if not is_dir:
|
||||
ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p))
|
||||
ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p))
|
||||
ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p))
|
||||
ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p))
|
||||
# 4. Legendas
|
||||
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')
|
||||
else:
|
||||
ui.label('Sem legendas internas').classes('italic text-gray-400')
|
||||
|
||||
# --- INICIALIZADOR ---
|
||||
def create_ui():
|
||||
fm = FileManager()
|
||||
fm.container = ui.column().classes('w-full h-full p-2 md:p-4 gap-4')
|
||||
fm.refresh()
|
||||
fm.create_layout()
|
||||
ui.timer(0, fm.refresh, once=True)
|
||||
@@ -1,181 +1,502 @@
|
||||
from nicegui import ui
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from nicegui import ui
|
||||
import tmdbsimple as tmdb
|
||||
from guessit import guessit
|
||||
|
||||
# ==============================================================================
|
||||
# 1. CONFIGURAÇÕES E CONSTANTES
|
||||
# ==============================================================================
|
||||
ROOT_DIR = "/downloads"
|
||||
DEST_DIR = os.path.join(ROOT_DIR, "preparados") # Nova raiz de destino
|
||||
CONFIG_FILE = 'config.json'
|
||||
|
||||
# --- UTILITÁRIOS ---
|
||||
def extract_season_episode(filename):
|
||||
"""Detecta Temporada e Episódio usando vários padrões"""
|
||||
patterns = [
|
||||
r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})',
|
||||
r'(?i)S(\d{1,4})[\s._-]*EP(\d{1,4})',
|
||||
r'(?i)(\d{1,4})x(\d{1,4})',
|
||||
r'(?i)Season[\s._-]*(\d{1,4})[\s._-]*Episode[\s._-]*(\d{1,4})',
|
||||
r'(?i)S(\d{1,4})[\s._-]*-\s*(\d{1,4})',
|
||||
r'(?i)\[(\d{1,4})x(\d{1,4})\]',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, filename)
|
||||
if match: return match.group(1), match.group(2)
|
||||
return None, None
|
||||
# ID do Gênero Animação no TMDb
|
||||
GENRE_ANIMATION = 16
|
||||
|
||||
class RenamerManager:
|
||||
def __init__(self):
|
||||
self.path = ROOT_DIR
|
||||
self.container = None
|
||||
self.preview_data = []
|
||||
self.view_mode = 'explorer'
|
||||
# Extensões para proteger pastas de exclusão
|
||||
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm', '.m4v'}
|
||||
# Extensões de legenda para mover junto
|
||||
SUBTITLE_EXTENSIONS = {'.srt', '.sub', '.ass', '.vtt', '.idx'}
|
||||
|
||||
def navigate(self, path):
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
self.path = path
|
||||
self.refresh()
|
||||
else:
|
||||
ui.notify('Erro ao acessar pasta', type='negative')
|
||||
|
||||
def refresh(self):
|
||||
if self.container:
|
||||
self.container.clear()
|
||||
with self.container:
|
||||
if self.view_mode == 'explorer':
|
||||
self.render_breadcrumbs()
|
||||
self.render_folder_list()
|
||||
else:
|
||||
self.render_preview()
|
||||
|
||||
def analyze_folder(self):
|
||||
self.preview_data = []
|
||||
for root, dirs, files in os.walk(self.path):
|
||||
if "finalizados" in root: continue
|
||||
for file in files:
|
||||
if file.lower().endswith(('.mkv', '.mp4', '.avi')):
|
||||
season, episode = extract_season_episode(file)
|
||||
if season and episode:
|
||||
try:
|
||||
s_fmt = f"{int(season):02d}"
|
||||
e_fmt = f"{int(episode):02d}"
|
||||
ext = os.path.splitext(file)[1]
|
||||
|
||||
# Estrutura: Temporada XX / Episódio YY.mkv
|
||||
new_struct = f"Temporada {s_fmt}/Episódio {e_fmt}{ext}"
|
||||
|
||||
src = os.path.join(root, file)
|
||||
dst = os.path.join(self.path, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}")
|
||||
|
||||
if src != dst:
|
||||
self.preview_data.append({
|
||||
'original': file,
|
||||
'new': new_struct,
|
||||
'src': src,
|
||||
'dst': dst
|
||||
})
|
||||
except: pass
|
||||
|
||||
if not self.preview_data:
|
||||
ui.notify('Nenhum padrão encontrado.', type='warning')
|
||||
else:
|
||||
self.view_mode = 'preview'
|
||||
self.refresh()
|
||||
|
||||
def execute_rename(self):
|
||||
count = 0
|
||||
for item in self.preview_data:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(item['dst']), exist_ok=True)
|
||||
if not os.path.exists(item['dst']):
|
||||
shutil.move(item['src'], item['dst'])
|
||||
count += 1
|
||||
except: pass
|
||||
|
||||
ui.notify(f'{count} Arquivos Organizados!', type='positive')
|
||||
self.view_mode = 'explorer'
|
||||
self.preview_data = []
|
||||
self.refresh()
|
||||
|
||||
def cancel(self):
|
||||
self.view_mode = 'explorer'
|
||||
self.preview_data = []
|
||||
self.refresh()
|
||||
|
||||
# --- RENDERIZADOR: BARRA DE NAVEGAÇÃO (CADEIA) ---
|
||||
def render_breadcrumbs(self):
|
||||
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'):
|
||||
# Botão Raiz
|
||||
ui.button('🏠', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense text-color=grey-8')
|
||||
|
||||
# Divide o caminho atual para criar os botões
|
||||
if self.path != ROOT_DIR:
|
||||
rel = os.path.relpath(self.path, ROOT_DIR)
|
||||
parts = rel.split(os.sep)
|
||||
|
||||
acc = ROOT_DIR
|
||||
for part in parts:
|
||||
ui.icon('chevron_right', color='grey')
|
||||
acc = os.path.join(acc, part)
|
||||
# Botão da Pasta
|
||||
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary')
|
||||
|
||||
ui.space()
|
||||
# Botão de Ação Principal
|
||||
ui.button("🔍 Analisar Pasta Atual", on_click=self.analyze_folder).props('push color=primary')
|
||||
|
||||
# --- RENDERIZADOR: LISTA DE PASTAS ---
|
||||
def render_folder_list(self):
|
||||
# ==============================================================================
|
||||
# 2. PERSISTÊNCIA (Salvar API Key)
|
||||
# ==============================================================================
|
||||
def load_config():
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
# Lista apenas diretórios, ignora arquivos
|
||||
entries = sorted([e for e in os.scandir(self.path) if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower())
|
||||
except:
|
||||
ui.label("Erro ao ler pasta").classes('text-red')
|
||||
with open(CONFIG_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except: pass
|
||||
return {}
|
||||
|
||||
def save_config(api_key):
|
||||
with open(CONFIG_FILE, 'w') as f:
|
||||
json.dump({'tmdb_api_key': api_key}, f)
|
||||
|
||||
# ==============================================================================
|
||||
# 3. LÓGICA DE ORGANIZAÇÃO (CORE)
|
||||
# ==============================================================================
|
||||
class MediaOrganizer:
|
||||
def __init__(self):
|
||||
config = load_config()
|
||||
self.api_key = config.get('tmdb_api_key', '')
|
||||
self.path = ROOT_DIR
|
||||
|
||||
# Estado
|
||||
self.preview_data = [] # Lista de arquivos para processar
|
||||
self.folders_to_clean = set()
|
||||
|
||||
# Configura TMDb
|
||||
if self.api_key:
|
||||
tmdb.API_KEY = self.api_key
|
||||
|
||||
def set_api_key(self, key):
|
||||
self.api_key = key.strip()
|
||||
tmdb.API_KEY = self.api_key
|
||||
save_config(self.api_key)
|
||||
ui.notify('API Key salva com sucesso!', type='positive')
|
||||
|
||||
async def search_tmdb(self, title, year, media_type):
|
||||
"""Consulta o TMDb e retorna candidatos com GÊNEROS."""
|
||||
if not self.api_key: return []
|
||||
|
||||
search = tmdb.Search()
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if media_type == 'movie':
|
||||
# Busca Filmes
|
||||
res = await loop.run_in_executor(None, lambda: search.movie(query=title, year=year, language='pt-BR'))
|
||||
else:
|
||||
# Busca Séries
|
||||
res = await loop.run_in_executor(None, lambda: search.tv(query=title, first_air_date_year=year, language='pt-BR'))
|
||||
|
||||
return res.get('results', [])
|
||||
except Exception as e:
|
||||
print(f"Erro TMDb: {e}")
|
||||
return []
|
||||
|
||||
def detect_category(self, match_data, media_type):
|
||||
"""
|
||||
Define a categoria baseada nos metadados do TMDb.
|
||||
Retorna: 'Filmes', 'Séries', 'Animes' ou 'Desenhos'
|
||||
"""
|
||||
if not match_data:
|
||||
# Fallback se não tiver match
|
||||
return 'Filmes' if media_type == 'movie' else 'Séries'
|
||||
|
||||
genre_ids = match_data.get('genre_ids', [])
|
||||
|
||||
# É ANIMAÇÃO? (ID 16)
|
||||
if GENRE_ANIMATION in genre_ids:
|
||||
# Verifica origem para diferenciar Anime de Desenho
|
||||
original_lang = match_data.get('original_language', '')
|
||||
origin_countries = match_data.get('origin_country', []) # Lista (comum em TV)
|
||||
|
||||
is_asian_lang = original_lang in ['ja', 'ko']
|
||||
is_asian_country = any(c in ['JP', 'KR'] for c in origin_countries)
|
||||
|
||||
# Se for animação japonesa ou coreana -> Animes
|
||||
if is_asian_lang or is_asian_country:
|
||||
return 'Animes'
|
||||
|
||||
# Caso contrário (Disney, Pixar, Cartoon Network) -> Desenhos
|
||||
return 'Desenhos'
|
||||
|
||||
# NÃO É ANIMAÇÃO (Live Action)
|
||||
return 'Filmes' if media_type == 'movie' else 'Séries'
|
||||
|
||||
async def analyze_folder(self):
|
||||
"""Analisa a pasta usando Guessit + TMDb."""
|
||||
self.preview_data = []
|
||||
self.folders_to_clean = set()
|
||||
|
||||
if not self.api_key:
|
||||
ui.notify('Por favor, configure a API Key do TMDb primeiro.', type='negative')
|
||||
return
|
||||
|
||||
with ui.column().classes('w-full gap-1 mt-2'):
|
||||
# Botão para subir nível (se não estiver na raiz)
|
||||
if self.path != ROOT_DIR:
|
||||
with ui.item(on_click=lambda: self.navigate(os.path.dirname(self.path))).classes('bg-blue-50 hover:bg-blue-100 cursor-pointer rounded'):
|
||||
with ui.item_section().props('avatar'):
|
||||
ui.icon('arrow_upward', color='grey')
|
||||
with ui.item_section():
|
||||
ui.item_label('Voltar / Subir Nível')
|
||||
loading = ui.notification(message='Analisando arquivos (Guessit + TMDb + Categorias)...', spinner=True, timeout=None)
|
||||
|
||||
if not entries:
|
||||
ui.label("Nenhuma subpasta aqui.").classes('text-gray-400 italic ml-4 mt-2')
|
||||
try:
|
||||
# Cria a estrutura base de destino
|
||||
categories = ['Filmes', 'Séries', 'Animes', 'Desenhos']
|
||||
for cat in categories:
|
||||
os.makedirs(os.path.join(DEST_DIR, cat), exist_ok=True)
|
||||
|
||||
# Lista de Subpastas
|
||||
for entry in entries:
|
||||
with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-gray-100 cursor-pointer rounded'):
|
||||
with ui.item_section().props('avatar'):
|
||||
ui.icon('folder', color='amber')
|
||||
with ui.item_section():
|
||||
ui.item_label(entry.name).classes('font-medium')
|
||||
for root, dirs, files in os.walk(self.path):
|
||||
# Ignora a pasta de destino "preparados" para não processar o que já foi feito
|
||||
if "preparados" in root: continue
|
||||
|
||||
# --- RENDERIZADOR: PREVIEW ---
|
||||
def render_preview(self):
|
||||
with ui.column().classes('w-full items-center gap-4'):
|
||||
ui.label(f'Detectados {len(self.preview_data)} arquivos para renomear').classes('text-xl font-bold text-green-700')
|
||||
|
||||
with ui.row():
|
||||
ui.button('Cancelar', on_click=self.cancel).props('outline color=red')
|
||||
ui.button('Confirmar Tudo', on_click=self.execute_rename).props('push color=green icon=check')
|
||||
files_in_dir = set(files)
|
||||
|
||||
# Tabela Simples
|
||||
with ui.card().classes('w-full p-0'):
|
||||
with ui.column().classes('w-full gap-0'):
|
||||
# Cabeçalho
|
||||
with ui.row().classes('w-full bg-gray-200 p-2 font-bold'):
|
||||
ui.label('Original').classes('w-1/2')
|
||||
ui.label('Novo Caminho').classes('w-1/2')
|
||||
for file in files:
|
||||
file_ext = os.path.splitext(file)[1].lower()
|
||||
if file_ext not in VIDEO_EXTENSIONS: continue
|
||||
|
||||
# 1. Análise Local (Guessit)
|
||||
guess = guessit(file)
|
||||
title = guess.get('title')
|
||||
year = guess.get('year')
|
||||
media_type = guess.get('type')
|
||||
|
||||
# Itens
|
||||
with ui.scroll_area().classes('h-96 w-full'):
|
||||
for item in self.preview_data:
|
||||
with ui.row().classes('w-full p-2 border-b border-gray-100 hover:bg-gray-50'):
|
||||
ui.label(item['original']).classes('w-1/2 text-sm truncate')
|
||||
ui.label(item['new']).classes('w-1/2 text-sm text-blue-600 font-mono truncate')
|
||||
if not title: continue
|
||||
|
||||
# --- INICIALIZADOR ---
|
||||
# 2. Consulta TMDb
|
||||
candidates = await self.search_tmdb(title, year, media_type)
|
||||
|
||||
# 3. Lógica de Decisão
|
||||
match_data = None
|
||||
status = 'AMBIGUO'
|
||||
|
||||
if candidates:
|
||||
first = candidates[0]
|
||||
tmdb_date = first.get('release_date') if media_type == 'movie' else first.get('first_air_date')
|
||||
tmdb_year = int(tmdb_date[:4]) if tmdb_date else None
|
||||
|
||||
if year and tmdb_year == year:
|
||||
match_data = first
|
||||
status = 'OK'
|
||||
elif not year:
|
||||
match_data = first
|
||||
status = 'CHECK'
|
||||
else:
|
||||
match_data = first
|
||||
status = 'CHECK'
|
||||
else:
|
||||
status = 'NAO_ENCONTRADO'
|
||||
|
||||
item = {
|
||||
'id': len(self.preview_data),
|
||||
'original_file': file,
|
||||
'original_root': root,
|
||||
'guess_title': title,
|
||||
'guess_year': year,
|
||||
'type': media_type,
|
||||
'candidates': candidates,
|
||||
'selected_match': match_data,
|
||||
'status': status,
|
||||
'target_path': None,
|
||||
'category': None, # Nova propriedade
|
||||
'subtitles': []
|
||||
}
|
||||
|
||||
if match_data:
|
||||
self.calculate_path(item)
|
||||
|
||||
# Procura Legendas
|
||||
video_stem = Path(file).stem
|
||||
for f in files_in_dir:
|
||||
if f == file: continue
|
||||
if os.path.splitext(f)[1].lower() in SUBTITLE_EXTENSIONS:
|
||||
if f.startswith(video_stem):
|
||||
suffix = f[len(video_stem):]
|
||||
item['subtitles'].append({
|
||||
'original': f,
|
||||
'suffix': suffix,
|
||||
'src': os.path.join(root, f)
|
||||
})
|
||||
|
||||
self.preview_data.append(item)
|
||||
self.folders_to_clean.add(root)
|
||||
|
||||
except Exception as e:
|
||||
ui.notify(f'Erro fatal: {e}', type='negative')
|
||||
print(e)
|
||||
|
||||
loading.dismiss()
|
||||
|
||||
if self.preview_data:
|
||||
return True
|
||||
else:
|
||||
ui.notify('Nenhum vídeo novo encontrado.', type='warning')
|
||||
return False
|
||||
|
||||
def calculate_path(self, item):
|
||||
"""Gera o caminho baseado na Categoria e no Tipo."""
|
||||
match = item['selected_match']
|
||||
if not match:
|
||||
item['target_path'] = None
|
||||
return
|
||||
|
||||
ext = os.path.splitext(item['original_file'])[1]
|
||||
|
||||
# 1. Determinar Categoria (Filmes, Séries, Animes, Desenhos)
|
||||
category = self.detect_category(match, item['type'])
|
||||
item['category'] = category # Salva para mostrar na UI se quiser
|
||||
|
||||
# Nome base limpo
|
||||
title_raw = match.get('title') if item['type'] == 'movie' else match.get('name')
|
||||
title_raw = title_raw or item['guess_title']
|
||||
final_title = title_raw.replace('/', '-').replace(':', '-').strip()
|
||||
|
||||
date = match.get('release_date') if item['type'] == 'movie' else match.get('first_air_date')
|
||||
year_str = date[:4] if date else '0000'
|
||||
|
||||
# 2. Lógica de Pasta baseada na Categoria
|
||||
base_folder = os.path.join(DEST_DIR, category)
|
||||
|
||||
# CASO 1: É FILME (Seja Live Action, Anime Movie ou Desenho Movie)
|
||||
if item['type'] == 'movie':
|
||||
new_filename = f"{final_title} ({year_str}){ext}"
|
||||
|
||||
if category == 'Filmes':
|
||||
# Filmes Live Action -> Pasta Própria (opcional, aqui pus arquivo direto na pasta Filmes)
|
||||
# Se quiser pasta por filme: os.path.join(base_folder, f"{final_title} ({year_str})", new_filename)
|
||||
item['target_path'] = os.path.join(base_folder, new_filename)
|
||||
else:
|
||||
# Animes/Desenhos -> Arquivo solto na raiz da categoria (Misto)
|
||||
item['target_path'] = os.path.join(base_folder, new_filename)
|
||||
|
||||
# CASO 2: É SÉRIE (Seja Live Action, Anime Serie ou Desenho Serie)
|
||||
else:
|
||||
# Séries sempre precisam de estrutura de pasta
|
||||
guess = guessit(item['original_file'])
|
||||
s = guess.get('season')
|
||||
e = guess.get('episode')
|
||||
|
||||
if not s or not e:
|
||||
item['status'] = 'ERRO_S_E'
|
||||
item['target_path'] = None
|
||||
return
|
||||
|
||||
if isinstance(s, list): s = s[0]
|
||||
if isinstance(e, list): e = e[0]
|
||||
|
||||
s_fmt = f"{s:02d}"
|
||||
e_fmt = f"{e:02d}"
|
||||
|
||||
# Caminho: Categoria / Nome da Série / Temporada XX / Episódio.ext
|
||||
item['target_path'] = os.path.join(
|
||||
base_folder,
|
||||
final_title,
|
||||
f"Temporada {s_fmt}",
|
||||
f"Episódio {e_fmt}{ext}" # Renomeia o EP para padrão limpo
|
||||
)
|
||||
|
||||
async def execute_move(self):
|
||||
"""Move os arquivos confirmados."""
|
||||
moved = 0
|
||||
n = ui.notification('Organizando biblioteca...', spinner=True, timeout=None)
|
||||
|
||||
for item in self.preview_data:
|
||||
if not item['target_path'] or item['status'] == 'NAO_ENCONTRADO':
|
||||
continue
|
||||
|
||||
try:
|
||||
# Mover Vídeo
|
||||
src = os.path.join(item['original_root'], item['original_file'])
|
||||
dst = item['target_path']
|
||||
|
||||
if os.path.exists(dst):
|
||||
ui.notify(f"Pulei {os.path.basename(dst)} (Já existe)", type='warning')
|
||||
continue
|
||||
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.move(src, dst)
|
||||
moved += 1
|
||||
|
||||
# Mover Legendas
|
||||
video_dst_stem = os.path.splitext(dst)[0]
|
||||
for sub in item['subtitles']:
|
||||
sub_dst = video_dst_stem + sub['suffix']
|
||||
if not os.path.exists(sub_dst):
|
||||
shutil.move(sub['src'], sub_dst)
|
||||
|
||||
except Exception as e:
|
||||
ui.notify(f"Erro ao mover {item['original_file']}: {e}", type='negative')
|
||||
|
||||
# Limpeza
|
||||
cleaned = 0
|
||||
sorted_folders = sorted(list(self.folders_to_clean), key=len, reverse=True)
|
||||
for folder in sorted_folders:
|
||||
if not os.path.exists(folder) or folder == ROOT_DIR or "preparados" in folder: continue
|
||||
|
||||
try:
|
||||
remaining = [f for f in os.listdir(folder) if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS]
|
||||
has_subfolder = any(os.path.isdir(os.path.join(folder, f)) for f in os.listdir(folder))
|
||||
|
||||
if not remaining and not has_subfolder:
|
||||
shutil.rmtree(folder)
|
||||
cleaned += 1
|
||||
except: pass
|
||||
|
||||
n.dismiss()
|
||||
ui.notify(f'{moved} arquivos organizados em "preparados". {cleaned} pastas limpas.', type='positive')
|
||||
return True
|
||||
|
||||
# ==============================================================================
|
||||
# 4. INTERFACE GRÁFICA
|
||||
# ==============================================================================
|
||||
def create_ui():
|
||||
rm = RenamerManager()
|
||||
rm.container = ui.column().classes('w-full h-full p-4 gap-4')
|
||||
rm.refresh()
|
||||
organizer = MediaOrganizer()
|
||||
|
||||
with ui.column().classes('w-full h-full p-0 gap-0'):
|
||||
# Header
|
||||
with ui.row().classes('w-full bg-indigo-900 text-white items-center p-3 shadow-md'):
|
||||
ui.icon('smart_display', size='md')
|
||||
ui.label('Renomeador Inteligente').classes('text-lg font-bold ml-2')
|
||||
ui.label('(Filmes • Séries • Animes • Desenhos)').classes('text-xs text-gray-300 ml-1 mt-1')
|
||||
ui.space()
|
||||
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
key_input = ui.input('TMDb API Key', password=True).props('dense dark input-class=text-white outlined').classes('w-64')
|
||||
key_input.value = organizer.api_key
|
||||
ui.button(icon='save', on_click=lambda: organizer.set_api_key(key_input.value)).props('flat dense round color=white')
|
||||
|
||||
main_content = ui.column().classes('w-full p-4 gap-4')
|
||||
|
||||
# Dialogo
|
||||
resolution_dialog = ui.dialog()
|
||||
|
||||
def open_resolution_dialog(item, row_refresh_callback):
|
||||
with resolution_dialog, ui.card().classes('w-full max-w-4xl'):
|
||||
ui.label(f"Arquivo: {item['original_file']}").classes('text-lg font-bold')
|
||||
ui.label(f"Guessit: {item['guess_title']} ({item['guess_year']})").classes('text-gray-500 text-sm')
|
||||
|
||||
with ui.grid(columns=4).classes('w-full gap-4 mt-4'):
|
||||
for cand in item['candidates']:
|
||||
# Helper para UI
|
||||
is_movie = (item['type'] == 'movie')
|
||||
title = cand.get('title') if is_movie else cand.get('name')
|
||||
date = cand.get('release_date') if is_movie else cand.get('first_air_date')
|
||||
year = date[:4] if date else '????'
|
||||
img = cand.get('poster_path')
|
||||
img_url = f"https://image.tmdb.org/t/p/w200{img}" if img else 'https://via.placeholder.com/200'
|
||||
|
||||
# Previsão da categoria deste candidato
|
||||
preview_cat = organizer.detect_category(cand, item['type'])
|
||||
|
||||
with ui.card().classes('cursor-pointer hover:bg-blue-50 p-0 gap-0 border relative').tight():
|
||||
# Badge de categoria na imagem
|
||||
ui.label(preview_cat).classes('absolute top-1 right-1 bg-black text-white text-xs px-1 rounded opacity-80')
|
||||
|
||||
ui.image(img_url).classes('h-48 w-full object-cover')
|
||||
with ui.column().classes('p-2 w-full'):
|
||||
ui.label(title).classes('font-bold text-sm leading-tight text-ellipsis overflow-hidden')
|
||||
ui.label(year).classes('text-xs text-gray-500')
|
||||
ui.button('Escolher', on_click=lambda c=cand: select_match(item, c, row_refresh_callback)).props('sm flat w-full')
|
||||
|
||||
ui.button('Cancelar', on_click=resolution_dialog.close).props('outline color=red').classes('mt-4 w-full')
|
||||
|
||||
resolution_dialog.open()
|
||||
|
||||
def select_match(item, match, refresh_cb):
|
||||
item['selected_match'] = match
|
||||
item['status'] = 'OK'
|
||||
organizer.calculate_path(item) # Recalcula caminho e categoria
|
||||
resolution_dialog.close()
|
||||
refresh_cb()
|
||||
|
||||
# Telas
|
||||
def render_explorer():
|
||||
main_content.clear()
|
||||
organizer.preview_data = []
|
||||
|
||||
with main_content:
|
||||
# Caminho atual
|
||||
with ui.row().classes('w-full bg-gray-100 p-2 rounded items-center shadow-sm'):
|
||||
ui.icon('folder', color='grey')
|
||||
ui.label(organizer.path).classes('font-mono ml-2 mr-auto text-sm md:text-base truncate')
|
||||
|
||||
async def run_analysis():
|
||||
has_data = await organizer.analyze_folder()
|
||||
if has_data: render_preview()
|
||||
|
||||
ui.button('ANALISAR PASTA', on_click=run_analysis).props('push color=indigo icon=search')
|
||||
|
||||
# Lista de Arquivos
|
||||
try:
|
||||
entries = sorted(list(os.scandir(organizer.path)), key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||
with ui.list().props('bordered separator dense').classes('w-full bg-white rounded shadow-sm'):
|
||||
if organizer.path != ROOT_DIR:
|
||||
ui.item(text='.. (Voltar)', on_click=lambda: navigate(os.path.dirname(organizer.path))).props('clickable icon=arrow_back')
|
||||
|
||||
for entry in entries:
|
||||
if entry.name.startswith('.') or entry.name == "preparados": continue
|
||||
|
||||
if entry.is_dir():
|
||||
with ui.item(on_click=lambda p=entry.path: navigate(p)).props('clickable'):
|
||||
with ui.item_section().props('avatar'):
|
||||
ui.icon('folder', color='amber')
|
||||
ui.item_section(entry.name)
|
||||
else:
|
||||
is_vid = os.path.splitext(entry.name)[1].lower() in VIDEO_EXTENSIONS
|
||||
icon = 'movie' if is_vid else 'insert_drive_file'
|
||||
color = 'blue' if is_vid else 'grey'
|
||||
with ui.item():
|
||||
with ui.item_section().props('avatar'):
|
||||
ui.icon(icon, color=color)
|
||||
ui.item_section(entry.name).classes('text-sm')
|
||||
except Exception as e:
|
||||
ui.label(f"Erro: {e}").classes('text-red')
|
||||
|
||||
def render_preview():
|
||||
main_content.clear()
|
||||
with main_content:
|
||||
with ui.row().classes('w-full items-center justify-between mb-2'):
|
||||
ui.label('Pré-visualização da Organização').classes('text-xl font-bold')
|
||||
with ui.row():
|
||||
ui.button('Voltar', on_click=render_explorer).props('outline color=red dense')
|
||||
|
||||
async def run_move():
|
||||
if await organizer.execute_move():
|
||||
render_explorer()
|
||||
|
||||
ui.button('MOVER ARQUIVOS', on_click=run_move).props('push color=green icon=check dense')
|
||||
|
||||
# Tabela Headers
|
||||
with ui.row().classes('w-full bg-gray-200 p-2 font-bold text-sm rounded hidden md:flex'):
|
||||
ui.label('Arquivo Original').classes('w-1/3')
|
||||
ui.label('Categoria / Destino').classes('w-1/3')
|
||||
ui.label('Ação').classes('w-1/4 text-center')
|
||||
|
||||
with ui.scroll_area().classes('h-[600px] w-full border rounded bg-white'):
|
||||
def render_row(item):
|
||||
@ui.refreshable
|
||||
def row_content():
|
||||
with ui.row().classes('w-full p-2 border-b items-center hover:bg-gray-50 text-sm'):
|
||||
# Coluna 1: Origem
|
||||
with ui.column().classes('w-full md:w-1/3'):
|
||||
ui.label(item['original_file']).classes('truncate font-medium w-full')
|
||||
ui.label(f"Guessit: {item['type'].upper()}").classes('text-xs text-gray-500')
|
||||
|
||||
# Coluna 2: Destino Calculado
|
||||
with ui.column().classes('w-full md:w-1/3'):
|
||||
if item['target_path']:
|
||||
cat = item.get('category', '???')
|
||||
# Badge da Categoria
|
||||
colors = {'Animes': 'pink', 'Desenhos': 'orange', 'Filmes': 'blue', 'Séries': 'green'}
|
||||
cat_color = colors.get(cat, 'grey')
|
||||
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
ui.badge(cat, color=cat_color).props('dense')
|
||||
rel_path = os.path.relpath(item['target_path'], DEST_DIR)
|
||||
ui.label(rel_path).classes('font-mono break-all text-xs text-gray-700')
|
||||
else:
|
||||
ui.label('--- (Sem destino)').classes('text-gray-400')
|
||||
|
||||
# Coluna 3: Status/Ação
|
||||
with ui.row().classes('w-full md:w-1/4 justify-center items-center gap-2'):
|
||||
status = item['status']
|
||||
color = 'green' if status == 'OK' else ('orange' if status == 'CHECK' else 'red')
|
||||
ui.badge(status, color=color).props('outline')
|
||||
|
||||
btn_icon = 'search' if status != 'OK' else 'edit'
|
||||
ui.button(icon=btn_icon, on_click=lambda: open_resolution_dialog(item, row_content.refresh)).props('flat round dense color=grey')
|
||||
|
||||
row_content()
|
||||
|
||||
for item in organizer.preview_data:
|
||||
render_row(item)
|
||||
|
||||
def navigate(path):
|
||||
organizer.path = path
|
||||
render_explorer()
|
||||
|
||||
render_explorer()
|
||||
|
||||
if __name__ in {"__main__", "__mp_main__"}:
|
||||
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"
|
||||
}
|
||||
67
data/history.log
Normal file
67
data/history.log
Normal file
@@ -0,0 +1,67 @@
|
||||
[2026-02-03 11:42:42] ❌ Erro em O Gerente da Noite: Exception in subprocess:
|
||||
Type: OSError
|
||||
Message: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin'
|
||||
Traceback (most recent call last):
|
||||
File "/usr/lib/python3.10/shutil.py", line 816, in move
|
||||
os.rename(src, real_dst)
|
||||
OSError: [Errno 107] Transport endpoint is not connected: '/downloads/finalizados/Séries/O Gerente da Noite' -> '/media/Jellyfin/onedrive/Jellyfin/Series/O Gerente da Noite'
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.10/dist-packages/nicegui/run.py", line 52, in safe_callback
|
||||
return callback(*args, **kwargs)
|
||||
File "/usr/lib/python3.10/shutil.py", line 832, in move
|
||||
copytree(src, real_dst, copy_function=copy_function,
|
||||
File "/usr/lib/python3.10/shutil.py", line 559, in copytree
|
||||
return _copytree(entries=entries, src=src, dst=dst, symlinks=symlinks,
|
||||
File "/usr/lib/python3.10/shutil.py", line 457, in _copytree
|
||||
os.makedirs(dst, exist_ok=dirs_exist_ok)
|
||||
File "/usr/lib/python3.10/os.py", line 215, in makedirs
|
||||
makedirs(head, exist_ok=exist_ok)
|
||||
File "/usr/lib/python3.10/os.py", line 215, in makedirs
|
||||
makedirs(head, exist_ok=exist_ok)
|
||||
File "/usr/lib/python3.10/os.py", line 225, in makedirs
|
||||
mkdir(name, mode)
|
||||
OSError: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin'
|
||||
|
||||
[2026-02-03 19:39:33] ✅ Movido: Meu Primeiro Amor de Verão (2024).mkv
|
||||
[2026-02-03 19:51:51] ✅ Movido: O Gerente da Noite
|
||||
[2026-02-03 22:51:16] ✅ Movido: Horny Milf Pristine Edge Pleasures Step Son And Step Dad At The Same Time POV Style - UsePOV.jpg
|
||||
[2026-02-04 20:00:14] ❌ Erro em Se Esse Amor Desaparecesse Hoje (2025).mkv: Exception in subprocess:
|
||||
Type: OSError
|
||||
Message: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv'
|
||||
Traceback (most recent call last):
|
||||
File "/usr/lib/python3.10/shutil.py", line 816, in move
|
||||
os.rename(src, real_dst)
|
||||
OSError: [Errno 107] Transport endpoint is not connected: '/downloads/finalizados/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv' -> '/media/Jellyfin/onedrive/Jellyfin/Filmes/Se Esse Amor Desaparecesse Hoje (2025).mkv'
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/usr/local/lib/python3.10/dist-packages/nicegui/run.py", line 52, in safe_callback
|
||||
return callback(*args, **kwargs)
|
||||
File "/usr/lib/python3.10/shutil.py", line 836, in move
|
||||
copy_function(src, real_dst)
|
||||
File "/usr/lib/python3.10/shutil.py", line 434, in copy2
|
||||
copyfile(src, dst, follow_symlinks=follow_symlinks)
|
||||
File "/usr/lib/python3.10/shutil.py", line 256, in copyfile
|
||||
with open(dst, 'wb') as fdst:
|
||||
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
|
||||
[2026-02-06 00:20:02] ✅ Movido: 21 SEXTURY - Hot Babe Veronica Leal Gets The Hottest Ass Creampie EVER After Hard Anal Sex.mp4
|
||||
[2026-02-06 05:16:26] ✅ Movido: TUSHY Abella Danger and Lena Paul Dominate Her Boyfriend and Get Gaped.mp4
|
||||
[2026-02-06 05:16:27] ✅ Movido: 21 NATURALS - Hot Redhead Veronica Leal Wants Her Tight Ass Fucked.mp4
|
||||
[2026-02-08 00:51:31] ⚠️ Pendência: Jujutsu Kaisen
|
||||
[2026-02-08 00:52:15] 🔄 Substituído: Jujutsu Kaisen
|
||||
[2026-02-08 20:47:28] ✅ Movido: Step Brother cums too early on sisters Mouth and Swallows Cum.mp4
|
||||
[2026-02-08 20:47:38] ✅ Movido: Horny Couple gets caught in Public Restroom making out.mp4
|
||||
[2026-02-08 22:31:46] ✅ Movido: Frieren e a Jornada para o Além
|
||||
[2026-02-08 22:44:01] ✅ Arquivo movido: Episódio 05.mkv
|
||||
1
data/presets.json
Normal file
1
data/presets.json
Normal file
@@ -0,0 +1 @@
|
||||
{"Filmes": {"src": "/downloads/finalizados/Filmes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Filmes"}, "S\u00e9ries": {"src": "/downloads/finalizados/S\u00e9ries", "dst": "/media/Jellyfin/onedrive/Jellyfin/Series"}, "Desenhos": {"src": "/downloads/finalizados/Desenhos", "dst": "/media/Jellyfin/onedrive/Jellyfin/Desenhos"}, "Animes": {"src": "/downloads/finalizados/Animes", "dst": "/media/Jellyfin/onedrive/Jellyfin/Animes"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"running": false, "file": "Finalizado \u2705", "pct_file": 100, "pct_total": 100, "log": "Finalizado \u2705"}
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
- /home/creidsu/pymediamanager/data:/app/data
|
||||
- /media/qbit/download:/downloads
|
||||
- /media:/media/Jellyfin
|
||||
# - /media/onedrive2/Stash:/media/HD_Externo
|
||||
# - /home/creidsu/outra_pasta:/media/Outros
|
||||
# - /media2:/media/HD_Externo
|
||||
# - /media3:/media/Outros
|
||||
ports:
|
||||
- 8086:8080
|
||||
|
||||
@@ -4,4 +4,5 @@ watchdog
|
||||
guessit
|
||||
requests
|
||||
ffmpeg-python
|
||||
yt-dlp
|
||||
yt-dlp
|
||||
tmdbsimple
|
||||
Reference in New Issue
Block a user