from nicegui import ui import os import re import shutil from pathlib import Path # ============================================================================== # 1. CONFIGURAÇÕES GERAIS # ============================================================================== ROOT_DIR = "/downloads" # Diretório fixo conforme solicitado # 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. Suporta padrões internacionais (S01E01), Brasileiros (Temp/Ep) e Ingleses (Season/Episode). """ patterns = [ # --- 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) 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.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(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: if self.view_mode == 'explorer': self.render_breadcrumbs() self.render_folder_list() else: self.render_preview() def cancel(self): """Reseta o estado para o modo Explorer.""" self.view_mode = 'explorer' self.preview_data = [] 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 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 # 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({ '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 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 de Temporada/Episódio encontrado.', type='warning') else: self.view_mode = 'preview' self.refresh() # ========================================================================== # 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) 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') # 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) 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) ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps text-color=primary') ui.space() ui.button("🔍 Analisar Pasta Atual", on_click=self.analyze_folder).props('push color=primary') def render_folder_list(self): """Lista de arquivos/pastas (Explorer).""" try: # 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 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('.. (Subir Nível)') if not entries: ui.label("Pasta vazia.").classes('text-gray-400 italic ml-4') for entry in entries: 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') 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') 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() if __name__ in {"__main__", "__mp_main__"}: create_ui()