depoly inteligente

This commit is contained in:
2026-02-01 23:38:06 +00:00
parent 3ebe723edb
commit 935b15980c
10 changed files with 807 additions and 453 deletions

View File

@@ -1,123 +1,162 @@
from nicegui import ui
from nicegui import ui, run
import os
import shutil
import datetime
import json
import asyncio
# Configurações de Raiz
# --- CONFIGURAÇÕES DE DIRETÓRIOS ---
SRC_ROOT = "/downloads"
DST_ROOT = "/media"
CONFIG_PATH = "/app/data/presets.json"
class DeployManager:
def __init__(self):
self.src_path = SRC_ROOT
self.dst_path = DST_ROOT
self.selected_items = [] # Lista de caminhos selecionados
self.selected_items = []
self.container = None
self.presets = self.load_presets()
self.pendencies = [] # {'name':, 'src':, 'dst':}
self.logs = []
# --- 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()
# --- 1. PERSISTÊNCIA (JSON) ---
def load_presets(self):
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r') as f:
return json.load(f)
except: return {}
return {}
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
def save_preset(self, name):
if not name: return
self.presets[name] = {'src': self.src_path, 'dst': self.dst_path}
with open(CONFIG_PATH, 'w') as f:
json.dump(self.presets, f)
ui.notify(f'Preset "{name}" salvo!')
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')
def delete_preset(self, name):
if name in self.presets:
del self.presets[name]
with open(CONFIG_PATH, 'w') as f:
json.dump(self.presets, f)
self.refresh()
# --- 2. DIÁLOGO DE CONFIRMAÇÃO DO PRESET (NOVO) ---
def confirm_preset_execution(self, name, paths):
"""Abre janela para confirmar antes de rodar o Smart Deploy"""
src = paths['src']
dst = paths['dst']
# Verificação básica antes de abrir o diálogo
if not os.path.exists(src):
ui.notify(f'Erro: Pasta de origem não existe: {src}', type='negative')
return
if self.src_path == self.dst_path:
ui.notify('Origem e Destino são iguais!', type='warning')
return
# Conta itens para mostrar no aviso
try:
items = [f for f in os.listdir(src) if not f.startswith('.')] # Ignora ocultos
count = len(items)
except: count = 0
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)}')
ui.label(f'Executar: {name}?').classes('text-xl font-bold text-blue-900')
# 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')
ui.label('Isso moverá TODOS os arquivos de:').classes('text-xs text-gray-500 mt-2')
ui.label(src).classes('font-mono text-sm bg-gray-100 p-1 rounded w-full break-all')
ui.label('Para:').classes('text-xs text-gray-500 mt-2')
ui.label(dst).classes('font-mono text-sm bg-gray-100 p-1 rounded w-full break-all')
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()
if count > 0:
ui.label(f'{count} itens encontrados prontos para mover.').classes('font-bold text-green-700 mt-2')
else:
ui.label('Atenção: A pasta de origem parece vazia.').classes('font-bold text-orange-600 mt-2')
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')
with ui.row().classes('w-full justify-end mt-4'):
ui.button('Cancelar', on_click=dialog.close).props('flat text-color=grey')
# Botão que realmente executa a ação
ui.button('CONFIRMAR MOVIMENTAÇÃO',
on_click=lambda: [dialog.close(), self.move_process_from_preset(paths)])\
.props('color=green icon=check')
dialog.open()
# --- RENDERIZADORES AUXILIARES ---
# --- 3. MOVIMENTAÇÃO E PENDÊNCIAS ---
async def move_process(self, items_to_move, target_folder):
"""Move arquivos em background e detecta conflitos"""
for item_path in items_to_move:
if not os.path.exists(item_path): continue
name = os.path.basename(item_path)
destination = os.path.join(target_folder, name)
if os.path.exists(destination):
self.add_log(f"⚠️ Pendência: {name}", "warning")
self.pendencies.append({'name': name, 'src': item_path, 'dst': destination})
self.refresh()
continue
try:
await run.cpu_bound(shutil.move, item_path, destination)
self.apply_permissions(destination)
self.add_log(f"✅ Movido: {name}", "positive")
except Exception as e:
self.add_log(f"❌ Erro em {name}: {e}", "negative")
self.selected_items = []
self.refresh()
async def move_process_from_preset(self, paths):
"""Executa a movimentação após confirmação"""
src, dst = paths['src'], paths['dst']
if os.path.exists(src):
items = [os.path.join(src, f) for f in os.listdir(src)]
await self.move_process(items, dst)
else: ui.notify('Origem do preset não encontrada!', type='negative')
def apply_permissions(self, path):
try:
if os.path.isdir(path): os.system(f'chmod -R 777 "{path}"')
else: os.chmod(path, 0o777)
except: pass
# --- 4. AÇÕES EM MASSA PARA PENDÊNCIAS ---
async def handle_all_pendencies(self, action):
temp_list = list(self.pendencies)
for i in range(len(temp_list)):
await self.handle_pendency(0, action, refresh=False)
self.refresh()
async def handle_pendency(self, index, action, refresh=True):
if index >= len(self.pendencies): return
item = self.pendencies.pop(index)
if action == 'replace':
try:
if os.path.isdir(item['dst']):
await run.cpu_bound(shutil.rmtree, item['dst'])
else:
await run.cpu_bound(os.remove, item['dst'])
await run.cpu_bound(shutil.move, item['src'], item['dst'])
self.apply_permissions(item['dst'])
self.add_log(f"🔄 Substituído: {item['name']}")
except Exception as e:
self.add_log(f"❌ Erro ao substituir {item['name']}: {e}", "negative")
if refresh: self.refresh()
# --- 5. NAVEGAÇÃO (BREADCRUMBS) ---
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'):
with ui.row().classes('items-center gap-1 bg-gray-100 p-1 rounded w-full mb-2'):
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:
for part in rel.split(os.sep):
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')
@@ -125,98 +164,130 @@ class DeployManager:
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')
ui.button(icon='arrow_upward', on_click=lambda: nav_callback(parent)).props('flat round dense size=sm color=primary')
def navigate_src(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.src_path = path
self.refresh()
def navigate_dst(self, path):
if os.path.exists(path) and os.path.isdir(path):
self.dst_path = path
self.refresh()
# --- 6. INTERFACE PRINCIPAL ---
def add_log(self, message, type="info"):
self.logs.insert(0, message)
if len(self.logs) > 30: self.logs.pop()
def refresh(self):
if self.container:
self.container.clear()
with self.container:
self.render_layout()
def render_layout(self):
# TOPBAR: PRESETS
with ui.row().classes('w-full bg-blue-50 p-3 rounded-lg items-center shadow-sm'):
ui.icon('bolt', color='blue').classes('text-2xl')
ui.label('SMART DEPLOYS:').classes('font-bold text-blue-900 mr-4')
for name, paths in self.presets.items():
with ui.button_group().props('rounded'):
# AQUI MUDOU: Chama o diálogo de confirmação em vez de mover direto
ui.button(name, on_click=lambda n=name, p=paths: self.confirm_preset_execution(n, p)).props('color=blue-6')
ui.button(on_click=lambda n=name: self.delete_preset(n)).props('icon=delete color=red-4')
ui.button('Salvar Favorito', on_click=self.prompt_save_preset).props('flat icon=add_circle color=green-7').classes('ml-auto')
# CONTEÚDO: NAVEGADORES
with ui.row().classes('w-full gap-6 mt-4'):
# ORIGEM
with ui.column().classes('flex-grow w-1/2'):
ui.label('📂 ORIGEM (Downloads)').classes('text-lg font-bold text-blue-700')
self.render_breadcrumbs(self.src_path, SRC_ROOT, self.navigate_src)
self.render_file_list(self.src_path, is_source=True)
# DESTINO
with ui.column().classes('flex-grow w-1/2'):
ui.label('🎯 DESTINO (Mídia)').classes('text-lg font-bold text-green-700')
self.render_breadcrumbs(self.dst_path, DST_ROOT, self.navigate_dst)
self.render_file_list(self.dst_path, is_source=False)
# SEÇÃO INFERIOR: LOGS E PENDÊNCIAS
with ui.row().classes('w-full gap-6 mt-6'):
# PAINEL DE PENDÊNCIAS
with ui.card().classes('flex-grow h-64 bg-orange-50 border-orange-200 shadow-none'):
with ui.row().classes('w-full items-center border-b pb-2'):
ui.label(f'⚠️ Pendências ({len(self.pendencies)})').classes('font-bold text-orange-900 text-lg')
if self.pendencies:
ui.button('SUBSTITUIR TODOS', on_click=lambda: self.handle_all_pendencies('replace')).props('color=green-8 size=sm icon=done_all')
ui.button('IGNORAR TODOS', on_click=lambda: self.handle_all_pendencies('ignore')).props('color=grey-7 size=sm icon=clear_all')
with ui.scroll_area().classes('w-full h-full'):
for i, p in enumerate(self.pendencies):
with ui.row().classes('w-full items-center p-2 border-b bg-white rounded mb-1'):
ui.label(p['name']).classes('flex-grow text-xs font-medium')
ui.button(icon='swap_horiz', on_click=lambda idx=i: self.handle_pendency(idx, 'replace')).props('flat dense color=green').tooltip('Substituir')
ui.button(icon='close', on_click=lambda idx=i: self.handle_pendency(idx, 'ignore')).props('flat dense color=red').tooltip('Manter Original')
# PAINEL DE LOGS
with ui.card().classes('flex-grow h-64 bg-slate-900 text-slate-200 shadow-none'):
ui.label('📜 Log de Atividades').classes('font-bold border-b border-slate-700 w-full pb-2')
with ui.scroll_area().classes('w-full h-full'):
for log in self.logs:
ui.label(f"> {log}").classes('text-[10px] font-mono leading-tight')
# BOTÃO GLOBAL
ui.button('INICIAR MOVIMENTAÇÃO DOS SELECIONADOS', on_click=lambda: self.move_process(self.selected_items, self.dst_path))\
.classes('w-full py-6 mt-4 text-xl font-black shadow-lg')\
.props('color=green-7 icon=forward')\
.bind_enabled_from(self, 'selected_items', backward=lambda x: len(x) > 0)
# --- AUXILIARES (Listas, Checkbox, etc) ---
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:
with ui.scroll_area().classes('h-[400px] border-2 rounded-lg bg-white w-full shadow-inner'):
if not entries:
ui.label('Pasta vazia').classes('p-4 text-gray-400 italic')
for entry in entries:
is_selected = entry.path in self.selected_items
bg = "bg-blue-100 border-blue-200" if is_selected else "hover:bg-gray-50 border-gray-100"
# 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))
with ui.row().classes(f'w-full p-2 border-b items-center {bg} transition-colors cursor-pointer') as r:
if is_source:
ui.checkbox(value=is_selected, on_change=lambda e, p=entry.path: self.toggle_selection(p)).props('dense')
icon = 'folder' if entry.is_dir() else 'movie' if entry.name.lower().endswith(('.mkv','.mp4')) else 'description'
ui.icon(icon, color='amber-500' if entry.is_dir() else 'blue-grey-400')
lbl = ui.label(entry.name).classes('text-sm flex-grow truncate select-none')
if entry.is_dir():
r.on('click', lambda p=entry.path: self.navigate_src(p) if is_source else self.navigate_dst(p))
elif is_source:
r.on('click', lambda p=entry.path: self.toggle_selection(p))
except Exception:
ui.label('Erro ao acessar diretório.').classes('text-red-500 p-4 font-bold')
# 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')
def prompt_save_preset(self):
with ui.dialog() as d, ui.card().classes('p-6'):
ui.label('Criar Novo Smart Deploy').classes('text-lg font-bold')
ui.label(f'Origem: {self.src_path}').classes('text-xs text-gray-500')
ui.label(f'Destino: {self.dst_path}').classes('text-xs text-gray-500')
name_input = ui.input('Nome do Atalho (ex: Filmes, 4K, Séries)')
with ui.row().classes('w-full justify-end mt-4'):
ui.button('Cancelar', on_click=d.close).props('flat')
ui.button('SALVAR', on_click=lambda: [self.save_preset(name_input.value), d.close()]).props('color=green')
d.open()
# COLUNA 3: Nome
ui.label(entry.name).classes('text-sm truncate flex-grow select-none')
def toggle_selection(self, path):
if path in self.selected_items: self.selected_items.remove(path)
else: self.selected_items.append(path)
self.refresh()
# --- 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():
os.makedirs("/app/data", exist_ok=True)
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.container = ui.column().classes('w-full h-full p-4 max-w-7xl mx-auto')
dm.refresh()