@danhachuel/thunderbolt 0.3.19 → 0.3.20

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/app/main.py CHANGED
@@ -34,7 +34,7 @@ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEd
34
34
  from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
35
35
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
36
36
  from hermes_ui.mcp_server import server_status, start_server, stop_server
37
- from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
37
+ from hermes_ui.material_sources import apply_material_source_cards_to_settings, ensure_material_source_cards, material_source_catalog, material_source_definition, new_material_card, normalize_material_card, selected_material_source
38
38
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
39
39
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
40
40
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
@@ -3663,51 +3663,86 @@ def render_google_accounts():
3663
3663
  st.success("Configuração global do YouTube guardada em Contas Google.")
3664
3664
  st.rerun()
3665
3665
 
3666
- def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3667
- st.subheader("Fontes de materiais")
3668
- st.caption("Seleccione a fonte que será usada na pipeline e guarde uma ou mais API keys. Qualidade, endpoints e parâmetros internos são definidos pelo Thunderbolt.")
3669
- source_catalog = material_source_catalog()
3670
- source_codes = [item["code"] for item in source_catalog] + ["local"]
3671
- source_labels = {item["code"]: item["label"] for item in source_catalog} | {"local": "Ficheiros locais"}
3672
- source_help = {item["code"]: item["description"] for item in source_catalog} | {"local": "Usar materiais já existentes no storage local; não requer API key."}
3673
- selected_source = st.selectbox(
3674
- "Fonte de materiais",
3675
- source_codes,
3676
- index=source_codes.index(selected_material_source(settings)) if selected_material_source(settings) in source_codes else 0,
3677
- format_func=lambda value: source_labels.get(value, value),
3678
- key="material_source_selector",
3679
- )
3680
- st.caption(source_help.get(selected_source, ""))
3681
- current_keys = material_api_keys(settings, selected_source)
3682
- row_key = f"material_source_key_rows_{selected_source}"
3683
- row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
3684
- if selected_source == "local":
3685
- _render_credential_status("", local=True, required=False)
3686
- st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
3687
- if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
3688
- settings["video_source"] = selected_source
3689
- write_json("settings.json", settings)
3690
- st.success("Fonte de materiais guardada: ficheiros locais.")
3666
+ def _persist_material_source_cards(settings: dict[str, Any], cards: list[dict[str, Any]], active_card_id: str = "") -> dict[str, Any]:
3667
+ updated = apply_material_source_cards_to_settings(settings, cards, active_card_id)
3668
+ write_json("settings.json", updated)
3669
+ return updated
3670
+
3671
+
3672
+ def _material_source_card_definition(provider: str) -> dict[str, str]:
3673
+ definition = material_source_definition(provider)
3674
+ if definition is not None:
3675
+ return definition
3676
+ return {
3677
+ "code": "local",
3678
+ "label": "Ficheiros locais",
3679
+ "description": "Usar materiais já existentes no storage local; não requer API key.",
3680
+ "legacy_key": "",
3681
+ }
3682
+
3683
+
3684
+ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int) -> None:
3685
+ card = normalize_material_card(cards[index], index)
3686
+ cards[index] = card
3687
+ card_id = str(card["id"])
3688
+ provider = str(card.get("provider") or "pexels")
3689
+ definition = _material_source_card_definition(provider)
3690
+ is_local = provider == "local"
3691
+ active_card_id = str(settings.get("material_active_card_id") or "")
3692
+ with st.container(border=True):
3693
+ header_cols = st.columns([3.2, 1.2])
3694
+ with header_cols[0]:
3695
+ st.subheader(definition["label"])
3696
+ st.caption(definition["description"])
3697
+ with header_cols[1]:
3698
+ _render_credential_status("" if is_local else card.get("api_key"), local=is_local, required=not is_local)
3699
+ with st.form(f"material_source_card_form_{card_id}"):
3700
+ content_cols = st.columns(2)
3701
+ with content_cols[0]:
3702
+ if is_local:
3703
+ st.caption("Esta fonte não usa API key.")
3704
+ api_key = ""
3705
+ else:
3706
+ api_key = st.text_input("API Key", value=str(card.get("api_key") or ""), type="password", key=f"material_card_{card_id}_api_key")
3707
+ with content_cols[1]:
3708
+ enabled = st.checkbox("Fonte activa", value=bool(card.get("enabled", True)), key=f"material_card_{card_id}_enabled")
3709
+ selected = st.checkbox(
3710
+ "Usar esta fonte na pipeline",
3711
+ value=active_card_id == card_id,
3712
+ key=f"material_card_{card_id}_selected",
3713
+ )
3714
+ save_card = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"material_card_{card_id}_save")
3715
+ if save_card:
3716
+ cards[index] = {**card, "api_key": str(api_key or "").strip(), "enabled": bool(enabled)}
3717
+ selected_id = card_id if selected and enabled else active_card_id
3718
+ _persist_material_source_cards(settings, cards, selected_id)
3719
+ st.success(f"Fonte {definition['label']} guardada.")
3691
3720
  st.rerun()
