@danhachuel/thunderbolt 0.4.9 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +2 -0
- package/README.md +3 -1
- package/app/main.py +23 -3
- package/hermes_ui/domain.py +102 -58
- package/hermes_ui/material_sources.py +45 -2
- package/hermes_ui/storage.py +123 -23
- package/hermes_ui/thumbnails.py +44 -16
- package/package.json +1 -1
- package/scripts/install.mjs +88 -14
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -485,6 +485,8 @@ O **Backlog Vídeos** e a lista **Automação > Automação Youtube > Vídeos ca
|
|
|
485
485
|
|
|
486
486
|
Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas. Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a partir dos artefactos persistidos: reutiliza roteiro, título/keywords, vídeo, prompt e thumbnail prontos e só executa novamente uma etapa cujo resultado não exista. O botão **Apagar** remove o card da fila depois de confirmação e preserva os ficheiros de artefactos; uma tarefa em execução deve ser parada antes da remoção.
|
|
487
487
|
|
|
488
|
+
Não há expiração automática nem limpeza periódica de `tasks.json`. A interface e os workers usam locks de ficheiro nas actualizações read-modify-write, evitando que estados concorrentes substituam a fila por uma fotografia antiga. Em actualizações que detectem uma instalação legada, as tarefas e filas são unidas por ID mesmo que o storage novo já exista. Se o JSON protegido estiver corrompido, a cópia original é preservada e a leitura falha de forma explícita, em vez de criar uma lista vazia e apagar os dados no passo seguinte.
|
|
489
|
+
|
|
488
490
|
### Upload Música — JewelMusic, Pushtunes, ytmusicapi e DistroKid
|
|
489
491
|
|
|
490
492
|
A área **Pipeline Música > Upload Música** separa três métodos com contratos diferentes. Em **JewelMusic**, active a integração, introduza a API Key fornecida pelo dashboard da JewelMusic e confirme a Base URL oficial `https://api.jewelmusic.com` e, se necessário, configure proxy e timeout. Carregue ou seleccione um ficheiro de música, indique artista e título e clique em **Enviar música para JewelMusic**. O teste de ligação consulta `/v1/ping`; o upload envia `multipart/form-data` para `/v1/tracks/upload` com os metadados preenchidos.
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ A migração é baseada no workflow público [AI Agents A-Z — episódio 35](ht
|
|
|
44
44
|
|
|
45
45
|
## Pipeline de vídeo — fontes e ordem de execução
|
|
46
46
|
|
|
47
|
-
Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações.
|
|
47
|
+
Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações. Em **Configurações > Configuração API > API Keys > Imagem e Video Montagem/MoviePy**, cada cartão possui o campo **Prioridade**: o menor número aparece primeiro e a ordenação fica guardada no storage local.
|
|
48
48
|
|
|
49
49
|
A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**. O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa o pool de vídeo configurável com **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen**, respeitando apenas cartões activos que declarem capacidade de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
|
|
50
50
|
|
|
@@ -148,6 +148,8 @@ Os dois cards mostram de forma consistente o estado técnico, o rótulo legível
|
|
|
148
148
|
|
|
149
149
|
Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a tarefa a partir dos artefactos persistidos. O worker reutiliza o roteiro, título/keywords, MP4, prompt de thumbnail e imagem já existentes; só volta a gerar uma etapa quando o respectivo resultado não está disponível, e repete o upload apenas quando ele ainda não foi concluído. O botão **Apagar** remove o vídeo da fila e de `tasks.json` após confirmação, preservando os ficheiros de artefactos; tarefas em execução devem ser paradas antes de serem removidas.
|
|
150
150
|
|
|
151
|
+
**Não existe expiração automática de vídeos ou limpeza periódica da fila.** As actualizações normais não usam `--purge-data`, preservam o storage local e, quando encontram uma instalação antiga juntamente com um storage novo, unem as tarefas e filas por ID em vez de substituir a lista. Se `tasks.json` ficar corrompido, a aplicação preserva a cópia danificada e interrompe a leitura protegida para evitar apresentar uma fila vazia e gravá-la por cima dos dados existentes. A remoção de uma tarefa só ocorre por confirmação explícita em **Apagar** ou pela opção destrutiva `--purge-data`.
|
|
152
|
+
|
|
151
153
|
## Canais Youtube — edição por cartão e vídeos recentes
|
|
152
154
|
|
|
153
155
|
A página **Canais Youtube** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Nicho**, **Blueprint Padrão**, **Narrador/Voz Padrão**, conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
|
package/app/main.py
CHANGED
|
@@ -5617,7 +5617,7 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
5617
5617
|
_render_credential_status("" if is_local else card.get("api_key"), local=is_local, required=not is_local)
|
|
5618
5618
|
card_form = nullcontext() if embedded else st.form(f"material_source_card_form_{card_id}")
|
|
5619
5619
|
with card_form:
|
|
5620
|
-
content_cols = st.columns(
|
|
5620
|
+
content_cols = st.columns([1.4, 1.25, 0.85])
|
|
5621
5621
|
with content_cols[0]:
|
|
5622
5622
|
if is_local:
|
|
5623
5623
|
st.caption("Esta fonte não usa API key.")
|
|
@@ -5631,6 +5631,16 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
5631
5631
|
value=active_card_id == card_id,
|
|
5632
5632
|
key=f"material_card_{card_id}_selected",
|
|
5633
5633
|
)
|
|
5634
|
+
with content_cols[2]:
|
|
5635
|
+
priority = st.number_input(
|
|
5636
|
+
"Prioridade",
|
|
5637
|
+
min_value=1,
|
|
5638
|
+
max_value=999,
|
|
5639
|
+
value=max(1, int(card.get("priority", index + 1))),
|
|
5640
|
+
step=1,
|
|
5641
|
+
help="1 é o primeiro provider da fila. Em caso de falha elegível, os providers seguintes são considerados por ordem crescente.",
|
|
5642
|
+
key=f"material_card_{card_id}_priority",
|
|
5643
|
+
)
|
|
5634
5644
|
if not is_local:
|
|
5635
5645
|
_render_api_test_control(
|
|
5636
5646
|
settings,
|
|
@@ -5640,7 +5650,12 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
|
|
|
5640
5650
|
)
|
|
5641
5651
|
save_card = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"material_card_{card_id}_save")
|
|
5642
5652
|
if save_card:
|
|
5643
|
-
cards[index] = {
|
|
5653
|
+
cards[index] = {
|
|
5654
|
+
**card,
|
|
5655
|
+
"api_key": str(api_key or "").strip(),
|
|
5656
|
+
"enabled": bool(enabled),
|
|
5657
|
+
"priority": max(1, int(priority)),
|
|
5658
|
+
}
|
|
5644
5659
|
selected_id = card_id if selected and enabled else active_card_id
|
|
5645
5660
|
_persist_material_source_cards(settings, cards, selected_id)
|
|
5646
5661
|
st.success(f"Fonte {definition['label']} guardada.")
|
|
@@ -5668,7 +5683,12 @@ def render_material_source_api_keys(settings: dict[str, Any], *, embedded: bool
|
|
|
5668
5683
|
)
|
|
5669
5684
|
add_source_clicked = st.form_submit_button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card") if embedded else st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card")
|
|
5670
5685
|
if add_source_clicked:
|
|
5671
|
-
|
|
5686
|
+
new_card = new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}")
|
|
5687
|
+
new_card["priority"] = max(
|
|
5688
|
+
(int(item.get("priority", index + 1)) for index, item in enumerate(cards)),
|
|
5689
|
+
default=0,
|
|
5690
|
+
) + 1
|
|
5691
|
+
cards.append(new_card)
|
|
5672
5692
|
_persist_material_source_cards(settings, cards, str(settings.get("material_active_card_id") or ""))
|
|
5673
5693
|
st.rerun()
|
|
5674
5694
|
|
package/hermes_ui/domain.py
CHANGED
|
@@ -5,7 +5,7 @@ import uuid
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from .notifications import record_notification
|
|
8
|
-
from .storage import append_json, now, read_json, write_json
|
|
8
|
+
from .storage import StorageIntegrityError, append_json, now, read_json, update_json, write_json
|
|
9
9
|
|
|
10
10
|
STAGES = ["niche", "blueprint", "brand", "topic", "script", "title", "keywords", "video", "thumbnail_prompt", "thumbnail", "upload"]
|
|
11
11
|
# Ordem executada pelo worker. Cada etapa só é executada quando o seu artefacto
|
|
@@ -116,7 +116,6 @@ def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, o
|
|
|
116
116
|
|
|
117
117
|
def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
118
118
|
"""Expand a batch into tasks, allowing independent payloads for each channel."""
|
|
119
|
-
tasks = read_json("tasks.json", [])
|
|
120
119
|
channels = {c["id"]: c for c in read_json("channels.json", [])}
|
|
121
120
|
options = batch.get("options") or {}
|
|
122
121
|
channel_payloads = options.get("channel_payloads") or {}
|
|
@@ -201,15 +200,26 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
201
200
|
"created_at": now(),
|
|
202
201
|
"updated_at": now(),
|
|
203
202
|
}
|
|
204
|
-
tasks.append(task)
|
|
205
203
|
created.append(task)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
204
|
+
|
|
205
|
+
def persist_tasks(tasks: Any) -> list[dict[str, Any]]:
|
|
206
|
+
if not isinstance(tasks, list):
|
|
207
|
+
raise StorageIntegrityError("O ficheiro tasks.json não contém uma lista válida.")
|
|
208
|
+
tasks.extend(created)
|
|
209
|
+
return created
|
|
210
|
+
|
|
211
|
+
update_json("tasks.json", [], persist_tasks)
|
|
212
|
+
|
|
213
|
+
def persist_queues(queues: Any) -> dict[str, Any]:
|
|
214
|
+
if not isinstance(queues, dict):
|
|
215
|
+
raise StorageIntegrityError("O ficheiro queues.json não contém um objecto válido.")
|
|
216
|
+
queues.setdefault("script", [])
|
|
217
|
+
if not isinstance(queues["script"], list):
|
|
218
|
+
queues["script"] = []
|
|
219
|
+
queues["script"].extend(task["id"] for task in created)
|
|
220
|
+
return queues
|
|
221
|
+
|
|
222
|
+
update_json("queues.json", {}, persist_queues)
|
|
213
223
|
return created
|
|
214
224
|
|
|
215
225
|
|
|
@@ -271,16 +281,24 @@ def _notify_task_completion(task: dict[str, Any], previous_state: str = "") -> N
|
|
|
271
281
|
|
|
272
282
|
|
|
273
283
|
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
+
previous_state = ""
|
|
285
|
+
|
|
286
|
+
def mutate(tasks: Any) -> dict[str, Any] | None:
|
|
287
|
+
nonlocal previous_state
|
|
288
|
+
if not isinstance(tasks, list):
|
|
289
|
+
raise StorageIntegrityError("O ficheiro tasks.json não contém uma lista válida.")
|
|
290
|
+
for task in tasks:
|
|
291
|
+
if isinstance(task, dict) and task.get("id") == task_id:
|
|
292
|
+
previous_state = str(task.get("state") or "")
|
|
293
|
+
task.update(updates)
|
|
294
|
+
task["updated_at"] = now()
|
|
295
|
+
return task
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
updated = update_json("tasks.json", [], mutate)
|
|
299
|
+
if updated is not None:
|
|
300
|
+
_notify_task_completion(updated, previous_state)
|
|
301
|
+
return updated
|
|
284
302
|
|
|
285
303
|
|
|
286
304
|
def delete_task(task_id: str) -> dict[str, Any] | None:
|
|
@@ -288,30 +306,50 @@ def delete_task(task_id: str) -> dict[str, Any] | None:
|
|
|
288
306
|
normalized_id = str(task_id or "").strip()
|
|
289
307
|
if not normalized_id:
|
|
290
308
|
return None
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
309
|
+
removed: dict[str, Any] | None = None
|
|
310
|
+
|
|
311
|
+
def mutate(tasks: Any) -> list[dict[str, Any]]:
|
|
312
|
+
nonlocal removed
|
|
313
|
+
if not isinstance(tasks, list):
|
|
314
|
+
raise StorageIntegrityError("O ficheiro tasks.json não contém uma lista válida.")
|
|
315
|
+
for task in tasks:
|
|
316
|
+
if isinstance(task, dict) and str(task.get("id") or "") == normalized_id:
|
|
317
|
+
if str(task.get("state") or "") == "doing":
|
|
318
|
+
raise ValueError("Pare a tarefa antes de a remover da fila.")
|
|
319
|
+
removed = task
|
|
320
|
+
break
|
|
321
|
+
if removed is not None:
|
|
322
|
+
tasks[:] = [task for task in tasks if not (isinstance(task, dict) and str(task.get("id") or "") == normalized_id)]
|
|
323
|
+
return tasks
|
|
324
|
+
|
|
325
|
+
update_json("tasks.json", [], mutate)
|
|
295
326
|
if removed is None:
|
|
296
327
|
return None
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if isinstance(queues, dict):
|
|
328
|
+
|
|
329
|
+
def clean_queues(queues: Any) -> dict[str, Any]:
|
|
330
|
+
if not isinstance(queues, dict):
|
|
331
|
+
raise StorageIntegrityError("O ficheiro queues.json não contém um objecto válido.")
|
|
302
332
|
for queue_name, queue_items in list(queues.items()):
|
|
303
333
|
if isinstance(queue_items, list):
|
|
304
334
|
queues[queue_name] = [item for item in queue_items if str(item) != normalized_id]
|
|
305
|
-
|
|
335
|
+
return queues
|
|
336
|
+
|
|
337
|
+
update_json("queues.json", {}, clean_queues)
|
|
306
338
|
return removed
|
|
307
339
|
|
|
308
340
|
|
|
309
341
|
def transition_task(task_id: str, state: str | None = None, stage: str | None = None, error: str | None = None) -> dict[str, Any] | None:
|
|
310
342
|
if state and state not in VALID_STATES:
|
|
311
343
|
raise ValueError(f"Estado inválido: {state}")
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
344
|
+
previous_state = ""
|
|
345
|
+
|
|
346
|
+
def mutate(tasks: Any) -> dict[str, Any] | None:
|
|
347
|
+
nonlocal previous_state
|
|
348
|
+
if not isinstance(tasks, list):
|
|
349
|
+
raise StorageIntegrityError("O ficheiro tasks.json não contém uma lista válida.")
|
|
350
|
+
for task in tasks:
|
|
351
|
+
if not isinstance(task, dict) or task.get("id") != task_id:
|
|
352
|
+
continue
|
|
315
353
|
previous_state = str(task.get("state") or "")
|
|
316
354
|
if state:
|
|
317
355
|
task["state"] = state
|
|
@@ -322,10 +360,13 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
|
|
|
322
360
|
if error is not None:
|
|
323
361
|
task["error"] = error
|
|
324
362
|
task["updated_at"] = now()
|
|
325
|
-
write_json("tasks.json", tasks)
|
|
326
|
-
_notify_task_completion(task, previous_state)
|
|
327
363
|
return task
|
|
328
|
-
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
updated = update_json("tasks.json", [], mutate)
|
|
367
|
+
if updated is not None:
|
|
368
|
+
_notify_task_completion(updated, previous_state)
|
|
369
|
+
return updated
|
|
329
370
|
|
|
330
371
|
|
|
331
372
|
def retry_task_with_current_settings(task_id: str) -> dict[str, Any] | None:
|
|
@@ -335,28 +376,31 @@ def retry_task_with_current_settings(task_id: str) -> dict[str, Any] | None:
|
|
|
335
376
|
retry always uses the currently saved API keys, active provider priorities,
|
|
336
377
|
and provider endpoints while retaining the task's completed artefacts.
|
|
337
378
|
"""
|
|
338
|
-
tasks
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
task.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
379
|
+
def mutate(tasks: Any) -> dict[str, Any] | None:
|
|
380
|
+
if not isinstance(tasks, list):
|
|
381
|
+
raise StorageIntegrityError("O ficheiro tasks.json não contém uma lista válida.")
|
|
382
|
+
for task in tasks:
|
|
383
|
+
if not isinstance(task, dict) or task.get("id") != task_id:
|
|
384
|
+
continue
|
|
385
|
+
previous_state = str(task.get("state") or "")
|
|
386
|
+
if previous_state not in {"failed", "blocked"}:
|
|
387
|
+
raise ValueError("Apenas tarefas falhadas ou bloqueadas podem ser retomadas.")
|
|
388
|
+
try:
|
|
389
|
+
retry_count = int(task.get("retry_count") or 0)
|
|
390
|
+
except (TypeError, ValueError):
|
|
391
|
+
retry_count = 0
|
|
392
|
+
task["state"] = "to_do"
|
|
393
|
+
task["error"] = None
|
|
394
|
+
task["retry_count"] = retry_count + 1
|
|
395
|
+
task["retry_requested_at"] = now()
|
|
396
|
+
task["retry_config_source"] = "settings.json_at_execution"
|
|
397
|
+
for field in ("failure_api", "failure_provider", "failure_service", "failure_config_fields"):
|
|
398
|
+
task.pop(field, None)
|
|
399
|
+
task["updated_at"] = now()
|
|
400
|
+
return task
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
return update_json("tasks.json", [], mutate)
|
|
360
404
|
|
|
361
405
|
|
|
362
406
|
def pipeline_summary() -> dict[str, Any]:
|
|
@@ -6,6 +6,7 @@ from uuid import uuid4
|
|
|
6
6
|
|
|
7
7
|
MATERIAL_CARDS_KEY = "material_source_cards"
|
|
8
8
|
MATERIAL_ACTIVE_CARD_KEY = "material_active_card_id"
|
|
9
|
+
DEFAULT_MATERIAL_PRIORITY = 1
|
|
9
10
|
|
|
10
11
|
MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
|
|
11
12
|
{"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
|
|
@@ -43,15 +44,29 @@ def material_source_definition(source: Any) -> dict[str, str] | None:
|
|
|
43
44
|
return _SOURCE_BY_CODE.get(str(source or "").strip().lower())
|
|
44
45
|
|
|
45
46
|
|
|
46
|
-
def _new_card(
|
|
47
|
+
def _new_card(
|
|
48
|
+
provider: str,
|
|
49
|
+
api_key: str = "",
|
|
50
|
+
*,
|
|
51
|
+
card_id: str | None = None,
|
|
52
|
+
priority: int = DEFAULT_MATERIAL_PRIORITY,
|
|
53
|
+
) -> dict[str, Any]:
|
|
47
54
|
return {
|
|
48
55
|
"id": card_id or f"material-{provider}-{uuid4().hex[:8]}",
|
|
49
56
|
"provider": provider,
|
|
50
57
|
"api_key": str(api_key or "").strip(),
|
|
51
58
|
"enabled": True,
|
|
59
|
+
"priority": max(1, int(priority)),
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
|
|
63
|
+
def _normalise_priority(value: Any, fallback: int = DEFAULT_MATERIAL_PRIORITY) -> int:
|
|
64
|
+
try:
|
|
65
|
+
return max(1, int(value))
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
return max(1, int(fallback or DEFAULT_MATERIAL_PRIORITY))
|
|
68
|
+
|
|
69
|
+
|
|
55
70
|
def normalize_material_card(card: Any, index: int = 0) -> dict[str, Any]:
|
|
56
71
|
raw = card if isinstance(card, dict) else {}
|
|
57
72
|
provider = str(raw.get("provider") or raw.get("source") or "pexels").strip().lower()
|
|
@@ -63,9 +78,25 @@ def normalize_material_card(card: Any, index: int = 0) -> dict[str, Any]:
|
|
|
63
78
|
"provider": provider,
|
|
64
79
|
"api_key": str(raw.get("api_key") or raw.get("key") or "").strip() if provider != "local" else "",
|
|
65
80
|
"enabled": bool(raw.get("enabled", True)),
|
|
81
|
+
"priority": _normalise_priority(raw.get("priority", index + 1), index + 1),
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
|
|
85
|
+
def _ordered_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
86
|
+
indexed = [(index, normalize_material_card(card, index)) for index, card in enumerate(cards)]
|
|
87
|
+
indexed.sort(key=lambda pair: (pair[1].get("priority", pair[0] + 1), pair[0]))
|
|
88
|
+
return [card for _index, card in indexed]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def material_source_cards(settings: dict[str, Any], *, enabled_only: bool = False) -> list[dict[str, Any]]:
|
|
92
|
+
"""Return material providers in stable priority order for the MoviePy queue."""
|
|
93
|
+
migrated, _ = ensure_material_source_cards(settings)
|
|
94
|
+
cards = [dict(item) for item in migrated.get(MATERIAL_CARDS_KEY, [])]
|
|
95
|
+
if enabled_only:
|
|
96
|
+
cards = [card for card in cards if card.get("enabled", True)]
|
|
97
|
+
return cards
|
|
98
|
+
|
|
99
|
+
|
|
69
100
|
def _cards_key_mapping(cards: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
70
101
|
mapping = {item["code"]: [] for item in MATERIAL_SOURCE_CATALOG}
|
|
71
102
|
for index, raw_card in enumerate(cards):
|
|
@@ -94,12 +125,23 @@ def ensure_material_source_cards(settings: dict[str, Any]) -> tuple[dict[str, An
|
|
|
94
125
|
cards: list[dict[str, Any]] = []
|
|
95
126
|
for item in MATERIAL_SOURCE_CATALOG:
|
|
96
127
|
for key in material_api_keys(settings, item["code"], _ignore_cards=True):
|
|
97
|
-
cards.append(
|
|
128
|
+
cards.append(
|
|
129
|
+
_new_card(
|
|
130
|
+
item["code"],
|
|
131
|
+
key,
|
|
132
|
+
card_id=f"material-{item['code']}-{len(cards)}",
|
|
133
|
+
priority=len(cards) + 1,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
98
136
|
if not cards:
|
|
99
137
|
selected = selected_material_source(settings)
|
|
100
138
|
cards.append(_new_card(selected if selected in _SOURCE_BY_CODE else "pexels", card_id="material-default-0"))
|
|
101
139
|
changed = True
|
|
102
140
|
|
|
141
|
+
ordered_cards = _ordered_cards(cards)
|
|
142
|
+
if ordered_cards != cards:
|
|
143
|
+
changed = True
|
|
144
|
+
cards = ordered_cards
|
|
103
145
|
settings[MATERIAL_CARDS_KEY] = cards
|
|
104
146
|
active_id = str(settings.get(MATERIAL_ACTIVE_CARD_KEY) or "").strip()
|
|
105
147
|
valid_ids = {str(card["id"]) for card in cards}
|
|
@@ -137,6 +179,7 @@ def apply_material_source_cards_to_settings(
|
|
|
137
179
|
normalized_cards = [normalize_material_card(item, index) for index, item in enumerate(cards)]
|
|
138
180
|
if not normalized_cards:
|
|
139
181
|
normalized_cards = [_new_card("pexels", card_id="material-default-0")]
|
|
182
|
+
normalized_cards = _ordered_cards(normalized_cards)
|
|
140
183
|
settings[MATERIAL_CARDS_KEY] = normalized_cards
|
|
141
184
|
selected_card = next((card for card in normalized_cards if str(card["id"]) == str(active_card_id)), None)
|
|
142
185
|
if selected_card is None:
|
package/hermes_ui/storage.py
CHANGED
|
@@ -4,9 +4,12 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
6
|
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from copy import deepcopy
|
|
7
10
|
from datetime import datetime, timezone
|
|
8
11
|
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
12
|
+
from typing import Any, Callable, Iterator
|
|
10
13
|
|
|
11
14
|
ROOT = Path(__file__).resolve().parents[1]
|
|
12
15
|
STORAGE = Path(os.getenv("THUNDERBOLT_STORAGE_DIR") or ROOT / "storage")
|
|
@@ -407,13 +410,55 @@ def ensure_storage() -> None:
|
|
|
407
410
|
atomic_write(target, default)
|
|
408
411
|
|
|
409
412
|
|
|
410
|
-
|
|
411
|
-
|
|
413
|
+
_LOCK_TIMEOUT_SECONDS = 30.0
|
|
414
|
+
_LOCK_POLL_SECONDS = 0.05
|
|
415
|
+
_LOCK_STALE_SECONDS = 15 * 60
|
|
416
|
+
_PROTECTED_STATE_FILES = {"channels.json", "tasks.json", "batches.json", "queues.json", "uploads.json"}
|
|
412
417
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
418
|
+
|
|
419
|
+
class StorageIntegrityError(RuntimeError):
|
|
420
|
+
"""Raised when protected state cannot be recovered without risking data loss."""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@contextmanager
|
|
424
|
+
def _state_lock(path: Path) -> Iterator[None]:
|
|
425
|
+
"""Serialise state mutations across the UI and both local workers.
|
|
426
|
+
|
|
427
|
+
A lock file is used instead of an in-memory mutex because the launcher
|
|
428
|
+
runs Streamlit and workers as separate Python processes. Stale locks from
|
|
429
|
+
a machine shutdown are reclaimed after a conservative timeout.
|
|
416
430
|
"""
|
|
431
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
432
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
433
|
+
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
|
|
434
|
+
descriptor: int | None = None
|
|
435
|
+
while descriptor is None:
|
|
436
|
+
try:
|
|
437
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
438
|
+
os.write(descriptor, f"pid={os.getpid()}\n".encode("ascii"))
|
|
439
|
+
except FileExistsError:
|
|
440
|
+
try:
|
|
441
|
+
if time.time() - lock_path.stat().st_mtime > _LOCK_STALE_SECONDS:
|
|
442
|
+
lock_path.unlink()
|
|
443
|
+
continue
|
|
444
|
+
except OSError:
|
|
445
|
+
pass
|
|
446
|
+
if time.monotonic() >= deadline:
|
|
447
|
+
raise TimeoutError(f"Não foi possível obter o lock de storage: {path.name}")
|
|
448
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
449
|
+
try:
|
|
450
|
+
yield
|
|
451
|
+
finally:
|
|
452
|
+
try:
|
|
453
|
+
os.close(descriptor)
|
|
454
|
+
finally:
|
|
455
|
+
try:
|
|
456
|
+
lock_path.unlink()
|
|
457
|
+
except FileNotFoundError:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _atomic_write_unlocked(path: Path, data: Any) -> None:
|
|
417
462
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
418
463
|
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
419
464
|
try:
|
|
@@ -428,25 +473,79 @@ def atomic_write(path: Path, data: Any) -> None:
|
|
|
428
473
|
os.unlink(temp_name)
|
|
429
474
|
|
|
430
475
|
|
|
476
|
+
def atomic_write(path: Path, data: Any) -> None:
|
|
477
|
+
"""Write JSON through a locked, same-directory temporary file and replace.
|
|
478
|
+
|
|
479
|
+
The complete payload is flushed and fsynced before replacement, so a
|
|
480
|
+
process interruption cannot leave a partially written JSON document at the
|
|
481
|
+
destination, while the lock prevents another process from racing with the
|
|
482
|
+
replacement.
|
|
483
|
+
"""
|
|
484
|
+
with _state_lock(path):
|
|
485
|
+
_atomic_write_unlocked(path, data)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _corrupt_backup(path: Path) -> Path | None:
|
|
489
|
+
if not path.exists():
|
|
490
|
+
return None
|
|
491
|
+
backup = path.with_suffix(path.suffix + f".corrupt-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}")
|
|
492
|
+
shutil.copy2(path, backup)
|
|
493
|
+
return backup
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _load_json_unlocked(path: Path) -> Any:
|
|
497
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
498
|
+
return json.load(handle)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _recover_json_unlocked(name: str, path: Path, default: Any | None) -> Any:
|
|
502
|
+
backup = _corrupt_backup(path)
|
|
503
|
+
if name in _PROTECTED_STATE_FILES:
|
|
504
|
+
candidates = sorted(path.parent.glob(f"{path.name}.corrupt-*"), key=lambda item: item.stat().st_mtime, reverse=True)
|
|
505
|
+
for candidate in candidates:
|
|
506
|
+
if backup is not None and candidate == backup:
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
recovered = _load_json_unlocked(candidate)
|
|
510
|
+
except (json.JSONDecodeError, OSError):
|
|
511
|
+
continue
|
|
512
|
+
_atomic_write_unlocked(path, recovered)
|
|
513
|
+
return recovered
|
|
514
|
+
location = str(backup or path)
|
|
515
|
+
raise StorageIntegrityError(f"O ficheiro protegido {name} está corrompido. A cópia foi preservada em {location}.")
|
|
516
|
+
fallback = deepcopy(DEFAULTS.get(name, [] if default is None else default))
|
|
517
|
+
_atomic_write_unlocked(path, fallback)
|
|
518
|
+
return fallback
|
|
519
|
+
|
|
520
|
+
|
|
431
521
|
def read_json(name: str, default: Any | None = None) -> Any:
|
|
432
522
|
ensure_storage()
|
|
433
523
|
path = STATE / name
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
data =
|
|
524
|
+
with _state_lock(path):
|
|
525
|
+
try:
|
|
526
|
+
data = _load_json_unlocked(path)
|
|
527
|
+
except (json.JSONDecodeError, OSError):
|
|
528
|
+
data = _recover_json_unlocked(name, path, default)
|
|
437
529
|
if name != "settings.json":
|
|
438
530
|
return data
|
|
439
531
|
migrated, changed = _migrate_settings(data)
|
|
440
532
|
if changed:
|
|
441
|
-
|
|
533
|
+
_atomic_write_unlocked(path, migrated)
|
|
442
534
|
return migrated
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def update_json(name: str, default: Any, mutator: Callable[[Any], Any]) -> Any:
|
|
538
|
+
"""Atomically mutate one JSON document under the cross-process state lock."""
|
|
539
|
+
ensure_storage()
|
|
540
|
+
path = STATE / name
|
|
541
|
+
with _state_lock(path):
|
|
542
|
+
try:
|
|
543
|
+
current = _load_json_unlocked(path)
|
|
544
|
+
except (json.JSONDecodeError, OSError):
|
|
545
|
+
current = _recover_json_unlocked(name, path, default)
|
|
546
|
+
result = mutator(current)
|
|
547
|
+
_atomic_write_unlocked(path, current)
|
|
548
|
+
return result
|
|
450
549
|
|
|
451
550
|
|
|
452
551
|
def write_json(name: str, data: Any) -> None:
|
|
@@ -456,12 +555,13 @@ def write_json(name: str, data: Any) -> None:
|
|
|
456
555
|
|
|
457
556
|
|
|
458
557
|
def append_json(name: str, item: dict[str, Any]) -> dict[str, Any]:
|
|
459
|
-
entries
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
558
|
+
def append(entries: Any) -> dict[str, Any]:
|
|
559
|
+
if not isinstance(entries, list):
|
|
560
|
+
raise StorageIntegrityError(f"O ficheiro {name} não contém uma lista válida.")
|
|
561
|
+
entries.append(item)
|
|
562
|
+
return item
|
|
563
|
+
|
|
564
|
+
return update_json(name, [], append)
|
|
465
565
|
|
|
466
566
|
|
|
467
567
|
def _display_name_key(kind: str, path: Path) -> str:
|
package/hermes_ui/thumbnails.py
CHANGED
|
@@ -10,7 +10,7 @@ from typing import Any
|
|
|
10
10
|
from .creative_generation import generate_thumbnail_prompt
|
|
11
11
|
from .media_generation import generate_image_from_pool
|
|
12
12
|
from .media_providers import media_cards_for_pool
|
|
13
|
-
from .storage import STORAGE, now, read_json,
|
|
13
|
+
from .storage import STORAGE, now, read_json, update_json
|
|
14
14
|
from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
15
15
|
|
|
16
16
|
|
|
@@ -230,6 +230,18 @@ def _find_task(tasks: list[Any], task_id: str) -> tuple[dict[str, Any], dict[str
|
|
|
230
230
|
raise ThumbnailGenerationError(f"A tarefa {task_id} não foi encontrada.")
|
|
231
231
|
|
|
232
232
|
|
|
233
|
+
def _update_thumbnail_task(task_id: str, callback: Any) -> dict[str, Any]:
|
|
234
|
+
"""Apply a thumbnail mutation to the latest task snapshot under the storage lock."""
|
|
235
|
+
def mutate(tasks: Any) -> dict[str, Any]:
|
|
236
|
+
if not isinstance(tasks, list):
|
|
237
|
+
raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
|
|
238
|
+
task, record = _find_task(tasks, task_id)
|
|
239
|
+
callback(task, record)
|
|
240
|
+
return task
|
|
241
|
+
|
|
242
|
+
return update_json("tasks.json", [], mutate)
|
|
243
|
+
|
|
244
|
+
|
|
233
245
|
def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
|
|
234
246
|
"""Generate an image from the task's current prompt and persist it on the task."""
|
|
235
247
|
tasks = read_json("tasks.json", [])
|
|
@@ -247,9 +259,11 @@ def generate_thumbnail_for_task(task_id: str, settings: dict[str, Any]) -> tuple
|
|
|
247
259
|
lettering_text=record.get("thumbnail_text") or "",
|
|
248
260
|
lettering_prompt=record.get("lettering_prompt") or "",
|
|
249
261
|
)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
262
|
+
updated = _update_thumbnail_task(
|
|
263
|
+
task_id,
|
|
264
|
+
lambda current_task, current_record: _persist_thumbnail_result([], current_task, current_record, image_path),
|
|
265
|
+
)
|
|
266
|
+
return updated, image_path
|
|
253
267
|
|
|
254
268
|
|
|
255
269
|
def regenerate_thumbnail_prompt(
|
|
@@ -275,9 +289,11 @@ def regenerate_thumbnail_prompt(
|
|
|
275
289
|
blueprint=blueprint,
|
|
276
290
|
language=language,
|
|
277
291
|
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
updated = _update_thumbnail_task(
|
|
293
|
+
task_id,
|
|
294
|
+
lambda current_task, current_record: _persist_thumbnail_prompt_result(current_task, current_record, variant),
|
|
295
|
+
)
|
|
296
|
+
return updated, variant
|
|
281
297
|
|
|
282
298
|
|
|
283
299
|
def regenerate_thumbnail_prompt_and_image(
|
|
@@ -302,9 +318,13 @@ def regenerate_thumbnail_prompt_and_image(
|
|
|
302
318
|
lettering_text=str((variant or {}).get("overlay_text") or ""),
|
|
303
319
|
lettering_prompt=str((variant or {}).get("lettering_prompt") or ""),
|
|
304
320
|
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
321
|
+
updated = _update_thumbnail_task(
|
|
322
|
+
task_id,
|
|
323
|
+
lambda current_task, current_record: _persist_thumbnail_result(
|
|
324
|
+
[], current_task, current_record, image_path, variant=variant, source="prompt_regenerated"
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
return updated, image_path
|
|
308
328
|
|
|
309
329
|
|
|
310
330
|
def regenerate_thumbnail_lettering(
|
|
@@ -346,9 +366,13 @@ def regenerate_thumbnail_lettering(
|
|
|
346
366
|
variant = _variant_for_record(record)
|
|
347
367
|
variant["image_prompt"] = base_prompt
|
|
348
368
|
variant["lettering_prompt"] = edit_prompt
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
369
|
+
updated = _update_thumbnail_task(
|
|
370
|
+
task_id,
|
|
371
|
+
lambda current_task, current_record: _persist_thumbnail_result(
|
|
372
|
+
[], current_task, current_record, image_path, variant=variant, source="lettering_regenerated", lettering_prompt=edit_prompt
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
return updated, image_path
|
|
352
376
|
|
|
353
377
|
|
|
354
378
|
def upload_thumbnail_image(
|
|
@@ -387,9 +411,13 @@ def upload_thumbnail_image(
|
|
|
387
411
|
if os.path.exists(temp_name):
|
|
388
412
|
os.unlink(temp_name)
|
|
389
413
|
variant = _variant_for_record(record)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
414
|
+
updated = _update_thumbnail_task(
|
|
415
|
+
task_id,
|
|
416
|
+
lambda current_task, current_record: _persist_thumbnail_result(
|
|
417
|
+
[], current_task, current_record, destination, variant=variant, source="uploaded"
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
return updated, destination
|
|
393
421
|
|
|
394
422
|
|
|
395
423
|
def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
|
package/package.json
CHANGED
package/scripts/install.mjs
CHANGED
|
@@ -177,23 +177,27 @@ function legacyNpxPackageRoots() {
|
|
|
177
177
|
for (const hashRoot of cacheHashes) {
|
|
178
178
|
const candidate = resolve(hashRoot, "node_modules", "@danhachuel", "thunderbolt");
|
|
179
179
|
if (candidate === root || roots.has(candidate)) continue;
|
|
180
|
-
|
|
180
|
+
const recoverableStateFiles = ["ai_influencers.db", "tasks.json", "channels.json", "batches.json", "queues.json"];
|
|
181
|
+
if (recoverableStateFiles.some((filename) => existsSync(join(candidate, "storage", "state", filename)))) roots.add(candidate);
|
|
181
182
|
}
|
|
182
183
|
return [...roots].sort((left, right) => {
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
const newestStateTime = (candidate) => Math.max(...["ai_influencers.db", "tasks.json", "channels.json", "batches.json", "queues.json"].map((filename) => {
|
|
185
|
+
try { return statSync(join(candidate, "storage", "state", filename)).mtimeMs; }
|
|
186
|
+
catch { return 0; }
|
|
187
|
+
}));
|
|
188
|
+
return newestStateTime(right) - newestStateTime(left);
|
|
185
189
|
});
|
|
186
190
|
}
|
|
187
191
|
|
|
188
192
|
function migrateLegacyNpxStorage() {
|
|
189
193
|
const targetStorage = join(thunderboltHome, "storage");
|
|
190
194
|
const candidates = legacyNpxPackageRoots();
|
|
191
|
-
let
|
|
195
|
+
let mergedFiles = 0;
|
|
192
196
|
for (const candidate of candidates) {
|
|
193
|
-
|
|
194
|
-
|
|
197
|
+
copyMissingTree(join(candidate, "storage"), targetStorage);
|
|
198
|
+
mergedFiles += mergeLegacyStorage(candidate);
|
|
195
199
|
}
|
|
196
|
-
if (
|
|
200
|
+
if (mergedFiles > 0) console.log(`Dados locais recuperados do cache npm para ${targetStorage} (${mergedFiles} ficheiro(s) unido(s)).`);
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
function copyOrMove(source, target, label) {
|
|
@@ -209,16 +213,86 @@ function copyOrMove(source, target, label) {
|
|
|
209
213
|
return true;
|
|
210
214
|
}
|
|
211
215
|
|
|
216
|
+
function parseJsonFile(path) {
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function mergeUniqueArray(target, source) {
|
|
225
|
+
const merged = [...target];
|
|
226
|
+
const knownIds = new Set(merged.filter((item) => item && typeof item === "object" && item.id).map((item) => String(item.id)));
|
|
227
|
+
const knownValues = new Set(merged.map((item) => JSON.stringify(item)));
|
|
228
|
+
for (const item of source) {
|
|
229
|
+
const id = item && typeof item === "object" && item.id ? String(item.id) : "";
|
|
230
|
+
const value = JSON.stringify(item);
|
|
231
|
+
if ((id && knownIds.has(id)) || knownValues.has(value)) continue;
|
|
232
|
+
merged.push(item);
|
|
233
|
+
if (id) knownIds.add(id);
|
|
234
|
+
knownValues.add(value);
|
|
235
|
+
}
|
|
236
|
+
return merged;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mergeJsonStateFile(source, target, filename) {
|
|
240
|
+
if (!existsSync(source) || !existsSync(target)) return false;
|
|
241
|
+
const sourceData = parseJsonFile(source);
|
|
242
|
+
const targetData = parseJsonFile(target);
|
|
243
|
+
if (sourceData === null) return false;
|
|
244
|
+
if (targetData === null) {
|
|
245
|
+
copyFileSync(target, `${target}.corrupt-${Date.now()}`);
|
|
246
|
+
writeFileSync(target, JSON.stringify(sourceData, null, 2) + "\n", "utf8");
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
let merged = targetData;
|
|
250
|
+
if (Array.isArray(sourceData) && Array.isArray(targetData)) {
|
|
251
|
+
merged = mergeUniqueArray(targetData, sourceData);
|
|
252
|
+
} else if (filename === "queues.json" && sourceData && targetData && typeof sourceData === "object" && typeof targetData === "object") {
|
|
253
|
+
merged = { ...targetData };
|
|
254
|
+
for (const [queueName, sourceItems] of Object.entries(sourceData)) {
|
|
255
|
+
if (!Array.isArray(sourceItems)) continue;
|
|
256
|
+
const targetItems = Array.isArray(merged[queueName]) ? merged[queueName] : [];
|
|
257
|
+
merged[queueName] = mergeUniqueArray(targetItems, sourceItems);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (JSON.stringify(merged) === JSON.stringify(targetData)) return false;
|
|
261
|
+
writeFileSync(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function mergeLegacyStorage(legacyRoot) {
|
|
266
|
+
const sourceStorage = join(legacyRoot, "storage");
|
|
267
|
+
const targetStorage = join(thunderboltHome, "storage");
|
|
268
|
+
if (!existsSync(sourceStorage)) return 0;
|
|
269
|
+
mkdirSync(targetStorage, { recursive: true });
|
|
270
|
+
copyMissingTree(sourceStorage, targetStorage);
|
|
271
|
+
const sourceState = join(sourceStorage, "state");
|
|
272
|
+
const targetState = join(targetStorage, "state");
|
|
273
|
+
if (!existsSync(sourceState) || !existsSync(targetState)) return 0;
|
|
274
|
+
let mergedCount = 0;
|
|
275
|
+
for (const entry of readdirSync(sourceState, { withFileTypes: true })) {
|
|
276
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
277
|
+
if (mergeJsonStateFile(join(sourceState, entry.name), join(targetState, entry.name), entry.name)) mergedCount += 1;
|
|
278
|
+
}
|
|
279
|
+
return mergedCount;
|
|
280
|
+
}
|
|
281
|
+
|
|
212
282
|
function migrateLegacyInstallation() {
|
|
213
283
|
if (explicitThunderboltHome) return;
|
|
214
284
|
const candidates = legacyRoots();
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
285
|
+
let found = false;
|
|
286
|
+
for (const legacy of candidates) {
|
|
287
|
+
if (!existsSync(join(legacy, "storage")) && !existsSync(join(legacy, ".venv")) && !existsSync(join(legacy, "MoneyPrinterTurbo"))) continue;
|
|
288
|
+
found = true;
|
|
289
|
+
console.warn(`Foi encontrada uma instalação antiga em ${legacy}. O Thunderbolt usará ${thunderboltHome}.`);
|
|
290
|
+
const mergedFiles = mergeLegacyStorage(legacy);
|
|
291
|
+
if (mergedFiles > 0) console.log(`Dados de estado recuperados da instalação antiga (${mergedFiles} ficheiro(s) unido(s)).`);
|
|
292
|
+
copyOrMove(join(legacy, ".venv"), join(thunderboltHome, ".venv"), "ambiente Python legado");
|
|
293
|
+
copyOrMove(join(legacy, "MoneyPrinterTurbo"), join(thunderboltHome, "MoneyPrinterTurbo"), "MoneyPrinterTurbo legado");
|
|
294
|
+
}
|
|
295
|
+
if (found) console.log("A instalação antiga foi preservada; o Thunderbolt não elimina tarefas, filas ou artefactos durante a actualização.");
|
|
222
296
|
}
|
|
223
297
|
|
|
224
298
|
function removePath(path) {
|