@danhachuel/thunderbolt 0.2.67 → 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.
@@ -345,14 +345,14 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
345
345
 
346
346
  ## 8. Configuração inicial da UI
347
347
 
348
- Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. O menu expansível **Automação** contém a subaba **Automação Youtube**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **Models AI** contém **Personagens** e **Redes Sociais**, nessa ordem; ambas estão reservadas para desenvolvimento futuro e não executam operações. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. Abra **Configurações > Configurações Técnicas** e reveja. A página está organizada em três subabas sem numeração visível, na ordem **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A subaba de contas contém os registos Google, sessionInfo e documentos de Upload directo; **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém apenas o preview de áudio. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. O menu **Configurações** contém **Canais Youtube**, **Blueprints Youtube**, **MCP** e **Configurações Técnicas**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica numa área opcional, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
348
+ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **Models AI** e **Configurações**. O menu expansível **Automação** contém a subaba **Automação Youtube**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **Models AI** contém **Personagens**, **Redes Sociais** e **Tutorial Meta**, nessa ordem. O **Tutorial Meta** apresenta o guia de configuração de Instagram e credenciais Meta para automações com n8n. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. O menu **Configurações** contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. Abra **Configurações > Contas Google** para gerir contas Google/YouTube, cartões, documentos de Upload directo, `sessionInfo`, `INNERTUBE_API_KEY`, contas adicionais e a configuração global do YouTube. Abra **Configurações > Configuração API** para configurar as restantes API Keys, providers, modelos, serviços e o **Teste de vozes**. A página **Configurações > Notificações** está reservada para futuros eventos locais. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** em **Contas Google** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica em **Contas Google**, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
349
349
 
350
350
  | Configuração | Finalidade |
351
351
  |---|---|
352
352
  | Porta Streamlit | Definir a porta local da UI |
353
353
  | Pasta MoneyPrinterTurbo | Indicar o clone local que será sincronizado com `config.toml` |
354
- | YouTube Data API Key (opcional) | Permitir o método oficial Data API para métricas públicas; não é necessária para a página pública nem para OAuth |
355
- | YouTube OAuth Client ID | Identificar a aplicação OAuth 2.0 do Google para autorizar operações autenticadas, como upload |
354
+ | YouTube Data API Key (opcional) | Em **Configurações > Contas Google**, permitir o método oficial Data API para métricas públicas; não é necessária para a página pública nem para OAuth |
355
+ | YouTube OAuth Client ID | Em **Configurações > Contas Google**, identificar a aplicação OAuth 2.0 do Google para autorizar operações autenticadas, como upload |
356
356
  | YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é uma Data API Key nem um token de acesso |
357
357
  | OpenAI/ NVIDIA NIM API key | Credencial do OpenAI ou NVIDIA Build/NIM, guardada apenas no storage local |
358
358
  | OpenAI/ NVIDIA NIM Base URL | Endpoint OpenAI-compatible; por padrão `https://integrate.api.nvidia.com/v1` |
package/README.md CHANGED
@@ -38,11 +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.56
41
+ ## Navegação da UI 0.2.68
42
42
 
43
- A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** 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. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, 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** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
43
+ A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **Models AI** 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. **Models AI** é 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
- Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém cartões expansíveis de contas Google, sessionInfo e documentos de Upload directo; o formulário **Adicionar outra conta Gmail** fica fora dos cartões. **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
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
+
47
+ A página **Models AI > 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.
46
48
 
47
49
  ## Canais Youtube — edição por cartão e vídeos recentes
48
50
 
package/app/main.py CHANGED
@@ -1010,7 +1010,7 @@ def render_channels():
1010
1010
  if lookup_mode.startswith("Página pública"):
1011
1011
  result = youtube.fetch_channel_public(source)
1012
1012
  elif not youtube.api_key:
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.", {"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"})
1014
1014
  else:
1015
1015
  result = youtube.fetch_channel(source)
1016
1016
  st.session_state["yt_import"] = result.data
@@ -1092,7 +1092,7 @@ def render_channels():
1092
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.")
1093
1093
  accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1094
