commit 56df0b1d1f129e99d3cc9b4a0f0ed55e660a4d5b Author: Creidsu Date: Mon Jan 26 23:07:17 2026 +0000 mudado o motor de renderização para o nicegui diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..99af754 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM ubuntu:22.04 +ENV DEBIAN_FRONTEND=noninteractive + +# Instala FFmpeg, Drivers Intel e Python +RUN apt-get update && \ + apt-get install -y python3 python3-pip intel-media-va-driver-non-free i965-va-driver-shaders libva-drm2 libva-x11-2 vainfo ffmpeg jq && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY app /app + +# NiceGUI roda na porta 8080 por padrão +CMD ["python3", "main.py"] diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..9872ad3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,33 @@ +from nicegui import ui, app +from modules import file_manager, renamer, encoder + +# Configuração Geral +ui.colors(primary='#5898d4', secondary='#26a69a', accent='#9c27b0', positive='#21ba45') + +# Cabeçalho +with ui.header().classes('items-center justify-between'): + ui.label('🎬 PyMedia Manager').classes('text-2xl font-bold') + ui.button('Sair', on_click=app.shutdown, icon='logout').props('flat color=white') + +# Abas +with ui.tabs().classes('w-full') as tabs: + t_files = ui.tab('Gerenciador', icon='folder') + t_rename = ui.tab('Renomeador', icon='edit') + t_encode = ui.tab('Encoder', icon='movie') + +# Painéis +with ui.tab_panels(tabs, value=t_files).classes('w-full p-0'): + + # PAINEL 1: FILE MANAGER + with ui.tab_panel(t_files).classes('p-0'): + file_manager.create_ui() + + # PAINEL 2: RENAMER + with ui.tab_panel(t_rename): + renamer.create_ui() + + # PAINEL 3: ENCODER + with ui.tab_panel(t_encode): + encoder.create_ui() + +ui.run(title='PyMedia Manager', port=8080, reload=True, storage_secret='secret') diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/app/modules/__pycache__/__init__.cpython-310.pyc b/app/modules/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2a1127b Binary files /dev/null and b/app/modules/__pycache__/__init__.cpython-310.pyc differ diff --git a/app/modules/__pycache__/encoder.cpython-310.pyc b/app/modules/__pycache__/encoder.cpython-310.pyc new file mode 100644 index 0000000..7b70c4a Binary files /dev/null and b/app/modules/__pycache__/encoder.cpython-310.pyc differ diff --git a/app/modules/__pycache__/file_manager.cpython-310.pyc b/app/modules/__pycache__/file_manager.cpython-310.pyc new file mode 100644 index 0000000..c7fa3a3 Binary files /dev/null and b/app/modules/__pycache__/file_manager.cpython-310.pyc differ diff --git a/app/modules/__pycache__/renamer.cpython-310.pyc b/app/modules/__pycache__/renamer.cpython-310.pyc new file mode 100644 index 0000000..0cc7023 Binary files /dev/null and b/app/modules/__pycache__/renamer.cpython-310.pyc differ diff --git a/app/modules/encoder.py b/app/modules/encoder.py new file mode 100755 index 0000000..cf869a2 --- /dev/null +++ b/app/modules/encoder.py @@ -0,0 +1,287 @@ +from nicegui import ui, app +import os +import threading +import time +import subprocess +import json +import re + +ROOT_DIR = "/downloads" +OUTPUT_BASE = "/downloads/finalizados" +STATUS_FILE = "/app/data/status.json" + +# --- BACKEND: PREPARAÇÃO DE DRIVERS --- +def prepare_driver_environment(): + os.environ["LIBVA_DRIVER_NAME"] = "i965" + drivers_ruins = ["/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so", "/usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so.1"] + for driver in drivers_ruins: + if os.path.exists(driver): + try: os.remove(driver) + except: pass + +# --- BACKEND: UTILS FFMPEG --- +def get_video_duration(filepath): + cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filepath] + try: return float(subprocess.check_output(cmd).decode().strip()) + except: return None + +def parse_time_to_seconds(time_str): + h, m, s = time_str.split(':') + return int(h) * 3600 + int(m) * 60 + float(s) + +def get_streams_map(filepath): + cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath] + try: + res = subprocess.run(cmd, capture_output=True, text=True, env=os.environ) + data = json.loads(res.stdout) + except: return ["-map", "0"] + + map_args = ["-map", "0:v"] + audio_found = False + for s in data.get('streams', []): + if s['codec_type'] == 'audio': + lang = s.get('tags', {}).get('language', 'und').lower() + if lang in ['por', 'pt', 'eng', 'en', 'jpn', 'ja', 'und']: + map_args.extend(["-map", f"0:{s['index']}"]) + audio_found = True + if not audio_found: map_args.extend(["-map", "0:a"]) + + for s in data.get('streams', []): + if s['codec_type'] == 'subtitle': + lang = s.get('tags', {}).get('language', 'und').lower() + if lang in ['por', 'pt', 'pob', 'pt-br']: + map_args.extend(["-map", f"0:{s['index']}"]) + return map_args + +# --- BACKEND: WORKER THREAD --- +class EncoderWorker(threading.Thread): + def __init__(self, input_folder): + super().__init__() + self.input_folder = input_folder + self.daemon = True + + def run(self): + prepare_driver_environment() + + files = [] + for r, d, f in os.walk(self.input_folder): + if "finalizados" in r or "temp" in r: continue + for file in f: + if file.lower().endswith(('.mkv', '.mp4', '.avi')): + files.append(os.path.join(r, file)) + + total_files = len(files) + stop_signal = False + + for i, fpath in enumerate(files): + # Verifica Parada antes de começar o próximo + if os.path.exists(STATUS_FILE): + with open(STATUS_FILE, 'r') as f: + if json.load(f).get('stop_requested'): + stop_signal = True + break + + fname = os.path.basename(fpath) + + # Status Inicial + status = { + "running": True, + "stop_requested": False, + "file": fname, + "pct_file": 0, + "pct_total": int((i / total_files) * 100), + "current_index": i + 1, + "total_files": total_files, + "log": "Iniciando..." + } + with open(STATUS_FILE, 'w') as f: json.dump(status, f) + + rel = os.path.relpath(fpath, self.input_folder) + out = os.path.join(OUTPUT_BASE, os.path.basename(self.input_folder), rel) + os.makedirs(os.path.dirname(out), exist_ok=True) + + map_args = get_streams_map(fpath) + cmd = [ + "ffmpeg", "-y", "-hwaccel", "vaapi", "-hwaccel_device", "/dev/dri/renderD128", + "-hwaccel_output_format", "vaapi", "-i", fpath + ] + cmd += map_args + cmd += [ + "-c:v", "h264_vaapi", "-qp", "25", "-compression_level", "0", + "-c:a", "copy", "-c:s", "copy", out + ] + + total_sec = get_video_duration(fpath) or 1 + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=os.environ) + + for line in proc.stdout: + # Verifica Parada DURANTE a conversão + if "time=" in line: # Checa a cada atualização de tempo + if os.path.exists(STATUS_FILE): + with open(STATUS_FILE, 'r') as f: + if json.load(f).get('stop_requested'): + proc.terminate() # Mata o FFmpeg + stop_signal = True + break + + match = re.search(r"time=(\d{2}:\d{2}:\d{2}\.\d{2})", line) + if match: + sec = parse_time_to_seconds(match.group(1)) + pct = min(int((sec/total_sec)*100), 100) + status["pct_file"] = pct + speed = re.search(r"speed=\s*(\S+)", line) + if speed: status["log"] = f"Velocidade: {speed.group(1)}" + with open(STATUS_FILE, 'w') as f: json.dump(status, f) + + proc.wait() + + if stop_signal: + # Limpa arquivo incompleto se foi cancelado + if os.path.exists(out): os.remove(out) + break + + # Status Final + final_msg = "Cancelado pelo usuário 🛑" if stop_signal else "Finalizado ✅" + with open(STATUS_FILE, 'w') as f: + json.dump({"running": False, "file": final_msg, "pct_file": 0 if stop_signal else 100, "pct_total": 100, "log": final_msg}, f) + +# --- FRONTEND: UI --- +class EncoderInterface: + def __init__(self): + self.path = ROOT_DIR + self.container = None + self.view_mode = 'explorer' + self.timer = None + + def navigate(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.path = path + self.refresh() + else: + ui.notify('Erro ao acessar pasta', type='negative') + + def refresh(self): + if self.container: + self.container.clear() + with self.container: + if self.view_mode == 'explorer': + self.render_breadcrumbs() + self.render_folder_list() + else: + self.render_monitor() + + def start_encoding(self): + if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE) + t = EncoderWorker(self.path) + t.start() + ui.notify('Iniciado!', type='positive') + self.view_mode = 'monitor' + self.refresh() + + def stop_encoding(self): + # Escreve o sinal de parada no arquivo JSON + if os.path.exists(STATUS_FILE): + try: + with open(STATUS_FILE, 'r+') as f: + data = json.load(f) + data['stop_requested'] = True + f.seek(0) + json.dump(data, f) + f.truncate() + ui.notify('Parando processo... aguarde.', type='warning') + except: pass + + def back_to_explorer(self): + self.view_mode = 'explorer' + self.refresh() + + def render_breadcrumbs(self): + with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded gap-1'): + ui.button('🏠', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense text-color=grey-8') + 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("🚀 Converter Esta Pasta", on_click=self.start_encoding).props('push color=primary') + + def render_folder_list(self): + try: + 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: return + with ui.column().classes('w-full gap-1 mt-2'): + 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') + 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') + + def render_monitor(self): + ui.label('Monitor de Conversão').classes('text-xl font-bold mb-4') + + lbl_file = ui.label('Inicializando...') + progress_file = ui.linear_progress(value=0).classes('w-full') + lbl_status = ui.label('---') + + ui.separator().classes('my-4') + + lbl_total = ui.label('Total: 0/0') + progress_total = ui.linear_progress(value=0).classes('w-full') + + # Botões de Controle + row_btns = ui.row().classes('mt-4 gap-2') + + # Botão de Parar (Só aparece se estiver rodando) + btn_stop = ui.button('🛑 Parar Processo', on_click=self.stop_encoding).props('color=red') + # Botão Voltar (Só aparece se acabou) + btn_back = ui.button('Voltar para Pastas', on_click=self.back_to_explorer).props('outline') + btn_back.set_visibility(False) + + def update_loop(): + if not os.path.exists(STATUS_FILE): return + try: + with open(STATUS_FILE, 'r') as f: data = json.load(f) + + is_running = data.get('running', False) + + lbl_file.text = f"Arquivo: {data.get('file', '?')}" + val_file = data.get('pct_file', 0) / 100 + progress_file.value = val_file + lbl_status.text = f"Status: {int(val_file*100)}% | {data.get('log', '')}" + + if 'total_files' in data: + curr = data.get('current_index', 0) + tot = data.get('total_files', 0) + lbl_total.text = f"Fila: {curr} de {tot} arquivos" + val_total = data.get('pct_total', 0) / 100 + progress_total.value = val_total + + # Controle de Visibilidade dos Botões + if is_running: + btn_stop.set_visibility(True) + btn_back.set_visibility(False) + else: + btn_stop.set_visibility(False) + btn_back.set_visibility(True) + + except: pass + + self.timer = ui.timer(1.0, update_loop) + +def create_ui(): + enc = EncoderInterface() + if os.path.exists(STATUS_FILE): + try: + with open(STATUS_FILE, 'r') as f: + if json.load(f).get('running'): enc.view_mode = 'monitor' + except: pass + enc.container = ui.column().classes('w-full h-full p-4 gap-4') + enc.refresh() \ No newline at end of file diff --git a/app/modules/file_manager.py b/app/modules/file_manager.py new file mode 100755 index 0000000..699d220 --- /dev/null +++ b/app/modules/file_manager.py @@ -0,0 +1,248 @@ +from nicegui import ui +import os +import shutil +import datetime + +ROOT_DIR = "/downloads" + +# --- UTILITÁRIOS --- +def get_human_size(size): + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: return f"{size:.2f} {unit}" + size /= 1024 + return f"{size:.2f} TB" + +def get_subfolders(root): + folders = [root] + try: + for r, d, f in os.walk(root): + if "finalizados" in r or "temp" in r: continue + for folder in d: + if not folder.startswith('.'): folders.append(os.path.join(r, folder)) + except: pass + return sorted(folders) + +# --- CLASSE GERENCIADORA --- +class FileManager: + def __init__(self): + self.path = ROOT_DIR + self.view_mode = 'grid' + self.container = None + + def navigate(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.path = path + self.refresh() + else: + ui.notify('Caminho inválido', type='negative') + + def navigate_up(self): + parent = os.path.dirname(self.path) + if self.path != ROOT_DIR: + self.navigate(parent) + + def toggle_view(self): + self.view_mode = 'list' if self.view_mode == 'grid' else 'grid' + self.refresh() + + def refresh(self): + if self.container: + self.container.clear() + with self.container: + self.render_header() + self.render_content() + + # --- DIÁLOGOS DE AÇÃO (ORDEM CORRIGIDA) --- + def open_delete_dialog(self, path): + with ui.dialog() as dialog, ui.card(): + ui.label('Excluir item permanentemente?').classes('text-lg font-bold') + ui.label(os.path.basename(path)) + with ui.row().classes('w-full justify-end'): + ui.button('Cancelar', on_click=dialog.close).props('flat') + def confirm(): + try: + if os.path.isdir(path): shutil.rmtree(path) + else: os.remove(path) + # 1. Notifica e Fecha ANTES de destruir a UI + ui.notify('Excluído!', type='positive') + dialog.close() + # 2. Atualiza a tela (Destrói elementos antigos) + self.refresh() + except Exception as e: ui.notify(str(e), type='negative') + ui.button('Excluir', on_click=confirm).props('color=red') + dialog.open() + + def open_rename_dialog(self, path): + with ui.dialog() as dialog, ui.card(): + ui.label('Renomear').classes('text-lg') + name_input = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full') + def save(): + try: + new_path = os.path.join(os.path.dirname(path), name_input.value) + os.rename(path, new_path) + ui.notify('Renomeado!', type='positive') + dialog.close() + self.refresh() + except Exception as e: ui.notify(str(e), type='negative') + ui.button('Salvar', on_click=save).props('color=primary') + dialog.open() + + def open_move_dialog(self, path): + folders = get_subfolders(ROOT_DIR) + if os.path.isdir(path) and path in folders: folders.remove(path) + + opts = {f: f.replace(ROOT_DIR, "Raiz") if f != ROOT_DIR else "Raiz" for f in folders} + + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label('Mover Para...').classes('text-lg') + target = ui.select(opts, value=ROOT_DIR, with_input=True).classes('w-full') + def confirm(): + try: + shutil.move(path, target.value) + ui.notify('Movido!', type='positive') + dialog.close() + self.refresh() + except Exception as e: ui.notify(str(e), type='negative') + ui.button('Mover', on_click=confirm).props('color=primary') + dialog.open() + + def open_create_folder(self): + with ui.dialog() as dialog, ui.card(): + ui.label('Nova Pasta') + name = ui.input('Nome') + def create(): + try: + os.makedirs(os.path.join(self.path, name.value)) + ui.notify('Pasta criada!', type='positive') + dialog.close() + self.refresh() + except Exception as e: ui.notify(str(e), type='negative') + ui.button('Criar', on_click=create) + dialog.open() + + # --- MENU DE CONTEXTO (CORRIGIDO) --- + def bind_context_menu(self, element, entry): + """ + CORREÇÃO: Usa 'contextmenu.prevent' para bloquear o menu do navegador. + """ + with ui.menu() as m: + ui.menu_item('Renomear', on_click=lambda: self.open_rename_dialog(entry.path)) + ui.menu_item('Mover Para...', on_click=lambda: self.open_move_dialog(entry.path)) + ui.separator() + ui.menu_item('Excluir', on_click=lambda: self.open_delete_dialog(entry.path)).props('text-color=red') + + # Sintaxe correta do NiceGUI para prevenir default (Botão direito nativo) + element.on('contextmenu.prevent', lambda: m.open()) + + # --- RENDERIZADORES --- + def render_header(self): + with ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2'): + if self.path != ROOT_DIR: + ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir') + else: + ui.button(icon='home').props('flat round dense disabled text-color=grey') + + # Breadcrumbs + rel = os.path.relpath(self.path, ROOT_DIR) + parts = rel.split(os.sep) if rel != '.' else [] + + with ui.row().classes('items-center gap-0'): + ui.button('/', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-2') + acc = ROOT_DIR + for part in parts: + acc = os.path.join(acc, part) + ui.label('/') + ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-2') + + ui.space() + ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense').tooltip('Nova Pasta') + + icon_view = 'view_list' if self.view_mode == 'grid' else 'grid_view' + ui.button(icon=icon_view, on_click=self.toggle_view).props('flat round dense').tooltip('Mudar Visualização') + + ui.button(icon='refresh', on_click=self.refresh).props('flat round dense') + + def render_content(self): + try: + entries = sorted(os.scandir(self.path), key=lambda e: (not e.is_dir(), e.name.lower())) + except: + ui.label('Erro ao ler pasta').classes('text-red') + return + + if not entries: + ui.label('Pasta vazia').classes('w-full text-center text-gray-400 mt-10') + return + + # === GRID === + if self.view_mode == 'grid': + with ui.grid().classes('w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3'): + for entry in entries: + is_dir = entry.is_dir() + icon = 'folder' if is_dir else 'description' + if not is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): icon = 'movie' + color = 'amber-8' if is_dir else 'blue-grey' + if icon == 'movie': color = 'purple-6' + + with ui.card().classes('w-full aspect-square p-2 items-center justify-center relative group hover:shadow-md cursor-pointer select-none') as card: + if is_dir: card.on('click', lambda p=entry.path: self.navigate(p)) + + # CORREÇÃO: Bind correto do menu de contexto + self.bind_context_menu(card, entry) + + ui.icon(icon, size='3rem', color=color) + ui.label(entry.name).classes('text-xs text-center leading-tight line-clamp-2 w-full break-all') + + if not is_dir: + ui.label(get_human_size(entry.stat().st_size)).classes('text-[10px] text-gray-400') + + with ui.button(icon='more_vert').props('flat round dense size=sm').classes('absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity bg-white/90'): + with ui.menu(): + ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p)) + ui.menu_item('Mover Para...', on_click=lambda p=entry.path: self.open_move_dialog(p)) + ui.separator() + ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p)).props('text-color=red') + + # === LIST === + else: + with ui.column().classes('w-full gap-0'): + with ui.row().classes('w-full px-2 py-1 bg-gray-100 text-xs font-bold text-gray-500 hidden sm:flex'): + ui.label('Nome').classes('flex-grow') + ui.label('Tamanho').classes('w-24 text-right') + ui.label('Data').classes('w-32 text-right') + ui.label('').classes('w-8') + + for entry in entries: + is_dir = entry.is_dir() + icon = 'folder' if is_dir else 'description' + color = 'amber-8' if is_dir else 'blue-grey' + + with ui.row().classes('w-full items-center px-2 py-2 border-b hover:bg-blue-50 cursor-pointer group') as row: + if is_dir: row.on('click', lambda p=entry.path: self.navigate(p)) + + self.bind_context_menu(row, entry) + + ui.icon(icon, color=color).classes('mr-2') + + with ui.column().classes('flex-grow gap-0'): + ui.label(entry.name).classes('text-sm font-medium break-all') + if not is_dir: + ui.label(get_human_size(entry.stat().st_size)).classes('text-[10px] text-gray-400 sm:hidden') + + sz = "-" if is_dir else get_human_size(entry.stat().st_size) + ui.label(sz).classes('w-24 text-right text-xs text-gray-500 hidden sm:block') + + dt = datetime.datetime.fromtimestamp(entry.stat().st_mtime).strftime('%d/%m/%Y') + ui.label(dt).classes('w-32 text-right text-xs text-gray-500 hidden sm:block') + + with ui.button(icon='more_vert').props('flat round dense size=sm').classes('sm:opacity-0 group-hover:opacity-100'): + with ui.menu(): + ui.menu_item('Renomear', on_click=lambda p=entry.path: self.open_rename_dialog(p)) + ui.menu_item('Mover Para...', on_click=lambda p=entry.path: self.open_move_dialog(p)) + ui.separator() + ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p)).props('text-color=red') + +# --- INICIALIZADOR --- +def create_ui(): + fm = FileManager() + fm.container = ui.column().classes('w-full h-full p-2 md:p-4 gap-4') + fm.refresh() \ No newline at end of file diff --git a/app/modules/renamer.py b/app/modules/renamer.py new file mode 100755 index 0000000..45c1690 --- /dev/null +++ b/app/modules/renamer.py @@ -0,0 +1,181 @@ +from nicegui import ui +import os +import re +import shutil + +ROOT_DIR = "/downloads" + +# --- UTILITÁRIOS --- +def extract_season_episode(filename): + """Detecta Temporada e Episódio usando vários padrões""" + 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})\]', + ] + for pattern in patterns: + match = re.search(pattern, filename) + if match: return match.group(1), match.group(2) + return None, None + +class RenamerManager: + def __init__(self): + self.path = ROOT_DIR + self.container = None + self.preview_data = [] + self.view_mode = 'explorer' + + def navigate(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.path = path + self.refresh() + else: + ui.notify('Erro ao acessar pasta', type='negative') + + def refresh(self): + 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 analyze_folder(self): + 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')): + 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] + + # 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: + self.preview_data.append({ + 'original': file, + 'new': new_struct, + 'src': src, + 'dst': dst + }) + except: pass + + if not self.preview_data: + ui.notify('Nenhum padrão encontrado.', type='warning') + else: + self.view_mode = 'preview' + self.refresh() + + def execute_rename(self): + count = 0 + for item in self.preview_data: + 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() + + 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') + + # Divide o caminho atual para criar os botões + 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): + 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') + return + + with ui.column().classes('w-full gap-1 mt-2'): + # Botão para subir nível (se não estiver na raiz) + 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') + + if not entries: + ui.label("Nenhuma subpasta aqui.").classes('text-gray-400 italic ml-4 mt-2') + + # 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') + + # 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') + +# --- INICIALIZADOR --- +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 diff --git a/data/status.json b/data/status.json new file mode 100644 index 0000000..3331ef4 --- /dev/null +++ b/data/status.json @@ -0,0 +1 @@ +{"running": false, "file": "Cancelado pelo usu\u00e1rio \ud83d\uded1", "pct_file": 0, "pct_total": 100, "log": "Cancelado pelo usu\u00e1rio \ud83d\uded1"} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..cc64ac5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.8" +services: + pymediamanager: + build: . + container_name: pymediamanager + privileged: true + restart: unless-stopped + devices: + - /dev/dri:/dev/dri + group_add: + - "993" + environment: + - TZ=America/Sao_Paulo + - LIBVA_DRIVER_NAME=i965 + volumes: + - /home/creidsu/pymediamanager/app:/app + - /home/creidsu/pymediamanager/data:/app/data + - /home/creidsu/downloads:/downloads + ports: + - 8086:8080 diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..8e1b4be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +nicegui +pandas +watchdog +guessit +requests