293 lines
14 KiB
Python
293 lines
14 KiB
Python
from nicegui import ui, run
|
|
import os
|
|
import shutil
|
|
import json
|
|
import asyncio
|
|
|
|
# --- 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 = []
|
|
self.container = None
|
|
self.presets = self.load_presets()
|
|
self.pendencies = [] # {'name':, 'src':, 'dst':}
|
|
self.logs = []
|
|
|
|
# --- 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 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()
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
with ui.dialog() as dialog, ui.card():
|
|
ui.label(f'Executar: {name}?').classes('text-xl font-bold text-blue-900')
|
|
|
|
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')
|
|
|
|
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 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()
|
|
|
|
# --- 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 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
|
|
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')
|
|
|
|
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 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()))
|
|
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"
|
|
|
|
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')
|
|
|
|
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()
|
|
|
|
def toggle_selection(self, path):
|
|
if path in self.selected_items: self.selected_items.remove(path)
|
|
else: self.selected_items.append(path)
|
|
self.refresh()
|
|
|
|
def create_ui():
|
|
os.makedirs("/app/data", exist_ok=True)
|
|
dm = DeployManager()
|
|
dm.container = ui.column().classes('w-full h-full p-4 max-w-7xl mx-auto')
|
|
dm.refresh() |