1094
  if not accounts:
1095
- 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.")
1096
1096
  else:
1097
1097
  account_ids = [str(account["id"]) for account in accounts]
1098
1098
  account_labels = {str(account["id"]): f"{account.get('email', 'Conta Google')} — {account.get('label', 'Canais YouTube')}" for account in accounts}
@@ -1545,7 +1545,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1545
1545
  payload["thumbnail_path"] = thumbnail_path
1546
1546
  payload["thumbnail_status"] = "generated"
1547
1547
  else:
1548
- 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.")
1549
1549
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
1550
1550
  else:
1551
1551
  topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
@@ -1599,7 +1599,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1599
1599
  payload["thumbnail_path"] = thumbnail_path
1600
1600
  payload["thumbnail_status"] = "generated"
1601
1601
  else:
1602
- 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.")
1603
1603
  st.session_state["new_video_creative_payload"] = payload
1604
1604
 
1605
1605
  with st.form("new_video_form"):
@@ -2588,11 +2588,11 @@ def render_upload():
2588
2588
 
2589
2589
  def render_upload_postiz():
2590
2590
  st.subheader("Upload para Postiz")
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çõ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.")
2592
2592
  settings = read_json("settings.json", {})
2593
2593
  postiz = PostizAdapter(settings)
2594
2594
  if not settings.get("postiz_enabled"):
2595
- 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.")
2596
2596
  if not postiz.api_key:
2597
2597
  st.info("Nenhuma API key Postiz configurada.")
2598
2598
  return
@@ -2619,7 +2619,7 @@ def render_upload_postiz():
2619
2619
  format_func=lambda value: integration_labels.get(value, value),
2620
2620
  key="postiz_upload_integration",
2621
2621
  )
2622
- 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.")
2623
2623
  else:
2624
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()
2625
2625
  st.info("Carregue as integrações para descobrir os canais Postiz ligados à sua conta.")
@@ -2758,7 +2758,7 @@ def render_upload_conventional():
2758
2758
  postiz_status = postiz.status()
2759
2759
  (st.success if postiz_status.ok else st.warning)(postiz_status.message)
2760
2760
  else:
2761
- 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.")
2762
2762
  status_cols = st.columns(2)
2763
2763
  with status_cols[0]:
2764
2764
  (st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
@@ -2861,9 +2861,239 @@ def render_upload_conventional():
2861
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.")
2862
2862
 
2863
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
+
2864
3094
  def render_settings():
2865
- st.title("Configurações Técnicas")
2866
- 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.")
2867
3097
  settings = read_json("settings.json", {})
2868
3098
 
2869
3099
  def text_setting(label: str, key: str, *, secret: bool = False, help_text: str | None = None) -> str:
@@ -2875,209 +3105,13 @@ def render_settings():
2875
3105
  key=f"settings_{key}",
2876
3106
  )
2877
3107
 
