diff --git a/app/config.json b/app/config.json new file mode 100644 index 0000000..90ac4d6 --- /dev/null +++ b/app/config.json @@ -0,0 +1 @@ +{"tmdb_api_key": "12856f632876dc743b6f6775f4e5bd7d"} \ No newline at end of file diff --git a/app/modules/__pycache__/renamer.cpython-310.pyc b/app/modules/__pycache__/renamer.cpython-310.pyc index 9e449ea..370723a 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 5940d88..65d1959 100755 --- a/app/modules/renamer.py +++ b/app/modules/renamer.py @@ -1,372 +1,465 @@ -from nicegui import ui import os -import re 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 GERAIS +# 1. CONFIGURAÇÕES E CONSTANTES # ============================================================================== -ROOT_DIR = "/downloads" # Diretório fixo conforme solicitado +ROOT_DIR = "/downloads" +CONFIG_FILE = 'config.json' -# 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') +# 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. SISTEMA DE DETECÇÃO (REGEX) +# 2. PERSISTÊNCIA (Salvar API Key) # ============================================================================== -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) - +def load_config(): + if os.path.exists(CONFIG_FILE): 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 + 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.""" + 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 + 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 [] + + 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 + + # Notificação de progresso + loading = ui.notification(message='Analisando arquivos (Guessit + TMDb)...', 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) + + 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 - # 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 + file_ext = os.path.splitext(file)[1].lower() + if file_ext not in VIDEO_EXTENSIONS: continue - # 2. Tenta extrair S/E - season, episode = extract_season_episode(file) - if not season or not episode: continue + # 1. Análise Local (Guessit) + guess = guessit(file) + title = guess.get('title') + year = guess.get('year') + media_type = guess.get('type') # 'movie' ou 'episode' + + if not title: continue # Se não achou nem título, ignora - try: - # 3. Define Nomes e Caminhos - s_fmt = f"{int(season):02d}" - e_fmt = f"{int(episode):02d}" + # 2. Consulta TMDb + candidates = await self.search_tmdb(title, year, media_type) + + # 3. Lógica de Decisão (Match ou Ambiguidade) + match_data = None + status = 'AMBIGUO' # Padrão: incerto + + 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 - 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) + # 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 + else: + # Ano diferente. Pode ser remake ou erro. + match_data = first + status = 'CHECK' + else: + status = 'NAO_ENCONTRADO' - # Verifica se origem e destino são iguais - if os.path.normpath(src_full) == os.path.normpath(dst_full): - continue + # Cria objeto de item + item = { + 'id': len(self.preview_data), + 'original_file': file, + 'original_root': root, + 'guess_title': title, + 'guess_year': year, + 'type': media_type, + 'candidates': candidates, # Lista para o modal de escolha + 'selected_match': match_data, + 'status': status, + 'target_path': None, # Será calculado + 'subtitles': [] # Lista de legendas associadas + } - # 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 + # Calcula caminho se tiver match + if match_data: + self.calculate_path(item) + + # Procura Legendas Associadas + 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): - # 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', + suffix = f[len(video_stem):] # Ex: .forced.srt + item['subtitles'].append({ '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 + 'suffix': suffix, + 'src': os.path.join(root, f) }) - except Exception as e: - print(f"Erro ao processar arquivo {file}: {e}") + self.preview_data.append(item) + self.folders_to_clean.add(root) except Exception as e: - ui.notify(f'Erro fatal na análise: {str(e)}', type='negative') + ui.notify(f'Erro fatal: {e}', type='negative') + print(e) - n.dismiss() - - if not self.preview_data: - ui.notify('Nenhum padrão de Temporada/Episódio encontrado.', type='warning') + loading.dismiss() + + # Se achou itens, muda visualização + if self.preview_data: + return True else: - self.view_mode = 'preview' - self.refresh() + ui.notify('Nenhum vídeo encontrado nesta pasta.', type='warning') + return False - # ========================================================================== - # 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') + def calculate_path(self, item): + """Gera o caminho final baseado no match selecionado.""" + match = item['selected_match'] + if not match: + item['target_path'] = None 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)') + ext = os.path.splitext(item['original_file'])[1] + + 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) + + 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 + 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['target_path'] = None + return - if not entries: - ui.label("Pasta vazia.").classes('text-gray-400 italic ml-4') + # 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}" + + item['target_path'] = os.path.join( + ROOT_DIR, "Séries", name, f"Temporada {s_fmt}", f"Episódio {e_fmt}{ext}" + ) - for entry in entries: - if entry.name.startswith('.'): continue # Ignora ocultos + 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: + # 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 + src = os.path.join(item['original_root'], item['original_file']) + dst = item['target_path'] - 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') + # Verifica colisão + 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 + + # 2. Mover Legendas + video_dst_stem = os.path.splitext(dst)[0] # Caminho sem extensão + for sub in item['subtitles']: + sub_dst = video_dst_stem + sub['suffix'] # Ex: /path/Filme.forced.srt + 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') - def render_preview(self): - """Tabela de confirmação.""" - with ui.column().classes('w-full h-full gap-4'): + # 3. Limpeza Segura + 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 - # 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'}, - ] + 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: + shutil.rmtree(folder) + cleaned += 1 + except: pass - 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') + n.dismiss() + ui.notify(f'{moved} arquivos organizados. {cleaned} pastas limpas.', type='positive') + return True # ============================================================================== -# 8. STARTUP +# 4. INTERFACE GRÁFICA (NiceGUI) - CORRIGIDA # ============================================================================== def create_ui(): - rm = RenamerManager() - rm.container = ui.column().classes('w-full h-full p-4 gap-4') - rm.refresh() + 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') + 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') -if __name__ in {"__main__", "__mp_main__"}: - create_ui() \ No newline at end of file + # --- CONTAINER DE CONTEÚDO --- + main_content = ui.column().classes('w-full p-4 gap-4') + + # --- DIALOGO DE RESOLUÇÃO MANUAL (Mantém igual) --- + 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') + + 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') + + 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' + + with ui.card().classes('cursor-pointer hover:bg-blue-50 p-0 gap-0 border').tight(): + 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('Fechar', 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) + resolution_dialog.close() + refresh_cb() + + # --- VIEWS (Mantém igual) --- + def render_explorer(): + main_content.clear() + organizer.preview_data = [] + + with main_content: + # Barra de Navegação + with ui.row().classes('w-full bg-gray-100 p-2 rounded items-center shadow-sm'): + ui.icon('folder_open', 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') + + # 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('.'): 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: + ext = os.path.splitext(entry.name)[1].lower() + is_vid = ext in VIDEO_EXTENSIONS + color = 'blue' if is_vid else 'grey' + icon = 'movie' if is_vid else 'insert_drive_file' + + 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') + + 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') + with ui.row(): + ui.button('Cancelar', 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') + + 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') + + 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.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') + + 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() + + 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 diff --git a/docker-compose.yml b/docker-compose.yml index 3148503..cd15308 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,10 @@ services: - TZ=America/Sao_Paulo - LIBVA_DRIVER_NAME=i965 volumes: - - ./app:/app - - ./data:/app/data - - /downloads:/downloads - - /media1:/media/Jellyfin + - /home/creidsu/pymediamanager/app:/app + - /home/creidsu/pymediamanager/data:/app/data + - /media/qbit/download:/downloads + - /media:/media/Jellyfin # - /media2:/media/HD_Externo # - /media3:/media/Outros ports: diff --git a/requirements.txt b/requirements.txt index 9bfd00f..0875497 100755 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ watchdog guessit requests ffmpeg-python -yt-dlp \ No newline at end of file +yt-dlp +tmdbsimple \ No newline at end of file