@danhachuel/thunderbolt 0.2.70 → 0.2.72

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/README.md CHANGED
@@ -38,13 +38,13 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
38
38
 
39
39
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
40
40
 
41
- ## Navegação da UI 0.2.70
41
+ ## Navegação da UI 0.2.72
42
42
 
43
43
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais** e **Tutorial Meta**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
44
44
 
45
45
  Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo`, documentos de Upload directo, `INNERTUBE_API_KEY`, o formulário **Adicionar outra conta Gmail** e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes API Keys, providers, modelos, serviços, materiais, Nano Banana, TikTok, Postiz e o **Teste de vozes**.
46
46
 
47
- A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** é o centro local reservado para futuros eventos de processamento, publicação e integrações.
47
+ A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada.
48
48
 
49
49
  ## Canais Youtube — edição por cartão e vídeos recentes
50
50
 
package/app/main.py CHANGED
@@ -34,6 +34,7 @@ from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips,
34
34
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
35
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
36
36
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
37
+ from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
37
38
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
38
39
  from hermes_ui.script_generation import generate_script_document
39
40
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
@@ -331,7 +332,16 @@ def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str
331
332
  blueprint_for_channel(channel),
332
333
  language=str(channel.get("language") or "Português"),
333
334
  )
334
- return creative_payload_from_result(channel, topic, creative, topic_source=topic_source)
335
+ payload = creative_payload_from_result(channel, topic, creative, topic_source=topic_source)
336
+ creative_key = hashlib.sha1(json.dumps({"channel_id": channel.get("id") or "", "topic": topic, "titles": payload.get("title_candidates", [])}, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")).hexdigest()
337
+ record_notification(
338
+ "title_generation_completed",
339
+ f"Títulos gerados: {payload.get('title') or topic or 'Vídeo'}",
340
+ f"O pacote de títulos para {channel.get('name') or 'o canal seleccionado'} terminou de ser gerado.",
341
+ metadata={"channel_name": channel.get("name") or "", "topic": topic},
342
+ dedupe_key=f"titles:{creative_key}",
343
+ )
344
+ return payload
335
345
 
336
346
 
337
347
  def valid_hhmm(value: str) -> bool:
@@ -671,8 +681,10 @@ def render_blueprints():
671
681
  try:
672
682
  blueprint, branding = create_blueprint_from_link(source_url, niche, language, creation_type == "Blueprint + Branding completo", channel_name, blueprint_name)
673
683
  blueprint_path, branding_path = save_generated_blueprint(blueprint, branding)
684
+ record_notification("blueprint_completed", f"Blueprint criado: {blueprint_path.stem}", "O Blueprint foi criado e guardado no storage local.", metadata={"name": blueprint_path.stem}, dedupe_key=f"blueprint:{blueprint_path}:{blueprint_path.stat().st_mtime_ns}")
674
685
  st.success(f"Blueprint criado: {blueprint_path.name}")
675
686
  if branding_path:
687
+ record_notification("branding_completed", f"Branding criado: {branding_path.stem}", "O Branding foi criado e guardado no storage local.", metadata={"name": branding_path.stem}, dedupe_key=f"branding:{branding_path}:{branding_path.stat().st_mtime_ns}")
676
688
  st.success(f"Branding completo criado: {branding_path.name}")
677
689
  st.rerun()
678
690
  except ValueError as exc:
@@ -692,6 +704,7 @@ def render_blueprints():
692
704
  st.warning("O ficheiro já existe. Confirme a substituição.")
693
705
  else:
694
706
  destination.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
707
+ record_notification("blueprint_completed", f"Blueprint guardado: {destination.stem}", "O Blueprint importado foi guardado no storage local.", metadata={"name": destination.stem}, dedupe_key=f"blueprint:{destination}:{destination.stat().st_mtime_ns}")
695
708
  st.success(f"Blueprint guardado em {destination}")
696
709
  st.rerun()
697
710
  except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
@@ -711,14 +724,13 @@ def render_blueprints():
711
724
  card_key = _library_card_key("blueprints", path)
712
725
  header_cols = st.columns([0.93, 0.07], vertical_alignment="center")
713
726
  with header_cols[0]:
714
- with st.expander(f"{title} — {path.relative_to(BLUEPRINTS)}"):
715
- st.caption(f"Ficheiro: {path}")
727
+ with st.expander(title):
716
728
  st.json(data)
717
729
  with header_cols[1]:
718
730
  _render_card_pencil(f"rename_blueprints_{card_key}")
719
731
  _render_library_name_editor("blueprints", path, title)
720
732
  except Exception as exc:
721
- with st.expander(f"Inválido — {path.name}"):
733
+ with st.expander(f"Inválido — {path.stem}"):
722
734
  st.error(str(exc))
723
735
  with branding_tab:
724
736
  st.subheader("Brandings completos")
@@ -732,6 +744,7 @@ def render_blueprints():
732
744
  target = BLUEPRINTS / "brandings" / (Path(branding_upload.name).stem.replace(" ", "-") + ".json")
733
745
  target.parent.mkdir(parents=True, exist_ok=True)
734
746
  target.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
747
+ record_notification("branding_completed", f"Branding guardado: {target.stem}", "O Branding importado foi guardado no storage local.", metadata={"name": target.stem}, dedupe_key=f"branding:{target}:{target.stat().st_mtime_ns}")
735
748
  st.success(f"Branding guardado em {target}")
736
749
  st.rerun()
737
750
  except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
@@ -751,7 +764,7 @@ def render_blueprints():
751
764
  st.caption(f"Blueprint associado: {data.get('blueprint_id') or 'não associado'}")
752
765
  st.json(data)
753
766
  except Exception as exc:
754
- with st.expander(f"Inválido — {path.name}"):
767
+ with st.expander(f"Inválido — {path.stem}"):
755
768
  st.error(str(exc))
756
769
 
757
770
 
@@ -963,8 +976,7 @@ def render_tiktok_prompt_masters():
963
976
  card_key = _library_card_key("prompt_masters", path)
964
977
  header_cols = st.columns([0.93, 0.07], vertical_alignment="center")
965
978
  with header_cols[0]:
966
- with st.expander(f"{heading} — {path.name}", expanded=False):
967
- st.caption(f"Ficheiro TikTok: `{path}`")
979
+ with st.expander(heading, expanded=False):
968
980
  edited_content = st.text_area("Conteúdo Markdown", value=content, height=360, key=f"tiktok_prompt_master_editor_{path.stem}")
969
981
  prompt_cols = st.columns(3)
970
982
  with prompt_cols[0]:
@@ -984,7 +996,7 @@ def render_tiktok_prompt_masters():
984
996
  _render_card_pencil(f"rename_prompt_masters_{card_key}")
985
997
  _render_library_name_editor("prompt_masters", path, heading)
986
998
  except (OSError, ValueError) as exc:
987
- with st.expander(f"Ficheiro inválido — {path.name}"):
999
+ with st.expander(f"Ficheiro inválido — {path.stem}"):
988
1000
  st.error(str(exc))
989
1001
 
990
1002
 
@@ -1536,6 +1548,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1536
1548
  payload["thumbnail_path"] = thumbnail_path
1537
1549
  payload["thumbnail_status"] = "generated"
1538
1550
  st.session_state["new_video_general_payloads"] = payloads
1551
+ record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": channel.get("name") or "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
1539
1552
  st.success("Thumbnail gerada com Nano Banana.")
1540
1553
  st.rerun()
1541
1554
  except ThumbnailGenerationError as exc:
@@ -1590,6 +1603,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1590
1603
  payload["thumbnail_path"] = thumbnail_path
1591
1604
  payload["thumbnail_status"] = "generated"
1592
1605
  st.session_state["new_video_creative_payload"] = payload
1606
+ record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": selected_one.get("name") if selected_one else "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
1593
1607
  st.success("Thumbnail gerada com Nano Banana.")
1594
1608
  st.rerun()
1595
1609
  except ThumbnailGenerationError as exc:
@@ -1763,6 +1777,16 @@ def render_scripts():
1763
1777
  structure_notes=structure_notes,
1764
1778
  generation_settings=script_settings,
1765
1779
  )
1780
+ generation_key = hashlib.sha1(f"{document_type}|{generated.get('title', '')}|{generated.get('content', '')}".encode("utf-8")).hexdigest()
1781
+ generated["notification_dedupe_key"] = f"script-generation:{generation_key}"
1782
+ script_event_type = "music_lyrics_generated" if document_type == "Letra de música" else "standalone_script_generated"
1783
+ record_notification(
1784
+ script_event_type,
1785
+ f"{'Letra de música' if document_type == 'Letra de música' else 'Roteiro autónomo'} gerado: {generated.get('title') or title or 'Documento'}",
1786
+ "A geração do documento terminou com sucesso e o rascunho está disponível para revisão.",
1787
+ metadata={"document_type": "music_lyrics" if document_type == "Letra de música" else "video_script", "title": generated.get("title") or title or "Documento"},
1788
+ dedupe_key=generated["notification_dedupe_key"],
1789
+ )
1766
1790
  st.session_state["script_draft"] = generated
1767
1791
  st.session_state["script_draft_title"] = generated["title"]
1768
1792
  st.session_state["script_draft_summary"] = generated.get("summary", "")
@@ -1888,6 +1912,9 @@ def render_niche_finder():
1888
1912
  )
1889
1913
  st.session_state["niche_results"] = results
1890
1914
  st.session_state["niche_last_parameters"] = current_parameters
1915
+ niche_key = hashlib.sha1(json.dumps(current_parameters, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")).hexdigest()
1916
+ summary = results.get("summary", {}) if isinstance(results, dict) else {}
1917
+ record_notification("niche_analysis_completed", "Análise de nicho concluída", "A análise Kaggle terminou com resultados prontos para consulta.", metadata={"source": "Kaggle", "rows_filtered": summary.get("rows_filtered", 0)}, dedupe_key=f"niche:kaggle:{niche_key}")
1891
1918
  st.success("Análise concluída.")
1892
1919
  except (NicheAnalysisError, DatasetError, OSError) as exc:
1893
1920
  st.error("Não foi possível concluir a análise solicitada com os parâmetros actuais.")
@@ -2031,6 +2058,7 @@ def render_niche_finder_apify():
2031
2058
  history = []
2032
2059
  history.insert(0, st.session_state["niche_apify_last_run"])
2033
2060
  write_json("niche_apify_runs.json", history[:20])
2061
+ record_notification("niche_analysis_completed", "Análise de nicho concluída", f"A pesquisa Apify terminou com {len(items)} vídeo(s) recebido(s).", metadata={"run_id": finished.run_id, "item_count": len(items)}, dedupe_key=f"niche:apify:{finished.run_id}")
2034
2062
  st.session_state.pop("niche_apify_active_run", None)
2035
2063
  progress.progress(100, text="Pesquisa Apify concluída.")
2036
2064
  st.success(f"Pesquisa concluída: {len(items)} vídeo(s) recebido(s).")
@@ -2572,6 +2600,7 @@ def render_upload_direct():
2572
2600
  uploads = read_json("uploads.json", [])
2573
2601
  uploads.append(record)
2574
2602
  write_json("uploads.json", uploads)
2603
+ reconcile_persisted_notifications()
2575
2604
  (st.success if result.ok else st.error)(result.message)
2576
2605
 
2577
2606
 
@@ -2659,6 +2688,7 @@ def render_upload_postiz():
2659
2688
  uploads = read_json("uploads.json", [])
2660
2689
  uploads.append(record)
2661
2690
  write_json("uploads.json", uploads)
2691
+ reconcile_persisted_notifications()
2662
2692
  (st.success if result.ok else st.error)(result.message)
2663
2693
 
2664
2694
 
@@ -2835,6 +2865,7 @@ def render_upload_conventional():
2835
2865
  uploads = read_json("uploads.json", [])
2836
2866
  uploads.append(record)
2837
2867
  write_json("uploads.json", uploads)
2868
+ reconcile_persisted_notifications()
2838
2869
  (st.success if result.ok else st.error)(result.message)
2839
2870
  if result.data.get("attempts"):
2840
2871
  with st.expander("Detalhes dos mecanismos de upload"):
@@ -2854,6 +2885,7 @@ def render_upload_conventional():
2854
2885
  uploads = read_json("uploads.json", [])
2855
2886
  uploads.append(record)
2856
2887
  write_json("uploads.json", uploads)
2888
+ reconcile_persisted_notifications()
2857
2889
  (st.success if result.ok else st.warning)(result.message)
2858
2890
  if "Instagram" in destination:
2859
2891
  st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
@@ -3360,13 +3392,91 @@ def render_settings():
3360
3392
 
3361
3393
  def render_notifications():
3362
3394
  st.title("Notificações")
3363
- st.caption("Centro de notificações locais do Thunderbolt.")
3364
- st.info("Ainda não existem notificações registadas. Os eventos de processamento, publicação e integração serão ligados aqui numa etapa posterior.")
3395
+ st.caption("Centro de notificações internas persistentes do Thunderbolt. As conclusões são guardadas no storage local e aparecem quando a aplicação é actualizada.")
3396
+ reconcile_persisted_notifications()
3397
+ preferences = notification_preferences()
3398
+ catalog = notification_event_catalog()
3399
+ notifications = list_notifications(limit=500)
3400
+ unread_count = unread_notification_count()
3401
+ summary_cols = st.columns(3)
3402
+ with summary_cols[0]:
3403
+ st.metric("Não lidas", unread_count)
3404
+ with summary_cols[1]:
3405
+ st.metric("Total guardado", len(notifications))
3406
+ with summary_cols[2]:
3407
+ st.metric("Operações mapeadas", len(catalog))
3408
+
3409
+ action_cols = st.columns([1.4, 1.4, 2.2])
3410
+ with action_cols[0]:
3411
+ if st.button("Marcar todas como lidas", use_container_width=True, disabled=unread_count == 0):
3412
+ mark_all_notifications_read()
3413
+ st.rerun()
3414
+ with action_cols[1]:
3415
+ if st.button("Actualizar notificações", use_container_width=True):
3416
+ reconcile_persisted_notifications()
3417
+ st.rerun()
3418
+ with action_cols[2]:
3419
+ confirm_clear = st.checkbox("Confirmar limpeza do histórico", key="confirm_clear_notifications")
3420
+ if st.button("Limpar histórico", use_container_width=True, disabled=not confirm_clear):
3421
+ clear_notifications()
3422
+ st.session_state.pop("confirm_clear_notifications", None)
3423
+ st.rerun()
3424
+
3425
+ st.divider()
3426
+ st.subheader("Operações notificadas")
3427
+ st.caption("Ligue ou desligue cada tipo de notificação. As preferências ficam guardadas no storage local e aplicam-se às próximas conclusões.")
3428
+ grouped: dict[str, list[dict[str, str]]] = {}
3429
+ for event in catalog:
3430
+ grouped.setdefault(event["category"], []).append(event)
3431
+ with st.form("notification_preferences_form"):
3432
+ pending_preferences: dict[str, bool] = {}
3433
+ for category, events in grouped.items():
3434
+ st.markdown(f"**{category}**")
3435
+ for event in events:
3436
+ pending_preferences[event["code"]] = st.checkbox(
3437
+ event["label"],
3438
+ value=bool(preferences.get(event["code"], True)),
3439
+ help=event["description"],
3440
+ key=f"notification_preference_{event['code']}",
3441
+ )
3442
+ if st.form_submit_button("Guardar preferências", type="primary", use_container_width=True):
3443
+ save_notification_preferences(pending_preferences)
3444
+ st.success("Preferências de notificação guardadas.")
3445
+ st.rerun()
3446
+
3365
3447
  st.divider()
3366
- st.subheader("Preferências de notificação")
3367
- st.caption("Os canais de e-mail, push e webhook ainda não estão activos nesta versão.")
3368
- st.checkbox("Notificações no painel", value=True, disabled=True, key="notifications_panel_preview")
3369
- st.checkbox("Notificações por e-mail", value=False, disabled=True, key="notifications_email_preview")
3448
+ st.subheader("Histórico de notificações")
3449
+ filter_cols = st.columns([1, 1.4])
3450
+ category_options = ["Todas"] + sorted({event["category"] for event in catalog})
3451
+ with filter_cols[0]:
3452
+ selected_category = st.selectbox("Categoria", category_options, key="notifications_category_filter")
3453
+ with filter_cols[1]:
3454
+ selected_state = st.selectbox("Estado", ["Todas", "Não lidas", "Lidas"], key="notifications_state_filter")
3455
+ category_filter = "" if selected_category == "Todas" else selected_category
3456
+ unread_filter = selected_state == "Não lidas"
3457
+ filtered = list_notifications(limit=500, category=category_filter, unread_only=unread_filter)
3458
+ if selected_state == "Lidas":
3459
+ filtered = [item for item in filtered if item.get("read")]
3460
+ if not filtered:
3461
+ st.info("Ainda não existem notificações para os filtros seleccionados.")
3462
+ for item in filtered:
3463
+ with st.container(border=True):
3464
+ notification_cols = st.columns([3.3, 1.4, 1])
3465
+ with notification_cols[0]:
3466
+ st.write(f"**{item.get('title') or item.get('label') or 'Notificação'}**")
3467
+ st.caption(item.get("message") or "")
3468
+ metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
3469
+ if metadata:
3470
+ public_details = " · ".join(f"{key}: {value}" for key, value in metadata.items() if value not in (None, ""))
3471
+ if public_details:
3472
+ st.caption(public_details)
3473
+ with notification_cols[1]:
3474
+ st.caption(f"{item.get('category', 'Sistema')} · {item.get('created_at', '—')}")
3475
+ st.write("Lida" if item.get("read") else "Não lida")
3476
+ with notification_cols[2]:
3477
+ if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", use_container_width=True):
3478
+ mark_notification_read(str(item.get("id")))
3479
+ st.rerun()
3370
3480
 
3371
3481
 
3372
3482
  def render_models_ai_tutorial():
@@ -3784,6 +3894,10 @@ def main():
3784
3894
  "Configuração API": render_settings,
3785
3895
  "Notificações": render_notifications,
3786
3896
  }
3897
+ try:
3898
+ reconcile_persisted_notifications()
3899
+ except Exception:
3900
+ pass
3787
3901
  renderers.get(current_page, render_dashboard)()
3788
3902
 
3789
3903
  if __name__ == "__main__":
@@ -10,6 +10,7 @@ from typing import Any
10
10
  from . import storage
11
11
  from .creative_generation import generate_creative_package, generate_topic_for_channel
12
12
  from .domain import create_batch, create_tasks_for_batch
13
+ from .notifications import record_notification
13
14
 
14
15
  WORKER_STATE_FILE = "automation_worker.json"
15
16
  LOCK_FILENAME = "automation_worker.lock"
@@ -257,6 +258,13 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
257
258
  "batch_id": batch["id"],
258
259
  "task_ids": [task["id"] for task in item["tasks"]],
259
260
  }
261
+ record_notification(
262
+ "automation_completed",
263
+ "Automação concluída",
264
+ f"O lote agendado do canal {item['channel_id']} foi criado com sucesso.",
265
+ metadata={"channel_id": item["channel_id"], "batch_id": batch["id"], "time": current_minute},
266
+ dedupe_key=f"automation:{batch['id']}",
267
+ )
260
268
  _write_status(status)
261
269
  return {
262
270
  "ok": True,
@@ -268,6 +276,13 @@ def run_once(when: datetime | None = None) -> dict[str, Any]:
268
276
  except Exception as exc:
269
277
  status["last_error"] = str(exc)
270
278
  _write_status(status)
279
+ record_notification(
280
+ "automation_failed",
281
+ "Automação falhou",
282
+ f"A execução automática das {current_minute} terminou com erro: {exc}",
283
+ metadata={"date": day, "time": current_minute},
284
+ dedupe_key=f"automation:failed:{day}:{current_minute}",
285
+ )
271
286
  return {"ok": False, "local_time": _local_iso(current), "error": str(exc), "created": created}
272
287
 
273
288
 
package/hermes_ui/cuts.py CHANGED
@@ -17,6 +17,7 @@ import requests
17
17
 
18
18
  from . import storage
19
19
  from .metadata_cleaner import VIDEO_EXTENSIONS, _resolve_ffmpeg
20
+ from .notifications import record_notification
20
21
 
21
22
 
22
23
  class CutsError(RuntimeError):
@@ -380,6 +381,14 @@ def generate_clips(
380
381
  record["error"] = str(exc)
381
382
  _write_manifest(record, run_dir)
382
383
  storage.append_json("cuts_runs.json", record)
384
+ if record["status"] == "complete":
385
+ record_notification(
386
+ "cuts_completed",
387
+ "Cortes concluídos",
388
+ f"A geração de cortes para {record['source_name']} terminou com {len(record['clips'])} clip(s).",
389
+ metadata={"run_id": record["id"], "clip_count": len(record["clips"]), "output_format": record.get("output_format") or ""},
390
+ dedupe_key=f"cuts:{record['id']}",
391
+ )
383
392
  if record["status"] == "error":
384
393
  raise CutsError(record["error"])
385
394
  return record
@@ -4,6 +4,7 @@ import re
4
4
  import uuid
5
5
  from typing import Any
6
6
 
7
+ from .notifications import record_notification
7
8
  from .storage import append_json, now, read_json, write_json
8
9
 
9
10
  STAGES = ["niche", "blueprint", "brand", "script", "title", "thumbnail", "video", "edit", "upload"]
@@ -199,13 +200,49 @@ def update_channel_video(video_id: str, updates: dict[str, Any]) -> dict[str, An
199
200
  return None
200
201
 
201
202
 
203
+ def _notify_task_completion(task: dict[str, Any], previous_state: str = "") -> None:
204
+ current_state = str(task.get("state") or "")
205
+ task_id = str(task.get("id") or "")
206
+ if not task_id or current_state == previous_state and current_state in {"done", "failed"}:
207
+ return
208
+ title = str(task.get("title") or task.get("topic") or "Actividade")
209
+ channel = str(task.get("channel_name") or "Canal")
210
+ if current_state == "failed":
211
+ record_notification(
212
+ "activity_failed",
213
+ f"Actividade falhou: {title}",
214
+ f"A actividade de {channel} terminou com erro.",
215
+ metadata={"task_id": task_id, "channel_name": channel, "error": task.get("error") or ""},
216
+ dedupe_key=f"task:{task_id}:failed",
217
+ )
218
+ return
219
+ if current_state != "done":
220
+ return
221
+ artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
222
+ if bool(task.get("music_mode")) or str(task.get("style_wide") or "") == "music":
223
+ event_type, label = "music_completed", "Música concluída"
224
+ elif str(task.get("stage") or "") == "script" and not artifacts.get("video"):
225
+ event_type, label = "script_stage_completed", "Roteiro concluído"
226
+ else:
227
+ event_type, label = "video_completed", "Vídeo concluído"
228
+ record_notification(
229
+ event_type,
230
+ f"{label}: {title}",
231
+ f"A actividade de {channel} terminou com sucesso.",
232
+ metadata={"task_id": task_id, "channel_name": channel, "stage": task.get("stage") or "", "creation_mode": task.get("creation_mode") or ""},
233
+ dedupe_key=f"task:{task_id}:{event_type}:done",
234
+ )
235
+
236
+
202
237
  def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
203
238
  tasks = read_json("tasks.json", [])
204
239
  for task in tasks:
205
240
  if task.get("id") == task_id:
241
+ previous_state = str(task.get("state") or "")
206
242
  task.update(updates)
207
243
  task["updated_at"] = now()
208
244
  write_json("tasks.json", tasks)
245
+ _notify_task_completion(task, previous_state)
209
246
  return task
210
247
  return None
211
248
 
@@ -216,6 +253,7 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
216
253
  tasks = read_json("tasks.json", [])
217
254
  for task in tasks:
218
255
  if task.get("id") == task_id:
256
+ previous_state = str(task.get("state") or "")
219
257
  if state:
220
258
  task["state"] = state
221
259
  if stage:
@@ -226,6 +264,7 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
226
264
  task["error"] = error
227
265
  task["updated_at"] = now()
228
266
  write_json("tasks.json", tasks)
267
+ _notify_task_completion(task, previous_state)
229
268
  return task
230
269
  return None
231
270
 
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from . import storage
14
+ from .notifications import record_notification
14
15
 
15
16
 
16
17
  def _metadata_root() -> Path:
@@ -175,6 +176,13 @@ def save_edit_record(source: Path, output: Path, metadata: dict[str, Any], run_i
175
176
  "created_at": run_info.get("created_at", _now()),
176
177
  }
177
178
  storage.append_json("metadata_edits.json", record)
179
+ record_notification(
180
+ "metadata_cleaning_completed",
181
+ f"Metadados limpos: {output.name}",
182
+ "A limpeza de metadados terminou com um ficheiro de saída guardado.",
183
+ metadata={"record_id": record["id"], "output_name": output.name},
184
+ dedupe_key=f"metadata_cleaning_completed:{record['id']}",
185
+ )
178
186
  return record
179
187
 
180
188
 
@@ -8,6 +8,7 @@ from typing import Any
8
8
  import requests
9
9
 
10
10
  from . import storage
11
+ from .notifications import record_notification
11
12
 
12
13
  MUSIC_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
13
14
 
@@ -35,6 +36,13 @@ def store_music_file(name: str, content: bytes) -> Path:
35
36
  raise ValueError("O ficheiro de música está vazio.")
36
37
  target = music_directory() / safe_music_name(name)
37
38
  target.write_bytes(content)
39
+ record_notification(
40
+ "music_completed",
41
+ f"Música concluída: {target.stem}",
42
+ f"O ficheiro de música {target.name} foi guardado no storage local.",
43
+ metadata={"filename": target.name, "source": "local_storage"},
44
+ dedupe_key=f"music:{target.name}:{target.stat().st_mtime_ns}",
45
+ )
38
46
  return target
39
47
 
40
48
 
@@ -0,0 +1,340 @@
1
+ """Persistent local notifications for Thunderbolt activity completion events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from . import storage
11
+
12
+ NOTIFICATIONS_FILE = "notifications.json"
13
+ MAX_NOTIFICATIONS = 500
14
+
15
+ EVENT_CATALOG: tuple[dict[str, str], ...] = (
16
+ {"code": "video_completed", "category": "Produção", "label": "Vídeo concluído", "description": "Quando uma tarefa de vídeo chegar ao estado final."},
17
+ {"code": "music_completed", "category": "Produção", "label": "Música concluída", "description": "Quando uma tarefa musical ou um ficheiro musical terminar de ser guardado."},
18
+ {"code": "standalone_script_generated", "category": "Roteiros", "label": "Roteiro autónomo gerado", "description": "Quando um roteiro independente terminar de ser gerado."},
19
+ {"code": "music_lyrics_generated", "category": "Roteiros", "label": "Letra de música gerada", "description": "Quando uma letra de música independente terminar de ser gerada."},
20
+ {"code": "script_stage_completed", "category": "Pipeline", "label": "Etapa de roteiro concluída", "description": "Quando a etapa de roteiro de uma tarefa terminar."},
21
+ {"code": "title_generation_completed", "category": "Pipeline", "label": "Títulos gerados", "description": "Quando o pacote de títulos terminar de ser gerado."},
22
+ {"code": "thumbnail_generation_completed", "category": "Pipeline", "label": "Thumbnail gerada", "description": "Quando a imagem final da thumbnail for criada."},
23
+ {"code": "blueprint_completed", "category": "Pipeline", "label": "Blueprint criado ou importado", "description": "Quando um Blueprint for criado, importado ou guardado."},
24
+ {"code": "branding_completed", "category": "Pipeline", "label": "Branding criado ou importado", "description": "Quando um Branding for criado, importado ou guardado."},
25
+ {"code": "niche_analysis_completed", "category": "Pipeline", "label": "Análise de nicho concluída", "description": "Quando uma análise Kaggle ou Apify terminar com resultados."},
26
+ {"code": "cuts_completed", "category": "Edição", "label": "Cortes concluídos", "description": "Quando a geração de cortes terminar com um manifesto completo."},
27
+ {"code": "metadata_cleaning_completed", "category": "Edição", "label": "Metadados limpos", "description": "Quando uma cópia com metadados limpos for criada."},
28
+ {"code": "python_edit_completed", "category": "Edição", "label": "Edição Python concluída", "description": "Quando uma operação do Editor Python guardar o artefacto."},
29
+ {"code": "automation_completed", "category": "Automação", "label": "Automação concluída", "description": "Quando o worker concluir o lote agendado de um canal."},
30
+ {"code": "automation_failed", "category": "Automação", "label": "Automação falhou", "description": "Quando uma execução automática terminar com erro."},
31
+ {"code": "activity_failed", "category": "Sistema", "label": "Actividade falhou", "description": "Quando uma tarefa ou operação persistida terminar em erro."},
32
+ {"code": "upload_youtube_success", "category": "Upload", "label": "Upload YouTube concluído", "description": "Quando um vídeo for publicado num canal YouTube."},
33
+ {"code": "upload_tiktok_success", "category": "Upload", "label": "Upload TikTok concluído", "description": "Quando um vídeo for publicado numa conta TikTok."},
34
+ {"code": "upload_instagram_success", "category": "Upload", "label": "Upload Instagram concluído", "description": "Quando um vídeo for publicado num perfil Instagram."},
35
+ {"code": "upload_facebook_pages_success", "category": "Upload", "label": "Upload Facebook Pages concluído", "description": "Quando um vídeo for publicado numa Facebook Page."},
36
+ {"code": "upload_postiz_success", "category": "Upload", "label": "Upload Postiz concluído", "description": "Quando um vídeo for enviado e publicado através do Postiz."},
37
+ {"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
38
+ )
39
+ EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
40
+ SENSITIVE_MARKERS = (
41
+ "token",
42
+ "secret",
43
+ "api_key",
44
+ "apikey",
45
+ "cookie",
46
+ "password",
47
+ "authorization",
48
+ "sessioninfo",
49
+ "session_info",
50
+ "access_token",
51
+ "client_secret",
52
+ "sid",
53
+ "ssid",
54
+ "hsid",
55
+ "apisid",
56
+ )
57
+
58
+
59
+ def notification_event_catalog() -> list[dict[str, str]]:
60
+ return [dict(item) for item in EVENT_CATALOG]
61
+
62
+
63
+ def default_notification_preferences() -> dict[str, bool]:
64
+ return {item["code"]: True for item in EVENT_CATALOG}
65
+
66
+
67
+ def notification_preferences() -> dict[str, bool]:
68
+ settings = storage.read_json("settings.json", {})
69
+ raw = settings.get("notification_preferences", {}) if isinstance(settings, dict) else {}
70
+ raw = raw if isinstance(raw, dict) else {}
71
+ preferences = default_notification_preferences()
72
+ for code in preferences:
73
+ if code in raw:
74
+ preferences[code] = bool(raw[code])
75
+ return preferences
76
+
77
+
78
+ def save_notification_preferences(preferences: dict[str, Any]) -> dict[str, bool]:
79
+ settings = storage.read_json("settings.json", {})
80
+ existing = settings.get("notification_preferences", {}) if isinstance(settings, dict) else {}
81
+ existing = dict(existing) if isinstance(existing, dict) else {}
82
+ for code in EVENTS_BY_CODE:
83
+ if code in preferences:
84
+ existing[code] = bool(preferences[code])
85
+ settings["notification_preferences"] = existing
86
+ storage.write_json("settings.json", settings)
87
+ return notification_preferences()
88
+
89
+
90
+ def _redact_text(value: Any) -> str:
91
+ text = str(value or "").strip()
92
+ if not text:
93
+ return ""
94
+ pattern = r"(?i)(api[_ -]?key|client[_ -]?secret|access[_ -]?token|authorization|bearer|session[_ -]?info)\s*[:=]\s*[^\s,;]+"
95
+ text = re.sub(pattern, r"\1=[redacted]", text)
96
+ return re.sub(r"(?i)\bbearer\s+[^\s,;]+", "Bearer [redacted]", text)
97
+
98
+
99
+ def _safe_metadata(value: Any, *, key: str = "") -> Any:
100
+ normalized_key = re.sub(r"[^a-z0-9_]", "", key.lower())
101
+ if any(marker in normalized_key for marker in SENSITIVE_MARKERS):
102
+ return "[redacted]"
103
+ if isinstance(value, dict):
104
+ return {str(item_key): _safe_metadata(item_value, key=str(item_key)) for item_key, item_value in value.items() if str(item_key).lower() not in {"payload", "response", "headers"}}
105
+ if isinstance(value, list):
106
+ return [_safe_metadata(item) for item in value[:50]]
107
+ if isinstance(value, (str, int, float, bool)) or value is None:
108
+ return _redact_text(value) if isinstance(value, str) else value
109
+ return _redact_text(value)
110
+
111
+
112
+ def _history() -> list[dict[str, Any]]:
113
+ saved = storage.read_json(NOTIFICATIONS_FILE, [])
114
+ if not isinstance(saved, list):
115
+ return []
116
+ return [item for item in saved if isinstance(item, dict)]
117
+
118
+
119
+ def record_notification(
120
+ event_type: str,
121
+ title: str,
122
+ message: str,
123
+ *,
124
+ metadata: dict[str, Any] | None = None,
125
+ dedupe_key: str = "",
126
+ ) -> dict[str, Any] | None:
127
+ event = EVENTS_BY_CODE.get(str(event_type))
128
+ if event is None:
129
+ raise ValueError(f"Tipo de notificação desconhecido: {event_type}")
130
+ if not notification_preferences().get(event_type, True):
131
+ return None
132
+ history = _history()
133
+ if dedupe_key:
134
+ for existing in history:
135
+ if str(existing.get("dedupe_key") or "") == dedupe_key:
136
+ return None
137
+ created_at = datetime.now(timezone.utc).isoformat()
138
+ entry = {
139
+ "id": f"notification_{uuid.uuid4().hex[:12]}",
140
+ "event_type": event_type,
141
+ "category": event["category"],
142
+ "label": event["label"],
143
+ "title": _redact_text(title),
144
+ "message": _redact_text(message),
145
+ "created_at": created_at,
146
+ "read": False,
147
+ "metadata": _safe_metadata(metadata or {}),
148
+ "dedupe_key": str(dedupe_key or ""),
149
+ }
150
+ storage.write_json(NOTIFICATIONS_FILE, [entry, *history][:MAX_NOTIFICATIONS])
151
+ return entry
152
+
153
+
154
+ def list_notifications(*, limit: int = 200, category: str = "", unread_only: bool = False) -> list[dict[str, Any]]:
155
+ entries = _history()
156
+ if category:
157
+ entries = [item for item in entries if item.get("category") == category]
158
+ if unread_only:
159
+ entries = [item for item in entries if not item.get("read")]
160
+ entries.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
161
+ return entries[: max(1, min(int(limit), MAX_NOTIFICATIONS))]
162
+
163
+
164
+ def unread_notification_count() -> int:
165
+ return sum(1 for item in _history() if not item.get("read"))
166
+
167
+
168
+ def mark_notification_read(notification_id: str) -> bool:
169
+ history = _history()
170
+ changed = False
171
+ for item in history:
172
+ if str(item.get("id")) == str(notification_id):
173
+ item["read"] = True
174
+ changed = True
175
+ break
176
+ if changed:
177
+ storage.write_json(NOTIFICATIONS_FILE, history)
178
+ return changed
179
+
180
+
181
+ def mark_all_notifications_read() -> int:
182
+ history = _history()
183
+ changed = sum(1 for item in history if not item.get("read"))
184
+ if changed:
185
+ for item in history:
186
+ item["read"] = True
187
+ storage.write_json(NOTIFICATIONS_FILE, history)
188
+ return changed
189
+
190
+
191
+ def clear_notifications() -> int:
192
+ count = len(_history())
193
+ storage.write_json(NOTIFICATIONS_FILE, [])
194
+ return count
195
+
196
+
197
+ def _task_notifications() -> int:
198
+ created = 0
199
+ tasks = storage.read_json("tasks.json", [])
200
+ if not isinstance(tasks, list):
201
+ return 0
202
+ for task in tasks:
203
+ if not isinstance(task, dict):
204
+ continue
205
+ task_id = str(task.get("id") or "")
206
+ if not task_id:
207
+ continue
208
+ state = str(task.get("state") or "")
209
+ title = str(task.get("title") or task.get("topic") or "Actividade")
210
+ channel = str(task.get("channel_name") or "Canal")
211
+ if state == "failed":
212
+ if record_notification("activity_failed", f"Actividade falhou: {title}", f"A actividade de {channel} terminou com erro.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:failed"):
213
+ created += 1
214
+ continue
215
+ if state != "done":
216
+ continue
217
+ artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
218
+ if bool(task.get("music_mode")) or str(task.get("style_wide") or "") == "music":
219
+ event_type = "music_completed"
220
+ label = "Música concluída"
221
+ elif str(task.get("stage") or "") == "script" and not artifacts.get("video"):
222
+ event_type = "script_stage_completed"
223
+ label = "Roteiro concluído"
224
+ else:
225
+ event_type = "video_completed"
226
+ label = "Vídeo concluído"
227
+ if record_notification(event_type, f"{label}: {title}", f"A actividade de {channel} terminou com sucesso.", metadata={"task_id": task_id, "channel_name": channel, "stage": task.get("stage") or "", "creation_mode": task.get("creation_mode") or ""}, dedupe_key=f"task:{task_id}:{event_type}:done"):
228
+ created += 1
229
+ if isinstance(task.get("title_candidates"), list) and task.get("title_candidates"):
230
+ if record_notification("title_generation_completed", f"Títulos gerados: {title}", f"O pacote de títulos para {channel} terminou de ser gerado.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:titles"):
231
+ created += 1
232
+ if str(task.get("thumbnail_status") or "") == "generated" or artifacts.get("thumbnail"):
233
+ if record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {title}", f"A thumbnail de {channel} está pronta.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:thumbnail"):
234
+ created += 1
235
+ return created
236
+
237
+
238
+ def _upload_notifications() -> int:
239
+ created = 0
240
+ uploads = storage.read_json("uploads.json", [])
241
+ if not isinstance(uploads, list):
242
+ return 0
243
+ event_by_destination = {
244
+ "youtube": "upload_youtube_success",
245
+ "tiktok": "upload_tiktok_success",
246
+ "instagram": "upload_instagram_success",
247
+ "facebook pages": "upload_facebook_pages_success",
248
+ "facebook_pages": "upload_facebook_pages_success",
249
+ "postiz": "upload_postiz_success",
250
+ "youtube direct frontend": "upload_youtube_success",
251
+ }
252
+ for upload in uploads:
253
+ if not isinstance(upload, dict) or str(upload.get("status") or "").lower() not in {"published", "success", "done"}:
254
+ continue
255
+ upload_id = str(upload.get("id") or "")
256
+ task_id = str(upload.get("task_id") or upload_id or uuid.uuid4().hex[:8])
257
+ upload_created_at = str(upload.get("created_at") or "")
258
+ destination = str(upload.get("destination") or "Upload").strip()
259
+ event_type = event_by_destination.get(destination.lower(), "upload_youtube_success" if destination.lower().startswith("youtube") else "upload_postiz_success" if destination.lower().startswith("postiz") else "upload_youtube_success")
260
+ target = upload.get("target") if isinstance(upload.get("target"), dict) else {}
261
+ target_name = str(target.get("name") or target.get("label") or target.get("handle") or target.get("username") or "destino seleccionado")
262
+ if record_notification(event_type, f"Upload concluído: {destination}", f"O vídeo foi enviado com sucesso para {target_name}.", metadata={"task_id": task_id, "destination": destination, "target": target, "route": (upload.get("data") or {}).get("route") if isinstance(upload.get("data"), dict) else ""}, dedupe_key=f"upload:{upload_id or task_id}:{destination.lower()}:{upload_created_at}"):
263
+ created += 1
264
+ return created
265
+
266
+
267
+ def _script_notifications() -> int:
268
+ created = 0
269
+ records = storage.read_json("scripts.json", [])
270
+ if not isinstance(records, list):
271
+ return 0
272
+ for record in records:
273
+ if not isinstance(record, dict) or not record.get("id"):
274
+ continue
275
+ event_type = "music_lyrics_generated" if str(record.get("document_type") or "") == "music_lyrics" else "standalone_script_generated"
276
+ label = "Letra de música" if event_type == "music_lyrics_generated" else "Roteiro autónomo"
277
+ document_id = str(record["id"])
278
+ dedupe_key = str(record.get("notification_dedupe_key") or f"script:{document_id}:generated")
279
+ if record_notification(event_type, f"{label} gerado: {record.get('title') or 'Documento'}", f"O documento foi guardado no histórico de roteiros.", metadata={"document_id": document_id, "document_type": record.get("document_type") or ""}, dedupe_key=dedupe_key):
280
+ created += 1
281
+ return created
282
+
283
+
284
+ def _automation_notifications() -> int:
285
+ created = 0
286
+ worker = storage.read_json("automation_worker.json", {})
287
+ last_runs = worker.get("last_runs", {}) if isinstance(worker, dict) else {}
288
+ if isinstance(last_runs, dict):
289
+ for channel_id, run in last_runs.items():
290
+ if not isinstance(run, dict) or not run.get("batch_id"):
291
+ continue
292
+ if record_notification("automation_completed", "Automação concluída", f"O lote agendado do canal {channel_id} foi criado com sucesso.", metadata={"channel_id": channel_id, "batch_id": run.get("batch_id"), "time": run.get("time") or ""}, dedupe_key=f"automation:{run['batch_id']}"):
293
+ created += 1
294
+ return created
295
+
296
+
297
+ def _generic_completion_notifications() -> int:
298
+ created = 0
299
+ cuts = storage.read_json("cuts_runs.json", [])
300
+ if isinstance(cuts, list):
301
+ for run in cuts:
302
+ if isinstance(run, dict) and str(run.get("status") or "") == "complete" and run.get("id"):
303
+ if record_notification("cuts_completed", "Cortes concluídos", "A geração de cortes terminou com um manifesto completo.", metadata={"run_id": run.get("id")}, dedupe_key=f"cuts:{run['id']}"):
304
+ created += 1
305
+ indexed_sources = (
306
+ ("metadata_edits.json", "metadata_cleaning_completed", "Metadados limpos", "metadata edit"),
307
+ ("python_editor_edits.json", "python_edit_completed", "Edição Python concluída", "Python edit"),
308
+ )
309
+ for filename, event_type, label, source_label in indexed_sources:
310
+ records = storage.read_json(filename, [])
311
+ if not isinstance(records, list):
312
+ continue
313
+ for record in records:
314
+ if not isinstance(record, dict) or not record.get("id"):
315
+ continue
316
+ record_id = str(record["id"])
317
+ output_name = str(record.get("output_name") or "artefacto guardado")
318
+ if record_notification(event_type, f"{label}: {output_name}", f"A operação {source_label} terminou com sucesso.", metadata={"record_id": record_id, "output_name": output_name, "operation": record.get("operation") or ""}, dedupe_key=f"{event_type}:{record_id}"):
319
+ created += 1
320
+ apify_runs = storage.read_json("niche_apify_runs.json", [])
321
+ if isinstance(apify_runs, list):
322
+ for run in apify_runs:
323
+ status = str(run.get("status") or "").lower() if isinstance(run, dict) else ""
324
+ if status in {"succeeded", "success", "completed", "complete"} and run.get("run_id"):
325
+ run_id = str(run["run_id"])
326
+ if record_notification("niche_analysis_completed", "Análise de nicho concluída", f"A pesquisa Apify terminou com {run.get('item_count', 0)} vídeo(s) recebido(s).", metadata={"run_id": run_id, "item_count": run.get("item_count", 0)}, dedupe_key=f"niche:apify:{run_id}"):
327
+ created += 1
328
+ return created
329
+
330
+
331
+ def reconcile_persisted_notifications() -> int:
332
+ """Emit idempotent events for completions written by other local processes."""
333
+ total = 0
334
+ for resolver in (_task_notifications, _upload_notifications, _script_notifications, _automation_notifications, _generic_completion_notifications):
335
+ try:
336
+ total += resolver()
337
+ except Exception:
338
+ # Notifications must never prevent the main production/upload flow.
339
+ continue
340
+ return total
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from typing import Any
12
12
 
13
13
  from . import storage
14
+ from .notifications import record_notification
14
15
  from .metadata_cleaner import VIDEO_EXTENSIONS as METADATA_VIDEO_EXTENSIONS
15
16
  from .metadata_cleaner import _resolve_ffmpeg
16
17
 
@@ -257,6 +258,13 @@ def save_edit_record(source: Path, output: Path, run_info: dict[str, Any]) -> di
257
258
  "created_at": run_info.get("created_at", _now()),
258
259
  }
259
260
  storage.append_json("python_editor_edits.json", record)
261
+ record_notification(
262
+ "python_edit_completed",
263
+ f"Edição Python concluída: {output.name}",
264
+ f"A operação {record.get('operation') or 'de edição'} terminou com um artefacto guardado.",
265
+ metadata={"record_id": record["id"], "output_name": output.name, "operation": record.get("operation") or ""},
266
+ dedupe_key=f"python_edit_completed:{record['id']}",
267
+ )
260
268
  return record
261
269
 
262
270
 
@@ -24,6 +24,7 @@ DEFAULTS: dict[str, Any] = {
24
24
  "queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
25
25
  "batches.json": [],
26
26
  "uploads.json": [],
27
+ "notifications.json": [],
27
28
  "display_names.json": {"blueprints": {}, "prompt_masters": {}},
28
29
  "niche_apify_runs.json": [],
29
30
  "metadata_edits.json": [],
@@ -90,6 +91,30 @@ DEFAULTS: dict[str, Any] = {
90
91
  "youtube_client_secret": "",
91
92
  "youtube_batch_accounts": [],
92
93
  "youtube_batch_selected_account_id": "",
94
+ "notification_preferences": {
95
+ "video_completed": True,
96
+ "music_completed": True,
97
+ "standalone_script_generated": True,
98
+ "music_lyrics_generated": True,
99
+ "script_stage_completed": True,
100
+ "title_generation_completed": True,
101
+ "thumbnail_generation_completed": True,
102
+ "blueprint_completed": True,
103
+ "branding_completed": True,
104
+ "niche_analysis_completed": True,
105
+ "cuts_completed": True,
106
+ "metadata_cleaning_completed": True,
107
+ "python_edit_completed": True,
108
+ "automation_completed": True,
109
+ "automation_failed": True,
110
+ "activity_failed": True,
111
+ "upload_youtube_success": True,
112
+ "upload_tiktok_success": True,
113
+ "upload_instagram_success": True,
114
+ "upload_facebook_pages_success": True,
115
+ "upload_postiz_success": True,
116
+ "mcp_operation_completed": True,
117
+ },
93
118
  "kaggle_username": "",
94
119
  "kaggle_api_key": "",
95
120
  "kaggle_kernel_slug": "thunderbolt-niche-finder",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.70",
3
+ "version": "0.2.72",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",