@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.
@@ -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**.
@@ -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
- write_json("tasks.json", tasks)
207
- queues = read_json("queues.json", {})
208
- if not isinstance(queues, dict):
209
- queues = {}
210
- queues.setdefault("script", [])
211
- queues["script"].extend(task["id"] for task in created)
212
- write_json("queues.json", queues)
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
- tasks = read_json("tasks.json", [])
275
- for task in tasks:
276
- if task.get("id") == task_id:
277
- previous_state = str(task.get("state") or "")
278
- task.update(updates)
279
- task["updated_at"] = now()
280
- write_json("tasks.json", tasks)
281
- _notify_task_completion(task, previous_state)
282
- return task
283
- return None
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
- tasks = read_json("tasks.json", [])
292
- if not isinstance(tasks, list):
293
- return None
294
- removed = next((task for task in tasks if isinstance(task, dict) and str(task.get("id") or "") == normalized_id), None)
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
- if str(removed.get("state") or "") == "doing":
298
- raise ValueError("Pare a tarefa antes de a remover da fila.")
299
- write_json("tasks.json", [task for task in tasks if not (isinstance(task, dict) and str(task.get("id") or "") == normalized_id)])
300
- queues = read_json("queues.json", {})
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
- write_json("queues.json", queues)
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
- tasks = read_json("tasks.json", [])
313
- for task in tasks:
314
- if task.get("id") == task_id:
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
- return None
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 = read_json("tasks.json", [])
339
- for task in tasks:
340
- if task.get("id") != task_id:
341
- continue
342
- previous_state = str(task.get("state") or "")
343
- if previous_state not in {"failed", "blocked"}:
344
- raise ValueError("Apenas tarefas falhadas ou bloqueadas podem ser retomadas.")
345
- try:
346
- retry_count = int(task.get("retry_count") or 0)
347
- except (TypeError, ValueError):
348
- retry_count = 0
349
- task["state"] = "to_do"
350
- task["error"] = None
351
- task["retry_count"] = retry_count + 1
352
- task["retry_requested_at"] = now()
353
- task["retry_config_source"] = "settings.json_at_execution"
354
- for field in ("failure_api", "failure_provider", "failure_service", "failure_config_fields"):
355
- task.pop(field, None)
356
- task["updated_at"] = now()
357
- write_json("tasks.json", tasks)
358
- return task
359
- return None
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]:
@@ -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
- def atomic_write(path: Path, data: Any) -> None:
411
- """Write JSON through a same-directory temporary file and atomic replace.
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
- The complete payload is flushed and fsynced before replacement, so a
414
- process interruption cannot leave a partially written JSON document at the
415
- destination.
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
- try:
435
- with path.open("r", encoding="utf-8") as handle:
436
- data = json.load(handle)
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
- atomic_write(path, migrated)
533
+ _atomic_write_unlocked(path, migrated)
442
534
  return migrated
443
- except (json.JSONDecodeError, OSError):
444
- backup = path.with_suffix(path.suffix + f".corrupt-{datetime.now().strftime('%Y%m%d%H%M%S')}")
445
- if path.exists():
446
- shutil.copy2(path, backup)
447
- fallback = DEFAULTS.get(name, [] if default is None else default)
448
- atomic_write(path, fallback)
449
- return fallback
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 = read_json(name, [])
460
- if not isinstance(entries, list):
461
- entries = []
462
- entries.append(item)
463
- write_json(name, entries)
464
- return item
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:
@@ -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, write_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
- _persist_thumbnail_result(tasks, task, record, image_path)
251
- write_json("tasks.json", tasks)
252
- return task, image_path
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
- _persist_thumbnail_prompt_result(task, record, variant)
279
- write_json("tasks.json", tasks)
280
- return task, variant
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
- _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="prompt_regenerated")
306
- write_json("tasks.json", tasks)
307
- return task, image_path
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
- _persist_thumbnail_result(tasks, task, record, image_path, variant=variant, source="lettering_regenerated", lettering_prompt=edit_prompt)
350
- write_json("tasks.json", tasks)
351
- return task, image_path
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
- _persist_thumbnail_result(tasks, task, record, destination, variant=variant, source="uploaded")
391
- write_json("tasks.json", tasks)
392
- return task, destination
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",
@@ -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
- if (existsSync(join(candidate, "storage", "state", "ai_influencers.db"))) roots.add(candidate);
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
- try { return statSync(join(right, "storage", "state", "ai_influencers.db")).mtimeMs - statSync(join(left, "storage", "state", "ai_influencers.db")).mtimeMs; }
184
- catch { return 0; }
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 copied = 0;
195
+ let mergedFiles = 0;
192
196
  for (const candidate of candidates) {
193
- copied += copyMissingTree(join(candidate, "storage"), targetStorage);
194
- if (existsSync(join(targetStorage, "state", "ai_influencers.db"))) break;
197
+ copyMissingTree(join(candidate, "storage"), targetStorage);
198
+ mergedFiles += mergeLegacyStorage(candidate);
195
199
  }
196
- if (copied > 0) console.log(`Dados locais AI Influencers recuperados do cache npm para ${targetStorage}.`);
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
- const legacy = candidates.find((candidate) => existsSync(join(candidate, "storage")) || existsSync(join(candidate, ".venv")) || existsSync(join(candidate, "MoneyPrinterTurbo")));
216
- if (!legacy) return;
217
- console.warn(`Foi encontrada uma instalação antiga em ${legacy}. O Thunderbolt usará ${thunderboltHome}.`);
218
- copyOrMove(join(legacy, "storage"), join(thunderboltHome, "storage"), "storage legado");
219
- copyOrMove(join(legacy, ".venv"), join(thunderboltHome, ".venv"), "ambiente Python legado");
220
- copyOrMove(join(legacy, "MoneyPrinterTurbo"), join(thunderboltHome, "MoneyPrinterTurbo"), "MoneyPrinterTurbo legado");
221
- console.log("A pasta legada não será usada pelo Thunderbolt; foi preservada quando a cópia foi necessária.");
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) {