@danhachuel/thunderbolt 0.2.66 → 0.2.68

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
@@ -40,6 +40,7 @@ from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesiz
40
40
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
41
41
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
42
42
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
43
+ from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
43
44
  from integrations.postiz import PostizAdapter
44
45
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
45
46
  from integrations.youtube_direct_upload import YouTubeDirectUploader
@@ -754,6 +755,159 @@ def render_blueprints():
754
755
  st.error(str(exc))
755
756
 
756
757
 
758
+ def _tiktok_accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
759
+ raw_accounts = settings.get("tiktok_accounts")
760
+ if not isinstance(raw_accounts, list) or not raw_accounts:
761
+ raw_accounts = settings.get("tiktok_profiles", [])
762
+ if not isinstance(raw_accounts, list):
763
+ return []
764
+ accounts: list[dict[str, Any]] = []
765
+ seen: set[str] = set()
766
+ for raw in raw_accounts:
767
+ if not isinstance(raw, dict):
768
+ continue
769
+ try:
770
+ reference = normalize_tiktok_reference(str(raw.get("url") or raw.get("handle") or raw.get("username") or raw.get("id") or ""))
771
+ except ValueError:
772
+ continue
773
+ account = {**raw, **reference}
774
+ account["name"] = str(raw.get("name") or raw.get("label") or reference["username"]).strip()
775
+ account["bio"] = str(raw.get("bio") or raw.get("description") or "").strip()
776
+ account["source"] = str(raw.get("source") or raw.get("origin") or "manual").strip()
777
+ key = account["id"]
778
+ if key in seen:
779
+ continue
780
+ seen.add(key)
781
+ accounts.append(account)
782
+ return accounts
783
+
784
+
785
+ def _save_tiktok_accounts(accounts: list[dict[str, Any]]) -> None:
786
+ settings = read_json("settings.json", {})
787
+ settings["tiktok_accounts"] = accounts
788
+ write_json("settings.json", settings)
789
+
790
+
791
+ def _upsert_tiktok_account(account: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
792
+ settings = read_json("settings.json", {})
793
+ accounts = _tiktok_accounts_from_settings(settings)
794
+ normalized = normalize_tiktok_reference(str(account.get("url") or account.get("handle") or account.get("username") or ""))
795
+ merged = {**account, **normalized}
796
+ merged["name"] = str(account.get("name") or normalized["username"]).strip() or normalized["username"]
797
+ existing_index = next((index for index, item in enumerate(accounts) if item.get("id") == normalized["id"] or item.get("username") == normalized["username"]), None)
798
+ created = existing_index is None
799
+ if existing_index is None:
800
+ accounts.append(merged)
801
+ else:
802
+ accounts[existing_index] = {**accounts[existing_index], **merged}
803
+ _save_tiktok_accounts(accounts)
804
+ return accounts, created
805
+
806
+
807
+ def _tiktok_account_metric(account: dict[str, Any], key: str, label: str) -> str:
808
+ value = account.get(key)
809
+ return f"{label}: {value:,}" if isinstance(value, int) else f"{label}: —"
810
+
811
+
812
+ def render_tiktok_accounts():
813
+ st.title("Contas TikTok")
814
+ st.caption("Pesquisa pública e cadastro manual de contas TikTok para alimentar o selector de destino no Upload. Esta área não usa OAuth, API de publicação nem lote.")
815
+ settings = read_json("settings.json", {})
816
+ accounts = _tiktok_accounts_from_settings(settings)
817
+
818
+ search_tab, manual_tab, library_tab = st.tabs(["Pesquisa pública", "Cadastro manual", "Contas cadastradas"])
819
+ with search_tab:
820
+ st.subheader("Pesquisar perfil público")
821
+ st.info("A pesquisa consulta apenas a página pública do perfil e pode devolver dados incompletos. Se o TikTok bloquear a consulta, utilize o cadastro manual.")
822
+ with st.form("tiktok_public_lookup_form"):
823
+ lookup_source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta ou @conta", key="tiktok_public_lookup_source")
824
+ lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", use_container_width=True)
825
+ if lookup_submitted:
826
+ result = fetch_public_tiktok_profile(lookup_source)
827
+ st.session_state["tiktok_public_lookup"] = {"ok": result.ok, "message": result.message, "data": result.data}
828
+ (st.success if result.ok else st.warning)(result.message)
829
+ lookup = st.session_state.get("tiktok_public_lookup", {})
830
+ lookup_data = lookup.get("data") if isinstance(lookup, dict) else None
831
+ if isinstance(lookup_data, dict) and lookup_data.get("url"):
832
+ with st.container(border=True):
833
+ st.subheader(str(lookup_data.get("name") or lookup_data.get("handle") or "Perfil TikTok"))
834
+ st.caption(f"{lookup_data.get('handle', '')} · {lookup_data.get('public_url') or lookup_data.get('url')}")
835
+ if lookup_data.get("bio"):
836
+ st.write(lookup_data["bio"])
837
+ metric_cols = st.columns(4)
838
+ with metric_cols[0]: st.metric("Seguidores", lookup_data.get("subscriber_count") if lookup_data.get("subscriber_count") is not None else "—")
839
+ with metric_cols[1]: st.metric("Seguindo", lookup_data.get("following_count") if lookup_data.get("following_count") is not None else "—")
840
+ with metric_cols[2]: st.metric("Gostos", lookup_data.get("likes_count") if lookup_data.get("likes_count") is not None else "—")
841
+ with metric_cols[3]: st.metric("Vídeos", lookup_data.get("video_count") if lookup_data.get("video_count") is not None else "—")
842
+ display_name = st.text_input("Nome da conta", value=str(lookup_data.get("name") or lookup_data.get("username") or ""), key="tiktok_lookup_display_name")
843
+ notes = st.text_area("Observações internas", value=str(lookup_data.get("notes") or ""), key="tiktok_lookup_notes", height=80)
844
+ if st.button("Cadastrar conta TikTok", type="primary", use_container_width=True, key="tiktok_register_public_account"):
845
+ try:
846
+ stored = {**lookup_data, "name": display_name.strip() or lookup_data.get("username", ""), "notes": notes.strip(), "source": "public_lookup"}
847
+ _upsert_tiktok_account(stored)
848
+ st.session_state.pop("tiktok_public_lookup", None)
849
+ st.success("Conta TikTok cadastrada e disponível no selector de Upload.")
850
+ st.rerun()
851
+ except ValueError as exc:
852
+ st.error(str(exc))
853
+
854
+ with manual_tab:
855
+ st.subheader("Cadastrar conta manualmente")
856
+ with st.form("tiktok_manual_account_form"):
857
+ manual_source = st.text_input("@handle ou URL pública", placeholder="@minhaconta", key="tiktok_manual_source")
858
+ manual_name = st.text_input("Nome da conta", placeholder="Nome de apresentação", key="tiktok_manual_name")
859
+ manual_notes = st.text_area("Observações internas", height=90, key="tiktok_manual_notes")
860
+ manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", use_container_width=True)
861
+ if manual_submitted:
862
+ try:
863
+ reference = normalize_tiktok_reference(manual_source)
864
+ _upsert_tiktok_account({**reference, "name": manual_name.strip() or reference["username"], "notes": manual_notes.strip(), "source": "manual"})
865
+ st.success("Conta TikTok cadastrada manualmente.")
866
+ st.rerun()
867
+ except ValueError as exc:
868
+ st.error(str(exc))
869
+
870
+ with library_tab:
871
+ st.subheader(f"Contas TikTok cadastradas ({len(accounts)})")
872
+ if not accounts:
873
+ st.info("Ainda não existem contas TikTok. Use Pesquisa pública ou Cadastro manual para adicionar a primeira conta.")
874
+ for account in accounts:
875
+ account_id = str(account.get("id") or account.get("username"))
876
+ with st.container(border=True):
877
+ card_cols = st.columns([0.18, 2.7, 1.1, 1.1, 0.8])
878
+ with card_cols[0]:
879
+ if account.get("avatar_url"):
880
+ st.image(account["avatar_url"], width=52)
881
+ else:
882
+ st.markdown("### TT")
883
+ with card_cols[1]:
884
+ st.write(f"**{account.get('name') or account.get('username')}**")
885
+ st.caption(f"{account.get('handle') or '@' + str(account.get('username', ''))} · {account.get('public_url') or account.get('url')}")
886
+ st.caption(str(account.get("source") or "manual").replace("_", " ").title())
887
+ with card_cols[2]: st.caption(_tiktok_account_metric(account, "subscriber_count", "Seguidores"))
888
+ with card_cols[3]: st.caption(_tiktok_account_metric(account, "video_count", "Vídeos"))
889
+ with card_cols[4]:
890
+ if st.button("Apagar", key=f"delete_tiktok_account_{account_id}"):
891
+ _save_tiktok_accounts([item for item in accounts if str(item.get("id")) != account_id])
892
+ st.success("Conta TikTok removida.")
893
+ st.rerun()
894
+ with st.expander("Editar conta", expanded=False):
895
+ with st.form(f"edit_tiktok_account_{account_id}"):
896
+ edited_name = st.text_input("Nome da conta", value=str(account.get("name") or account.get("username") or ""), key=f"edit_tiktok_name_{account_id}")
897
+ edited_source = st.text_input("@handle ou URL pública", value=str(account.get("public_url") or account.get("url") or account.get("handle") or ""), key=f"edit_tiktok_source_{account_id}")
898
+ edited_notes = st.text_area("Observações internas", value=str(account.get("notes") or ""), key=f"edit_tiktok_notes_{account_id}", height=80)
899
+ edit_submitted = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
900
+ if edit_submitted:
901
+ try:
902
+ reference = normalize_tiktok_reference(edited_source)
903
+ updated = {**account, **reference, "name": edited_name.strip() or reference["username"], "notes": edited_notes.strip(), "source": account.get("source") or "manual"}
904
+ _upsert_tiktok_account(updated)
905
+ st.success("Conta TikTok actualizada.")
906
+ st.rerun()
907
+ except ValueError as exc:
908
+ st.error(str(exc))
909
+
910
+
757
911
  def render_tiktok_prompt_masters():
758
912
  st.title("Prompts Master")
759
913
  st.caption(f"Biblioteca exclusiva para vídeos TikTok. Os ficheiros ficam em `{TIKTOK_PROMPT_MASTERS}` e nunca entram na pasta de Blueprints YouTube.")
@@ -856,7 +1010,7 @@ def render_channels():
856
1010
  if lookup_mode.startswith("Página pública"):
857
1011
  result = youtube.fetch_channel_public(source)
858
1012
  elif not youtube.api_key:
859
- result = IntegrationResult(False, "A YouTube Data API Key não está configurada. Escolha a opção Página pública — sem API Key ou configure a chave em Configurações.", {"status": "api_key_not_configured"})
1013
+ result = IntegrationResult(False, "A YouTube Data API Key não está configurada. Escolha a opção Página pública — sem API Key ou configure a chave em Configurações > Contas Google.", {"status": "api_key_not_configured"})
860
1014
  else:
861
1015
  result = youtube.fetch_channel(source)
862
1016
  st.session_state["yt_import"] = result.data
@@ -938,7 +1092,7 @@ def render_channels():
938
1092
  st.caption("Esta subaba usa a conta Google/YouTube seleccionada para listar os canais que ela gere. Não lê a caixa Gmail e não usa e-mails como pesquisa pública.")
939
1093
  accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
940
1094
  if not accounts:
941
- st.info("Configure primeiro uma conta em Configurações > Configurações Técnicas > Contas Google/YouTube para canais em lote.")
1095
+ st.info("Configure primeiro uma conta em Configurações > Configuração API > Contas Google/YouTube para canais em lote.")
942
1096
  else:
943
1097
  account_ids = [str(account["id"]) for account in accounts]
944
1098
  account_labels = {str(account["id"]): f"{account.get('email', 'Conta Google')} — {account.get('label', 'Canais YouTube')}" for account in accounts}
@@ -1391,7 +1545,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1391
1545
  payload["thumbnail_path"] = thumbnail_path
1392
1546
  payload["thumbnail_status"] = "generated"
1393
1547
  else:
1394
- st.caption("A imagem ainda não foi gerada. Configure a API key em Configurações Técnicas > API Keys.")
1548
+ st.caption("A imagem ainda não foi gerada. Configure a API key em Configuração API > API Keys.")
1395
1549
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
1396
1550
  else:
1397
1551
  topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
@@ -1445,7 +1599,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1445
1599
  payload["thumbnail_path"] = thumbnail_path
1446
1600
  payload["thumbnail_status"] = "generated"
1447
1601
  else:
1448
- st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configurações Técnicas > API Keys.")
1602
+ st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configuração API > API Keys.")
1449
1603
  st.session_state["new_video_creative_payload"] = payload
1450
1604
 
1451
1605
  with st.form("new_video_form"):
@@ -2434,11 +2588,11 @@ def render_upload():
2434
2588
 
2435
2589
  def render_upload_postiz():
2436
2590
  st.subheader("Upload para Postiz")
2437
- st.caption("O Thunderbolt envia primeiro o MP4 para o Postiz e cria um post na integração seleccionada. A API key e o servidor são configurados em Configurações Técnicas.")
2591
+ st.caption("O Thunderbolt envia primeiro o MP4 para o Postiz e cria um post na integração seleccionada. A API key e o servidor são configurados em Configuração API.")
2438
2592
  settings = read_json("settings.json", {})
2439
2593
  postiz = PostizAdapter(settings)
2440
2594
  if not settings.get("postiz_enabled"):
2441
- st.warning("Postiz está desactivado. Active-o em Configurações Técnicas > API Keys e guarde a API key antes de enviar.")
2595
+ st.warning("Postiz está desactivado. Active-o em Configuração API > API Keys e guarde a API key antes de enviar.")
2442
2596
  if not postiz.api_key:
2443
2597
  st.info("Nenhuma API key Postiz configurada.")
2444
2598
  return
@@ -2465,7 +2619,7 @@ def render_upload_postiz():
2465
2619
  format_func=lambda value: integration_labels.get(value, value),
2466
2620
  key="postiz_upload_integration",
2467
2621
  )