3692
- return
3693
- _render_credential_status(any(str(item or "").strip() for item in current_keys))
3694
- with st.form(f"material_api_keys_form_{selected_source}"):
3695
- key_values: list[str] = []
3696
- for index in range(row_count):
3697
- key_values.append(st.text_input(f"API Key {index + 1}", value=current_keys[index] if index < len(current_keys) else "", type="password", key=f"material_api_key_{selected_source}_{index}"))
3698
- save_keys = st.form_submit_button("Guardar fonte e chaves", type="primary", use_container_width=True)
3699
- add_key = st.form_submit_button("Adicionar outra chave", use_container_width=True)
3700
- if add_key:
3701
- st.session_state[row_key] = row_count + 1
3702
- st.rerun()
3703
- if save_keys:
3704
- update_material_api_keys(settings, selected_source, key_values)
3705
- settings["video_source"] = selected_source
3721
+
3722
+
3723
+ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3724
+ st.subheader("Fontes de Materiais")
3725
+ st.caption("Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.")
3726
+ migrated, changed = ensure_material_source_cards(settings)
3727
+ cards = [dict(item) for item in migrated.get("material_source_cards", [])]
3728
+ if changed:
3706
3729
  write_json("settings.json", settings)
3707
- st.session_state[row_key] = max(1, len(material_api_keys(settings, selected_source)))
3708
- st.success(f"Fonte {source_labels.get(selected_source, selected_source)} guardada com {len(material_api_keys(settings, selected_source))} chave(s).")
3730
+ for index in range(len(cards)):
3731
+ _render_material_source_card(settings, cards, index)
3732
+
3733
+ st.divider()
3734
+ st.markdown("**Adicionar fonte de materiais**")
3735
+ provider_codes = [item["code"] for item in material_source_catalog()] + ["local"]
3736
+ provider_to_add = st.selectbox(
3737
+ "Provedor de materiais",
3738
+ provider_codes,
3739
+ format_func=lambda value: _material_source_card_definition(value)["label"],
3740
+ key="material_new_provider_choice",
3741
+ )
3742
+ if st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card"):
3743
+ cards.append(new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
3744
+ _persist_material_source_cards(settings, cards, str(settings.get("material_active_card_id") or ""))
3709
3745
  st.rerun()
3710
- st.caption(f"{len(current_keys)} chave(s) actualmente guardada(s) para {source_labels.get(selected_source, selected_source)}. As chaves são mantidas no storage local e usadas com rotação interna.")
3711
3746
 
3712
3747
 
3713
3748
  def _api_status_badge(label: str, kind: str = "missing") -> None:
@@ -3927,7 +3962,7 @@ def render_settings():
3927
3962
  key=f"settings_{key}",
3928
3963
  )