2878
- google_accounts_tab, api_keys_tab, voice_test_tab = st.tabs(["Contas Google/YouTube — canais em lote", "API Keys", "Teste de vozes"])
2879
-
2880
- with google_accounts_tab:
2881
- st.subheader("Contas Google/YouTube — canais em lote")
2882
- 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.")
2883
- batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
2884
- channel_state = read_json("channels.json", [])
2885
- youtube_accounts_missing_document: list[str] = []
2886
-
2887
- for batch_account in batch_accounts:
2888
- account_id = str(batch_account["id"])
2889
- account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
2890
- account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
2891
- ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
2892
- direct_status = direct_account_status(STORAGE, batch_account, settings)
2893
- missing_document_parts = list(direct_status.get("missing_cookies", []))
2894
- if not direct_status.get("has_session_info"):
2895
- missing_document_parts.append("sessionInfo")
2896
- if missing_document_parts:
2897
- youtube_accounts_missing_document.append(account_email_snapshot)
2898
-
2899
- with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
2900
- with st.form(f"batch_account_form_{account_id}"):
2901
- account_cols = st.columns(2)
2902
- with account_cols[0]:
2903
- account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
2904
- 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}")
2905
- account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
2906
- with account_cols[1]:
2907
- 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}")
2908
- account_session_info = st.text_input(
2909
- "sessionInfo token desta conta Google",
2910
- value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
2911
- type="password",
2912
- key=f"batch_session_info_{account_id}",
2913
- help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
2914
- )
2915
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
2916
-
2917
- st.markdown("**Documento de cookies/credenciais desta conta Google**")
2918
- 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.")
2919
- document_upload = st.file_uploader(
2920
- "Subir documento de cookies/credenciais",
2921
- type=["json"],
2922
- key=f"direct_credentials_document_{account_id}",
2923
- help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
2924
- )
2925
- if missing_document_parts:
2926
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
2927
- else:
2928
- st.success("Documento completo para a conta Google")
2929
- st.caption(f"Documento guardado em: {direct_status['document_file']}")
2930
- document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
2931
-
2932
- account_status = youtube_batch_account_status(batch_account, STORAGE)
2933
- status_cols = st.columns(3)
2934
- with status_cols[0]:
2935
- (st.success if account_status.ok else st.warning)(account_status.message)
2936
- with status_cols[1]:
2937
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
2938
- result = authorize_youtube_batch_account(batch_account, STORAGE)
2939
- (st.success if result.ok else st.error)(result.message)
2940
- if result.ok:
2941
- st.rerun()
2942
- with status_cols[2]:
2943
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
2944
- delete_youtube_batch_token(batch_account, STORAGE)
2945
- delete_credentials_document(STORAGE, batch_account)
2946
- remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
2947
- settings["youtube_batch_accounts"] = remaining_accounts
2948
- if settings.get("youtube_batch_selected_account_id") == account_id:
2949
- settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
2950
- for channel in channel_state:
2951
- if str(channel.get("google_account_id") or "") == account_id:
2952
- channel.update({"google_account_id": "", "google_account_email": ""})
2953
- write_json("channels.json", channel_state)
2954
- write_json("settings.json", settings)
2955
- st.rerun()
2956
-
2957
- if save_account:
2958
- if "@" not in account_email.strip():
2959
- st.error("Informe um e-mail Google válido.")
2960
- elif not account_client_id.strip() or not account_client_secret.strip():
2961
- st.error("Informe o Client ID e o Client Secret desta conta.")
2962
- else:
2963
- for existing in batch_accounts:
2964
- if str(existing.get("id")) == account_id:
2965
- 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())))
2966
- if credentials_changed:
2967
- delete_youtube_batch_token(existing, STORAGE)
2968
- 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()})
2969
- update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
2970
- ensure_credentials_document(STORAGE, existing, settings, channel_state)
2971
- settings["youtube_batch_accounts"] = batch_accounts
2972
- write_json("settings.json", settings)
2973
- st.success("Conta Google/YouTube guardada.")
2974
- st.rerun()
2975
-
2976
- if document_save:
2977
- if document_upload is None:
2978
- st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
2979
- else:
2980
- try:
2981
- merge_credentials_document(
2982
- STORAGE,
2983
- batch_account,
2984
- document_upload.getvalue(),
2985
- document_upload.name,
2986
- session_info_override=str(batch_account.get("sessionInfo") or ""),
2987
- channels=channel_state,
2988
- )
2989
- st.success("Documento incorporado e guardado nesta conta Google.")
2990
- st.rerun()
2991
- except ValueError as exc:
2992
- st.error(str(exc))
2993
-
2994
- if youtube_accounts_missing_document:
2995
- st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
2996
-
2997
- st.divider()
2998
- st.markdown("### INNERTUBE_API_KEY")
2999
- 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.")
3000
- account_key_options = [str(account.get("id")) for account in batch_accounts if account.get("id")]
3001
- 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")}
3002
- if account_key_options:
3003
- with st.form("innertube_api_key_form"):
3004
- 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")
3005
- selected_key_account = next(account for account in batch_accounts if str(account.get("id")) == selected_key_account_id)
3006
- current_innertube_api_key = direct_account_status(STORAGE, selected_key_account, settings).get("innertube_api_key", "")
3007
- 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.")
3008
- save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY", type="primary", use_container_width=True)
3009
- if save_innertube_api_key:
3010
- selected_key_account["innertube_api_key"] = innertube_api_key_value.strip()
3011
- selected_key_account.pop("INNERTUBE_API_KEY", None)
3012
- settings.pop("direct_innertube_api_key", None)
3013
- settings["youtube_batch_accounts"] = batch_accounts
3014
- write_json("settings.json", settings)
3015
- document = load_credentials_document(STORAGE, selected_key_account, settings, channel_state, create=True)
3016
- save_credentials_document(STORAGE, selected_key_account, document)
3017
- st.success("INNERTUBE_API_KEY guardada na configuração da conta Google/YouTube, fora do documento de cookies.")
3018
- st.rerun()
3019
- else:
3020
- st.info("Adicione primeiro uma conta Google/YouTube para configurar a INNERTUBE_API_KEY.")
3021
-
3022
- st.divider()
3023
- st.markdown("### Adicionar outra conta Gmail")
3024
- 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.")
3025
- with st.form("add_batch_account_form"):
3026
- add_cols = st.columns(2)
3027
- with add_cols[0]:
3028
- new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
3029
- new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
3030
- new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
3031
- with add_cols[1]:
3032
- new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
3033
- 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.")
3034
- 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.")
3035
- add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
3036
- if add_account:
3037
- document_error = ""
3038
- if "@" not in new_account_email.strip():
3039
- st.error("Informe um e-mail Google válido.")
3040
- elif not new_account_client_id.strip() or not new_account_client_secret.strip():
3041
- st.error("Informe o Client ID e o Client Secret da nova conta.")
3042
- elif new_account_document is not None:
3043
- try:
3044
- raw_document = new_account_document.getvalue()
3045
- json.loads(raw_document.decode("utf-8-sig", errors="replace"))
3046
- except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
3047
- document_error = f"O documento {new_account_document.name} deve ser JSON válido."
3048
- st.error(document_error)
3049
- if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
3050
- 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()}
3051
- batch_accounts.append(new_account)
3052
- settings["youtube_batch_accounts"] = batch_accounts
3053
- settings["youtube_batch_selected_account_id"] = new_account["id"]
3054
- write_json("settings.json", settings)
3055
- ensure_credentials_document(STORAGE, new_account, settings, channel_state)
3056
- if new_account_document is not None:
3057
- try:
3058
- 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)
3059
- except ValueError as exc:
3060
- st.error(str(exc))
3061
- st.stop()
3062
- st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
3063
- else:
3064
- 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.")
3065
- st.rerun()
3108
+ api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
3066
3109
 
