503 lines
22 KiB
Python
Executable File
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()
|