From 3d77959d1ddd9577e6b6c6069e7825f42a6e5926 Mon Sep 17 00:00:00 2001 From: Creidsu Date: Sun, 8 Feb 2026 22:45:43 +0000 Subject: [PATCH] =?UTF-8?q?corrigido=20o=20merger=20para=20n=C3=A3o=20perd?= =?UTF-8?q?er=20pastas=20inteiras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/deployer.cpython-310.pyc | Bin 15234 -> 15111 bytes app/modules/deployer.py | 173 ++++++++---------- data/history.log | 9 + 3 files changed, 82 insertions(+), 100 deletions(-) diff --git a/app/modules/__pycache__/deployer.cpython-310.pyc b/app/modules/__pycache__/deployer.cpython-310.pyc index 940ee31330ce11aa4d43f643f7e8a8810abdcf65..b74ead4e39c72f496d809e34600a9a358e275049 100644 GIT binary patch delta 4921 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 Qs2ayy;AgC9bsH)EFRc=E9smFU delta 5119 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 diff --git a/app/modules/deployer.py b/app/modules/deployer.py index a2e44e2..0f44ddd 100644 --- a/app/modules/deployer.py +++ b/app/modules/deployer.py @@ -4,13 +4,13 @@ import shutil import json import asyncio import datetime -from collections import deque # Import necessário para ler as últimas linhas de forma eficiente +from collections import deque # --- CONFIGURAÇÕES DE DIRETÓRIOS --- SRC_ROOT = "/downloads" DST_ROOT = "/media" CONFIG_PATH = "/app/data/presets.json" -LOG_PATH = "/app/data/history.log" # Novo arquivo de log persistente +LOG_PATH = "/app/data/history.log" class DeployManager: def __init__(self): @@ -20,123 +20,124 @@ class DeployManager: self.container = None self.presets = self.load_presets() self.pendencies = [] - # CARREGA OS LOGS DO ARQUIVO AO INICIAR self.logs = self.load_logs_from_file() - # --- NOVO: GERENCIAMENTO DE LOGS PERSISTENTES --- + # --- GERENCIAMENTO DE LOGS --- def load_logs_from_file(self): - """Lê as últimas 50 linhas do arquivo de log""" if not os.path.exists(LOG_PATH): return [] try: - # Lê as últimas 50 linhas do arquivo with open(LOG_PATH, 'r', encoding='utf-8') as f: - # deque(..., maxlen=50) pega automaticamente as últimas 50 last_lines = list(deque(f, maxlen=50)) - - # Inverte a ordem para mostrar o mais recente no topo da lista visual - # remove quebras de linha com .strip() return [line.strip() for line in reversed(last_lines)] except: return [] def add_log(self, message, type="info"): - """Adiciona log na memória E no arquivo""" timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") full_msg = f"[{timestamp}] {message}" - - # 1. Atualiza a lista da memória (para a UI) self.logs.insert(0, full_msg) - if len(self.logs) > 50: self.logs.pop() # Limpa memória excedente - - # 2. Salva no arquivo (Modo 'a' = append/adicionar no fim) + if len(self.logs) > 50: self.logs.pop() try: with open(LOG_PATH, 'a', encoding='utf-8') as f: f.write(full_msg + "\n") - except Exception as e: - print(f"Erro ao salvar log: {e}") + except: pass - # --- 1. PERSISTÊNCIA (JSON) --- + # --- 1. PERSISTÊNCIA --- def load_presets(self): if os.path.exists(CONFIG_PATH): try: - with open(CONFIG_PATH, 'r') as f: - return json.load(f) + 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) + 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) + with open(CONFIG_PATH, 'w') as f: json.dump(self.presets, f) self.refresh() - # --- 2. DIÁLOGO DE CONFIRMAÇÃO DO PRESET --- + # --- 2. DIÁLOGO --- def confirm_preset_execution(self, name, paths): - src = paths['src'] - dst = paths['dst'] - + src, dst = paths['src'], paths['dst'] if not os.path.exists(src): - ui.notify(f'Erro: Pasta de origem não existe: {src}', type='negative') + ui.notify(f'Erro: Origem não existe: {src}', type='negative') return - try: - items = [f for f in os.listdir(src) if not f.startswith('.')] - count = len(items) + try: count = len([f for f in os.listdir(src) if not f.startswith('.')]) 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(f'Origem: {src}').classes('font-mono text-xs bg-gray-100 p-1 rounded w-full break-all') - ui.label(f'Destino: {dst}').classes('font-mono text-xs bg-gray-100 p-1 rounded w-full break-all') + ui.label(f'Origem: {src}').classes('text-xs bg-gray-100 p-1 w-full break-all') + ui.label(f'Destino: {dst}').classes('text-xs bg-gray-100 p-1 w-full break-all') + ui.label(f'{count} itens encontrados.').classes('font-bold text-green-700 mt-2') if count > 0 else None - if count > 0: - ui.label(f'{count} itens encontrados.').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') - - # Função wrapper para rodar o async corretamente async def execute_action(): dialog.close() await asyncio.sleep(0.1) await self.move_process_from_preset(paths) with ui.row().classes('w-full justify-end mt-4'): - ui.button('Cancelar', on_click=dialog.close).props('flat text-color=grey') - ui.button('CONFIRMAR', on_click=execute_action).props('color=green icon=check') - + ui.button('Cancelar', on_click=dialog.close).props('flat') + ui.button('CONFIRMAR', on_click=execute_action).props('color=green') dialog.open() - # --- 3. MOVIMENTAÇÃO E PENDÊNCIAS --- + # --- 3. MOVIMENTAÇÃO BLINDADA (SAFE MERGE) --- async def move_process(self, items_to_move, target_folder): + """ + Nova lógica: Nunca move pastas inteiras. + Sempre cria a estrutura no destino e move apenas arquivos. + """ 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) + # --- CASO 1: É UMA PASTA? --- + if os.path.isdir(item_path): + # Não verifica se existe ou não. Simplesmente garante que a pasta + # exista no destino (mkdir -p) e entra nela. + try: + if not os.path.exists(destination): + os.makedirs(destination, exist_ok=True) + self.apply_permissions(destination) # Garante permissão na nova pasta + + # Pega conteúdo e RECURSIVIDADE (mergulha na pasta) + sub_items = [os.path.join(item_path, f) for f in os.listdir(item_path)] + await self.move_process(sub_items, destination) + + # Limpeza: Se a pasta de origem ficou vazia, remove ela + if not os.listdir(item_path): + os.rmdir(item_path) + + except Exception as e: + self.add_log(f"❌ Erro na pasta {name}: {e}", "negative") + continue + + # --- CASO 2: É UM ARQUIVO? --- + # Se já existe o arquivo exato no destino -> Pendência if os.path.exists(destination): - self.add_log(f"⚠️ Pendência: {name}", "warning") + self.add_log(f"⚠️ Conflito de arquivo: {name}", "warning") self.pendencies.append({'name': name, 'src': item_path, 'dst': destination}) self.refresh() continue + # Se não existe -> Move o arquivo try: await run.cpu_bound(shutil.move, item_path, destination) self.apply_permissions(destination) - self.add_log(f"✅ Movido: {name}", "positive") + self.add_log(f"✅ Arquivo movido: {name}", "positive") except Exception as e: - self.add_log(f"❌ Erro em {name}: {e}", "negative") + self.add_log(f"❌ Erro arquivo {name}: {e}", "negative") self.selected_items = [] self.refresh() @@ -146,7 +147,7 @@ class DeployManager: 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') + else: ui.notify('Origem não encontrada!', type='negative') def apply_permissions(self, path): try: @@ -154,7 +155,7 @@ class DeployManager: else: os.chmod(path, 0o777) except: pass - # --- 4. AÇÕES EM MASSA PARA PENDÊNCIAS --- + # --- 4. AÇÕES PENDÊNCIAS --- async def handle_all_pendencies(self, action): temp_list = list(self.pendencies) for i in range(len(temp_list)): @@ -167,6 +168,7 @@ class DeployManager: if action == 'replace': try: + # Como agora só arquivos caem aqui, remove e substitui if os.path.isdir(item['dst']): await run.cpu_bound(shutil.rmtree, item['dst']) else: @@ -184,7 +186,6 @@ class DeployManager: 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 @@ -192,7 +193,6 @@ class DeployManager: 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) @@ -200,23 +200,19 @@ class DeployManager: def navigate_src(self, path): if os.path.exists(path) and os.path.isdir(path): - self.src_path = path - self.refresh() + 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() + self.dst_path = path; self.refresh() - # --- 6. INTERFACE PRINCIPAL --- + # --- 6. UI --- def refresh(self): if self.container: self.container.clear() - with self.container: - self.render_layout() + 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') @@ -224,88 +220,65 @@ class DeployManager: with ui.button_group().props('rounded'): 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') + ui.label('📂 ORIGEM').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') + ui.label('🎯 DESTINO').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') + ui.button(icon='swap_horiz', on_click=lambda idx=i: self.handle_pendency(idx, 'replace')).props('flat dense color=green') + ui.button(icon='close', on_click=lambda idx=i: self.handle_pendency(idx, 'ignore')).props('flat dense color=red') - # PAINEL DE LOGS with ui.card().classes('flex-grow h-64 bg-slate-900 text-slate-200 shadow-none'): - ui.label('📜 Log de Atividades (Persistente)').classes('font-bold border-b border-slate-700 w-full pb-2') + ui.label('📜 Logs').classes('font-bold border-b border-slate-700 w-full pb-2') with ui.scroll_area().classes('w-full h-full'): - # Renderiza os logs carregados for log in self.logs: - # Pinta de vermelho se tiver erro, verde se sucesso - color_cls = 'text-red-400' if '❌' in log else 'text-green-400' if '✅' in log else 'text-slate-300' - ui.label(f"> {log}").classes(f'text-[10px] font-mono leading-tight {color_cls}') + color = 'text-red-400' if '❌' in log else 'text-green-400' if '✅' in log else 'text-slate-300' + ui.label(f"> {log}").classes(f'text-[10px] font-mono leading-tight {color}') - # BOTÃO GLOBAL - ui.button('INICIAR MOVIMENTAÇÃO DOS SELECIONADOS', on_click=lambda: self.move_process(self.selected_items, self.dst_path))\ + ui.button('INICIAR MOVIMENTAÇÃO', 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 --- 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') + 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') - + 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') + 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: 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)') + ui.label('Criar Preset').classes('text-lg font-bold') + name_input = ui.input('Nome') 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') diff --git a/data/history.log b/data/history.log index 7951228..43e38a9 100644 --- a/data/history.log +++ b/data/history.log @@ -56,3 +56,12 @@ OSError: [Errno 107] Transport endpoint is not connected: '/media/Jellyfin/onedr [2026-02-05 13:23:13] ✅ Movido: Episódio 07.mp4 [2026-02-05 13:23:13] ⚠️ Pendência: Episódio 14.mp4 [2026-02-05 13:23:27] 🔄 Substituído: Episódio 14.mp4 +[2026-02-06 00:20:02] ✅ Movido: 21 SEXTURY - Hot Babe Veronica Leal Gets The Hottest Ass Creampie EVER After Hard Anal Sex.mp4 +[2026-02-06 05:16:26] ✅ Movido: TUSHY Abella Danger and Lena Paul Dominate Her Boyfriend and Get Gaped.mp4 +[2026-02-06 05:16:27] ✅ Movido: 21 NATURALS - Hot Redhead Veronica Leal Wants Her Tight Ass Fucked.mp4 +[2026-02-08 00:51:31] ⚠️ Pendência: Jujutsu Kaisen +[2026-02-08 00:52:15] 🔄 Substituído: Jujutsu Kaisen +[2026-02-08 20:47:28] ✅ Movido: Step Brother cums too early on sisters Mouth and Swallows Cum.mp4 +[2026-02-08 20:47:38] ✅ Movido: Horny Couple gets caught in Public Restroom making out.mp4 +[2026-02-08 22:31:46] ✅ Movido: Frieren e a Jornada para o Além +[2026-02-08 22:44:01] ✅ Arquivo movido: Episódio 05.mkv