3929
3964
 
3930
- api_keys_tab, material_sources_tab, voice_test_tab = render_localized_tabs(["API Keys", "Fontes de Materiais", "Teste de Voz"])
3965
+ api_keys_tab, google_accounts_tab, material_sources_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "Fontes de Materiais", "Teste de Voz"])
3931
3966
 
3932
3967
  with api_keys_tab:
3933
3968
  with st.container(border=True):
@@ -4062,8 +4097,11 @@ def render_settings():
4062
4097
  st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
4063
4098
  except Exception as exc:
4064
4099
  st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
4065
- with material_sources_tab:
4066
- render_material_source_api_keys(settings)
4100
+ with google_accounts_tab:
4101
+ render_google_accounts()
4102
+
4103
+ with material_sources_tab:
4104
+ render_material_source_api_keys(settings)
4067
4105
 
4068
4106
  with voice_test_tab:
4069
4107
  st.subheader("Teste de Voz")
@@ -4527,7 +4565,6 @@ def main():
4527
4565
  ]
4528
4566
  settings_items = [
4529
4567
  ("MCP", ":material/hub:", "MCP"),
4530
- ("Contas Google", ":material/account_circle:", "Contas Google"),
4531
4568
  ("Notificações", ":material/notifications:", "Notificações"),
4532
4569
  ("Configuração API", ":material/settings:", "Configuração API"),
4533
4570
  ]
@@ -4585,7 +4622,8 @@ def main():
4585
4622
  "Blueprints": "Blueprints Youtube",
4586
4623
  "Configurações Técnicas": "Configuração API",
4587
4624
  "Models AI": "AI Influencers",
4588
- "Contas Google/YouTube — canais em lote": "Contas Google",
4625
+ "Contas Google/YouTube — canais em lote": "Configuração API",
4626
+ "Contas Google": "Configuração API",
4589
4627
  }
4590
4628
  all_children = [item for items in groups.values() for item in items]
4591
4629
  valid_targets = {item[0] for item in top_pages + all_children}
@@ -922,3 +922,109 @@ __all__ = [
922
922
  "language_label", "ui_language_menu_label", "language_locale", "language_option_codes", "language_option_labels",
923
923
  "ui_text", "video_language_label", "video_language_options",
924
924
  ]