2468
- st.caption("Para alterar a integração padrão, guarde o ID seleccionado em Configurações Técnicas.")
2622
+ st.caption("Para alterar a integração padrão, guarde o ID seleccionado em Configuração API.")
2469
2623
  else:
2470
2624
  selected_integration = st.text_input("ID da integração Postiz", value=integration_default, key="postiz_upload_integration_manual", help="Carregue as integrações para seleccionar uma conta ou introduza o ID devolvido pela API do Postiz.").strip()
2471
2625
  st.info("Carregue as integrações para descobrir os canais Postiz ligados à sua conta.")
@@ -2509,7 +2663,7 @@ def render_upload_postiz():
2509
2663
 
2510
2664
 
2511
2665
  UPLOAD_DESTINATION_TARGET_KEYS = {
2512
- "TikTok": "tiktok_profiles",
2666
+ "TikTok": "tiktok_accounts",
2513
2667
  "Instagram": "instagram_profiles",
2514
2668
  "Facebook Pages": "facebook_pages",
2515
2669
  }
@@ -2539,6 +2693,8 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
2539
2693
  if not setting_key:
2540
2694
  return []
2541
2695
  configured_targets = settings.get(setting_key, [])
2696
+ if destination == "TikTok" and (not isinstance(configured_targets, list) or not configured_targets):
2697
+ configured_targets = settings.get("tiktok_profiles", [])
2542
2698
  if not isinstance(configured_targets, list):
