diff --git a/app/modules/__pycache__/renamer.cpython-310.pyc b/app/modules/__pycache__/renamer.cpython-310.pyc index 370723a..c3d4121 100644 Binary files a/app/modules/__pycache__/renamer.cpython-310.pyc and b/app/modules/__pycache__/renamer.cpython-310.pyc differ diff --git a/app/modules/renamer.py b/app/modules/renamer.py index 65d1959..9e93e0c 100755 --- a/app/modules/renamer.py +++ b/app/modules/renamer.py @@ -11,8 +11,12 @@ 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 @@ -57,12 +61,11 @@ class MediaOrganizer: ui.notify('API Key salva com sucesso!', type='positive') async def search_tmdb(self, title, year, media_type): - """Consulta o TMDb e retorna candidatos.""" + """Consulta o TMDb e retorna candidatos com GÊNEROS.""" 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 @@ -76,6 +79,36 @@ class MediaOrganizer: 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 = [] @@ -85,17 +118,17 @@ class MediaOrganizer: 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) + loading = ui.notification(message='Analisando arquivos (Guessit + TMDb + Categorias)...', 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) + # 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 pastas de destino para evitar loop - if "Filmes" in root or "Séries" in root: continue + # 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) @@ -107,40 +140,34 @@ class MediaOrganizer: guess = guessit(file) title = guess.get('title') year = guess.get('year') - media_type = guess.get('type') # 'movie' ou 'episode' + media_type = guess.get('type') - if not title: continue # Se não achou nem título, ignora + if not title: continue # 2. Consulta TMDb candidates = await self.search_tmdb(title, year, media_type) - # 3. Lógica de Decisão (Match ou Ambiguidade) + # 3. Lógica de Decisão match_data = None - status = 'AMBIGUO' # Padrão: incerto + status = 'AMBIGUO' 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 + status = 'CHECK' 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, @@ -148,24 +175,24 @@ class MediaOrganizer: 'guess_title': title, 'guess_year': year, 'type': media_type, - 'candidates': candidates, # Lista para o modal de escolha + 'candidates': candidates, 'selected_match': match_data, 'status': status, - 'target_path': None, # Será calculado - 'subtitles': [] # Lista de legendas associadas + 'target_path': None, + 'category': None, # Nova propriedade + 'subtitles': [] } - # Calcula caminho se tiver match if match_data: self.calculate_path(item) - # Procura Legendas Associadas + # 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):] # Ex: .forced.srt + suffix = f[len(video_stem):] item['subtitles'].append({ 'original': f, 'suffix': suffix, @@ -181,15 +208,14 @@ class MediaOrganizer: 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') + ui.notify('Nenhum vídeo novo encontrado.', type='warning') return False def calculate_path(self, item): - """Gera o caminho final baseado no match selecionado.""" + """Gera o caminho baseado na Categoria e no Tipo.""" match = item['selected_match'] if not match: item['target_path'] = None @@ -197,40 +223,57 @@ class MediaOrganizer: 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': - # 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) + 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: - # 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 + # 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: - # Caso extremo: não achou temporada/ep no nome do arquivo - item['status'] = 'ERRO_S_E' # Erro de Season/Episode + item['status'] = 'ERRO_S_E' 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}" + # Caminho: Categoria / Nome da Série / Temporada XX / Episódio.ext item['target_path'] = os.path.join( - ROOT_DIR, "Séries", name, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}" + 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): @@ -239,16 +282,14 @@ class MediaOrganizer: 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 + # 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 @@ -257,27 +298,24 @@ class MediaOrganizer: shutil.move(src, dst) moved += 1 - # 2. Mover Legendas - video_dst_stem = os.path.splitext(dst)[0] # Caminho sem extensão + # Mover Legendas + video_dst_stem = os.path.splitext(dst)[0] for sub in item['subtitles']: - sub_dst = video_dst_stem + sub['suffix'] # Ex: /path/Filme.forced.srt + 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') - # 3. Limpeza Segura + # 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: continue + if not os.path.exists(folder) or folder == ROOT_DIR or "preparados" in folder: 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: @@ -286,93 +324,88 @@ class MediaOrganizer: except: pass n.dismiss() - ui.notify(f'{moved} arquivos organizados. {cleaned} pastas limpas.', type='positive') + ui.notify(f'{moved} arquivos organizados em "preparados". {cleaned} pastas limpas.', type='positive') return True # ============================================================================== -# 4. INTERFACE GRÁFICA (NiceGUI) - CORRIGIDA +# 4. INTERFACE GRÁFICA # ============================================================================== 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') + # 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() - # 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) --- + # 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"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') + 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']: - 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') - + # 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_url = f"https://image.tmdb.org/t/p/w200{img}" if img else 'https://via.placeholder.com/200x300?text=No+Image' + img = cand.get('poster_path') + img_url = f"https://image.tmdb.org/t/p/w200{img}" if img else 'https://via.placeholder.com/200' - with ui.card().classes('cursor-pointer hover:bg-blue-50 p-0 gap-0 border').tight(): + # 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('Selecionar', on_click=lambda c=cand: select_match(item, c, row_refresh_callback)).props('sm flat w-full') + ui.button('Escolher', 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') + 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) + organizer.calculate_path(item) # Recalcula caminho e categoria resolution_dialog.close() refresh_cb() - # --- VIEWS (Mantém igual) --- + # Telas def render_explorer(): main_content.clear() organizer.preview_data = [] with main_content: - # Barra de Navegação + # Caminho atual with ui.row().classes('w-full bg-gray-100 p-2 rounded items-center shadow-sm'): - ui.icon('folder_open', color='grey') + 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', on_click=run_analysis).props('push color=primary icon=search') + ui.button('ANALISAR PASTA', on_click=run_analysis).props('push color=indigo icon=search') # Lista de Arquivos try: @@ -382,7 +415,7 @@ def create_ui(): 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.name.startswith('.') or entry.name == "preparados": continue if entry.is_dir(): with ui.item(on_click=lambda p=entry.path: navigate(p)).props('clickable'): @@ -390,76 +423,80 @@ def create_ui(): 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' + 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 ao ler pasta: {e}").classes('text-red') + 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('Revisão').classes('text-xl font-bold') + ui.label('Pré-visualização da Organização').classes('text-xl font-bold') with ui.row(): - ui.button('Cancelar', on_click=render_explorer).props('outline color=red dense') + 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', on_click=run_move).props('push color=green icon=check dense') + ui.button('MOVER ARQUIVOS', 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') + # 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-[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.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') - 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') + # 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') - 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() + 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') - for item in organizer.preview_data: - render_row(item) + # 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() - # Inicia render_explorer() -# Removemos o ui.run() daqui, pois o main.py é quem controla o loop \ No newline at end of file +if __name__ in {"__main__", "__mp_main__"}: + create_ui() diff --git a/data/status.json b/data/status.json index a0085a0..8fa1483 100644 --- a/data/status.json +++ b/data/status.json @@ -1 +1 @@ -{"running": false, "file": "Finalizado \u2705", "pct_file": 100, "pct_total": 100, "log": "Finalizado \u2705"} \ No newline at end of file +{"running": true, "stop_requested": false, "file": "Press\u00e1gio (2009).mkv", "pct_file": 84, "pct_total": 0, "current_index": 1, "total_files": 2, "log": "Velocidade: 9.61x"} \ No newline at end of file