identificação com integração com tmdb

This commit is contained in:
2026-02-01 15:43:32 +00:00
parent 48d0dbf7d3
commit 832fdfd35a
5 changed files with 430 additions and 335 deletions

View File

@@ -1,372 +1,465 @@
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 GERAIS
# 1. CONFIGURAÇÕES E CONSTANTES
# ==============================================================================
ROOT_DIR = "/downloads" # Diretório fixo conforme solicitado
ROOT_DIR = "/downloads"
CONFIG_FILE = 'config.json'
# Extensões consideradas para manter a pasta viva (não apagar se sobrarem)
VIDEO_EXTENSIONS = ('.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm')
# Extensões de legendas para mover junto
SUBTITLE_EXTENSIONS = ('.srt', '.sub', '.ass', '.vtt')
# 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'}
# ==============================================================================
# 2. SISTEMA DE DETECÇÃO (REGEX)
# 2. PERSISTÊNCIA (Salvar API Key)
# ==============================================================================
def extract_season_episode(filename):
"""
Detecta Temporada e Episódio.
Suporta padrões internacionais (S01E01), Brasileiros (Temp/Ep) e Ingleses (Season/Episode).
"""
patterns = [
# --- PADRÕES UNIVERSAIS (S01E01, s1e1, S01.E01, S01_E01) ---
r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})',
# --- PADRÃO X (1x01, 01x01) ---
r'(?i)(\d{1,4})x(\d{1,4})',
# --- INGLÊS VERBOSO (Season 1 Episode 1, Season 01 - Episode 05) ---
# O '.*?' permite textos no meio ex: "Season 1 [1080p] Episode 5"
r'(?i)Season[\s._-]*(\d{1,4}).*?Episode[\s._-]*(\d{1,4})',
# --- PORTUGUÊS VERBOSO (Temporada 1 Episódio 1) ---
r'(?i)Temporada[\s._-]*(\d{1,4}).*?Epis[oó]dio[\s._-]*(\d{1,4})',
# --- ABREVIAÇÕES (Temp 1 Ep 1, T01 E01, S1 Ep1) ---
r'(?i)(?:Temp|T|S)[\s._-]*(\d{1,4})[\s._-]*E(?:p)?[\s._-]*(\d{1,4})',
# --- PADRÃO EPISÓDIO ISOLADO (S01EP01) ---
r'(?i)S(\d{1,4})[\s._-]*EP(\d{1,4})',
# --- COLCHETES ([1x01]) ---
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
def is_video(filename):
return filename.lower().endswith(VIDEO_EXTENSIONS)
def is_subtitle(filename):
return filename.lower().endswith(SUBTITLE_EXTENSIONS)
# ==============================================================================
# 3. CLASSE DE GERENCIAMENTO (ESTADO)
# ==============================================================================
class RenamerManager:
def __init__(self):
self.path = ROOT_DIR
self.container = None
self.preview_data = []
self.folders_to_clean = set() # Lista de pastas candidatas à exclusão
self.view_mode = 'explorer' # Alterna entre 'explorer' e 'preview'
# ==========================================================================
# 4. NAVEGAÇÃO
# ==========================================================================
def navigate(self, path):
"""Muda o path atual e atualiza a tela."""
if os.path.exists(path) and os.path.isdir(path):
self.path = path
self.refresh()
else:
ui.notify(f'Erro ao acessar: {path}', type='negative')
def refresh(self):
"""Atualiza a UI baseada no modo atual."""
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 cancel(self):
"""Reseta o estado para o modo Explorer."""
self.view_mode = 'explorer'
self.preview_data = []
self.folders_to_clean = set()
self.refresh()
# ==========================================================================
# 5. ANÁLISE E PREPARAÇÃO (CORE)
# ==========================================================================
async def analyze_folder(self):
"""Lê arquivos recursivamente e prepara a lista de movimentos."""
self.preview_data = []
self.folders_to_clean = set()
# Feedback visual
n = ui.notification(message='Analisando arquivos...', spinner=True, timeout=None)
def load_config():
if os.path.exists(CONFIG_FILE):
try:
for root, dirs, files in os.walk(self.path):
# Ignora pasta de destino para não entrar em loop
if "finalizados" in root.lower(): continue
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."""
if not self.api_key: return []
search = tmdb.Search()
try:
# Roda em thread separada para não travar a UI
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 []
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
# Notificação de progresso
loading = ui.notification(message='Analisando arquivos (Guessit + TMDb)...', spinner=True, timeout=None)
try:
# Cria pastas base se não existirem
os.makedirs(os.path.join(ROOT_DIR, "Filmes"), exist_ok=True)
os.makedirs(os.path.join(ROOT_DIR, "Séries"), exist_ok=True)
for root, dirs, files in os.walk(self.path):
# Ignora pastas de destino para evitar loop
if "Filmes" in root or "Séries" in root: continue
# Cria conjunto para busca rápida de legendas
files_in_dir = set(files)
for file in files:
# 1. Filtra apenas vídeos
if not is_video(file): continue
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in VIDEO_EXTENSIONS: continue
# 2. Tenta extrair S/E
season, episode = extract_season_episode(file)
if not season or not episode: continue
# 1. Análise Local (Guessit)
guess = guessit(file)
title = guess.get('title')
year = guess.get('year')
media_type = guess.get('type') # 'movie' ou 'episode'
if not title: continue # Se não achou nem título, ignora
try:
# 3. Define Nomes e Caminhos
s_fmt = f"{int(season):02d}"
e_fmt = f"{int(episode):02d}"
# 2. Consulta TMDb
candidates = await self.search_tmdb(title, year, media_type)
# 3. Lógica de Decisão (Match ou Ambiguidade)
match_data = None
status = 'AMBIGUO' # Padrão: incerto
if candidates:
# Tenta match exato (Primeiro resultado geralmente é o melhor no TMDb)
first = candidates[0]
tmdb_title = first.get('title') if media_type == 'movie' else first.get('name')
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
ext = os.path.splitext(file)[1]
# Estrutura Final: /downloads/Temporada XX/Episódio YY.mkv
# Nota: Cria pasta Temporada XX dentro de ROOT_DIR (ou self.path se preferir relativo)
# Aqui estou criando relativo ao ROOT_DIR atual para organização centralizada
target_season_folder = f"Temporada {s_fmt}"
target_filename = f"Episódio {e_fmt}{ext}"
src_full = os.path.join(root, file)
dst_full = os.path.join(self.path, target_season_folder, target_filename)
# Se ano bate ou não tem ano no arquivo original, confia no primeiro resultado
if year and tmdb_year == year:
match_data = first
status = 'OK'
elif not year:
# Sem ano no arquivo, mas achou resultado. Marca como Ambíguo mas sugere o primeiro
match_data = first
status = 'CHECK' # Requer atenção visual
else:
# Ano diferente. Pode ser remake ou erro.
match_data = first
status = 'CHECK'
else:
status = 'NAO_ENCONTRADO'
# Verifica se origem e destino são iguais
if os.path.normpath(src_full) == os.path.normpath(dst_full):
continue
# Cria objeto de item
item = {
'id': len(self.preview_data),
'original_file': file,
'original_root': root,
'guess_title': title,
'guess_year': year,
'type': media_type,
'candidates': candidates, # Lista para o modal de escolha
'selected_match': match_data,
'status': status,
'target_path': None, # Será calculado
'subtitles': [] # Lista de legendas associadas
}
# Verifica conflito
status = 'OK'
if os.path.exists(dst_full):
status = 'CONFLITO (Já existe)'
# Adiciona à lista de preview
self.preview_data.append({
'type': 'Vídeo',
'original': file,
'new_path': os.path.join(target_season_folder, target_filename),
'src': src_full,
'dst': dst_full,
'status': status
})
self.folders_to_clean.add(root)
# 4. Processamento de Legendas Associadas
video_stem = Path(file).stem # Nome do vídeo sem extensão
for f in files_in_dir:
if f == file: continue # Pula o próprio vídeo
if not is_subtitle(f): continue
# Se a legenda começa com o nome do arquivo de vídeo
# Calcula caminho se tiver match
if match_data:
self.calculate_path(item)
# Procura Legendas Associadas
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):
# Pega o que vem depois do nome do vídeo (ex: .forced.srt, .pt.srt)
suffix = f[len(video_stem):]
sub_target_name = f"Episódio {e_fmt}{suffix}"
sub_dst_full = os.path.join(self.path, target_season_folder, sub_target_name)
sub_status = 'OK'
if os.path.exists(sub_dst_full):
sub_status = 'CONFLITO'
self.preview_data.append({
'type': 'Legenda',
suffix = f[len(video_stem):] # Ex: .forced.srt
item['subtitles'].append({
'original': f,
'new_path': os.path.join(target_season_folder, sub_target_name),
'src': os.path.join(root, f),
'dst': sub_dst_full,
'status': sub_status
'suffix': suffix,
'src': os.path.join(root, f)
})
except Exception as e:
print(f"Erro ao processar arquivo {file}: {e}")
self.preview_data.append(item)
self.folders_to_clean.add(root)
except Exception as e:
ui.notify(f'Erro fatal na análise: {str(e)}', type='negative')
ui.notify(f'Erro fatal: {e}', type='negative')
print(e)
n.dismiss()
if not self.preview_data:
ui.notify('Nenhum padrão de Temporada/Episódio encontrado.', type='warning')
loading.dismiss()
# Se achou itens, muda visualização
if self.preview_data:
return True
else:
self.view_mode = 'preview'
self.refresh()
ui.notify('Nenhum vídeo encontrado nesta pasta.', type='warning')
return False
# ==========================================================================
# 6. EXECUÇÃO E LIMPEZA
# ==========================================================================
async def execute_rename(self):
"""Move os arquivos e limpa pastas 'lixo'."""
count_moved = 0
errors = 0
n = ui.notification(message='Movendo e Organizando...', spinner=True, timeout=None)
# 1. MOVER ARQUIVOS
for item in self.preview_data:
if item['status'] != 'OK': continue # Ignora conflitos
try:
os.makedirs(os.path.dirname(item['dst']), exist_ok=True)
shutil.move(item['src'], item['dst'])
count_moved += 1
except Exception as e:
errors += 1
ui.notify(f"Erro ao mover {item['original']}: {e}", type='negative')
# 2. LIMPEZA DE PASTAS (SAFE CLEANUP)
cleaned_folders = 0
if self.folders_to_clean:
# Ordena do caminho mais longo para o mais curto (apaga subpastas antes das pais)
sorted_folders = sorted(list(self.folders_to_clean), key=len, reverse=True)
for folder in sorted_folders:
# Segurança: Nunca apagar a raiz ou pastas inexistentes
if not os.path.exists(folder) or os.path.normpath(folder) == os.path.normpath(self.path):
continue
try:
# Verifica o conteúdo restante
remaining = os.listdir(folder)
has_video = False
for f in remaining:
full_p = os.path.join(folder, f)
# Se tiver subpasta, assumimos que tem algo importante dentro (pela lógica recursiva,
# se a subpasta estivesse vazia/lixo, ela já teria sido apagada no loop anterior).
# Se sobrou subpasta, NÃO APAGA a pasta pai.
if os.path.isdir(full_p):
has_video = True
break
# Se tiver arquivo de vídeo, NÃO APAGA.
if is_video(f):
has_video = True
break
if not has_video:
# Se só sobrou lixo (txt, nfo, imagens, etc), apaga tudo.
shutil.rmtree(folder)
cleaned_folders += 1
except Exception as e:
print(f"Impossível limpar {folder}: {e}")
n.dismiss()
msg_type = 'positive' if errors == 0 else 'warning'
ui.notify(f'Sucesso! {count_moved} arquivos movidos. {cleaned_folders} pastas limpas.', type=msg_type)
self.cancel() # Volta para o explorer
# ==========================================================================
# 7. INTERFACE (UI)
# ==========================================================================
def render_breadcrumbs(self):
"""Barra de topo com caminho e botão de ação."""
with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'):
ui.button('ROOT', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense text-color=grey-8')
# Divide o caminho para criar botões clicáveis
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)
ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary')
ui.space()
ui.button("🔍 Analisar Pasta Atual", on_click=self.analyze_folder).props('push color=primary')
def render_folder_list(self):
"""Lista de arquivos/pastas (Explorer)."""
try:
# Ordena: Pastas primeiro, depois arquivos alfabeticamente
entries = sorted(list(os.scandir(self.path)), key=lambda e: (not e.is_dir(), e.name.lower()))
except Exception as e:
ui.label(f"Erro de permissão ou leitura: {e}").classes('text-red font-bold')
def calculate_path(self, item):
"""Gera o caminho final baseado no match selecionado."""
match = item['selected_match']
if not match:
item['target_path'] = None
return
with ui.column().classes('w-full gap-1 mt-2'):
# Botão Voltar
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('.. (Subir Nível)')
ext = os.path.splitext(item['original_file'])[1]
if item['type'] == 'movie':
# Estrutura: /downloads/Filmes/Nome (Ano).ext
title = match.get('title', item['guess_title']).replace('/', '-').replace(':', '-')
date = match.get('release_date', '')
year = date[:4] if date else '0000'
new_name = f"{title} ({year}){ext}"
item['target_path'] = os.path.join(ROOT_DIR, "Filmes", new_name)
else:
# Estrutura: /downloads/Séries/Nome/Temporada XX/Episódio YY.ext
name = match.get('name', item['guess_title']).replace('/', '-').replace(':', '-')
# Tenta pegar temporada/episódio do guessit
# Se falhar, usa S00E00 como fallback seguro para não perder arquivo
guess = guessit(item['original_file'])
s = guess.get('season')
e = guess.get('episode')
if not s or not e:
# Caso extremo: não achou temporada/ep no nome do arquivo
item['status'] = 'ERRO_S_E' # Erro de Season/Episode
item['target_path'] = None
return
if not entries:
ui.label("Pasta vazia.").classes('text-gray-400 italic ml-4')
# Suporte a múltiplas temporadas/episodios (lista)
if isinstance(s, list): s = s[0]
if isinstance(e, list): e = e[0]
s_fmt = f"{s:02d}"
e_fmt = f"{e:02d}"
item['target_path'] = os.path.join(
ROOT_DIR, "Séries", name, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}"
)
for entry in entries:
if entry.name.startswith('.'): continue # Ignora ocultos
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:
# Só move se tiver Status OK ou CHECK (confirmado pelo usuário) e tiver destino
if not item['target_path'] or item['status'] == 'NAO_ENCONTRADO':
continue
try:
# 1. Mover Vídeo
src = os.path.join(item['original_root'], item['original_file'])
dst = item['target_path']
if entry.is_dir():
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')
else:
# Visualização simples de arquivos
icon = 'movie' if is_video(entry.name) else ('subtitles' if is_subtitle(entry.name) else 'description')
color = 'blue' if is_video(entry.name) else ('green' if is_subtitle(entry.name) else 'grey')
with ui.item().classes('hover:bg-gray-50 rounded pl-8'):
with ui.item_section().props('avatar'):
ui.icon(icon, color=color).props('size=sm')
with ui.item_section():
ui.item_label(entry.name).classes('text-sm text-gray-600')
# Verifica colisão
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
# 2. Mover Legendas
video_dst_stem = os.path.splitext(dst)[0] # Caminho sem extensão
for sub in item['subtitles']:
sub_dst = video_dst_stem + sub['suffix'] # Ex: /path/Filme.forced.srt
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')
def render_preview(self):
"""Tabela de confirmação."""
with ui.column().classes('w-full h-full gap-4'):
# 3. Limpeza Segura
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: continue
# Cabeçalho
with ui.row().classes('w-full items-center justify-between'):
ui.label(f'Detectados {len(self.preview_data)} arquivos').classes('text-xl font-bold text-gray-700')
with ui.row():
ui.button('Cancelar', on_click=self.cancel).props('outline color=red')
ui.button('CONFIRMAR ORGANIZAÇÃO', on_click=self.execute_rename).props('push color=green icon=check')
# Tabela (AgGrid para performance)
cols = [
{'name': 'type', 'label': 'Tipo', 'field': 'type', 'sortable': True, 'align': 'left', 'classes': 'w-24'},
{'name': 'original', 'label': 'Arquivo Original', 'field': 'original', 'sortable': True, 'align': 'left'},
{'name': 'new_path', 'label': 'Destino (Simulado)', 'field': 'new_path', 'sortable': True, 'align': 'left', 'classes': 'text-blue-700 font-mono'},
{'name': 'status', 'label': 'Status', 'field': 'status', 'sortable': True, 'align': 'center'},
]
try:
# Verifica se sobrou vídeo
remaining = [f for f in os.listdir(folder) if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS]
# Verifica se sobrou subpasta não vazia
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
ui.table(
columns=cols,
rows=self.preview_data,
pagination=50
).classes('w-full').props('dense flat bordered')
ui.label('* Pastas originais serão excluídas somente se restarem apenas arquivos inúteis.').classes('text-xs text-gray-500 mt-2')
n.dismiss()
ui.notify(f'{moved} arquivos organizados. {cleaned} pastas limpas.', type='positive')
return True
# ==============================================================================
# 8. STARTUP
# 4. INTERFACE GRÁFICA (NiceGUI) - CORRIGIDA
# ==============================================================================
def create_ui():
rm = RenamerManager()
rm.container = ui.column().classes('w-full h-full p-4 gap-4')
rm.refresh()
organizer = MediaOrganizer()
# Container principal que envolve tudo (substitui o layout de página)
with ui.column().classes('w-full h-full p-0 gap-0'):
# --- FALSO HEADER (ui.row em vez de ui.header) ---
# Agora ele pode viver dentro de uma TabPanel sem dar erro
with ui.row().classes('w-full bg-blue-900 text-white items-center p-2 shadow-md'):
ui.icon('movie_filter', size='md')
ui.label('Media Organizer Pro').classes('text-lg font-bold ml-2')
ui.space()
# Campo API Key
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')
if __name__ in {"__main__", "__mp_main__"}:
create_ui()
# --- CONTAINER DE CONTEÚDO ---
main_content = ui.column().classes('w-full p-4 gap-4')
# --- DIALOGO DE RESOLUÇÃO MANUAL (Mantém igual) ---
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"Resolvendo: {item['original_file']}").classes('text-lg font-bold')
ui.label(f"Identificado como: {item['guess_title']} ({item['guess_year']})").classes('text-gray-500')
if not item['candidates']:
ui.label('Nenhum resultado encontrado no TMDb.').classes('text-red font-bold')
with ui.grid(columns=4).classes('w-full gap-4 mt-4'):
for cand in item['candidates']:
if item['type'] == 'movie':
title = cand.get('title')
date = cand.get('release_date', '')
img = cand.get('poster_path')
else:
title = cand.get('name')
date = cand.get('first_air_date', '')
img = cand.get('poster_path')
year = date[:4] if date else '????'
img_url = f"https://image.tmdb.org/t/p/w200{img}" if img else 'https://via.placeholder.com/200x300?text=No+Image'
with ui.card().classes('cursor-pointer hover:bg-blue-50 p-0 gap-0 border').tight():
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('Selecionar', on_click=lambda c=cand: select_match(item, c, row_refresh_callback)).props('sm flat w-full')
ui.button('Fechar', 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)
resolution_dialog.close()
refresh_cb()
# --- VIEWS (Mantém igual) ---
def render_explorer():
main_content.clear()
organizer.preview_data = []
with main_content:
# Barra de Navegação
with ui.row().classes('w-full bg-gray-100 p-2 rounded items-center shadow-sm'):
ui.icon('folder_open', 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', on_click=run_analysis).props('push color=primary 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('.'): 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:
ext = os.path.splitext(entry.name)[1].lower()
is_vid = ext in VIDEO_EXTENSIONS
color = 'blue' if is_vid else 'grey'
icon = 'movie' if is_vid else 'insert_drive_file'
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 ao ler pasta: {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('Revisão').classes('text-xl font-bold')
with ui.row():
ui.button('Cancelar', on_click=render_explorer).props('outline color=red dense')
async def run_move():
if await organizer.execute_move():
render_explorer()
ui.button('MOVER', on_click=run_move).props('push color=green icon=check dense')
with ui.column().classes('w-full gap-2'):
# Cabeçalho da Lista
with ui.row().classes('w-full bg-gray-200 p-2 font-bold text-sm rounded hidden md:flex'):
ui.label('Original').classes('w-1/3')
ui.label('Destino').classes('w-1/3')
ui.label('Status').classes('w-1/4 text-center')
with ui.scroll_area().classes('h-[500px] w-full border rounded bg-white'):
def render_row(item):
# Usei refreshable aqui para que o botão atualize apenas a linha
@ui.refreshable
def row_content():
with ui.row().classes('w-full p-2 border-b items-center hover:bg-gray-50 text-sm'):
with ui.column().classes('w-full md:w-1/3'):
ui.label(item['original_file']).classes('truncate font-medium w-full')
ui.label(f"{item['type'].upper()}{len(item['subtitles'])} leg.").classes('text-xs text-gray-500')
with ui.column().classes('w-full md:w-1/3'):
if item['target_path']:
rel_path = os.path.relpath(item['target_path'], ROOT_DIR)
ui.label(rel_path).classes('text-blue-700 font-mono break-all text-xs')
else:
ui.label('---').classes('text-gray-400')
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')
if status != 'OK' and status != 'ERRO_S_E':
ui.button(icon='search', on_click=lambda: open_resolution_dialog(item, row_content.refresh)).props('flat round dense color=primary')
elif status == 'OK':
ui.button(icon='edit', 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()
# Inicia
render_explorer()
# Removemos o ui.run() daqui, pois o main.py é quem controla o loop