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

1
app/config.json Normal file
View File

@@ -0,0 +1 @@
{"tmdb_api_key": "12856f632876dc743b6f6775f4e5bd7d"}

View File

@@ -1,372 +1,465 @@
from nicegui import ui
import os import os
import re
import shutil import shutil
import json
import asyncio
from pathlib import Path 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) # Extensões para proteger pastas de exclusão
VIDEO_EXTENSIONS = ('.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm') VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm', '.m4v'}
# Extensões de legendas para mover junto # Extensões de legenda para mover junto
SUBTITLE_EXTENSIONS = ('.srt', '.sub', '.ass', '.vtt') SUBTITLE_EXTENSIONS = {'.srt', '.sub', '.ass', '.vtt', '.idx'}
# ============================================================================== # ==============================================================================
# 2. SISTEMA DE DETECÇÃO (REGEX) # 2. PERSISTÊNCIA (Salvar API Key)
# ============================================================================== # ==============================================================================
def extract_season_episode(filename): def load_config():
""" if os.path.exists(CONFIG_FILE):
Detecta Temporada e Episódio. try:
Suporta padrões internacionais (S01E01), Brasileiros (Temp/Ep) e Ingleses (Season/Episode). with open(CONFIG_FILE, 'r') as f:
""" return json.load(f)
patterns = [ except: pass
# --- PADRÕES UNIVERSAIS (S01E01, s1e1, S01.E01, S01_E01) --- return {}
r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})',
# --- PADRÃO X (1x01, 01x01) --- def save_config(api_key):
r'(?i)(\d{1,4})x(\d{1,4})', with open(CONFIG_FILE, 'w') as f:
json.dump({'tmdb_api_key': api_key}, f)
# --- 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) # 3. LÓGICA DE ORGANIZAÇÃO (CORE)
# ============================================================================== # ==============================================================================
class RenamerManager: class MediaOrganizer:
def __init__(self): def __init__(self):
config = load_config()
self.api_key = config.get('tmdb_api_key', '')
self.path = ROOT_DIR 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'
# ========================================================================== # Estado
# 4. NAVEGAÇÃO self.preview_data = [] # Lista de arquivos para processar
# ==========================================================================
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.folders_to_clean = set()
self.refresh()
# ========================================================================== # Configura TMDb
# 5. ANÁLISE E PREPARAÇÃO (CORE) 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): async def analyze_folder(self):
"""Lê arquivos recursivamente e prepara a lista de movimentos.""" """Analisa a pasta usando Guessit + TMDb."""
self.preview_data = [] self.preview_data = []
self.folders_to_clean = set() self.folders_to_clean = set()
# Feedback visual if not self.api_key:
n = ui.notification(message='Analisando arquivos...', spinner=True, timeout=None) 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: try:
for root, dirs, files in os.walk(self.path): # Cria pastas base se não existirem
# Ignora pasta de destino para não entrar em loop os.makedirs(os.path.join(ROOT_DIR, "Filmes"), exist_ok=True)
if "finalizados" in root.lower(): continue 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) files_in_dir = set(files)
for file in files: for file in files:
# 1. Filtra apenas vídeos file_ext = os.path.splitext(file)[1].lower()
if not is_video(file): continue if file_ext not in VIDEO_EXTENSIONS: continue
# 2. Tenta extrair S/E # 1. Análise Local (Guessit)
season, episode = extract_season_episode(file) guess = guessit(file)
if not season or not episode: continue title = guess.get('title')
year = guess.get('year')
media_type = guess.get('type') # 'movie' ou 'episode'
try: if not title: continue # Se não achou nem título, ignora
# 3. Define Nomes e Caminhos
s_fmt = f"{int(season):02d}"
e_fmt = f"{int(episode):02d}"
ext = os.path.splitext(file)[1] # 2. Consulta TMDb
candidates = await self.search_tmdb(title, year, media_type)
# Estrutura Final: /downloads/Temporada XX/Episódio YY.mkv # 3. Lógica de Decisão (Match ou Ambiguidade)
# Nota: Cria pasta Temporada XX dentro de ROOT_DIR (ou self.path se preferir relativo) match_data = None
# Aqui estou criando relativo ao ROOT_DIR atual para organização centralizada status = 'AMBIGUO' # Padrão: incerto
target_season_folder = f"Temporada {s_fmt}"
target_filename = f"Episódio {e_fmt}{ext}"
src_full = os.path.join(root, file) if candidates:
dst_full = os.path.join(self.path, target_season_folder, target_filename) # 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
# Verifica se origem e destino são iguais # Se ano bate ou não tem ano no arquivo original, confia no primeiro resultado
if os.path.normpath(src_full) == os.path.normpath(dst_full): if year and tmdb_year == year:
continue 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 conflito # Cria objeto de item
status = 'OK' item = {
if os.path.exists(dst_full): 'id': len(self.preview_data),
status = 'CONFLITO (Já existe)' '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
}
# Adiciona à lista de preview # Calcula caminho se tiver match
self.preview_data.append({ if match_data:
'type': 'Vídeo', self.calculate_path(item)
'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 # Procura Legendas Associadas
video_stem = Path(file).stem # Nome do vídeo sem extensão video_stem = Path(file).stem
for f in files_in_dir:
for f in files_in_dir: if f == file: continue
if f == file: continue # Pula o próprio vídeo if os.path.splitext(f)[1].lower() in SUBTITLE_EXTENSIONS:
if not is_subtitle(f): continue
# Se a legenda começa com o nome do arquivo de vídeo
if f.startswith(video_stem): 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):] # Ex: .forced.srt
suffix = f[len(video_stem):] item['subtitles'].append({
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',
'original': f, 'original': f,
'new_path': os.path.join(target_season_folder, sub_target_name), 'suffix': suffix,
'src': os.path.join(root, f), 'src': os.path.join(root, f)
'dst': sub_dst_full,
'status': sub_status
}) })
except Exception as e: self.preview_data.append(item)
print(f"Erro ao processar arquivo {file}: {e}") self.folders_to_clean.add(root)
except Exception as e: 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() loading.dismiss()
if not self.preview_data: # Se achou itens, muda visualização
ui.notify('Nenhum padrão de Temporada/Episódio encontrado.', type='warning') if self.preview_data:
return True
else: else:
self.view_mode = 'preview' ui.notify('Nenhum vídeo encontrado nesta pasta.', type='warning')
self.refresh() return False
# ========================================================================== def calculate_path(self, item):
# 6. EXECUÇÃO E LIMPEZA """Gera o caminho final baseado no match selecionado."""
# ========================================================================== match = item['selected_match']
async def execute_rename(self): if not match:
"""Move os arquivos e limpa pastas 'lixo'.""" item['target_path'] = None
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')
return return
with ui.column().classes('w-full gap-1 mt-2'): ext = os.path.splitext(item['original_file'])[1]
# 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)')
if not entries: if item['type'] == 'movie':
ui.label("Pasta vazia.").classes('text-gray-400 italic ml-4') # 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'
for entry in entries: new_name = f"{title} ({year}){ext}"
if entry.name.startswith('.'): continue # Ignora ocultos item['target_path'] = os.path.join(ROOT_DIR, "Filmes", new_name)
if entry.is_dir(): else:
with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-gray-100 cursor-pointer rounded'): # Estrutura: /downloads/Séries/Nome/Temporada XX/Episódio YY.ext
with ui.item_section().props('avatar'): name = match.get('name', item['guess_title']).replace('/', '-').replace(':', '-')
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'): # Tenta pegar temporada/episódio do guessit
with ui.item_section().props('avatar'): # Se falhar, usa S00E00 como fallback seguro para não perder arquivo
ui.icon(icon, color=color).props('size=sm') guess = guessit(item['original_file'])
with ui.item_section(): s = guess.get('season')
ui.item_label(entry.name).classes('text-sm text-gray-600') e = guess.get('episode')
def render_preview(self): if not s or not e:
"""Tabela de confirmação.""" # Caso extremo: não achou temporada/ep no nome do arquivo
with ui.column().classes('w-full h-full gap-4'): item['status'] = 'ERRO_S_E' # Erro de Season/Episode
item['target_path'] = None
return
# Cabeçalho # Suporte a múltiplas temporadas/episodios (lista)
with ui.row().classes('w-full items-center justify-between'): if isinstance(s, list): s = s[0]
ui.label(f'Detectados {len(self.preview_data)} arquivos').classes('text-xl font-bold text-gray-700') if isinstance(e, list): e = e[0]
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) s_fmt = f"{s:02d}"
cols = [ e_fmt = f"{e:02d}"
{'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'},
]
ui.table( item['target_path'] = os.path.join(
columns=cols, ROOT_DIR, "Séries", name, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}"
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') 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']
# 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')
# 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
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
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(): def create_ui():
rm = RenamerManager() organizer = MediaOrganizer()
rm.container = ui.column().classes('w-full h-full p-4 gap-4')
rm.refresh()
if __name__ in {"__main__", "__mp_main__"}: # Container principal que envolve tudo (substitui o layout de página)
create_ui() 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')
# --- 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

View File

@@ -13,10 +13,10 @@ services:
- TZ=America/Sao_Paulo - TZ=America/Sao_Paulo
- LIBVA_DRIVER_NAME=i965 - LIBVA_DRIVER_NAME=i965
volumes: volumes:
- ./app:/app - /home/creidsu/pymediamanager/app:/app
- ./data:/app/data - /home/creidsu/pymediamanager/data:/app/data
- /downloads:/downloads - /media/qbit/download:/downloads
- /media1:/media/Jellyfin - /media:/media/Jellyfin
# - /media2:/media/HD_Externo # - /media2:/media/HD_Externo
# - /media3:/media/Outros # - /media3:/media/Outros
ports: ports:

View File

@@ -5,3 +5,4 @@ guessit
requests requests
ffmpeg-python ffmpeg-python
yt-dlp yt-dlp
tmdbsimple