2543
2699
  return []
2544
2700
  targets: list[Any] = []
@@ -2553,12 +2709,14 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
2553
2709
  def render_upload_destination_target(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> Any | None:
2554
2710
  options = upload_targets_for_destination(destination, channels, settings)
2555
2711
  destination_key = re.sub(r"[^a-z0-9]+", "_", destination.lower()).strip("_")
2556
- select_label = "Canal" if destination == "YouTube" else "Perfil / página"
2557
- empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else f"Nenhum {destination} configurado"
2712
+ select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else "Perfil / página")
2713
+ empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else f"Nenhum {destination} configurado")
2558
2714
  if not options:
2559
2715
  st.selectbox(select_label, [empty_label], disabled=True, key=f"upload_target_{destination_key}")
2560
2716
  if destination == "YouTube":
2561
2717
  st.caption("Cadastre ou liste pelo menos um canal YouTube antes de escolher o destino de envio.")
2718
+ elif destination == "TikTok":
2719
+ st.caption("Cadastre uma conta em Pipeline TikTok > Contas TikTok antes de escolher o destino de envio.")
2562
2720
  else:
2563
2721
  st.caption(f"A lista de {destination} será ligada numa etapa própria de credenciais/API.")
2564
2722
  return None
@@ -2600,7 +2758,7 @@ def render_upload_conventional():
2600
2758
  postiz_status = postiz.status()
