Files
pymediamanager/app/modules/renamer.py

465 lines
21 KiB
Python
Executable File

import os
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"
CONFIG_FILE = 'config.json'
# 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. PERSISTÊNCIA (Salvar API Key)
# ==============================================================================
def load_config():
if os.path.exists(CONFIG_FILE):
try:
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
files_in_dir = set(files)
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') # 'movie' ou 'episode'
if not title: continue # Se não achou nem título, ignora
# 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
# 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'
# 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
}
# 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):
suffix = f[len(video_stem):] # Ex: .forced.srt
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()
# Se achou itens, muda visualização
if self.preview_data:
return True
else:
ui.notify('Nenhum vídeo encontrado nesta pasta.', type='warning')
return False
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
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
# 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}"
)
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
# ==============================================================================
# 4. INTERFACE GRÁFICA (NiceGUI) - CORRIGIDA
# ==============================================================================
def create_ui():
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')
# --- 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