From 8d92610c4cf836c406fbe1b16b758bf723085fb2 Mon Sep 17 00:00:00 2001 From: Creidsu Date: Tue, 27 Jan 2026 00:28:48 +0000 Subject: [PATCH] =?UTF-8?q?adicionado=20dowloader=20do=20youtube=20e=20a?= =?UTF-8?q?=20op=C3=A3o'deploy'=20mover=20o=20arquivo=20para=20diret=C3=B3?= =?UTF-8?q?rio=20final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 30 +-- .../__pycache__/deployer.cpython-310.pyc | Bin 0 -> 7668 bytes .../__pycache__/downloader.cpython-310.pyc | Bin 0 -> 6211 bytes .../__pycache__/file_manager.cpython-310.pyc | Bin 11686 -> 14260 bytes app/modules/deployer.py | 222 ++++++++++++++++++ app/modules/downloader.py | 186 +++++++++++++++ app/modules/file_manager.py | 199 +++++++++++----- data/dl_status.json | 1 + data/status.json | 2 +- docker-compose.yml | 3 + requirements.txt | 2 + 11 files changed, 569 insertions(+), 76 deletions(-) create mode 100644 app/modules/__pycache__/deployer.cpython-310.pyc create mode 100644 app/modules/__pycache__/downloader.cpython-310.pyc create mode 100644 app/modules/deployer.py create mode 100644 app/modules/downloader.py create mode 100644 data/dl_status.json 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 0000000000000000000000000000000000000000..92c9c6fa2fc5b1862448490699454f01b9862d05 GIT binary patch literal 7668 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 U>BL7r+X8U1+`@FRzFfWczbUf%nE(I) literal 0 HcmV?d00001 diff --git a/app/modules/__pycache__/file_manager.cpython-310.pyc b/app/modules/__pycache__/file_manager.cpython-310.pyc index c7fa3a38bf17251b6a6bf42dae372b4a78c2b55c..a82a08096954bee34bf49483a1581a0a4b741c15 100644 GIT binary patch literal 14260 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 H@%{e+CHZJP 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