222 lines
9.4 KiB
Python
222 lines
9.4 KiB
Python
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() |