from nicegui import ui, run import os import shutil import asyncio import datetime import subprocess import json ROOT_DIR = "/downloads" # --- UTILITÁRIOS ASSÍNCRONOS --- async 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" async def get_media_info_async(filepath): def _probe(): cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", filepath] try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=10) return json.loads(res.stdout) except: return None data = await run.io_bound(_probe) if not data: return None info = { "filename": os.path.basename(filepath), "size": int(data['format'].get('size', 0)), "duration": float(data['format'].get('duration', 0)), "bitrate": int(data['format'].get('bit_rate', 0)), "video": [], "audio": [], "subtitle": [] } for s in data.get('streams', []): stype = s['codec_type'] lang = s.get('tags', {}).get('language', 'und').upper() codec = s.get('codec_name', 'unknown').upper() if stype == 'video': info['video'].append({"codec": codec, "res": f"{s.get('width','?')}x{s.get('height','?')}", "fps": s.get('r_frame_rate', '')}) elif stype == 'audio': info['audio'].append({"lang": lang, "codec": codec, "ch": s.get('channels', 0), "title": s.get('tags', {}).get('title', '')}) elif stype == 'subtitle': info['subtitle'].append({"lang": lang, "codec": codec}) return info # --- CLASSE GERENCIADORA --- class FileManager: def __init__(self): self.path = ROOT_DIR self.view_mode = 'grid' self.is_selecting = False self.selected_items = set() self.search_term = "" self.refreshing = False # Elementos UI self.container_content = None self.footer = None self.lbl_selection_count = None self.btn_select_mode = None self.header_row = None # Para atualizar breadcrumbs # --- NAVEGAÇÃO --- async def navigate(self, path): if os.path.exists(path) and os.path.isdir(path): self.path = path self.selected_items.clear() self.is_selecting = False self.search_term = "" self.update_footer_state() # Atualiza o header completamente para refazer breadcrumbs if self.header_row: self.header_row.clear() with self.header_row: self.build_header_content() await self.refresh() else: ui.notify('Caminho inválido.', type='negative') async def navigate_up(self): parent = os.path.dirname(self.path) if self.path != ROOT_DIR: await self.navigate(parent) # --- UPLOAD CORRIGIDO FINAL (NOME E CONTEÚDO) --- def open_upload_dialog(self): with ui.dialog() as dialog, ui.card(): ui.label(f'Upload para: {os.path.basename(self.path)}').classes('font-bold') async def handle(e): try: # 1. Recuperação do Nome (Prioridade para .filename do Starlette) name = None # Verifica se existe o objeto interno 'file' (detectado nos logs anteriores) if hasattr(e, 'file'): # .filename é onde fica o nome original do arquivo no upload web name = getattr(e.file, 'filename', None) # Se não achar, tenta .name if not name: name = getattr(e.file, 'name', None) # Se ainda não achou, tenta direto no evento (fallback) if not name: name = getattr(e, 'name', 'arquivo_sem_nome') # 2. Leitura do Conteúdo (Assíncrona) content = b'' if hasattr(e, 'file'): # Tenta resetar o ponteiro de leitura para o início if hasattr(e.file, 'seek'): await e.file.seek(0) # Lê os bytes (await é necessário aqui) if hasattr(e.file, 'read'): content = await e.file.read() if not content: ui.notify('Erro: Arquivo vazio ou ilegível', type='warning') return # 3. Salva no disco target = os.path.join(self.path, name) # Executa a gravação em thread separada para não travar o servidor await run.io_bound(self._save_file_bytes, target, content) ui.notify(f'Sucesso: {name}', type='positive') except Exception as ex: # Imprime no log do container para diagnóstico se falhar print(f"ERRO CRITICO UPLOAD: {ex}") ui.notify(f'Erro: {ex}', type='negative') # Componente UI ui.upload(on_upload=handle, auto_upload=True, multiple=True).props('accept=*').classes('w-full') async def close_and_refresh(): dialog.close() await self.refresh() ui.button('Fechar e Atualizar', on_click=close_and_refresh).props('flat w-full') dialog.open() # Função auxiliar simples para salvar bytes def _save_file_bytes(self, target, content_bytes): with open(target, 'wb') as f: f.write(content_bytes) # --- LÓGICA DE SELEÇÃO --- def toggle_select_mode(self): self.is_selecting = not self.is_selecting if not self.is_selecting: self.selected_items.clear() self.update_footer_state() ui.timer(0, self.refresh, once=True) def toggle_selection(self, item_path): if item_path in self.selected_items: self.selected_items.remove(item_path) else: self.selected_items.add(item_path) self.update_footer_state() ui.timer(0, self.refresh, once=True) def select_all(self, entries): current = {e.path for e in entries} if self.selected_items.issuperset(current): self.selected_items.difference_update(current) else: self.selected_items.update(current) self.update_footer_state() ui.timer(0, self.refresh, once=True) def update_footer_state(self): if self.footer: self.footer.set_visibility(self.is_selecting) if self.lbl_selection_count: self.lbl_selection_count.text = f'{len(self.selected_items)} item(s) selecionados' # --- AÇÕES EM LOTE --- async def delete_selected(self): if not self.selected_items: return with ui.dialog() as dialog, ui.card(): ui.label(f'Excluir {len(self.selected_items)} itens?').classes('font-bold text-red') async def confirm(): dialog.close() count = 0 items_copy = list(self.selected_items) for item in items_copy: try: if os.path.isdir(item): await run.io_bound(shutil.rmtree, item) else: await run.io_bound(os.remove, item) count += 1 except: pass ui.notify(f'{count} excluídos.', type='positive') self.selected_items.clear() self.update_footer_state() await self.refresh() with ui.row().classes('w-full justify-end'): ui.button('Cancelar', on_click=dialog.close).props('flat') ui.button('Confirmar', on_click=confirm).props('color=red') dialog.open() async def open_move_dialog(self, target_items=None): items_to_move = target_items if target_items else list(self.selected_items) if not items_to_move: ui.notify('Nada para mover.', type='warning') return browser_path = ROOT_DIR with ui.dialog() as dialog, ui.card().classes('w-96 h-[500px] flex flex-col p-4'): ui.label(f'Mover {len(items_to_move)} item(s) para...').classes('font-bold') lbl_path = ui.label(ROOT_DIR).classes('text-xs bg-gray-100 p-2 w-full truncate border rounded') scroll = ui.scroll_area().classes('flex-grow border rounded p-1 bg-white') async def load_folders(p): nonlocal browser_path browser_path = p lbl_path.text = p scroll.clear() try: entries = await run.io_bound(os.scandir, p) sorted_e = sorted([e for e in entries if e.is_dir() and not e.name.startswith('.')], key=lambda e: e.name.lower()) with scroll: if p != ROOT_DIR: ui.button('.. (Voltar)', on_click=lambda: load_folders(os.path.dirname(p))).props('flat dense icon=arrow_upward w-full align=left') for e in sorted_e: ui.button(e.name, on_click=lambda path=e.path: load_folders(path)).props('flat dense w-full align=left icon=folder color=amber') except: pass ui.timer(0, lambda: load_folders(ROOT_DIR), once=True) async def execute_move(): dialog.close() ui.notify('Movendo...', type='info') count = 0 for item in items_to_move: try: tgt = os.path.join(browser_path, os.path.basename(item)) if item != tgt: await run.io_bound(shutil.move, item, tgt) count += 1 except Exception as e: ui.notify(f"Erro: {e}", type='negative') ui.notify(f'{count} movidos!', type='positive') if not target_items: self.selected_items.clear() self.update_footer_state() await self.refresh() with ui.row().classes('w-full justify-between mt-auto'): ui.button('Cancelar', on_click=dialog.close).props('flat') ui.button('Mover Aqui', on_click=execute_move).props('color=green icon=drive_file_move') dialog.open() # --- INTERFACE PRINCIPAL --- def create_layout(self): # 1. Header (Fixo) self.header_row = ui.row().classes('w-full items-center bg-gray-100 p-2 rounded-lg gap-2 sticky top-0 z-20 shadow-sm') with self.header_row: self.build_header_content() # 2. Área de Conteúdo self.container_content = ui.column().classes('w-full gap-4 pb-24') # 3. Footer (SIMULADO com ROW FIXED) with ui.row().classes('fixed bottom-0 left-0 w-full z-50 bg-white border-t p-2 justify-center gap-4 shadow-[0_-4px_10px_rgba(0,0,0,0.1)]') as f: self.footer = f self.lbl_selection_count = ui.label('0 selecionados').classes('font-bold self-center') ui.button('Mover Seleção', on_click=lambda: self.open_move_dialog(None)).props('color=amber icon=drive_file_move dense') ui.button('Excluir Seleção', on_click=self.delete_selected).props('color=red icon=delete dense') ui.button(icon='close', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Sair da Seleção') self.footer.visible = False def build_header_content(self): # Botão Subir Nível if self.path != ROOT_DIR: ui.button(icon='arrow_upward', on_click=self.navigate_up).props('flat round dense').tooltip('Subir Nível') else: ui.button(icon='home').props('flat round dense disabled text-color=grey') # BREADCRUMBS (Barra de Endereço Restaurada) rel = os.path.relpath(self.path, ROOT_DIR) parts = rel.split(os.sep) if rel != '.' else [] with ui.row().classes('items-center gap-0 overflow-hidden flex-grow'): ui.button('root', on_click=lambda: self.navigate(ROOT_DIR)).props('flat dense no-caps min-w-0 px-1 text-xs') acc = ROOT_DIR for part in parts: acc = os.path.join(acc, part) ui.label('/').classes('text-gray-400') # Precisamos capturar o valor de acc no lambda ui.button(part, on_click=lambda p=acc: self.navigate(p)).props('flat dense no-caps min-w-0 px-1 text-xs') # Botões de Ação self.btn_select_mode = ui.button(icon='check_box', on_click=self.toggle_select_mode).props('flat round dense').tooltip('Modo Seleção') ui.button(icon='create_new_folder', on_click=self.open_create_folder).props('flat round dense').tooltip('Nova Pasta') ui.button(icon='cloud_upload', on_click=self.open_upload_dialog).props('flat round dense').tooltip('Upload') ui.button(icon='view_list', on_click=self.toggle_view).props('flat round dense') ui.button(icon='refresh', on_click=self.refresh).props('flat round dense') def toggle_view(self): self.view_mode = 'list' if self.view_mode == 'grid' else 'grid' ui.timer(0, self.refresh, once=True) def open_create_folder(self): with ui.dialog() as dialog, ui.card(): ui.label('Nova Pasta') name = ui.input('Nome') async def create(): try: await run.io_bound(os.makedirs, os.path.join(self.path, name.value)) dialog.close() await self.refresh() except Exception as e: ui.notify(str(e), type='negative') ui.button('Criar', on_click=create) dialog.open() def open_rename_dialog(self, path): with ui.dialog() as dialog, ui.card(): ui.label('Renomear') name = ui.input('Novo Nome', value=os.path.basename(path)).classes('w-full') async def save(): try: new_path = os.path.join(os.path.dirname(path), name.value) await run.io_bound(os.rename, path, new_path) dialog.close() await self.refresh() except Exception as e: ui.notify(str(e), type='negative') ui.button('Salvar', on_click=save) dialog.open() # --- RENDERIZAÇÃO DE CONTEÚDO --- async def refresh(self): if self.refreshing: return self.refreshing = True color = 'green' if self.is_selecting else 'grey' if self.btn_select_mode: self.btn_select_mode.props(f'text-color={color}') if self.container_content: self.container_content.clear() try: entries = await run.io_bound(os.scandir, self.path) entries = sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower())) if self.is_selecting: with self.container_content: with ui.row().classes('w-full px-2 py-1 bg-green-50 items-center justify-between text-xs text-green-800 rounded border border-green-200'): ui.checkbox(f'Selecionar Todos ({len(entries)})', on_change=lambda: self.select_all(entries)).props('dense size=xs') with self.container_content: if not entries: ui.label('Pasta Vazia').classes('w-full text-center text-gray-400 mt-10') elif 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: self.render_card(entry) else: with ui.column().classes('w-full gap-0'): for entry in entries: self.render_list_item(entry) except Exception as e: with self.container_content: ui.label(f'Erro ao ler pasta: {e}').classes('text-red') self.refreshing = False def render_card(self, entry): is_selected = entry.path in self.selected_items 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' bg_cls = 'bg-green-100 ring-2 ring-green-500' if is_selected else 'bg-white hover:shadow-md' if self.is_selecting and not is_selected: bg_cls += ' border-dashed border-2 border-gray-300' with ui.card().classes(f'w-full aspect-square p-2 items-center justify-center relative group cursor-pointer select-none {bg_cls}') as card: self.bind_context_menu(card, entry) if self.is_selecting: card.on('click', lambda: self.toggle_selection(entry.path)) with ui.column().classes('absolute top-1 left-1 z-10'): ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense').on('click', lambda e: e.stop_propagation()) else: if is_dir: card.on('click', lambda e, p=entry.path: self.navigate(p)) elif icon == 'movie': card.on('click', lambda e, p=entry.path: self.open_inspector(p)) 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 mt-2') def render_list_item(self, entry): is_selected = entry.path in self.selected_items bg_cls = 'bg-green-100' if is_selected else 'hover:bg-gray-50' icon = 'folder' if entry.is_dir() else 'description' with ui.row().classes(f'w-full items-center px-2 py-2 border-b cursor-pointer group {bg_cls}') as row: if self.is_selecting: ui.checkbox(value=is_selected, on_change=lambda: self.toggle_selection(entry.path)).props('dense') row.on('click', lambda: self.toggle_selection(entry.path)) else: if entry.is_dir(): row.on('click', lambda p=entry.path: self.navigate(p)) self.bind_context_menu(row, entry) ui.icon(icon, color='amber' if entry.is_dir() else 'grey') ui.label(entry.name).classes('text-sm font-medium flex-grow') # --- MENU DE CONTEXTO (CORRIGIDO) --- def bind_context_menu(self, element, entry): with ui.menu() as m: # Opções de mídia apenas para vídeos if not entry.is_dir() and entry.name.lower().endswith(('.mkv', '.mp4', '.avi')): ui.menu_item('Media Info', on_click=lambda: self.open_inspector(entry.path)) 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])) async def delete_single(): try: ui.notify(f'Excluindo {entry.name}...') # CORREÇÃO AQUI: Adicionados parênteses () em is_dir() # Sem eles, o Python acha que sempre é True (porque o método existe) if entry.is_dir(): await run.io_bound(shutil.rmtree, entry.path) else: await run.io_bound(os.remove, entry.path) await self.refresh() ui.notify('Item excluído.', type='positive') except Exception as e: ui.notify(f"Erro ao excluir: {e}", type='negative') print(f"DEBUG DELETE ERROR: {e}") ui.menu_item('Excluir', on_click=delete_single).props('text-color=red') element.on('contextmenu.prevent', lambda: m.open()) # --- INSPECTOR (RESTAURADO E RICO) --- async def open_inspector(self, path): dialog = ui.dialog() with dialog, ui.card().classes('w-full max-w-3xl'): with ui.row().classes('w-full justify-between items-start'): ui.label(os.path.basename(path)).classes('text-lg font-bold break-all w-10/12 text-blue-800') ui.button(icon='close', on_click=dialog.close).props('flat dense round') content = ui.column().classes('w-full') with content: ui.spinner('dots').classes('self-center') dialog.open() info = await get_media_info_async(path) content.clear() if not info: with content: ui.label('Não foi possível ler os metadados.').classes('text-red font-bold') return with content: # 1. Resumo Geral (Stats) with ui.row().classes('w-full bg-blue-50 p-2 rounded mb-4 gap-4'): ui.label(f"⏱️ {datetime.timedelta(seconds=int(info['duration']))}").tooltip('Duração') ui.label(f"📦 {await get_human_size(info['size'])}").tooltip('Tamanho') ui.label(f"🚀 {int(info['bitrate']/1000)} kbps").tooltip('Bitrate Total') # 2. Vídeo ui.label('Vídeo').classes('text-xs font-bold text-gray-500 uppercase mt-2') for v in info['video']: with ui.card().classes('w-full p-2 bg-gray-50 border-l-4 border-blue-500'): ui.label(f"{v['codec']} • {v['res']} • {v['fps']} fps").classes('font-bold') # 3. Áudio ui.label('Áudio').classes('text-xs font-bold text-gray-500 uppercase mt-4') if info['audio']: with ui.grid().classes('grid-cols-[auto_1fr_auto] w-full gap-2 text-sm'): for a in info['audio']: ui.label(a['lang']).classes('font-bold bg-gray-200 px-2 rounded text-center') ui.label(f"{a['codec']} - {a['title']}") ui.label(a['ch']).classes('text-gray-500') else: ui.label('Sem faixas de áudio').classes('italic text-gray-400') # 4. Legendas ui.label('Legendas').classes('text-xs font-bold text-gray-500 uppercase mt-4') if info['subtitle']: with ui.row().classes('w-full gap-2'): for s in info['subtitle']: color = 'green' if s['codec'] in ['subrip', 'ass'] else 'grey' ui.chip(f"{s['lang']} ({s['codec']})", color=color).props('dense icon=subtitles') else: ui.label('Sem legendas internas').classes('italic text-gray-400') # --- INICIALIZADOR --- def create_ui(): fm = FileManager() fm.create_layout() ui.timer(0, fm.refresh, once=True)