@danhachuel/thunderbolt 0.4.9 → 0.4.10
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 +2 -0
- package/hermes_ui/domain.py +102 -58
- 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
|
@@ -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/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]:
|
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) {
|