From 48d0dbf7d3d94467d6b2161c6d351176c2e4f67b Mon Sep 17 00:00:00 2001 From: Creidsu Date: Sun, 1 Feb 2026 01:29:45 +0000 Subject: [PATCH] =?UTF-8?q?melhorado=20a=20identifica=C3=A7=C3=A3o=20de=20?= =?UTF-8?q?temporadas=20e=20epis=C3=B3dios,=20e=20axclu=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20pastas=20vazias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/renamer.cpython-310.pyc | Bin 7086 -> 10007 bytes app/modules/renamer.py | 389 +++++++++++++----- 2 files changed, 290 insertions(+), 99 deletions(-) diff --git a/app/modules/__pycache__/renamer.cpython-310.pyc b/app/modules/__pycache__/renamer.cpython-310.pyc index 0cc7023356f2784044a7b9dfb17418afde9e4a52..9e449ea899b9b254fa8eb9f11e4a778e31cad083 100644 GIT binary patch literal 10007 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 diff --git a/app/modules/renamer.py b/app/modules/renamer.py index 45c1690..5940d88 100755 --- a/app/modules/renamer.py +++ b/app/modules/renamer.py @@ -2,40 +2,86 @@ from nicegui import ui import os import re import shutil +from pathlib import Path -ROOT_DIR = "/downloads" +# ============================================================================== +# 1. CONFIGURAÇÕES GERAIS +# ============================================================================== +ROOT_DIR = "/downloads" # Diretório fixo conforme solicitado -# --- UTILITÁRIOS --- +# Extensões consideradas para manter a pasta viva (não apagar se sobrarem) +VIDEO_EXTENSIONS = ('.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm') +# Extensões de legendas para mover junto +SUBTITLE_EXTENSIONS = ('.srt', '.sub', '.ass', '.vtt') + +# ============================================================================== +# 2. SISTEMA DE DETECÇÃO (REGEX) +# ============================================================================== def extract_season_episode(filename): - """Detecta Temporada e Episódio usando vários padrões""" + """ + Detecta Temporada e Episódio. + Suporta padrões internacionais (S01E01), Brasileiros (Temp/Ep) e Ingleses (Season/Episode). + """ patterns = [ - r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})', - r'(?i)S(\d{1,4})[\s._-]*EP(\d{1,4})', - r'(?i)(\d{1,4})x(\d{1,4})', - r'(?i)Season[\s._-]*(\d{1,4})[\s._-]*Episode[\s._-]*(\d{1,4})', - r'(?i)S(\d{1,4})[\s._-]*-\s*(\d{1,4})', - r'(?i)\[(\d{1,4})x(\d{1,4})\]', + # --- PADRÕES UNIVERSAIS (S01E01, s1e1, S01.E01, S01_E01) --- + r'(?i)S(\d{1,4})[\s._-]*E(\d{1,4})', + + # --- PADRÃO X (1x01, 01x01) --- + r'(?i)(\d{1,4})x(\d{1,4})', + + # --- 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) + 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) +# ============================================================================== class RenamerManager: def __init__(self): self.path = ROOT_DIR self.container = None self.preview_data = [] - self.view_mode = 'explorer' + self.folders_to_clean = set() # Lista de pastas candidatas à exclusão + self.view_mode = 'explorer' # Alterna entre 'explorer' e 'preview' + # ========================================================================== + # 4. NAVEGAÇÃO + # ========================================================================== 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('Erro ao acessar pasta', type='negative') + 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: @@ -45,137 +91,282 @@ class RenamerManager: else: self.render_preview() - def analyze_folder(self): + def cancel(self): + """Reseta o estado para o modo Explorer.""" + self.view_mode = 'explorer' self.preview_data = [] - for root, dirs, files in os.walk(self.path): - if "finalizados" in root: continue - for file in files: - if file.lower().endswith(('.mkv', '.mp4', '.avi')): + self.folders_to_clean = set() + self.refresh() + + # ========================================================================== + # 5. ANÁLISE E PREPARAÇÃO (CORE) + # ========================================================================== + async def analyze_folder(self): + """Lê arquivos recursivamente e prepara a lista de movimentos.""" + self.preview_data = [] + self.folders_to_clean = set() + + # Feedback visual + n = ui.notification(message='Analisando arquivos...', spinner=True, timeout=None) + + try: + for root, dirs, files in os.walk(self.path): + # Ignora pasta de destino para não entrar em loop + if "finalizados" in root.lower(): continue + + # Cria conjunto para busca rápida de legendas + files_in_dir = set(files) + + for file in files: + # 1. Filtra apenas vídeos + if not is_video(file): continue + + # 2. Tenta extrair S/E season, episode = extract_season_episode(file) - if season and episode: - try: - s_fmt = f"{int(season):02d}" - e_fmt = f"{int(episode):02d}" - ext = os.path.splitext(file)[1] + if not season or not episode: continue + + try: + # 3. Define Nomes e Caminhos + s_fmt = f"{int(season):02d}" + e_fmt = f"{int(episode):02d}" + + ext = os.path.splitext(file)[1] + + # Estrutura Final: /downloads/Temporada XX/Episódio YY.mkv + # Nota: Cria pasta Temporada XX dentro de ROOT_DIR (ou self.path se preferir relativo) + # Aqui estou criando relativo ao ROOT_DIR atual para organização centralizada + target_season_folder = f"Temporada {s_fmt}" + target_filename = f"Episódio {e_fmt}{ext}" + + src_full = os.path.join(root, file) + dst_full = os.path.join(self.path, target_season_folder, target_filename) + + # Verifica se origem e destino são iguais + if os.path.normpath(src_full) == os.path.normpath(dst_full): + continue + + # Verifica conflito + status = 'OK' + if os.path.exists(dst_full): + status = 'CONFLITO (Já existe)' + + # Adiciona à lista de preview + self.preview_data.append({ + 'type': 'Vídeo', + '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 + video_stem = Path(file).stem # Nome do vídeo sem extensão + + for f in files_in_dir: + if f == file: continue # Pula o próprio vídeo + if not is_subtitle(f): continue - # Estrutura: Temporada XX / Episódio YY.mkv - new_struct = f"Temporada {s_fmt}/Episódio {e_fmt}{ext}" - - src = os.path.join(root, file) - dst = os.path.join(self.path, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}") - - if src != dst: + # Se a legenda começa com o nome do arquivo de vídeo + 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):] + + 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({ - 'original': file, - 'new': new_struct, - 'src': src, - 'dst': dst + 'type': 'Legenda', + 'original': f, + 'new_path': os.path.join(target_season_folder, sub_target_name), + 'src': os.path.join(root, f), + 'dst': sub_dst_full, + 'status': sub_status }) - except: pass + + except Exception as e: + print(f"Erro ao processar arquivo {file}: {e}") + + except Exception as e: + ui.notify(f'Erro fatal na análise: {str(e)}', type='negative') + + n.dismiss() if not self.preview_data: - ui.notify('Nenhum padrão encontrado.', type='warning') + ui.notify('Nenhum padrão de Temporada/Episódio encontrado.', type='warning') else: self.view_mode = 'preview' self.refresh() - def execute_rename(self): - count = 0 + # ========================================================================== + # 6. EXECUÇÃO E LIMPEZA + # ========================================================================== + async def execute_rename(self): + """Move os arquivos e limpa pastas 'lixo'.""" + 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) - if not os.path.exists(item['dst']): - shutil.move(item['src'], item['dst']) - count += 1 - except: pass - - ui.notify(f'{count} Arquivos Organizados!', type='positive') - self.view_mode = 'explorer' - self.preview_data = [] - self.refresh() + 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') - def cancel(self): - self.view_mode = 'explorer' - self.preview_data = [] - self.refresh() - - # --- RENDERIZADOR: BARRA DE NAVEGAÇÃO (CADEIA) --- - def render_breadcrumbs(self): - with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'): - # Botão Raiz - ui.button('🏠', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense text-color=grey-8') + # 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) - # Divide o caminho atual para criar os botões + 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) - # Botão da Pasta ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary') ui.space() - # Botão de Ação Principal ui.button("🔍 Analisar Pasta Atual", on_click=self.analyze_folder).props('push color=primary') - # --- RENDERIZADOR: LISTA DE PASTAS --- def render_folder_list(self): + """Lista de arquivos/pastas (Explorer).""" try: - # Lista apenas diretórios, ignora arquivos - entries = sorted([e for e in os.scandir(self.path) if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower()) - except: - ui.label("Erro ao ler pasta").classes('text-red') + # 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 with ui.column().classes('w-full gap-1 mt-2'): - # Botão para subir nível (se não estiver na raiz) + # 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('Voltar / Subir Nível') + ui.item_label('.. (Subir Nível)') if not entries: - ui.label("Nenhuma subpasta aqui.").classes('text-gray-400 italic ml-4 mt-2') + ui.label("Pasta vazia.").classes('text-gray-400 italic ml-4') - # Lista de Subpastas for entry in entries: - with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-gray-100 cursor-pointer rounded'): - with ui.item_section().props('avatar'): - ui.icon('folder', color='amber') - with ui.item_section(): - ui.item_label(entry.name).classes('font-medium') - - # --- RENDERIZADOR: PREVIEW --- - def render_preview(self): - with ui.column().classes('w-full items-center gap-4'): - ui.label(f'Detectados {len(self.preview_data)} arquivos para renomear').classes('text-xl font-bold text-green-700') - - with ui.row(): - ui.button('Cancelar', on_click=self.cancel).props('outline color=red') - ui.button('Confirmar Tudo', on_click=self.execute_rename).props('push color=green icon=check') - - # Tabela Simples - with ui.card().classes('w-full p-0'): - with ui.column().classes('w-full gap-0'): - # Cabeçalho - with ui.row().classes('w-full bg-gray-200 p-2 font-bold'): - ui.label('Original').classes('w-1/2') - ui.label('Novo Caminho').classes('w-1/2') + if entry.name.startswith('.'): continue # Ignora ocultos + + if entry.is_dir(): + with ui.item(on_click=lambda p=entry.path: self.navigate(p)).classes('hover:bg-gray-100 cursor-pointer rounded'): + with ui.item_section().props('avatar'): + 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') - # Itens - with ui.scroll_area().classes('h-96 w-full'): - for item in self.preview_data: - with ui.row().classes('w-full p-2 border-b border-gray-100 hover:bg-gray-50'): - ui.label(item['original']).classes('w-1/2 text-sm truncate') - ui.label(item['new']).classes('w-1/2 text-sm text-blue-600 font-mono truncate') + with ui.item().classes('hover:bg-gray-50 rounded pl-8'): + with ui.item_section().props('avatar'): + ui.icon(icon, color=color).props('size=sm') + with ui.item_section(): + ui.item_label(entry.name).classes('text-sm text-gray-600') -# --- INICIALIZADOR --- + def render_preview(self): + """Tabela de confirmação.""" + with ui.column().classes('w-full h-full gap-4'): + + # Cabeçalho + with ui.row().classes('w-full items-center justify-between'): + ui.label(f'Detectados {len(self.preview_data)} arquivos').classes('text-xl font-bold text-gray-700') + 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) + cols = [ + {'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( + columns=cols, + 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') + +# ============================================================================== +# 8. STARTUP +# ============================================================================== def create_ui(): rm = RenamerManager() rm.container = ui.column().classes('w-full h-full p-4 gap-4') - rm.refresh() \ No newline at end of file + rm.refresh() + +if __name__ in {"__main__", "__mp_main__"}: + create_ui() \ No newline at end of file