diff --git a/app/main.py b/app/main.py index 9872ad3..66c3b88 100755 --- a/app/main.py +++ b/app/main.py @@ -1,33 +1,33 @@ from nicegui import ui, app -from modules import file_manager, renamer, encoder +# ADICIONE 'downloader' AQUI: +from modules import file_manager, renamer, encoder, downloader, deployer +app.add_static_files('/files', '/downloads') -# 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 +# ATUALIZE AS 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') + t_down = ui.tab('Downloader', icon='download') # NOVA ABA + t_deploy = ui.tab('Mover Final', icon='publish') # NOVA ABA -# Painéis +# ATUALIZE OS 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() + + # NOVO PAINEL: + with ui.tab_panel(t_down): + downloader.create_ui() -ui.run(title='PyMedia Manager', port=8080, reload=True, storage_secret='secret') + with ui.tab_panel(t_deploy): + deployer.create_ui() + +ui.run(title='PyMedia Manager', port=8080, reload=True, storage_secret='secret') \ No newline at end of file diff --git a/app/modules/__pycache__/deployer.cpython-310.pyc b/app/modules/__pycache__/deployer.cpython-310.pyc new file mode 100644 index 0000000..92c9c6f Binary files /dev/null and b/app/modules/__pycache__/deployer.cpython-310.pyc differ diff --git a/app/modules/__pycache__/downloader.cpython-310.pyc b/app/modules/__pycache__/downloader.cpython-310.pyc new file mode 100644 index 0000000..608d4cf Binary files /dev/null and b/app/modules/__pycache__/downloader.cpython-310.pyc differ diff --git a/app/modules/__pycache__/file_manager.cpython-310.pyc b/app/modules/__pycache__/file_manager.cpython-310.pyc index c7fa3a3..a82a080 100644 Binary files a/app/modules/__pycache__/file_manager.cpython-310.pyc and b/app/modules/__pycache__/file_manager.cpython-310.pyc differ diff --git a/app/modules/deployer.py b/app/modules/deployer.py new file mode 100644 index 0000000..7a103e4 --- /dev/null +++ b/app/modules/deployer.py @@ -0,0 +1,222 @@ +from nicegui import ui +import os +import shutil +import datetime + +# Configurações de Raiz +SRC_ROOT = "/downloads" +DST_ROOT = "/media" + +class DeployManager: + def __init__(self): + self.src_path = SRC_ROOT + self.dst_path = DST_ROOT + self.selected_items = [] # Lista de caminhos selecionados + self.container = None + + # --- NAVEGAÇÃO --- + def navigate_src(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.src_path = path + # Nota: Não limpamos a seleção ao navegar para permitir selecionar coisas de pastas diferentes se quiser + # self.selected_items = [] + self.refresh() + + def navigate_dst(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.dst_path = path + self.refresh() + + def refresh(self): + if self.container: + self.container.clear() + with self.container: + self.render_layout() + + # --- LÓGICA DE SELEÇÃO --- + def toggle_selection(self, path): + if path in self.selected_items: + self.selected_items.remove(path) + else: + self.selected_items.append(path) + # Recarrega para mostrar o checkbox marcado/desmarcado e a cor de fundo + self.refresh() + + # --- AÇÃO DE MOVER --- + def execute_move(self): + if not self.selected_items: + ui.notify('Selecione itens na esquerda para mover.', type='warning') + return + + if self.src_path == self.dst_path: + ui.notify('Origem e Destino são iguais!', type='warning') + return + + count = 0 + errors = 0 + + with ui.dialog() as dialog, ui.card(): + ui.label('Confirmar Movimentação Definitiva').classes('text-lg font-bold') + ui.label(f'Destino: {self.dst_path}') + ui.label(f'Itens selecionados: {len(self.selected_items)}') + + # Lista itens no dialog para conferência + with ui.scroll_area().classes('h-32 w-full border p-2 bg-gray-50'): + for item in self.selected_items: + ui.label(os.path.basename(item)).classes('text-xs') + + def confirm(): + nonlocal count, errors + dialog.close() + ui.notify('Iniciando movimentação...', type='info') + + for item_path in self.selected_items: + if not os.path.exists(item_path): continue # Já foi movido ou deletado + + item_name = os.path.basename(item_path) + target = os.path.join(self.dst_path, item_name) + + try: + if os.path.exists(target): + ui.notify(f'Erro: {item_name} já existe no destino!', type='negative') + errors += 1 + continue + + shutil.move(item_path, target) + # Tenta ajustar permissões após mover para garantir que o Jellyfin leia + try: + if os.path.isdir(target): + os.system(f'chmod -R 777 "{target}"') + else: + os.chmod(target, 0o777) + except: pass + + count += 1 + except Exception as e: + ui.notify(f'Erro ao mover {item_name}: {e}', type='negative') + errors += 1 + + if count > 0: + ui.notify(f'{count} itens movidos com sucesso!', type='positive') + + self.selected_items = [] # Limpa seleção após sucesso + self.refresh() + + with ui.row().classes('w-full justify-end'): + ui.button('Cancelar', on_click=dialog.close).props('flat') + ui.button('Mover Agora', on_click=confirm).props('color=green icon=move_to_inbox') + + dialog.open() + + # --- RENDERIZADORES AUXILIARES --- + def render_breadcrumbs(self, current_path, root_dir, nav_callback): + with ui.row().classes('items-center gap-1 bg-gray-100 p-1 rounded w-full'): + ui.button('🏠', on_click=lambda: nav_callback(root_dir)).props('flat dense size=sm') + + rel = os.path.relpath(current_path, root_dir) + if rel != '.': + acc = root_dir + parts = rel.split(os.sep) + for part in parts: + ui.label('/') + acc = os.path.join(acc, part) + ui.button(part, on_click=lambda p=acc: nav_callback(p)).props('flat dense no-caps size=sm') + + if current_path != root_dir: + ui.space() + parent = os.path.dirname(current_path) + ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm') + + def render_file_list(self, path, is_source): + try: + entries = sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name.lower())) + except: + ui.label('Erro ao ler pasta').classes('text-red') + return + + with ui.scroll_area().classes('h-96 border rounded bg-white'): + if not entries: + ui.label('Pasta Vazia').classes('p-4 text-gray-400 italic') + + 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')): icon = 'movie' + color = 'amber' if is_dir else 'grey' + + # Verifica se está selecionado + is_selected = entry.path in self.selected_items + bg_color = 'bg-blue-100' if is_selected else 'hover:bg-gray-50' + + # Linha do Arquivo/Pasta + with ui.row().classes(f'w-full items-center p-1 cursor-pointer border-b {bg_color}') as row: + + # Lógica de Clique na Linha (Texto) + if is_source: + if is_dir: + # Se for pasta na origem: Clique entra na pasta + row.on('click', lambda p=entry.path: self.navigate_src(p)) + else: + # Se for arquivo na origem: Clique seleciona + row.on('click', lambda p=entry.path: self.toggle_selection(p)) + else: + # No destino: Clique sempre navega (se for pasta) + if is_dir: + row.on('click', lambda p=entry.path: self.navigate_dst(p)) + + # COLUNA 1: Checkbox (Apenas na Origem) + if is_source: + # O checkbox permite selecionar pastas sem entrar nelas + # stop_propagation impede que o clique no checkbox acione o clique da linha (entrar na pasta) + ui.checkbox('', value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense').on('click', lambda e: e.stop_propagation()) + + # COLUNA 2: Ícone + ui.icon(icon, color=color).classes('mx-2') + + # COLUNA 3: Nome + ui.label(entry.name).classes('text-sm truncate flex-grow select-none') + + # --- LAYOUT PRINCIPAL --- + def render_layout(self): + with ui.row().classes('w-full h-full gap-4'): + + # ESQUERDA (ORIGEM) + with ui.column().classes('w-1/2 h-full'): + ui.label('📂 Origem (Downloads)').classes('text-lg font-bold text-blue-600') + self.render_breadcrumbs(self.src_path, SRC_ROOT, self.navigate_src) + + # Contador + if self.selected_items: + ui.label(f'{len(self.selected_items)} itens selecionados').classes('text-sm font-bold text-blue-800') + else: + ui.label('Selecione arquivos ou pastas').classes('text-xs text-gray-400') + + self.render_file_list(self.src_path, is_source=True) + + # DIREITA (DESTINO) + with ui.column().classes('w-1/2 h-full'): + ui.label('🏁 Destino (Mídia Final)').classes('text-lg font-bold text-green-600') + self.render_breadcrumbs(self.dst_path, DST_ROOT, self.navigate_dst) + + # Espaçador visual + ui.label('Navegue até a pasta de destino').classes('text-xs text-gray-400') + + self.render_file_list(self.dst_path, is_source=False) + + # Botão de Ação Principal + with ui.row().classes('w-full justify-end mt-4'): + ui.button('Mover Selecionados >>>', on_click=self.execute_move)\ + .props('icon=arrow_forward color=green')\ + .bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0) + +# --- INICIALIZADOR --- +def create_ui(): + dm = DeployManager() + # Garante pastas + for d in [SRC_ROOT, DST_ROOT]: + if not os.path.exists(d): + try: os.makedirs(d) + except: pass + + dm.container = ui.column().classes('w-full h-full p-4') + dm.refresh() \ No newline at end of file diff --git a/app/modules/downloader.py b/app/modules/downloader.py new file mode 100644 index 0000000..249dd94 --- /dev/null +++ b/app/modules/downloader.py @@ -0,0 +1,186 @@ +from nicegui import ui +import os +import threading +import json +import time +import yt_dlp + +# --- CONFIGURAÇÕES --- +DOWNLOAD_DIR = "/downloads/Youtube" +STATUS_FILE = "/app/data/dl_status.json" + +# --- UTILITÁRIOS --- +def save_status(data): + try: + with open(STATUS_FILE, 'w') as f: json.dump(data, f) + except: pass + +def load_status(): + if not os.path.exists(STATUS_FILE): return None + try: + with open(STATUS_FILE, 'r') as f: return json.load(f) + except: return None + +# --- WORKER (BACKEND) --- +class DownloadWorker(threading.Thread): + def __init__(self, url, format_type): + super().__init__() + self.url = url + self.format_type = format_type + self.daemon = True + self.stop_requested = False + + def progress_hook(self, d): + if self.stop_requested: + raise Exception("Cancelado pelo usuário") + + if d['status'] == 'downloading': + total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0 + downloaded = d.get('downloaded_bytes', 0) + pct = int((downloaded / total) * 100) if total > 0 else 0 + + speed = d.get('speed', 0) or 0 + speed_str = f"{speed / 1024 / 1024:.2f} MiB/s" + filename = os.path.basename(d.get('filename', 'Baixando...')) + + save_status({ + "running": True, + "file": filename, + "progress": pct, + "log": f"Baixando: {speed_str} | {d.get('_eta_str', '?')} restantes", + "stop_requested": False + }) + + elif d['status'] == 'finished': + save_status({ + "running": True, + "file": "Processando...", + "progress": 99, + "log": "Convertendo/Juntando arquivos...", + "stop_requested": False + }) + + def run(self): + if not os.path.exists(DOWNLOAD_DIR): os.makedirs(DOWNLOAD_DIR, exist_ok=True) + + ydl_opts = { + 'outtmpl': f'{DOWNLOAD_DIR}/%(title)s.%(ext)s', + 'progress_hooks': [self.progress_hook], + 'nocheckcertificate': True, + 'ignoreerrors': True, + 'ffmpeg_location': '/usr/bin/ffmpeg' + } + + if self.format_type == 'best': + ydl_opts['format'] = 'bestvideo+bestaudio/best' + ydl_opts['merge_output_format'] = 'mkv' + elif self.format_type == 'audio': + ydl_opts['format'] = 'bestaudio/best' + ydl_opts['postprocessors'] = [{'key': 'FFmpegExtractAudio','preferredcodec': 'mp3','preferredquality': '192'}] + elif self.format_type == '1080p': + ydl_opts['format'] = 'bestvideo[height<=1080]+bestaudio/best[height<=1080]' + ydl_opts['merge_output_format'] = 'mkv' + + try: + save_status({"running": True, "file": "Iniciando...", "progress": 0, "log": "Conectando..."}) + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([self.url]) + save_status({"running": False, "file": "Concluído!", "progress": 100, "log": "Sucesso."}) + + except Exception as e: + msg = "Cancelado." if "Cancelado" in str(e) else str(e) + save_status({"running": False, "file": "Parado", "progress": 0, "log": msg}) + +# --- INTERFACE (FRONTEND) --- +class DownloaderInterface: + def __init__(self): + self.container = None + self.timer = None + self.btn_download = None + self.card_status = None + + # Elementos dinâmicos + self.lbl_file = None + self.progress = None + self.lbl_log = None + self.btn_stop = None + + def start_download(self, url, fmt): + if not url: + ui.notify('Cole uma URL!', type='warning') + return + + if os.path.exists(STATUS_FILE): os.remove(STATUS_FILE) + t = DownloadWorker(url, fmt) + t.start() + ui.notify('Iniciando...') + self.render_update() + + def stop_download(self): + data = load_status() + if data: + data['stop_requested'] = True + save_status(data) + ui.notify('Parando...') + + def render(self): + ui.label('📺 YouTube Downloader').classes('text-xl font-bold mb-2') + + # --- INPUT --- + with ui.card().classes('w-full p-4 mb-4'): + url_input = ui.input('URL do Vídeo').classes('w-full').props('clearable placeholder="https://youtube.com/..."') + + with ui.row().classes('items-center mt-2'): + fmt_select = ui.select( + {'best': 'Melhor Qualidade (MKV)', '1080p': 'Limitado a 1080p (MKV)', 'audio': 'Apenas Áudio (MP3)'}, + value='best', label='Formato' + ).classes('w-64') + + self.btn_download = ui.button('Baixar', on_click=lambda: self.start_download(url_input.value, fmt_select.value))\ + .props('icon=download color=primary') + + # --- MONITORAMENTO --- + # CORREÇÃO AQUI: Criamos o card primeiro, depois definimos visibilidade + self.card_status = ui.card().classes('w-full p-4') + self.card_status.visible = False # Esconde inicialmente + + with self.card_status: + ui.label('Progresso').classes('font-bold') + self.lbl_file = ui.label('Aguardando...') + self.progress = ui.linear_progress(value=0).classes('w-full') + self.lbl_log = ui.label('---').classes('text-sm text-gray-500 font-mono') + + with ui.row().classes('w-full justify-end mt-2'): + self.btn_stop = ui.button('🛑 Cancelar', on_click=self.stop_download).props('color=red flat') + + self.timer = ui.timer(1.0, self.render_update) + + def render_update(self): + data = load_status() + + if not data: + if self.card_status: self.card_status.visible = False + if self.btn_download: self.btn_download.enable() + return + + # Atualiza UI + is_running = data.get('running', False) + + if self.btn_download: + if is_running: self.btn_download.disable() + else: self.btn_download.enable() + + if self.card_status: self.card_status.visible = True + + if self.lbl_file: self.lbl_file.text = f"Arquivo: {data.get('file', '?')}" + if self.progress: self.progress.value = data.get('progress', 0) / 100 + if self.lbl_log: self.lbl_log.text = data.get('log', '') + + if self.btn_stop: self.btn_stop.visible = is_running + +# --- INICIALIZADOR --- +def create_ui(): + dl = DownloaderInterface() + dl.container = ui.column().classes('w-full h-full p-4 gap-4') + with dl.container: + dl.render() \ No newline at end of file diff --git a/app/modules/file_manager.py b/app/modules/file_manager.py index 699d220..79c0077 100755 --- a/app/modules/file_manager.py +++ b/app/modules/file_manager.py @@ -1,7 +1,9 @@ -from nicegui import ui +from nicegui import ui, app import os import shutil import datetime +import subprocess +import json ROOT_DIR = "/downloads" @@ -22,6 +24,45 @@ def get_subfolders(root): except: pass return sorted(folders) +# --- LEITOR DE METADADOS (FFPROBE) --- +def get_media_info(filepath): + """Lê as faixas de áudio e legenda do arquivo""" + cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath] + try: + res = subprocess.run(cmd, capture_output=True, text=True) + data = json.loads(res.stdout) + + info = { + "duration": float(data['format'].get('duration', 0)), + "bitrate": int(data['format'].get('bit_rate', 0)), + "video": [], + "audio": [], + "subtitle": [] + } + + for s in data.get('streams', []): + type = s['codec_type'] + lang = s.get('tags', {}).get('language', 'und') + title = s.get('tags', {}).get('title', '') + codec = s.get('codec_name', 'unknown') + + desc = f"[{lang.upper()}] {codec}" + if title: desc += f" - {title}" + + if type == 'video': + w = s.get('width', 0) + h = s.get('height', 0) + info['video'].append(f"{codec.upper()} ({w}x{h})") + elif type == 'audio': + ch = s.get('channels', 0) + info['audio'].append(f"{desc} ({ch}ch)") + elif type == 'subtitle': + info['subtitle'].append(desc) + + return info + except: + return None + # --- CLASSE GERENCIADORA --- class FileManager: def __init__(self): @@ -52,10 +93,69 @@ class FileManager: self.render_header() self.render_content() - # --- DIÁLOGOS DE AÇÃO (ORDEM CORRIGIDA) --- + # --- PLAYER DE VÍDEO --- + def open_player(self, path): + filename = os.path.basename(path) + + # Converte caminho local (/downloads/pasta/video.mkv) para URL (/files/pasta/video.mkv) + # O prefixo /files foi configurado no main.py + rel_path = os.path.relpath(path, ROOT_DIR) + video_url = f"/files/{rel_path}" + + # Pega dados técnicos + info = get_media_info(path) + + with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl h-[80vh] p-0 gap-0'): + # Header + with ui.row().classes('w-full bg-gray-100 p-2 justify-between items-center'): + ui.label(filename).classes('font-bold text-lg truncate') + ui.button(icon='close', on_click=dialog.close).props('flat round dense') + + with ui.row().classes('w-full h-full'): + # Coluna Esquerda: Player + with ui.column().classes('w-2/3 h-full bg-black justify-center'): + # Player HTML5 Nativo + ui.video(video_url).classes('w-full max-h-full') + ui.label('Nota: Áudios AC3/DTS podem ficar mudos no navegador.').classes('text-gray-500 text-xs text-center w-full') + + # Coluna Direita: Informações + with ui.column().classes('w-1/3 h-full p-4 overflow-y-auto bg-white border-l'): + ui.label('📋 Detalhes do Arquivo').classes('text-lg font-bold mb-4 text-blue-600') + + if info: + # Vídeo + ui.label('Vídeo').classes('font-bold text-xs text-gray-500 uppercase') + for v in info['video']: + ui.label(f"📺 {v}").classes('ml-2 text-sm') + + ui.separator().classes('my-2') + + # Áudio + ui.label('Áudio').classes('font-bold text-xs text-gray-500 uppercase') + if info['audio']: + for a in info['audio']: + ui.label(f"🔊 {a}").classes('ml-2 text-sm') + else: + ui.label("Sem áudio").classes('ml-2 text-sm text-gray-400') + + ui.separator().classes('my-2') + + # Legenda + ui.label('Legendas').classes('font-bold text-xs text-gray-500 uppercase') + if info['subtitle']: + for s in info['subtitle']: + ui.label(f"💬 {s}").classes('ml-2 text-sm') + else: + ui.label("Sem legendas").classes('ml-2 text-sm text-gray-400') + else: + ui.label('Não foi possível ler os metadados.').classes('text-red') + + dialog.open() + + # --- DIÁLOGOS DE AÇÃO --- 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('Excluir item?').classes('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') @@ -63,47 +163,42 @@ class FileManager: 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() + ui.notify('Excluído!') 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') + ui.label('Renomear') + name = 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') + os.rename(path, os.path.join(os.path.dirname(path), name.value)) dialog.close() self.refresh() + ui.notify('Renomeado!') except Exception as e: ui.notify(str(e), type='negative') - ui.button('Salvar', on_click=save).props('color=primary') + ui.button('Salvar', on_click=save) 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') + ui.label('Mover Para') 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() + ui.notify('Movido!') except Exception as e: ui.notify(str(e), type='negative') - ui.button('Mover', on_click=confirm).props('color=primary') + ui.button('Mover', on_click=confirm) dialog.open() def open_create_folder(self): @@ -113,25 +208,24 @@ class FileManager: 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) --- + # --- MENU DE CONTEXTO --- def bind_context_menu(self, element, entry): - """ - CORREÇÃO: Usa 'contextmenu.prevent' para bloquear o menu do navegador. - """ with ui.menu() as m: + if not entry.is_dir and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): + ui.menu_item('▶️ Reproduzir / Detalhes', on_click=lambda: self.open_player(entry.path)) + ui.separator() + 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 --- @@ -142,10 +236,8 @@ class FileManager: 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 @@ -155,25 +247,19 @@ class FileManager: 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='create_new_folder', on_click=self.open_create_folder).props('flat round dense') + ui.button(icon='view_list' if self.view_mode == 'grid' else 'grid_view', on_click=self.toggle_view).props('flat round dense') 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 + except: 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: @@ -184,33 +270,30 @@ class FileManager: 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)) + if is_dir: + card.on('click', lambda p=entry.path: self.navigate(p)) + elif icon == 'movie': + # Duplo clique no vídeo abre o player + card.on('dblclick', lambda p=entry.path: self.open_player(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') + 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(): + if icon == 'movie': + ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p)) + ui.separator() 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.menu_item('Mover', 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' @@ -218,30 +301,26 @@ class FileManager: 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)) + elif entry.name.lower().endswith(('.mkv', '.mp4')): + row.on('dblclick', lambda p=entry.path: self.open_player(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') + + if not is_dir: + ui.label(get_human_size(entry.stat().st_size)).classes('text-xs text-gray-500 mr-4') with ui.button(icon='more_vert').props('flat round dense size=sm').classes('sm:opacity-0 group-hover:opacity-100'): with ui.menu(): + if not is_dir: + ui.menu_item('▶️ Play', on_click=lambda p=entry.path: self.open_player(p)) 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') + ui.menu_item('Mover', on_click=lambda p=entry.path: self.open_move_dialog(p)) + ui.menu_item('Excluir', on_click=lambda p=entry.path: self.open_delete_dialog(p)) -# --- INICIALIZADOR --- def create_ui(): fm = FileManager() fm.container = ui.column().classes('w-full h-full p-2 md:p-4 gap-4') diff --git a/data/dl_status.json b/data/dl_status.json new file mode 100644 index 0000000..418ed57 --- /dev/null +++ b/data/dl_status.json @@ -0,0 +1 @@ +{"running": false, "file": "Conclu\u00eddo!", "progress": 100, "log": "Sucesso."} \ No newline at end of file diff --git a/data/status.json b/data/status.json index 3331ef4..a0085a0 100644 --- a/data/status.json +++ b/data/status.json @@ -1 +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 +{"running": false, "file": "Finalizado \u2705", "pct_file": 100, "pct_total": 100, "log": "Finalizado \u2705"} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cc64ac5..4a0a191 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,5 +16,8 @@ services: - /home/creidsu/pymediamanager/app:/app - /home/creidsu/pymediamanager/data:/app/data - /home/creidsu/downloads:/downloads + - /media:/media/Jellyfin + # - /media/onedrive2/Stash:/media/HD_Externo + # - /home/creidsu/outra_pasta:/media/Outros ports: - 8086:8080 diff --git a/requirements.txt b/requirements.txt index 8e1b4be..9bfd00f 100755 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ pandas watchdog guessit requests +ffmpeg-python +yt-dlp \ No newline at end of file