925
+
926
+
927
+ _MATERIAL_SOURCE_TRANSLATIONS: dict[str, dict[str, str]] = {
928
+ "pt": {
929
+ "Fontes de Materiais": "Fontes de Materiais",
930
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.",
931
+ "Adicionar fonte de materiais": "Adicionar fonte de materiais",
932
+ "Provedor de materiais": "Provedor de materiais",
933
+ "Configurar Nova Fonte de Materiais": "Configurar Nova Fonte de Materiais",
934
+ "Fonte activa": "Fonte activa",
935
+ "Usar esta fonte na pipeline": "Usar esta fonte na pipeline",
936
+ "Esta fonte não usa API key.": "Esta fonte não usa API key.",
937
+ },
938
+ "en": {
939
+ "Fontes de Materiais": "Media Sources",
940
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configure each provider in an independent card. You can repeat the same provider to store multiple API keys; the selected source will be used by the pipeline.",
941
+ "Adicionar fonte de materiais": "Add media source",
942
+ "Provedor de materiais": "Media provider",
943
+ "Configurar Nova Fonte de Materiais": "Configure New Media Source",
944
+ "Fonte activa": "Source active",
945
+ "Usar esta fonte na pipeline": "Use this source in the pipeline",
946
+ "Esta fonte não usa API key.": "This source does not use an API key.",
947
+ },
948
+ "zh": {
949
+ "Fontes de Materiais": "素材来源",
950
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "在独立卡片中配置每个提供商。可以重复同一提供商以保存多个 API 密钥;所选来源将由流水线使用。",
951
+ "Adicionar fonte de materiais": "添加素材来源",
952
+ "Provedor de materiais": "素材提供商",
953
+ "Configurar Nova Fonte de Materiais": "配置新的素材来源",
954
+ "Fonte activa": "来源已启用",
955
+ "Usar esta fonte na pipeline": "在流水线中使用此来源",
956
+ "Esta fonte não usa API key.": "此来源不使用 API 密钥。",
957
+ },
958
+ "de": {
959
+ "Fontes de Materiais": "Medienquellen",
960
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Konfigurieren Sie jeden Anbieter in einer eigenen Karte. Derselbe Anbieter kann für mehrere API-Schlüssel wiederholt werden; die ausgewählte Quelle wird von der Pipeline verwendet.",
961
+ "Adicionar fonte de materiais": "Medienquelle hinzufügen",
962
+ "Provedor de materiais": "Medienanbieter",
963
+ "Configurar Nova Fonte de Materiais": "Neue Medienquelle konfigurieren",
964
+ "Fonte activa": "Quelle aktiv",
965
+ "Usar esta fonte na pipeline": "Diese Quelle in der Pipeline verwenden",
966
+ "Esta fonte não usa API key.": "Diese Quelle verwendet keinen API-Schlüssel.",
967
+ },
968
+ "vi": {
969
+ "Fontes de Materiais": "Nguồn phương tiện",
970
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Định cấu hình từng nhà cung cấp trong một thẻ riêng. Có thể lặp lại cùng nhà cung cấp để lưu nhiều API key; nguồn được chọn sẽ được quy trình sử dụng.",
971
+ "Adicionar fonte de materiais": "Thêm nguồn phương tiện",
972
+ "Provedor de materiais": "Nhà cung cấp phương tiện",
973
+ "Configurar Nova Fonte de Materiais": "Định cấu hình nguồn phương tiện mới",
974
+ "Fonte activa": "Nguồn đang hoạt động",
975
+ "Usar esta fonte na pipeline": "Dùng nguồn này trong quy trình",
976
+ "Esta fonte não usa API key.": "Nguồn này không dùng API key.",
977
+ },
978
+ "tr": {
979
+ "Fontes de Materiais": "Medya kaynakları",
980
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Her sağlayıcıyı bağımsız bir kartta yapılandırın. Birden fazla API anahtarı saklamak için aynı sağlayıcıyı tekrarlayabilirsiniz; seçilen kaynak akış tarafından kullanılacaktır.",
981
+ "Adicionar fonte de materiais": "Medya kaynağı ekle",
982
+ "Provedor de materiais": "Medya sağlayıcısı",
983
+ "Configurar Nova Fonte de Materiais": "Yeni medya kaynağını yapılandır",
984
+ "Fonte activa": "Kaynak etkin",
985
+ "Usar esta fonte na pipeline": "Bu kaynağı akışta kullan",
986
+ "Esta fonte não usa API key.": "Bu kaynak API anahtarı kullanmaz.",
987
+ },
988
+ "ru": {
989
+ "Fontes de Materiais": "Источники материалов",
990
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Настройте каждого провайдера в отдельной карточке. Один и тот же провайдер можно повторить для хранения нескольких API-ключей; выбранный источник будет использоваться конвейером.",
991
+ "Adicionar fonte de materiais": "Добавить источник материалов",
992
+ "Provedor de materiais": "Провайдер материалов",
993
+ "Configurar Nova Fonte de Materiais": "Настроить новый источник материалов",
994
+ "Fonte activa": "Источник активен",
995
+ "Usar esta fonte na pipeline": "Использовать этот источник в конвейере",
996
+ "Esta fonte não usa API key.": "Этот источник не использует API-ключ.",
997
+ },
998
+ "es": {
999
+ "Fontes de Materiais": "Fuentes de medios",
1000
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configura cada proveedor en una tarjeta independiente. Puedes repetir el mismo proveedor para guardar varias claves API; la fuente seleccionada será utilizada por el flujo.",
1001
+ "Adicionar fonte de materiais": "Añadir fuente de medios",
1002
+ "Provedor de materiais": "Proveedor de medios",
1003
+ "Configurar Nova Fonte de Materiais": "Configurar nueva fuente de medios",
1004
+ "Fonte activa": "Fuente activa",
1005
+ "Usar esta fonte na pipeline": "Usar esta fuente en el flujo",
1006
+ "Esta fonte não usa API key.": "Esta fuente no utiliza una clave API.",
1007
+ },
1008
+ "id": {
1009
+ "Fontes de Materiais": "Sumber media",
1010
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Konfigurasikan setiap penyedia dalam kartu terpisah. Penyedia yang sama dapat diulang untuk menyimpan beberapa kunci API; sumber yang dipilih akan digunakan oleh pipeline.",
1011
+ "Adicionar fonte de materiais": "Tambah sumber media",
1012
+ "Provedor de materiais": "Penyedia media",
1013
+ "Configurar Nova Fonte de Materiais": "Konfigurasikan Sumber Media Baru",
1014
+ "Fonte activa": "Sumber aktif",
1015
+ "Usar esta fonte na pipeline": "Gunakan sumber ini di pipeline",
1016
+ "Esta fonte não usa API key.": "Sumber ini tidak menggunakan kunci API.",
1017
+ },
1018
+ "it": {
1019
+ "Fontes de Materiais": "Fonti multimediali",
1020
+ "Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.": "Configura ogni provider in una scheda indipendente. Puoi ripetere lo stesso provider per conservare più API key; la fonte selezionata sarà usata dalla pipeline.",
1021
+ "Adicionar fonte de materiais": "Aggiungi fonte multimediale",
1022
+ "Provedor de materiais": "Provider multimediale",
1023
+ "Configurar Nova Fonte de Materiais": "Configura nuova fonte multimediale",
1024
+ "Fonte activa": "Fonte attiva",
1025
+ "Usar esta fonte na pipeline": "Usa questa fonte nella pipeline",
1026
+ "Esta fonte não usa API key.": "Questa fonte non usa una API key.",
1027
+ },
1028
+ }
1029
+ for _language_code, _material_source_translation in _MATERIAL_SOURCE_TRANSLATIONS.items():
1030
+ UI_TRANSLATIONS[_language_code].update(_material_source_translation)
@@ -1,8 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Any
4
+ from uuid import uuid4
4
5
 