2601
2759
  (st.success if postiz_status.ok else st.warning)(postiz_status.message)
2602
2760
  else:
2603
- st.caption("Postiz está desactivado; active-o em Configurações Técnicas para o usar como fallback final.")
2761
+ st.caption("Postiz está desactivado; active-o em Configuração API para o usar como fallback final.")
2604
2762
  status_cols = st.columns(2)
2605
2763
  with status_cols[0]:
2606
2764
  (st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
@@ -2703,9 +2861,239 @@ def render_upload_conventional():
2703
2861
  st.button("Preparar Facebook Pages", key=f"upload_facebook_{task['id']}", disabled=True, help="UI preparada; publicação Facebook Pages ainda não está activa.")
2704
2862
 
2705
2863
 
2864
+ def render_google_accounts():
2865
+ st.title("Contas Google")
2866
+ st.caption("Gestão das contas Google/YouTube, documentos de credenciais, canais em lote e credenciais globais do YouTube.")
2867
+ settings = read_json("settings.json", {})
2868
+
2869
+ st.subheader("Contas Google/YouTube — canais em lote")
2870
+ st.caption("Abra uma conta para ver e editar os detalhes. O e-mail identifica a conta Google; esta área não lê a caixa Gmail.")
2871
+ batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
2872
+ channel_state = read_json("channels.json", [])
2873
+ youtube_accounts_missing_document: list[str] = []
2874
+
2875
+ for batch_account in batch_accounts:
2876
+ account_id = str(batch_account["id"])
2877
+ account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
2878
+ account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
2879
+ ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
2880
+ direct_status = direct_account_status(STORAGE, batch_account, settings)
2881
+ missing_document_parts = list(direct_status.get("missing_cookies", []))
2882
+ if not direct_status.get("has_session_info"):
2883
+ missing_document_parts.append("sessionInfo")
2884
+ if missing_document_parts:
2885
+ youtube_accounts_missing_document.append(account_email_snapshot)
2886
+
2887
+ with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
2888
+ with st.form(f"batch_account_form_{account_id}"):
2889
+ account_cols = st.columns(2)
2890
+ with account_cols[0]:
2891
+ account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
2892
+ account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
2893
+ account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
2894
+ with account_cols[1]:
2895
+ account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
2896
+ account_session_info = st.text_input(
2897
+ "sessionInfo token desta conta Google",
2898
+ value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
2899
+ type="password",
2900
+ key=f"batch_session_info_{account_id}",
2901
+ help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
2902
+ )
2903
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
2904
+
2905
+ st.markdown("**Documento de cookies/credenciais desta conta Google**")
2906
+ st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
2907
+ document_upload = st.file_uploader(
2908
+ "Subir documento de cookies/credenciais",
2909
+ type=["json"],
2910
+ key=f"direct_credentials_document_{account_id}",
2911
+ help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
2912
+ )
2913
+ if missing_document_parts:
2914
+ st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
2915
+ else:
2916
+ st.success("Documento completo para a conta Google")
2917
+ st.caption(f"Documento guardado em: {direct_status['document_file']}")
2918
+ document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
2919
+
2920
+ account_status = youtube_batch_account_status(batch_account, STORAGE)
2921
+ status_cols = st.columns(3)
2922
+ with status_cols[0]:
2923
+ (st.success if account_status.ok else st.warning)(account_status.message)
2924
+ with status_cols[1]:
2925
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
2926
+ result = authorize_youtube_batch_account(batch_account, STORAGE)
2927
+ (st.success if result.ok else st.error)(result.message)
2928
+ if result.ok:
2929
+ st.rerun()
2930
+ with status_cols[2]:
2931
+ if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
2932
+ delete_youtube_batch_token(batch_account, STORAGE)
2933
+ delete_credentials_document(STORAGE, batch_account)
2934
+ remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
2935
+ settings["youtube_batch_accounts"] = remaining_accounts
2936
+ if settings.get("youtube_batch_selected_account_id") == account_id:
2937
+ settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
2938
+ for channel in channel_state:
2939
+ if str(channel.get("google_account_id") or "") == account_id:
2940
+ channel.update({"google_account_id": "", "google_account_email": ""})
2941
+ write_json("channels.json", channel_state)
2942
+ write_json("settings.json", settings)
2943
+ st.rerun()
2944
+
2945
+ if save_account:
2946
+ if "@" not in account_email.strip():
2947
+ st.error("Informe um e-mail Google válido.")
2948
+ elif not account_client_id.strip() or not account_client_secret.strip():
2949
+ st.error("Informe o Client ID e o Client Secret desta conta.")
2950
+ else:
2951
+ for existing in batch_accounts:
2952
+ if str(existing.get("id")) == account_id:
2953
+ credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
2954
+ if credentials_changed:
2955
+ delete_youtube_batch_token(existing, STORAGE)
2956
+ existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
2957
+ update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
2958
+ ensure_credentials_document(STORAGE, existing, settings, channel_state)
2959
+ settings["youtube_batch_accounts"] = batch_accounts
2960
+ write_json("settings.json", settings)
2961
+ st.success("Conta Google/YouTube guardada.")
2962
+ st.rerun()
2963
+
2964
+ if document_save:
2965
+ if document_upload is None:
2966
+ st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
2967
+ else:
2968
+ try:
2969
+ merge_credentials_document(
2970
+ STORAGE,
2971
+ batch_account,
2972
+ document_upload.getvalue(),
2973
+ document_upload.name,
2974
+ session_info_override=str(batch_account.get("sessionInfo") or ""),
2975
+ channels=channel_state,
2976
+ )
2977
+ st.success("Documento incorporado e guardado nesta conta Google.")
2978
+ st.rerun()
2979
+ except ValueError as exc:
2980
+ st.error(str(exc))
2981
+
2982
+ if youtube_accounts_missing_document:
2983
+ st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
2984
+
2985
+ st.divider()
2986
+ st.markdown("### INNERTUBE_API_KEY")
2987
+ st.caption("Esta chave pertence à conta Google/YouTube seleccionada e fica guardada na configuração da conta. Não faz parte do documento de cookies/credenciais e não é editada no separador API Keys.")
2988
+ account_key_options = [str(account.get("id")) for account in batch_accounts if account.get("id")]
2989
+ account_key_labels = {str(account.get("id")): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in batch_accounts if account.get("id")}
2990
+ if account_key_options:
2991
+ with st.form("innertube_api_key_form"):
2992
+ selected_key_account_id = st.selectbox("Conta Google/YouTube", account_key_options, format_func=lambda value: account_key_labels.get(value, value), key="innertube_key_account")
2993
+ selected_key_account = next(account for account in batch_accounts if str(account.get("id")) == selected_key_account_id)
2994
+ current_innertube_api_key = direct_account_status(STORAGE, selected_key_account, settings).get("innertube_api_key", "")
2995
+ innertube_api_key_value = st.text_input("INNERTUBE_API_KEY", value=current_innertube_api_key, type="password", key=f"innertube_api_key_{selected_key_account_id}", help="Chave usada pelo Upload directo desta conta. Guarde-a aqui, separada do documento de cookies.")
2996
+ save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY", type="primary", use_container_width=True)
2997
+ if save_innertube_api_key:
2998
+ selected_key_account["innertube_api_key"] = innertube_api_key_value.strip()
2999
+ selected_key_account.pop("INNERTUBE_API_KEY", None)
3000
+ settings.pop("direct_innertube_api_key", None)
3001
+ settings["youtube_batch_accounts"] = batch_accounts
3002
+ write_json("settings.json", settings)
3003
+ document = load_credentials_document(STORAGE, selected_key_account, settings, channel_state, create=True)
3004
+ save_credentials_document(STORAGE, selected_key_account, document)
3005
+ st.success("INNERTUBE_API_KEY guardada na configuração da conta Google/YouTube, fora do documento de cookies.")
3006
+ st.rerun()
3007
+ else:
3008
+ st.info("Adicione primeiro uma conta Google/YouTube para configurar a INNERTUBE_API_KEY.")
3009
+
3010
+ st.divider()
3011
+ st.markdown("### Adicionar outra conta Gmail")
3012
+ st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
3013
+ with st.form("add_batch_account_form"):
3014
+ add_cols = st.columns(2)
3015
+ with add_cols[0]:
3016
+ new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
3017
+ new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
3018
+ new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
3019
+ with add_cols[1]:
3020
+ new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
3021
+ new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
3022
+ new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
3023
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
3024
+ if add_account:
3025
+ document_error = ""
3026
+ if "@" not in new_account_email.strip():
3027
+ st.error("Informe um e-mail Google válido.")
3028
+ elif not new_account_client_id.strip() or not new_account_client_secret.strip():
3029
+ st.error("Informe o Client ID e o Client Secret da nova conta.")
3030
+ elif new_account_document is not None:
3031
+ try:
3032
+ raw_document = new_account_document.getvalue()
3033
+ json.loads(raw_document.decode("utf-8-sig", errors="replace"))
3034
+ except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
3035
+ document_error = f"O documento {new_account_document.name} deve ser JSON válido."
3036
+ st.error(document_error)
3037
+ if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
3038
+ new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip(), "sessionInfo": new_account_session_info.strip()}
3039
+ batch_accounts.append(new_account)
3040
+ settings["youtube_batch_accounts"] = batch_accounts
3041
+ settings["youtube_batch_selected_account_id"] = new_account["id"]
3042
+ write_json("settings.json", settings)
3043
+ ensure_credentials_document(STORAGE, new_account, settings, channel_state)
3044
+ if new_account_document is not None:
3045
+ try:
3046
+ merge_credentials_document(STORAGE, new_account, new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip(), channels=channel_state)
3047
+ except ValueError as exc:
3048
+ st.error(str(exc))
3049
+ st.stop()
3050
+ st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
3051
+ else:
3052
+ st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
3053
+ st.rerun()
3054
+
3055
+ st.divider()
3056
+ st.subheader("Configuração global do YouTube")
3057
+ st.caption("Estas credenciais pertencem à aplicação Google/YouTube e ficam separadas das restantes APIs do Thunderbolt.")
3058
+ with st.form("google_global_api_settings_form"):
3059
+ google_api_cols = st.columns(2)
3060
+ with google_api_cols[0]:
3061
+ youtube_client_id = st.text_input(
3062
+ "YouTube OAuth Client ID",
3063
+ value=str(settings.get("youtube_client_id", "") or ""),
3064
+ key="google_page_youtube_client_id",
3065
+ help="Client ID OAuth 2.0 criado no Google Cloud para autorizar a conta YouTube.",
3066
+ )
3067
+ with google_api_cols[1]:
3068
+ youtube_client_secret = st.text_input(
3069
+ "YouTube OAuth Client Secret",
3070
+ value=str(settings.get("youtube_client_secret", "") or ""),
3071
+ type="password",
3072
+ key="google_page_youtube_client_secret",
3073
+ help="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.",
3074
+ )
3075
+ st.caption(f"OAuth local: use um cliente do tipo Desktop app. Se o Google Cloud pedir uma URI autorizada, registe exactamente `{loopback_redirect_uri()}`.")
3076
+ youtube_api_key = st.text_input(
3077
+ "YouTube Data API Key (opcional)",
3078
+ value=str(settings.get("youtube_api_key", "") or ""),
3079
+ type="password",
3080
+ key="google_page_youtube_api_key",
3081
+ help="Credencial Google Cloud separada, usada apenas para métricas oficiais da YouTube Data API.",
3082
+ )
3083
+ save_google_global = st.form_submit_button("Guardar configuração global do YouTube", type="primary", use_container_width=True)
3084
+ if save_google_global:
3085
+ settings.update({
3086
+ "youtube_client_id": youtube_client_id.strip(),
3087
+ "youtube_client_secret": youtube_client_secret.strip(),
3088
+ "youtube_api_key": youtube_api_key.strip(),
3089
+ })
3090
+ write_json("settings.json", settings)
3091
+ st.success("Configuração global do YouTube guardada em Contas Google.")
3092
+ st.rerun()
3093
+
2706
3094
  def render_settings():
