atualizado para separa desenhos filmes animes e series

This commit is contained in:
2026-02-01 17:39:27 +00:00
parent 832fdfd35a
commit 3ebe723edb
3 changed files with 174 additions and 137 deletions

View File

@@ -11,8 +11,12 @@ from guessit import guessit
# 1. CONFIGURAÇÕES E CONSTANTES # 1. CONFIGURAÇÕES E CONSTANTES
# ============================================================================== # ==============================================================================
ROOT_DIR = "/downloads" ROOT_DIR = "/downloads"
DEST_DIR = os.path.join(ROOT_DIR, "preparados") # Nova raiz de destino
CONFIG_FILE = 'config.json' CONFIG_FILE = 'config.json'
# ID do Gênero Animação no TMDb
GENRE_ANIMATION = 16
# Extensões para proteger pastas de exclusão # Extensões para proteger pastas de exclusão
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm', '.m4v'} VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.mov', '.iso', '.wmv', '.flv', '.webm', '.m4v'}
# Extensões de legenda para mover junto # Extensões de legenda para mover junto
@@ -57,12 +61,11 @@ class MediaOrganizer:
ui.notify('API Key salva com sucesso!', type='positive') ui.notify('API Key salva com sucesso!', type='positive')
async def search_tmdb(self, title, year, media_type): 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 [] if not self.api_key: return []
search = tmdb.Search() search = tmdb.Search()
try: try:
# Roda em thread separada para não travar a UI
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
if media_type == 'movie': if media_type == 'movie':
# Busca Filmes # Busca Filmes
@@ -76,6 +79,36 @@ class MediaOrganizer:
print(f"Erro TMDb: {e}") print(f"Erro TMDb: {e}")
return [] 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): async def analyze_folder(self):
"""Analisa a pasta usando Guessit + TMDb.""" """Analisa a pasta usando Guessit + TMDb."""
self.preview_data = [] self.preview_data = []
@@ -85,17 +118,17 @@ class MediaOrganizer:
ui.notify('Por favor, configure a API Key do TMDb primeiro.', type='negative') ui.notify('Por favor, configure a API Key do TMDb primeiro.', type='negative')
return return
# Notificação de progresso loading = ui.notification(message='Analisando arquivos (Guessit + TMDb + Categorias)...', spinner=True, timeout=None)
loading = ui.notification(message='Analisando arquivos (Guessit + TMDb)...', spinner=True, timeout=None)
try: try:
# Cria pastas base se não existirem # Cria a estrutura base de destino
os.makedirs(os.path.join(ROOT_DIR, "Filmes"), exist_ok=True) categories = ['Filmes', 'Séries', 'Animes', 'Desenhos']
os.makedirs(os.path.join(ROOT_DIR, "Séries"), exist_ok=True) for cat in categories:
os.makedirs(os.path.join(DEST_DIR, cat), exist_ok=True)
for root, dirs, files in os.walk(self.path): for root, dirs, files in os.walk(self.path):
# Ignora pastas de destino para evitar loop # Ignora a pasta de destino "preparados" para não processar o que já foi feito
if "Filmes" in root or "Séries" in root: continue if "preparados" in root: continue
files_in_dir = set(files) files_in_dir = set(files)
@@ -107,40 +140,34 @@ class MediaOrganizer:
guess = guessit(file) guess = guessit(file)
title = guess.get('title') title = guess.get('title')
year = guess.get('year') 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 # 2. Consulta TMDb
candidates = await self.search_tmdb(title, year, media_type) 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 match_data = None
status = 'AMBIGUO' # Padrão: incerto status = 'AMBIGUO'
if candidates: if candidates:
# Tenta match exato (Primeiro resultado geralmente é o melhor no TMDb)
first = candidates[0] 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_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 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: if year and tmdb_year == year:
match_data = first match_data = first
status = 'OK' status = 'OK'
elif not year: elif not year:
# Sem ano no arquivo, mas achou resultado. Marca como Ambíguo mas sugere o primeiro
match_data = first match_data = first
status = 'CHECK' # Requer atenção visual status = 'CHECK'
else: else:
# Ano diferente. Pode ser remake ou erro.
match_data = first match_data = first
status = 'CHECK' status = 'CHECK'
else: else:
status = 'NAO_ENCONTRADO' status = 'NAO_ENCONTRADO'
# Cria objeto de item
item = { item = {
'id': len(self.preview_data), 'id': len(self.preview_data),
'original_file': file, 'original_file': file,
@@ -148,24 +175,24 @@ class MediaOrganizer:
'guess_title': title, 'guess_title': title,
'guess_year': year, 'guess_year': year,
'type': media_type, 'type': media_type,
'candidates': candidates, # Lista para o modal de escolha 'candidates': candidates,
'selected_match': match_data, 'selected_match': match_data,
'status': status, 'status': status,
'target_path': None, # Será calculado 'target_path': None,
'subtitles': [] # Lista de legendas associadas 'category': None, # Nova propriedade
'subtitles': []
} }
# Calcula caminho se tiver match
if match_data: if match_data:
self.calculate_path(item) self.calculate_path(item)
# Procura Legendas Associadas # Procura Legendas
video_stem = Path(file).stem video_stem = Path(file).stem
for f in files_in_dir: for f in files_in_dir:
if f == file: continue if f == file: continue
if os.path.splitext(f)[1].lower() in SUBTITLE_EXTENSIONS: if os.path.splitext(f)[1].lower() in SUBTITLE_EXTENSIONS:
if f.startswith(video_stem): if f.startswith(video_stem):
suffix = f[len(video_stem):] # Ex: .forced.srt suffix = f[len(video_stem):]
item['subtitles'].append({ item['subtitles'].append({
'original': f, 'original': f,
'suffix': suffix, 'suffix': suffix,
@@ -181,15 +208,14 @@ class MediaOrganizer:
loading.dismiss() loading.dismiss()
# Se achou itens, muda visualização
if self.preview_data: if self.preview_data:
return True return True
else: else:
ui.notify('Nenhum vídeo encontrado nesta pasta.', type='warning') ui.notify('Nenhum vídeo novo encontrado.', type='warning')
return False return False
def calculate_path(self, item): 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'] match = item['selected_match']
if not match: if not match:
item['target_path'] = None item['target_path'] = None
@@ -197,40 +223,57 @@ class MediaOrganizer:
ext = os.path.splitext(item['original_file'])[1] 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': if item['type'] == 'movie':
# Estrutura: /downloads/Filmes/Nome (Ano).ext new_filename = f"{final_title} ({year_str}){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}" if category == 'Filmes':
item['target_path'] = os.path.join(ROOT_DIR, "Filmes", new_name) # 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: else:
# Estrutura: /downloads/Séries/Nome/Temporada XX/Episódio YY.ext # Séries sempre precisam de estrutura de pasta
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']) guess = guessit(item['original_file'])
s = guess.get('season') s = guess.get('season')
e = guess.get('episode') e = guess.get('episode')
if not s or not e: if not s or not e:
# Caso extremo: não achou temporada/ep no nome do arquivo item['status'] = 'ERRO_S_E'
item['status'] = 'ERRO_S_E' # Erro de Season/Episode
item['target_path'] = None item['target_path'] = None
return return
# Suporte a múltiplas temporadas/episodios (lista)
if isinstance(s, list): s = s[0] if isinstance(s, list): s = s[0]
if isinstance(e, list): e = e[0] if isinstance(e, list): e = e[0]
s_fmt = f"{s:02d}" s_fmt = f"{s:02d}"
e_fmt = f"{e:02d}" e_fmt = f"{e:02d}"
# Caminho: Categoria / Nome da Série / Temporada XX / Episódio.ext
item['target_path'] = os.path.join( 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): async def execute_move(self):
@@ -239,16 +282,14 @@ class MediaOrganizer:
n = ui.notification('Organizando biblioteca...', spinner=True, timeout=None) n = ui.notification('Organizando biblioteca...', spinner=True, timeout=None)
for item in self.preview_data: 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': if not item['target_path'] or item['status'] == 'NAO_ENCONTRADO':
continue continue
try: try:
# 1. Mover Vídeo # Mover Vídeo
src = os.path.join(item['original_root'], item['original_file']) src = os.path.join(item['original_root'], item['original_file'])
dst = item['target_path'] dst = item['target_path']
# Verifica colisão
if os.path.exists(dst): if os.path.exists(dst):
ui.notify(f"Pulei {os.path.basename(dst)} (Já existe)", type='warning') ui.notify(f"Pulei {os.path.basename(dst)} (Já existe)", type='warning')
continue continue
@@ -257,27 +298,24 @@ class MediaOrganizer:
shutil.move(src, dst) shutil.move(src, dst)
moved += 1 moved += 1
# 2. Mover Legendas # Mover Legendas
video_dst_stem = os.path.splitext(dst)[0] # Caminho sem extensão video_dst_stem = os.path.splitext(dst)[0]
for sub in item['subtitles']: 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): if not os.path.exists(sub_dst):
shutil.move(sub['src'], sub_dst) shutil.move(sub['src'], sub_dst)
except Exception as e: except Exception as e:
ui.notify(f"Erro ao mover {item['original_file']}: {e}", type='negative') ui.notify(f"Erro ao mover {item['original_file']}: {e}", type='negative')
# 3. Limpeza Segura # Limpeza
cleaned = 0 cleaned = 0
sorted_folders = sorted(list(self.folders_to_clean), key=len, reverse=True) sorted_folders = sorted(list(self.folders_to_clean), key=len, reverse=True)
for folder in sorted_folders: 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: try:
# Verifica se sobrou vídeo
remaining = [f for f in os.listdir(folder) if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS] 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)) has_subfolder = any(os.path.isdir(os.path.join(folder, f)) for f in os.listdir(folder))
if not remaining and not has_subfolder: if not remaining and not has_subfolder:
@@ -286,93 +324,88 @@ class MediaOrganizer:
except: pass except: pass
n.dismiss() 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 return True
# ============================================================================== # ==============================================================================
# 4. INTERFACE GRÁFICA (NiceGUI) - CORRIGIDA # 4. INTERFACE GRÁFICA
# ============================================================================== # ==============================================================================
def create_ui(): def create_ui():
organizer = MediaOrganizer() 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'): with ui.column().classes('w-full h-full p-0 gap-0'):
# Header
# --- FALSO HEADER (ui.row em vez de ui.header) --- with ui.row().classes('w-full bg-indigo-900 text-white items-center p-3 shadow-md'):
# Agora ele pode viver dentro de uma TabPanel sem dar erro ui.icon('smart_display', size='md')
with ui.row().classes('w-full bg-blue-900 text-white items-center p-2 shadow-md'): ui.label('Media Organizer v2').classes('text-lg font-bold ml-2')
ui.icon('movie_filter', size='md') ui.label('(Filmes • Séries • Animes • Desenhos)').classes('text-xs text-gray-300 ml-1 mt-1')
ui.label('Media Organizer Pro').classes('text-lg font-bold ml-2')
ui.space() ui.space()
# Campo API Key
with ui.row().classes('items-center gap-2'): 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 = ui.input('TMDb API Key', password=True).props('dense dark input-class=text-white outlined').classes('w-64')
key_input.value = organizer.api_key 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') 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') main_content = ui.column().classes('w-full p-4 gap-4')
# --- DIALOGO DE RESOLUÇÃO MANUAL (Mantém igual) --- # Dialogo
resolution_dialog = ui.dialog() resolution_dialog = ui.dialog()
def open_resolution_dialog(item, row_refresh_callback): def open_resolution_dialog(item, row_refresh_callback):
with resolution_dialog, ui.card().classes('w-full max-w-4xl'): 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"Arquivo: {item['original_file']}").classes('text-lg font-bold')
ui.label(f"Identificado como: {item['guess_title']} ({item['guess_year']})").classes('text-gray-500') ui.label(f"Guessit: {item['guess_title']} ({item['guess_year']})").classes('text-gray-500 text-sm')
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'): with ui.grid(columns=4).classes('w-full gap-4 mt-4'):
for cand in item['candidates']: for cand in item['candidates']:
if item['type'] == 'movie': # Helper para UI
title = cand.get('title') is_movie = (item['type'] == 'movie')
date = cand.get('release_date', '') title = cand.get('title') if is_movie else cand.get('name')
img = cand.get('poster_path') date = cand.get('release_date') if is_movie else cand.get('first_air_date')
else:
title = cand.get('name')
date = cand.get('first_air_date', '')
img = cand.get('poster_path')
year = date[:4] if date else '????' 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'
# 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')
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') ui.image(img_url).classes('h-48 w-full object-cover')
with ui.column().classes('p-2 w-full'): with ui.column().classes('p-2 w-full'):
ui.label(title).classes('font-bold text-sm leading-tight text-ellipsis overflow-hidden') ui.label(title).classes('font-bold text-sm leading-tight text-ellipsis overflow-hidden')
ui.label(year).classes('text-xs text-gray-500') 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() resolution_dialog.open()
def select_match(item, match, refresh_cb): def select_match(item, match, refresh_cb):
item['selected_match'] = match item['selected_match'] = match
item['status'] = 'OK' item['status'] = 'OK'
organizer.calculate_path(item) organizer.calculate_path(item) # Recalcula caminho e categoria
resolution_dialog.close() resolution_dialog.close()
refresh_cb() refresh_cb()
# --- VIEWS (Mantém igual) --- # Telas
def render_explorer(): def render_explorer():
main_content.clear() main_content.clear()
organizer.preview_data = [] organizer.preview_data = []
with main_content: 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'): 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') ui.label(organizer.path).classes('font-mono ml-2 mr-auto text-sm md:text-base truncate')
async def run_analysis(): async def run_analysis():
has_data = await organizer.analyze_folder() has_data = await organizer.analyze_folder()
if has_data: render_preview() 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 # Lista de Arquivos
try: 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') ui.item(text='.. (Voltar)', on_click=lambda: navigate(os.path.dirname(organizer.path))).props('clickable icon=arrow_back')
for entry in entries: for entry in entries:
if entry.name.startswith('.'): continue if entry.name.startswith('.') or entry.name == "preparados": continue
if entry.is_dir(): if entry.is_dir():
with ui.item(on_click=lambda p=entry.path: navigate(p)).props('clickable'): 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.icon('folder', color='amber')
ui.item_section(entry.name) ui.item_section(entry.name)
else: else:
ext = os.path.splitext(entry.name)[1].lower() is_vid = os.path.splitext(entry.name)[1].lower() in VIDEO_EXTENSIONS
is_vid = ext in VIDEO_EXTENSIONS
color = 'blue' if is_vid else 'grey'
icon = 'movie' if is_vid else 'insert_drive_file' icon = 'movie' if is_vid else 'insert_drive_file'
color = 'blue' if is_vid else 'grey'
with ui.item(): with ui.item():
with ui.item_section().props('avatar'): with ui.item_section().props('avatar'):
ui.icon(icon, color=color) ui.icon(icon, color=color)
ui.item_section(entry.name).classes('text-sm') ui.item_section(entry.name).classes('text-sm')
except Exception as e: 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(): def render_preview():
main_content.clear() main_content.clear()
with main_content: with main_content:
with ui.row().classes('w-full items-center justify-between mb-2'): 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(): 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(): async def run_move():
if await organizer.execute_move(): if await organizer.execute_move():
render_explorer() 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'): # Tabela Headers
# Cabeçalho da Lista with ui.row().classes('w-full bg-gray-200 p-2 font-bold text-sm rounded hidden md:flex'):
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('Original').classes('w-1/3') ui.label('Categoria / Destino').classes('w-1/3')
ui.label('Destino').classes('w-1/3') ui.label('Ação').classes('w-1/4 text-center')
ui.label('Status').classes('w-1/4 text-center')
with ui.scroll_area().classes('h-[500px] w-full border rounded bg-white'): with ui.scroll_area().classes('h-[600px] w-full border rounded bg-white'):
def render_row(item): def render_row(item):
# Usei refreshable aqui para que o botão atualize apenas a linha @ui.refreshable
@ui.refreshable def row_content():
def row_content(): with ui.row().classes('w-full p-2 border-b items-center hover:bg-gray-50 text-sm'):
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'): with ui.column().classes('w-full md:w-1/3'):
ui.label(item['original_file']).classes('truncate font-medium w-full') 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') ui.label(f"Guessit: {item['type'].upper()}").classes('text-xs text-gray-500')
with ui.column().classes('w-full md:w-1/3'): # Coluna 2: Destino Calculado
if item['target_path']: with ui.column().classes('w-full md:w-1/3'):
rel_path = os.path.relpath(item['target_path'], ROOT_DIR) if item['target_path']:
ui.label(rel_path).classes('text-blue-700 font-mono break-all text-xs') cat = item.get('category', '???')
else: # Badge da Categoria
ui.label('---').classes('text-gray-400') colors = {'Animes': 'pink', 'Desenhos': 'orange', 'Filmes': 'blue', 'Séries': 'green'}
cat_color = colors.get(cat, 'grey')
with ui.row().classes('w-full md:w-1/4 justify-center items-center gap-2'): with ui.row().classes('items-center gap-2'):
status = item['status'] ui.badge(cat, color=cat_color).props('dense')
color = 'green' if status == 'OK' else ('orange' if status == 'CHECK' else 'red') rel_path = os.path.relpath(item['target_path'], DEST_DIR)
ui.badge(status, color=color).props('outline') ui.label(rel_path).classes('font-mono break-all text-xs text-gray-700')
else:
ui.label('--- (Sem destino)').classes('text-gray-400')
if status != 'OK' and status != 'ERRO_S_E': # Coluna 3: Status/Ação
ui.button(icon='search', on_click=lambda: open_resolution_dialog(item, row_content.refresh)).props('flat round dense color=primary') with ui.row().classes('w-full md:w-1/4 justify-center items-center gap-2'):
elif status == 'OK': status = item['status']
ui.button(icon='edit', on_click=lambda: open_resolution_dialog(item, row_content.refresh)).props('flat round dense color=grey') color = 'green' if status == 'OK' else ('orange' if status == 'CHECK' else 'red')
ui.badge(status, color=color).props('outline')
row_content() 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')
for item in organizer.preview_data: row_content()
render_row(item)
for item in organizer.preview_data:
render_row(item)
def navigate(path): def navigate(path):
organizer.path = path organizer.path = path
render_explorer() render_explorer()
# Inicia
render_explorer() render_explorer()
# Removemos o ui.run() daqui, pois o main.py é quem controla o loop if __name__ in {"__main__", "__mp_main__"}:
create_ui()

View File

@@ -1 +1 @@
{"running": false, "file": "Finalizado \u2705", "pct_file": 100, "pct_total": 100, "log": "Finalizado \u2705"} {"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"}