3067
3110
  with api_keys_tab:
3068
3111
  with st.form("settings_form"):
3069
3112
  st.subheader("API Keys")
3070
3113
  port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3071
3114
  moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3072
- st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
3073
- 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.")
3074
- 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()}`.")
3075
- youtube_cols = st.columns(2)
3076
- with youtube_cols[0]:
3077
- 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.")
3078
- with youtube_cols[1]:
3079
- 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.")
3080
-
3081
3115
  with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
3082
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.")
3083
3117
  kaggle_cols = st.columns(3)
@@ -3100,10 +3134,6 @@ def render_settings():
3100
3134
  with apify_cols[3]:
3101
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)
3102
3136
 
3103
- with st.expander("Consulta oficial de métricas — opcional"):
3104
- 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.")
3105
- 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.")
3106
-
3107
3137
  with st.expander("Serviço, materiais e rede"):
3108
3138
  cols = st.columns(2)
3109
3139
  with cols[0]:
@@ -3260,8 +3290,7 @@ def render_settings():
3260
3290
  save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3261
3291
  if save_all_settings:
3262
3292
  settings.update({
3263
- "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
3264
- "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
3293
+ "port": port, "moneyprinter_path": moneyprinter_path,
3265
3294
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3266
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),
3267
3296
  "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
@@ -3329,6 +3358,32 @@ def render_settings():
3329
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.")
3330
3359
 
3331
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
+
3332
3387
  def render_mcp():
3333
3388
  st.title("MCP")
3334
3389
  st.caption("Clientes externos, servidor MCP do Thunderbolt e a skill local ficam separados para evitar confundir funções diferentes.")
@@ -3615,12 +3670,15 @@ def main():
3615
3670
  models_ai_items = [
3616
3671
  ("Personagens", ":material/person:", "Personagens"),
3617
3672
  ("Redes Sociais", ":material/share:", "Redes Sociais"),
3673
+ ("Tutorial Meta", ":material/menu_book:", "Tutorial Meta"),
3618
3674
  ]
3619
3675
  settings_items = [
3620
3676
  ("Canais Youtube", ":material/ondemand_video:", "Canais Youtube"),
3621
3677
  ("Blueprints Youtube", ":material/library_books:", "Blueprints Youtube"),
3622
3678
  ("MCP", ":material/hub:", "MCP"),
3623
- ("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"),
3624
3682
  ]
3625
3683
  niche_finder_items = [
3626
3684
  ("Niche Finder Kaggle", ":material/search:", "Niche Finder Kaggle"),
@@ -3648,7 +3706,8 @@ def main():
3648
3706
  "Automação": "Automação Youtube",
3649
3707
  "Canais": "Canais Youtube",
3650
3708
  "Blueprints": "Blueprints Youtube",
3651
- "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",
3652
3711
  }
3653
3712
  current_page = aliases.get(st.session_state.get("page", "Início"), st.session_state.get("page", "Início"))
3654
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}:
@@ -3713,13 +3772,16 @@ def main():
3713
3772
  "Cortes": render_cuts,
3714
3773
  "Editor Python": render_python_editor,
3715
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,
3716
3776
  "Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
3717
3777
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
3718
3778
  "Upload": render_upload,
3719
3779
  "Canais Youtube": render_channels,
3720
3780
  "Blueprints Youtube": render_blueprints,
3721
3781
  "MCP": render_mcp,
3722
- "Configurações Técnicas": render_settings,
3782
+ "Contas Google": render_google_accounts,
3783
+ "Configuração API": render_settings,
3784
+ "Notificações": render_notifications,
3723
3785
  }
3724
3786
  renderers.get(current_page, render_dashboard)()
3725
3787
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.67",
3
+ "version": "0.2.68",
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",
@@ -0,0 +1,179 @@
1
+ # Setting up Instagram for automations with n8n
2
+
3
+ ## [📚 Join our Skool community for support, premium content and more!](https://www.skool.com/ai-agents-az/about?gw8)
4
+
5
+ ### Be part of a growing community and help us create more content like this
6
+
7
+ ## Convert the Instagram account to a professional account
8
+
9
+ > [!IMPORTANT]
10
+ > Converting the Instagram account to a professional (business or creator) account will make the account public
11
+
12
+ In the Instagram application, follow these steps to convert your account to a professional account.
13
+
14
+ <table>
15
+ <tr>
16
+ <td>
17
+ 1. Open the settings, and select `Account type and tools`
18
+ <img src="https://github.com/user-attachments/assets/adffda31-f8ce-4a49-a971-40e5cd5f5395" alt="" />
19
+ </td>
20
+ <td>
21
+ 2. Click on `Switch to professinal account`
22
+ <img src="https://github.com/user-attachments/assets/5ea13aeb-b6ac-40b4-906f-2fc563625c52" alt="" />
23
+ </td>
24
+ <td>
25
+ 3. Click on `Next`
26
+ <img src="https://github.com/user-attachments/assets/10942cc8-6045-4ca1-a14a-241a2d94591c" />
27
+ </td>
28
+ </tr>
29
+ <tr>
30
+ <td>
31
+ 4. Select the type of account your want to create
32
+ <img src="https://github.com/user-attachments/assets/c9e19bf8-b48d-49e7-b560-e8edf423233d" alt="" />
33
+ </td>
34
+ <td>
35
+ 5. Select the right category, that describe your account
36
+ <img src="https://github.com/user-attachments/assets/eb4680b2-1d38-476c-8fb0-c428d1e12db9" alt="" />
37
+ </td>
38
+ <td>
39
+ 6. Confirm the choice
40
+ <img src="https://github.com/user-attachments/assets/22ee13cf-f751-4cea-9fc0-be466c885b02" alt="" />
41
+ </td>
42
+ </tr>
43
+ </table>
44
+
45
+ ## Create credentials to use with n8n
46
+
47
+ ### 1. Create a Facebook page
48
+
49
+ Navigate to [https://www.facebook.com/pages/create](https://www.facebook.com/pages/create) to create a new Facebook page.
50
+ Fill out the required parameters (name, category) and create the page.
51
+
52
+ <img width="1554" alt="Screenshot 2025-05-05 at 10 02 00 AM" src="https://github.com/user-attachments/assets/f0e321a2-7b85-4b23-b85b-f8e63281b6b9" />
53
+
54
+ Now, you need to be acting on behalf of the page - if it's not active for some reason, select it from the menu.
55
+
56
+ <img width="372" alt="image" src="https://github.com/user-attachments/assets/7aaaccc8-4b0a-4f72-a8c9-f2ecc453584a" />
57
+
58
+ ### 2. Connect Instagram account to the Facebook page
59
+
60
+ Click on your page's name on the left side.
61
+
62
+ <img width="379" alt="image" src="https://github.com/user-attachments/assets/fd1ed1d8-8890-4636-a4ae-0496db252283" />
63
+
64
+ Select `Settings` from the menu.
65
+
66
+ <img width="368" alt="image" src="https://github.com/user-attachments/assets/641904ec-764e-4d56-bede-618da33db7fe" />
67
+
68
+ Scroll down to `Permissions` and select `Linked accounts`.
69
+
70
+ <img width="369" alt="image" src="https://github.com/user-attachments/assets/a024ad7e-8358-4329-a864-c7b621810405" />
71
+
72
+ Select `Instagram`.
73
+
74
+ <img width="740" alt="image" src="https://github.com/user-attachments/assets/141164a0-c88e-46a0-a335-466f81297bc6" />
75
+
76
+ Click on `Connect account` and sign in with your Instagram account.
77
+
78
+ <img width="708" alt="image" src="https://github.com/user-attachments/assets/5324b945-621b-4f2b-b513-9b51e5b5c071" />
79
+
80
+ Click on `Connect`.
81
+
82
+ <img width="573" alt="image" src="https://github.com/user-attachments/assets/85cad094-a815-441a-a0e5-84081fd0ee2d" />
83
+
84
+ Click on `Confirm`.
85
+
86
+ <img width="570" alt="image" src="https://github.com/user-attachments/assets/df5bb377-22a8-41de-b99d-0f80052cb372" />
87
+
88
+ Click on `Continue`.
89
+
90
+ <img width="561" alt="image" src="https://github.com/user-attachments/assets/81fd3143-f00b-4e33-9521-12c0c9ce623d" />
91
+
92
+ You are done.
93
+
94
+ <img width="713" alt="image" src="https://github.com/user-attachments/assets/ce4c0bbe-beed-4c6c-8731-23216efbccb5" />
95
+
96
+ ### 3. Create a Facebook application
97
+
98
+ Head to [https://developers.facebook.com/apps](https://developers.facebook.com/apps) and click on `Create app`
99
+
100
+ <img width="1027" alt="image" src="https://github.com/user-attachments/assets/ca5c79b9-974a-4c55-8f88-6def8c1b98d1" />
101
+
102
+ Select the `Other` use case and hit `Next`.
103
+
104
+ <img width="1033" alt="image" src="https://github.com/user-attachments/assets/31f296bd-3b47-432b-9037-7974e821a311" />
105
+
106
+ Select `Business` app type.
107
+
108
+ <img width="818" alt="image" src="https://github.com/user-attachments/assets/2ab06dd1-33ba-4d87-8365-3637c8327877" />
109
+
110
+ Review, and click on `Create app`
111
+
112
+ <img width="815" alt="image" src="https://github.com/user-attachments/assets/c8cc8e1d-c006-428a-9bfa-8cc78a2932c9" />
113
+
114
+ Add the Instagram product to your app.
115
+
116
+ <img width="1008" alt="image" src="https://github.com/user-attachments/assets/4186f90b-b619-4c1c-9ea8-bfc5294eaeb4" />
117
+
118
+ You don't need to configure it, just leave it as it is.
119
+
120
+ ### 4. Create an access token
121
+
122
+ From the tools menu, select the `Graph API Explorer`
123
+
124
+ <img width="877" alt="image" src="https://github.com/user-attachments/assets/006a40a7-894a-4bd0-9483-0d495b77206f" />
125
+
126
+ You'll see this interface below.
127
+
128
+ <img width="541" alt="image" src="https://github.com/user-attachments/assets/414ece47-2f1d-4d08-9998-ada85ffb065f" />
129
+
130
+ 1. Make sure the Meta app is the one you just created
131
+ 2. In the permissions panel, add the following permissions
132
+
133
+ - `pages_show_list`
134
+ - `business_management`
135
+ - `instagram_basic`
136
+ - `instagram_content_publish`
137
+ - `pages_read_engagement`
138
+
139
+ 3. Click on `Generate Access Token`
140
+
141
+ 4. You'll be prompted to select the Facebook page you give access to, the business account and the Instagram account. Select all of them. During the process, take a not of the **Instagram account id** you are connecting, you'll need that later.
142
+
143
+ <table>
144
+ <tr>
145
+ <td>
146
+ <img width="564" alt="Screenshot 2025-05-09 at 11 07 22 AM" src="https://github.com/user-attachments/assets/6b77415e-1ca2-47f0-b046-974fd391c90a" />
147
+ </td>
148
+ <td>
149
+ <img width="562" alt="Screenshot 2025-05-09 at 11 07 27 AM" src="https://github.com/user-attachments/assets/353b9973-426b-4034-b5fc-e77840ca0d18" />
150
+ </td>
151
+ <td>
152
+ <img width="562" alt="Screenshot 2025-05-09 at 11 08 35 AM" src="https://github.com/user-attachments/assets/58132694-1376-4c40-9d20-61cb8fdb9df5" />
153
+ </td>
154
+ <td>
155
+ <img width="562" alt="Screenshot 2025-05-09 at 11 08 58 AM" src="https://github.com/user-attachments/assets/c2676755-f12f-49d6-9a92-49ea688fc832" />
156
+ </td>
157
+ </tr>
158
+ </table>
159
+
160
+ 5. Copy the access token, and head to the [Facebook token debugger tool](https://developers.facebook.com/tools/debug/accesstoken) and paste in the access token, and click `debug`. Scroll to the very bottom of the page, and click on `Extend Access Token`. Copy the long-lived access token.
161
+
162
+ 6. Go back to the `Graph API Explorer` and paste the access token to the access token field and add `me/accounts` to the graph path and click `Submit`. In the results, you'll find field `access_token` that contains the long-lived page access token that will never expire. Copy that.
163
+
164
+ <img width="693" alt="image" src="https://github.com/user-attachments/assets/0e1bd821-5c27-4c9a-b14e-eeed9c17850f" />
165
+
166
+ 7. (optional) you can verify the expiration of the token, if you paste it to the [Facebook token debugger tool](https://developers.facebook.com/tools/debug/accesstoken).
167
+
168
+ 8. (optional) you can also get the Instagram Business Account's id, if you click on the id of the page in the Graph API explorer, and add `?fields=instagram_business_account` after the id, then click `Submit`
169
+
170
+ <img width="766" alt="image" src="https://github.com/user-attachments/assets/ac0cb186-849a-4c55-8f88-6def8c1b98d1" />
171
+
172
+ **If you did everything above, you'll have two things**
173
+
174
+ 1. A page token, that won't expire to manage your Instagram account. You will use the page token to setup the Facebook Graph node in n8n.
175
+ 2. The Instagram account id which you'll need to set in the `Configure` node in the n8n workflow
176
+
177
+ ---
178
+
179
+ > **Source:** [ai_agents_az/episode_8/guide-instagram.md](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md)