2707
- st.title("Configurações Técnicas")
2708
- st.caption("Configuração do motor de vídeo e dos serviços usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
3095
+ st.title("Configuração API")
3096
+ st.caption("Configuração das APIs, providers, serviços e ferramentas técnicas usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
2709
3097
  settings = read_json("settings.json", {})
2710
3098
 
2711
3099
  def text_setting(label: str, key: str, *, secret: bool = False, help_text: str | None = None) -> str:
@@ -2717,209 +3105,13 @@ def render_settings():
2717
3105
  key=f"settings_{key}",
2718
3106
  )
2719
3107
 
2720
- google_accounts_tab, api_keys_tab, voice_test_tab = st.tabs(["Contas Google/YouTube — canais em lote", "API Keys", "Teste de vozes"])
2721
-
2722
- with google_accounts_tab:
2723
- st.subheader("Contas Google/YouTube — canais em lote")
2724
- st.caption("Abra uma conta para ver e editar os detalhes. O e-mail identifica a conta Google; esta área não lê a caixa Gmail.")
2725
- batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
2726
- channel_state = read_json("channels.json", [])
2727
- youtube_accounts_missing_document: list[str] = []
2728
-
2729
- for batch_account in batch_accounts:
2730
- account_id = str(batch_account["id"])
2731
- account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
2732
- account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
2733
- ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
2734
- direct_status = direct_account_status(STORAGE, batch_account, settings)
2735
- missing_document_parts = list(direct_status.get("missing_cookies", []))
2736
- if not direct_status.get("has_session_info"):
2737
- missing_document_parts.append("sessionInfo")
2738
- if missing_document_parts:
2739
- youtube_accounts_missing_document.append(account_email_snapshot)
2740
-
2741
- with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
2742
- with st.form(f"batch_account_form_{account_id}"):
2743
- account_cols = st.columns(2)
2744
- with account_cols[0]:
2745
- account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
2746
- account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
2747
- account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
2748
- with account_cols[1]:
2749
- account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
2750
- account_session_info = st.text_input(
2751
- "sessionInfo token desta conta Google",
2752
- value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
2753
- type="password",
2754
- key=f"batch_session_info_{account_id}",
2755
- help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
2756
- )
2757
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
2758
-
2759
- st.markdown("**Documento de cookies/credenciais desta conta Google**")
2760
- st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
2761
- document_upload = st.file_uploader(
2762
- "Subir documento de cookies/credenciais",
2763
- type=["json"],
2764
- key=f"direct_credentials_document_{account_id}",
2765
- help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
2766
- )
2767
- if missing_document_parts:
2768
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
2769
- else:
2770
- st.success("Documento completo para a conta Google")
2771
- st.caption(f"Documento guardado em: {direct_status['document_file']}")
2772
- document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
2773
-
2774
- account_status = youtube_batch_account_status(batch_account, STORAGE)
2775
- status_cols = st.columns(3)
2776
- with status_cols[0]:
2777
- (st.success if account_status.ok else st.warning)(account_status.message)
2778
- with status_cols[1]:
2779
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
2780
- result = authorize_youtube_batch_account(batch_account, STORAGE)
2781
- (st.success if result.ok else st.error)(result.message)
2782
- if result.ok:
2783
- st.rerun()
2784
- with status_cols[2]:
2785
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
2786
- delete_youtube_batch_token(batch_account, STORAGE)
2787
- delete_credentials_document(STORAGE, batch_account)
2788
- remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
2789
- settings["youtube_batch_accounts"] = remaining_accounts
2790
- if settings.get("youtube_batch_selected_account_id") == account_id:
2791
- settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
2792
- for channel in channel_state:
2793
- if str(channel.get("google_account_id") or "") == account_id:
2794
- channel.update({"google_account_id": "", "google_account_email": ""})
2795
- write_json("channels.json", channel_state)
2796
- write_json("settings.json", settings)
2797
- st.rerun()
2798
-
2799
- if save_account:
2800
- if "@" not in account_email.strip():
2801
- st.error("Informe um e-mail Google válido.")
2802
- elif not account_client_id.strip() or not account_client_secret.strip():
2803
- st.error("Informe o Client ID e o Client Secret desta conta.")
2804
- else:
2805
- for existing in batch_accounts:
2806
- if str(existing.get("id")) == account_id:
2807
- credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
2808
- if credentials_changed:
2809
- delete_youtube_batch_token(existing, STORAGE)
2810
- existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
2811
- update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
2812
- ensure_credentials_document(STORAGE, existing, settings, channel_state)
2813
- settings["youtube_batch_accounts"] = batch_accounts
2814
- write_json("settings.json", settings)
2815
- st.success("Conta Google/YouTube guardada.")
2816
- st.rerun()
2817
-
2818
- if document_save:
2819
- if document_upload is None:
2820
- st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
2821
- else:
2822
- try:
2823
- merge_credentials_document(
2824
- STORAGE,
2825
- batch_account,
2826
- document_upload.getvalue(),
2827
- document_upload.name,
2828
- session_info_override=str(batch_account.get("sessionInfo") or ""),
2829
- channels=channel_state,
2830
- )
2831
- st.success("Documento incorporado e guardado nesta conta Google.")
2832
- st.rerun()
2833
- except ValueError as exc:
2834
- st.error(str(exc))
2835
-
2836
- if youtube_accounts_missing_document:
2837
- st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
2838
-
2839
- st.divider()
2840
- st.markdown("### INNERTUBE_API_KEY")
2841
- st.caption("Esta chave pertence à conta Google/YouTube seleccionada e fica guardada na configuração da conta. Não faz parte do documento de cookies/credenciais e não é editada no separador API Keys.")
2842
- account_key_options = [str(account.get("id")) for account in batch_accounts if account.get("id")]
2843
- account_key_labels = {str(account.get("id")): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in batch_accounts if account.get("id")}
2844
- if account_key_options:
2845
- with st.form("innertube_api_key_form"):
2846
- selected_key_account_id = st.selectbox("Conta Google/YouTube", account_key_options, format_func=lambda value: account_key_labels.get(value, value), key="innertube_key_account")
2847
- selected_key_account = next(account for account in batch_accounts if str(account.get("id")) == selected_key_account_id)
2848
- current_innertube_api_key = direct_account_status(STORAGE, selected_key_account, settings).get("innertube_api_key", "")
2849
- innertube_api_key_value = st.text_input("INNERTUBE_API_KEY", value=current_innertube_api_key, type="password", key=f"innertube_api_key_{selected_key_account_id}", help="Chave usada pelo Upload directo desta conta. Guarde-a aqui, separada do documento de cookies.")
2850
- save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY", type="primary", use_container_width=True)
2851
- if save_innertube_api_key:
2852
- selected_key_account["innertube_api_key"] = innertube_api_key_value.strip()
2853
- selected_key_account.pop("INNERTUBE_API_KEY", None)
2854
- settings.pop("direct_innertube_api_key", None)
2855
- settings["youtube_batch_accounts"] = batch_accounts
2856
- write_json("settings.json", settings)
2857
- document = load_credentials_document(STORAGE, selected_key_account, settings, channel_state, create=True)
2858
- save_credentials_document(STORAGE, selected_key_account, document)
2859
- st.success("INNERTUBE_API_KEY guardada na configuração da conta Google/YouTube, fora do documento de cookies.")
2860
- st.rerun()
2861
- else:
2862
- st.info("Adicione primeiro uma conta Google/YouTube para configurar a INNERTUBE_API_KEY.")
2863
-
2864
- st.divider()
2865
- st.markdown("### Adicionar outra conta Gmail")
2866
- st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
2867
- with st.form("add_batch_account_form"):
2868
- add_cols = st.columns(2)
2869
- with add_cols[0]:
2870
- new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
2871
- new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
2872
- new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
2873
- with add_cols[1]:
2874
- new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
2875
- new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
2876
- new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
2877
- add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
2878
- if add_account:
2879
- document_error = ""
2880
- if "@" not in new_account_email.strip():
2881
- st.error("Informe um e-mail Google válido.")
2882
- elif not new_account_client_id.strip() or not new_account_client_secret.strip():
2883
- st.error("Informe o Client ID e o Client Secret da nova conta.")
2884
- elif new_account_document is not None:
2885
- try:
2886
- raw_document = new_account_document.getvalue()
2887
- json.loads(raw_document.decode("utf-8-sig", errors="replace"))
2888
- except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
2889
- document_error = f"O documento {new_account_document.name} deve ser JSON válido."
2890
- st.error(document_error)
2891
- if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
2892
- new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip(), "sessionInfo": new_account_session_info.strip()}
2893
- batch_accounts.append(new_account)
2894
- settings["youtube_batch_accounts"] = batch_accounts
2895
- settings["youtube_batch_selected_account_id"] = new_account["id"]
2896
- write_json("settings.json", settings)
2897
- ensure_credentials_document(STORAGE, new_account, settings, channel_state)
2898
- if new_account_document is not None:
2899
- try:
2900
- merge_credentials_document(STORAGE, new_account, new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip(), channels=channel_state)
2901
- except ValueError as exc:
2902
- st.error(str(exc))
2903
- st.stop()
2904
- st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
2905
- else:
2906
- st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
2907
- st.rerun()
3108
+ api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
2908
3109
 
