Files
pymediamanager/app/modules/renamer.py

503 lines
22 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"
DEST_DIR = os.path.join(ROOT_DIR, "preparados") # Nova raiz de destino
CONFIG_FILE = 'config.json'
# ID do Gênero Animação no TMDb
GENRE_ANIMATION = 16
# 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 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
loading = ui.notification(message='Analisando arquivos (Guessit + TMDb + Categorias)...', spinner=True, timeout=None)
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)
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
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')
if not title: continue
# 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():
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('Media Organizer v2').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()