5
6
 
7
+ MATERIAL_CARDS_KEY = "material_source_cards"
8
+ MATERIAL_ACTIVE_CARD_KEY = "material_active_card_id"
9
+
6
10
  MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
7
11
  {"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
8
12
  {"code": "pixabay", "label": "Pixabay", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pixabay_api_keys"},
@@ -13,6 +17,9 @@ MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
13
17
  )
14
18
 
15
19
 
20
+ _SOURCE_BY_CODE = {item["code"]: item for item in MATERIAL_SOURCE_CATALOG}
21
+
22
+
16
23
  def _as_key_list(value: Any) -> list[str]:
17
24
  if isinstance(value, list):
18
25
  values = value
@@ -32,12 +39,124 @@ def material_source_catalog() -> list[dict[str, str]]:
32
39
  return [dict(item) for item in MATERIAL_SOURCE_CATALOG]
33
40
 
34
41
 
35
- def material_api_keys(settings: dict[str, Any], source: str) -> list[str]:
42
+ def material_source_definition(source: Any) -> dict[str, str] | None:
43
+ return _SOURCE_BY_CODE.get(str(source or "").strip().lower())
44
+
45
+
46
+ def _new_card(provider: str, api_key: str = "", *, card_id: str | None = None) -> dict[str, Any]:
47
+ return {
48
+ "id": card_id or f"material-{provider}-{uuid4().hex[:8]}",
49
+ "provider": provider,
50
+ "api_key": str(api_key or "").strip(),
51
+ "enabled": True,
52
+ }
53
+
54
+
55
+ def normalize_material_card(card: Any, index: int = 0) -> dict[str, Any]:
56
+ raw = card if isinstance(card, dict) else {}
57
+ provider = str(raw.get("provider") or raw.get("source") or "pexels").strip().lower()
58
+ if provider not in _SOURCE_BY_CODE and provider != "local":
59
+ provider = "pexels"
60
+ card_id = str(raw.get("id") or f"material-{provider}-{index}").strip()
61
+ return {
62
+ "id": card_id or f"material-{provider}-{index}",
63
+ "provider": provider,
64
+ "api_key": str(raw.get("api_key") or raw.get("key") or "").strip() if provider != "local" else "",
65
+ "enabled": bool(raw.get("enabled", True)),
66
+ }
67
+
68
+
69
+ def _cards_key_mapping(cards: list[dict[str, Any]]) -> dict[str, list[str]]:
70
+ mapping = {item["code"]: [] for item in MATERIAL_SOURCE_CATALOG}
71
+ for index, raw_card in enumerate(cards):
72
+ card = normalize_material_card(raw_card, index)
73
+ provider = card["provider"]
74
+ api_key = str(card.get("api_key") or "").strip()
75
+ if card.get("enabled", True) and provider in mapping and api_key and api_key not in mapping[provider]:
76
+ mapping[provider].append(api_key)
77
+ return mapping
78
+
79
+
80
+ def _sync_legacy_material_keys(settings: dict[str, Any], mapping: dict[str, list[str]]) -> None:
81
+ settings["material_api_keys"] = mapping
82
+ for item in MATERIAL_SOURCE_CATALOG:
83
+ settings[item["legacy_key"]] = list(mapping.get(item["code"], []))
84
+
85
+
86
+ def ensure_material_source_cards(settings: dict[str, Any]) -> tuple[dict[str, Any], bool]:
87
+ """Migrate legacy per-source key lists into stable independent material cards."""
88
+ original = settings.get(MATERIAL_CARDS_KEY)
89
+ changed = False
90
+ if isinstance(original, list) and original:
91
+ cards = [normalize_material_card(item, index) for index, item in enumerate(original)]
92
+ changed = cards != original
93
+ else:
94
+ cards: list[dict[str, Any]] = []
95
+ for item in MATERIAL_SOURCE_CATALOG:
96
+ for key in material_api_keys(settings, item["code"], _ignore_cards=True):
97
+ cards.append(_new_card(item["code"], key, card_id=f"material-{item['code']}-{len(cards)}"))
98
+ if not cards:
99
+ selected = selected_material_source(settings)
100
+ cards.append(_new_card(selected if selected in _SOURCE_BY_CODE else "pexels", card_id="material-default-0"))
101
+ changed = True
102
+
103
+ settings[MATERIAL_CARDS_KEY] = cards
104
+ active_id = str(settings.get(MATERIAL_ACTIVE_CARD_KEY) or "").strip()
105
+ valid_ids = {str(card["id"]) for card in cards}
106
+ if active_id not in valid_ids:
107
+ selected_source = selected_material_source(settings)
108
+ matching = next((card for card in cards if card["provider"] == selected_source), None)
109
+ active_id = str(matching["id"]) if matching else str(cards[0]["id"])
110
+ settings[MATERIAL_ACTIVE_CARD_KEY] = active_id
111
+ changed = True
112
+
113
+ mapping = _cards_key_mapping(cards)
114
+ if settings.get("material_api_keys") != mapping:
115
+ _sync_legacy_material_keys(settings, mapping)
116
+ changed = True
117
+ return settings, changed
118
+
119
+
120
+ def ensure_material_source_cards_for_ui(settings: dict[str, Any]) -> list[dict[str, Any]]:
121
+ migrated, _ = ensure_material_source_cards(settings)
122
+ return [dict(item) for item in migrated.get(MATERIAL_CARDS_KEY, [])]
123
+
124
+
125
+ def new_material_card(provider: str, *, card_id: str | None = None) -> dict[str, Any]:
126
+ code = str(provider or "").strip().lower()
127
+ if code not in _SOURCE_BY_CODE:
128
+ raise ValueError("Fonte de materiais inválida.")
129
+ return _new_card(code, card_id=card_id)
130
+
131
+
132
+ def apply_material_source_cards_to_settings(
133
+ settings: dict[str, Any],
134
+ cards: list[dict[str, Any]],
135
+ active_card_id: str = "",
136
+ ) -> dict[str, Any]:
137
+ normalized_cards = [normalize_material_card(item, index) for index, item in enumerate(cards)]
138
+ if not normalized_cards:
139
+ normalized_cards = [_new_card("pexels", card_id="material-default-0")]
140
+ settings[MATERIAL_CARDS_KEY] = normalized_cards
141
+ selected_card = next((card for card in normalized_cards if str(card["id"]) == str(active_card_id)), None)
142
+ if selected_card is None:
143
+ selected_card = next((card for card in normalized_cards if card.get("enabled", True)), normalized_cards[0])
144
+ settings[MATERIAL_ACTIVE_CARD_KEY] = str(selected_card["id"])
145
+ settings["video_source"] = str(selected_card["provider"])
146
+ _sync_legacy_material_keys(settings, _cards_key_mapping(normalized_cards))
147
+ return settings
148
+
149
+
150
+ def material_api_keys(settings: dict[str, Any], source: str, _ignore_cards: bool = False) -> list[str]:
36
151
  code = str(source or "").strip().lower()
152
+ if not _ignore_cards:
153
+ cards = settings.get(MATERIAL_CARDS_KEY)
154
+ if isinstance(cards, list) and cards:
155
+ return _cards_key_mapping(cards).get(code, [])
37
156
  saved = settings.get("material_api_keys", {})
38
157
  if isinstance(saved, dict) and code in saved:
39
158
  return _as_key_list(saved.get(code))
40
- legacy_key = next((item["legacy_key"] for item in MATERIAL_SOURCE_CATALOG if item["code"] == code), f"{code}_api_keys")
159
+ legacy_key = _SOURCE_BY_CODE.get(code, {}).get("legacy_key", f"{code}_api_keys")
41
160
  return _as_key_list(settings.get(legacy_key, ""))
42
161
 
43
162
 
@@ -47,18 +166,16 @@ def all_material_api_keys(settings: dict[str, Any]) -> dict[str, list[str]]:
47
166
 
48
167
  def update_material_api_keys(settings: dict[str, Any], source: str, keys: list[str]) -> dict[str, Any]:
49
168
  code = str(source or "").strip().lower()
50
- if code not in {item["code"] for item in MATERIAL_SOURCE_CATALOG}:
169
+ if code not in _SOURCE_BY_CODE:
51
170
  raise ValueError("Fonte de materiais inválida.")
52
171
  cleaned = _as_key_list(keys)
53
172
  mapping = all_material_api_keys(settings)
54
173
  mapping[code] = cleaned
55
- settings["material_api_keys"] = mapping
56
- source_entry = next(item for item in MATERIAL_SOURCE_CATALOG if item["code"] == code)
57
- settings[source_entry["legacy_key"]] = cleaned
174
+ _sync_legacy_material_keys(settings, mapping)
58
175
  return settings
59
176
 
60
177
 
61
178
  def selected_material_source(settings: dict[str, Any]) -> str:
62
179
  source = str(settings.get("video_source") or "pexels").strip().lower()
63
- valid = {item["code"] for item in MATERIAL_SOURCE_CATALOG} | {"local"}
180
+ valid = set(_SOURCE_BY_CODE) | {"local"}
64
181
  return source if source in valid else "pexels"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
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",