2909
3110
  with api_keys_tab:
2910
3111
  with st.form("settings_form"):
2911
3112
  st.subheader("API Keys")
2912
3113
  port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
2913
3114
  moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
2914
- st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
2915
- st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
2916
- st.info(f"OAuth local: use um cliente do tipo Desktop app. Se o Google Cloud pedir uma URI autorizada, registe exactamente `{loopback_redirect_uri()}`.")
2917
- youtube_cols = st.columns(2)
2918
- with youtube_cols[0]:
2919
- youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
2920
- with youtube_cols[1]:
2921
- youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
2922
-
2923
3115
  with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
2924
3116
  st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
2925
3117
  kaggle_cols = st.columns(3)
@@ -2942,10 +3134,6 @@ def render_settings():
2942
3134
  with apify_cols[3]:
2943
3135
  apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
2944
3136
 
2945
- with st.expander("Consulta oficial de métricas — opcional"):
2946
- st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
2947
- youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
2948
-
2949
3137
  with st.expander("Serviço, materiais e rede"):
2950
3138
  cols = st.columns(2)
2951
3139
  with cols[0]:
@@ -3102,8 +3290,7 @@ def render_settings():
3102
3290
  save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3103
3291
  if save_all_settings:
3104
3292
  settings.update({
3105
- "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
3106
- "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
3293
+ "port": port, "moneyprinter_path": moneyprinter_path,
3107
3294
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3108
3295
  "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3109
3296
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
@@ -3171,6 +3358,32 @@ def render_settings():
3171
3358
  st.warning("A amostra de voz anterior não é um ficheiro de áudio legível e foi removida do estado local. Teste a voz novamente.")
3172
3359
 
3173
3360
 
3361
+ def render_notifications():
3362
+ 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.")
3365
+ 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")
3370
+
3371
+
3372
+ def render_models_ai_tutorial():
3373
+ tutorial_url = "https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md"
3374
+ tutorial_path = ROOT / "seed" / "references" / "guide-instagram.md"
3375
+ st.title("Tutorial Meta")
3376
+ st.caption("Guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n.")
3377
+ st.markdown(f"[Abrir fonte original no GitHub]({tutorial_url})")
3378
+ try:
3379
+ tutorial_content = tutorial_path.read_text(encoding="utf-8").strip()
3380
+ except OSError:
3381
+ tutorial_content = ""
3382
+ if not tutorial_content:
3383
+ st.error("O conteúdo local do tutorial não está disponível. Consulte a fonte original no GitHub.")
3384
+ return
3385
+ st.markdown(tutorial_content, unsafe_allow_html=True)
3386
+
3174
3387
  def render_mcp():
3175
3388
  st.title("MCP")
3176
3389
  st.caption("Clientes externos, servidor MCP do Thunderbolt e a skill local ficam separados para evitar confundir funções diferentes.")
@@ -3447,6 +3660,7 @@ def main():
3447
3660
  ]
3448
3661
  pipeline_tiktok_items = [
3449
3662
  ("Prompts Master", ":material/auto_awesome:", "Prompts Master"),
3663
+ ("Contas TikTok", ":material/account_circle:", "Contas TikTok"),
3450
3664
  ]
3451
3665
  edition_items = [
3452
3666
  ("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
@@ -3456,12 +3670,15 @@ def main():
3456
3670
  models_ai_items = [
3457
3671
  ("Personagens", ":material/person:", "Personagens"),
3458
3672
  ("Redes Sociais", ":material/share:", "Redes Sociais"),
3673
+ ("Tutorial Meta", ":material/menu_book:", "Tutorial Meta"),
3459
3674
  ]
3460
3675
  settings_items = [
3461
3676
  ("Canais Youtube", ":material/ondemand_video:", "Canais Youtube"),
3462
3677
  ("Blueprints Youtube", ":material/library_books:", "Blueprints Youtube"),
3463
3678
  ("MCP", ":material/hub:", "MCP"),
3464
- ("Configurações Técnicas", ":material/settings:", "Configurações Técnicas"),
3679
+ ("Contas Google", ":material/account_circle:", "Contas Google"),
3680
+ ("Configuração API", ":material/settings:", "Configuração API"),
3681
+ ("Notificações", ":material/notifications:", "Notificações"),
3465
3682
  ]
3466
3683
  niche_finder_items = [
3467
3684
  ("Niche Finder Kaggle", ":material/search:", "Niche Finder Kaggle"),
@@ -3489,7 +3706,8 @@ def main():
3489
3706
  "Automação": "Automação Youtube",
3490
3707
  "Canais": "Canais Youtube",
3491
3708
  "Blueprints": "Blueprints Youtube",
3492
- "Configurações Técnicas": "Configurações Técnicas",
3709
+ "Configurações Técnicas": "Configuração API",
3710
+ "Contas Google/YouTube — canais em lote": "Contas Google",
3493
3711
  }
3494
3712
  current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
3495
3713
  if current_page not in {item[0] for item in top_pages + pipeline_items + pipeline_tiktok_items + automation_items + edition_items + models_ai_items + niche_finder_items + settings_items}:
@@ -3545,6 +3763,7 @@ def main():
3545
3763
  "Criação de Músicas": render_music_creation,
3546
3764
  "Roteiros": render_scripts,
3547
3765
  "Prompts Master": render_tiktok_prompt_masters,
3766
+ "Contas TikTok": render_tiktok_accounts,
3548
3767
  "Automação Youtube": render_automation,
3549
3768
  "Niche Finder Kaggle": render_niche_finder,
3550
3769
  "Niche Finder Apify": render_niche_finder_apify,
@@ -3553,13 +3772,16 @@ def main():
3553
3772
  "Cortes": render_cuts,
3554
3773
  "Editor Python": render_python_editor,
3555
3774
  "Models AI": lambda: render_edit_placeholder("Models AI", "Seleccione uma das abas Models AI no menu expansível."),
3775
+ "Tutorial Meta": render_models_ai_tutorial,
3556
3776
  "Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
3557
3777
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
3558
3778
  "Upload": render_upload,
3559
3779
  "Canais Youtube": render_channels,
3560
3780
  "Blueprints Youtube": render_blueprints,
3561
3781
  "MCP": render_mcp,
3562
- "Configurações Técnicas": render_settings,
3782
+ "Contas Google": render_google_accounts,
3783
+ "Configuração API": render_settings,
3784
+ "Notificações": render_notifications,
3563
3785
  }
3564
3786
  renderers.get(current_page, render_dashboard)()
3565
3787