@danhachuel/thunderbolt 0.2.48 → 0.2.49

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.2.48
5
+ > **Versão deste manual:** 0.2.49
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.48 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.49 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.48 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.49 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -345,7 +345,7 @@ 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 **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. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. O menu **Configurações** contém **Canais**, **Blueprints**, **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**, **Automação**, **Edição**, **Models AI** e **Configurações**. 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**, **Blueprints**, **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.
349
349
 
350
350
  | Configuração | Finalidade |
351
351
  |---|---|
@@ -418,7 +418,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
418
418
 
419
419
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
420
420
 
421
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.48 install`; o instalador detecta e reutiliza o que já estiver válido.
421
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.49 install`; o instalador detecta e reutiliza o que já estiver válido.
422
422
 
423
423
  ### Niche Finder Apify
424
424
 
package/README.md CHANGED
@@ -36,10 +36,12 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
36
36
 
37
37
  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.
38
38
 
39
- ## Navegação da UI 0.2.48
39
+ ## Navegação da UI 0.2.49
40
40
 
41
41
  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** e **Upload**. **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**, **Blueprints**, **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.
42
42
 
43
+ 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 contas 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 exclusivamente o preview de áudio.
44
+
43
45
  ## OpenAI/ NVIDIA NIM — descoberta de modelos
44
46
 
45
47
  Em **Configurações Técnicas > LLM — providers e modelos**, a área anteriormente chamada **OpenAI — API key, Base URL e modelo** passa a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. O campo de modelo manual usa o parâmetro `help` suportado pelas versões do Streamlit instaladas pelo Thunderbolt, evitando o erro de argumento `help_text`. O provider interno continua a ser `openai`, para preservar a compatibilidade com o MoneyPrinterTurbo e com os restantes endpoints OpenAI-compatible.
@@ -139,9 +141,9 @@ thunderbolt
139
141
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
140
142
 
141
143
  ```powershell
142
- npx.cmd --yes @danhachuel/thunderbolt@0.2.48 install
143
- npx.cmd --yes @danhachuel/thunderbolt@0.2.48 doctor
144
- npx.cmd --yes @danhachuel/thunderbolt@0.2.48
144
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.49 install
145
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.49 doctor
146
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.49
145
147
  ```
146
148
 
147
149
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -165,7 +167,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
165
167
 
166
168
  A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
167
169
 
168
- **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.47 install`; o instalador detecta e reutiliza componentes já válidos.
170
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.49 install`; o instalador detecta e reutiliza componentes já válidos.
169
171
 
170
172
  ## Editor Python baseado no PYEdit
171
173
 
package/app/main.py CHANGED
@@ -1448,402 +1448,406 @@ def render_settings():
1448
1448
  key=f"settings_{key}",
1449
1449
  )
1450
1450
 
1451
- st.subheader("Contas Google/YouTube — canais em lote")
1452
- st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
1453
- batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1454
- direct_document_uploads: dict[str, Any] = {}
1455
- direct_account_save_buttons: dict[str, bool] = {}
1456
- for batch_account in batch_accounts:
1457
- account_id = str(batch_account["id"])
1458
- with st.container(border=True):
1459
- with st.form(f"batch_account_form_{account_id}"):
1460
- account_cols = st.columns(4)
1461
- with account_cols[0]:
1462
- account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1463
- with account_cols[1]:
1464
- account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1465
- with account_cols[2]:
1466
- account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1467
- with account_cols[3]:
1468
- 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}")
1469
- account_session_info = st.text_input(
1470
- "sessionInfo token desta conta Google",
1471
- value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
1472
- type="password",
1473
- key=f"batch_session_info_{account_id}",
1474
- help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
1475
- )
1476
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1477
- direct_status = direct_account_status(STORAGE, batch_account)
1478
- st.markdown("**Credenciais do Upload directo desta conta Google**")
1479
- st.caption("Estas credenciais pertencem apenas a este Gmail. O ficheiro contém SID, SSID, HSID, APISID e SAPISID e é guardado por ID da conta.")
1480
- direct_account_cols = st.columns([2.4, 1])
1481
- with direct_account_cols[0]:
1482
- direct_document_uploads[account_id] = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key=f"direct_credentials_document_{account_id}", help="Documento JSON com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo token, INNERTUBE_API_KEY, chunk_size e mapa delegated_session_ids.")
1483
- with direct_account_cols[1]:
1484
- if direct_status["document_exists"] and direct_status["ready"]:
1485
- st.success("Documento completo")
1486
- elif direct_status["document_exists"]:
1487
- missing_document_parts = list(direct_status["missing_cookies"])
1488
- if not direct_status["has_session_info"]:
1489
- missing_document_parts.append("sessionInfo")
1490
- if not direct_status["has_innertube_api_key"]:
1491
- missing_document_parts.append("INNERTUBE_API_KEY")
1492
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1493
- else:
1494
- st.warning("Sem documento de credenciais")
1495
- st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1496
- direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
1497
- account_status = youtube_batch_account_status(batch_account, STORAGE)
1498
- status_cols = st.columns([2, 1, 1])
1499
- with status_cols[0]:
1500
- (st.success if account_status.ok else st.warning)(account_status.message)
1501
- with status_cols[1]:
1502
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
1503
- result = authorize_youtube_batch_account(batch_account, STORAGE)
1504
- (st.success if result.ok else st.error)(result.message)
1505
- if result.ok:
1451
+ google_accounts_tab, api_keys_tab, voice_test_tab = st.tabs(["Contas Google/YouTube — canais em lote", "API Keys", "Teste de vozes"])
1452
+
1453
+ with google_accounts_tab:
1454
+ st.subheader("Contas Google/YouTube canais em lote")
1455
+ st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
1456
+ batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1457
+ direct_document_uploads: dict[str, Any] = {}
1458
+ direct_account_save_buttons: dict[str, bool] = {}
1459
+ for batch_account in batch_accounts:
1460
+ account_id = str(batch_account["id"])
1461
+ with st.container(border=True):
1462
+ with st.form(f"batch_account_form_{account_id}"):
1463
+ account_cols = st.columns(4)
1464
+ with account_cols[0]:
1465
+ account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1466
+ with account_cols[1]:
1467
+ account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1468
+ with account_cols[2]:
1469
+ account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1470
+ with account_cols[3]:
1471
+ 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}")
1472
+ account_session_info = st.text_input(
1473
+ "sessionInfo token desta conta Google",
1474
+ value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
1475
+ type="password",
1476
+ key=f"batch_session_info_{account_id}",
1477
+ help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
1478
+ )
1479
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1480
+ direct_status = direct_account_status(STORAGE, batch_account)
1481
+ st.markdown("**Credenciais do Upload directo desta conta Google**")
1482
+ st.caption("Estas credenciais pertencem apenas a este Gmail. O ficheiro contém SID, SSID, HSID, APISID e SAPISID e é guardado por ID da conta.")
1483
+ direct_account_cols = st.columns([2.4, 1])
1484
+ with direct_account_cols[0]:
1485
+ direct_document_uploads[account_id] = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key=f"direct_credentials_document_{account_id}", help="Documento JSON com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo token, INNERTUBE_API_KEY, chunk_size e mapa delegated_session_ids.")
1486
+ with direct_account_cols[1]:
1487
+ if direct_status["document_exists"] and direct_status["ready"]:
1488
+ st.success("Documento completo")
1489
+ elif direct_status["document_exists"]:
1490
+ missing_document_parts = list(direct_status["missing_cookies"])
1491
+ if not direct_status["has_session_info"]:
1492
+ missing_document_parts.append("sessionInfo")
1493
+ if not direct_status["has_innertube_api_key"]:
1494
+ missing_document_parts.append("INNERTUBE_API_KEY")
1495
+ st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1496
+ else:
1497
+ st.warning("Sem documento de credenciais")
1498
+ st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1499
+ direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
1500
+ account_status = youtube_batch_account_status(batch_account, STORAGE)
1501
+ status_cols = st.columns([2, 1, 1])
1502
+ with status_cols[0]:
1503
+ (st.success if account_status.ok else st.warning)(account_status.message)
1504
+ with status_cols[1]:
1505
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
1506
+ result = authorize_youtube_batch_account(batch_account, STORAGE)
1507
+ (st.success if result.ok else st.error)(result.message)
1508
+ if result.ok:
1509
+ st.rerun()
1510
+ with status_cols[2]:
1511
+ if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
1512
+ delete_youtube_batch_token(batch_account, STORAGE)
1513
+ delete_credentials_document(STORAGE, batch_account)
1514
+ remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
1515
+ settings["youtube_batch_accounts"] = remaining_accounts
1516
+ if settings.get("youtube_batch_selected_account_id") == account_id:
1517
+ settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
1518
+ channels = read_json("channels.json", [])
1519
+ channels_changed = False
1520
+ for channel in channels:
1521
+ if str(channel.get("google_account_id") or "") == account_id:
1522
+ channel.update({"google_account_id": "", "google_account_email": ""})
1523
+ channels_changed = True
1524
+ if channels_changed:
1525
+ write_json("channels.json", channels)
1526
+ write_json("settings.json", settings)
1506
1527
  st.rerun()
1507
- with status_cols[2]:
1508
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
1509
- delete_youtube_batch_token(batch_account, STORAGE)
1510
- delete_credentials_document(STORAGE, batch_account)
1511
- remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
1512
- settings["youtube_batch_accounts"] = remaining_accounts
1513
- if settings.get("youtube_batch_selected_account_id") == account_id:
1514
- settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
1515
- channels = read_json("channels.json", [])
1516
- channels_changed = False
1517
- for channel in channels:
1518
- if str(channel.get("google_account_id") or "") == account_id:
1519
- channel.update({"google_account_id": "", "google_account_email": ""})
1520
- channels_changed = True
1521
- if channels_changed:
1522
- write_json("channels.json", channels)
1523
- write_json("settings.json", settings)
1528
+ if st.button("Repetir campos para nova conta", icon=":material/content_copy:", key=f"batch_repeat_settings_{account_id}", use_container_width=True):
1529
+ st.session_state["new_batch_account_label"] = account_label
1530
+ st.session_state["new_batch_account_email"] = account_email
1531
+ st.session_state["new_batch_account_client_id"] = account_client_id
1532
+ st.session_state["new_batch_account_client_secret"] = account_client_secret
1533
+ st.session_state["new_batch_account_session_info"] = account_session_info
1524
1534
  st.rerun()
1525
- if st.button("Repetir campos para nova conta", icon=":material/content_copy:", key=f"batch_repeat_settings_{account_id}", use_container_width=True):
1526
- st.session_state["new_batch_account_label"] = account_label
1527
- st.session_state["new_batch_account_email"] = account_email
1528
- st.session_state["new_batch_account_client_id"] = account_client_id
1529
- st.session_state["new_batch_account_client_secret"] = account_client_secret
1530
- st.session_state["new_batch_account_session_info"] = account_session_info
1531
- st.rerun()
1532
- if save_account:
1533
- if "@" not in account_email.strip():
1534
- st.error("Informe um e-mail Google válido.")
1535
- elif not account_client_id.strip() or not account_client_secret.strip():
1536
- st.error("Informe o Client ID e o Client Secret desta conta.")
1537
- else:
1538
- for existing in batch_accounts:
1539
- if str(existing.get("id")) == account_id:
1540
- 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())))
1541
- if credentials_changed:
1542
- delete_youtube_batch_token(existing, STORAGE)
1543
- 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()})
1544
- update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
1545
- settings["youtube_batch_accounts"] = batch_accounts
1546
- write_json("settings.json", settings)
1547
- st.success("Conta Google/YouTube guardada.")
1548
- st.rerun()
1549
- if direct_account_save_buttons.get(account_id):
1550
- uploaded_document = direct_document_uploads.get(account_id)
1551
- if uploaded_document is None:
1552
- st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
1553
- else:
1554
- try:
1555
- document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name, session_info_override=account_session_info.strip())
1556
- document["account_id"] = account_id
1557
- document["email"] = str(batch_account.get("email", ""))
1558
- save_credentials_document(STORAGE, batch_account, document)
1559
- st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
1535
+ if save_account:
1536
+ if "@" not in account_email.strip():
1537
+ st.error("Informe um e-mail Google válido.")
1538
+ elif not account_client_id.strip() or not account_client_secret.strip():
1539
+ st.error("Informe o Client ID e o Client Secret desta conta.")
1540
+ else:
1541
+ for existing in batch_accounts:
1542
+ if str(existing.get("id")) == account_id:
1543
+ 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())))
1544
+ if credentials_changed:
1545
+ delete_youtube_batch_token(existing, STORAGE)
1546
+ 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()})
1547
+ update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
1548
+ settings["youtube_batch_accounts"] = batch_accounts
1549
+ write_json("settings.json", settings)
1550
+ st.success("Conta Google/YouTube guardada.")
1560
1551
  st.rerun()
1561
- except ValueError as exc:
1562
- st.error(str(exc))
1563
- with st.form("add_batch_account_form"):
1564
- st.markdown("**Credenciais do Upload directo desta conta Google**")
1565
- st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
1566
- add_cols = st.columns(4)
1567
- with add_cols[0]:
1568
- new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
1569
- with add_cols[1]:
1570
- new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
1571
- with add_cols[2]:
1572
- new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
1573
- with add_cols[3]:
1574
- new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
1575
- 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, INNERTUBE_API_KEY, chunk_size e delegated_session_ids continuam apenas no documento JSON.")
1576
- new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, INNERTUBE_API_KEY, chunk_size e delegated_session_ids. O sessionInfo pode ser preenchido no campo acima ou no documento.")
1577
- st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. O campo sessionInfo é específico desta conta; os cookies e restantes credenciais continuam apenas no documento.")
1578
- add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
1579
- if add_account:
1580
- document_error = ""
1581
- if "@" not in new_account_email.strip():
1582
- st.error("Informe um e-mail Google válido.")
1583
- elif not new_account_client_id.strip() or not new_account_client_secret.strip():
1584
- st.error("Informe o Client ID e o Client Secret da nova conta.")
1585
- elif new_account_document is not None:
1586
- try:
1587
- parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
1588
- except ValueError as exc:
1589
- document_error = str(exc)
1590
- st.error(document_error)
1591
- if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
1592
- 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()}
1593
- if new_account_document is not None:
1594
- document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
1595
- document["account_id"] = new_account["id"]
1596
- document["email"] = new_account["email"]
1597
- save_credentials_document(STORAGE, new_account, document)
1598
- batch_accounts.append(new_account)
1599
- settings["youtube_batch_accounts"] = batch_accounts
1600
- settings["youtube_batch_selected_account_id"] = new_account["id"]
1601
- write_json("settings.json", settings)
1602
- if new_account_document is not None:
1603
- st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
1604
- else:
1605
- st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
1606
- st.rerun()
1607
-
1608
- with st.form("settings_form"):
1609
- st.subheader("Execução local")
1610
- port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
1611
- moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
1612
- st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
1613
- 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.")
1614
- 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()}`.")
1615
- youtube_cols = st.columns(2)
1616
- with youtube_cols[0]:
1617
- 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.")
1618
- with youtube_cols[1]:
1619
- 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.")
1620
-
1621
- with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
1622
- 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.")
1623
- kaggle_cols = st.columns(3)
1624
- with kaggle_cols[0]:
1625
- kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
1626
- with kaggle_cols[1]:
1627
- kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
1628
- with kaggle_cols[2]:
1629
- kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
1630
-
1631
- with st.expander("Niche Finder — execução através da Apify", expanded=True):
1632
- st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
1633
- apify_cols = st.columns(4)
1634
- with apify_cols[0]:
1635
- apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
1636
- with apify_cols[1]:
1637
- apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
1638
- with apify_cols[2]:
1639
- apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
1640
- with apify_cols[3]:
1641
- 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)
1642
-
1643
- with st.expander("Consulta oficial de métricas — opcional"):
1644
- 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.")
1645
- 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.")
1646
-
1647
- st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
1648
- direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
1649
- direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
1650
-
1651
- with st.expander("Serviço, materiais e rede"):
1652
- cols = st.columns(2)
1653
- with cols[0]:
1654
- log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
1655
- listen_host = text_setting("API listen host", "listen_host")
1656
- listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
1657
- video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
1658
- with cols[1]:
1659
- endpoint = text_setting("Endpoint público", "endpoint")
1660
- proxy_http = text_setting("Proxy HTTP", "proxy_http")
1661
- proxy_https = text_setting("Proxy HTTPS", "proxy_https")
1662
- match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
1663
-
1664
- with st.expander("LLM — providers e modelos", expanded=True):
1665
- provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
1666
- llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
1667
- st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
1668
- st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
1669
- openai_cols = st.columns(3)
1670
- with openai_cols[0]:
1671
- openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
1672
- with openai_cols[1]:
1673
- openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
1674
- with openai_cols[2]:
1675
- cached_catalog = st.session_state.get("openai_model_catalog", {})
1676
- catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
1677
- cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
1678
- current_model_name = str(settings.get("openai_model_name", "") or "")
1679
- manual_option = "__manual_model__"
1680
- if cached_models:
1681
- model_options = [manual_option, *cached_models]
1682
- model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
1683
- selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
1684
- if selected_model == manual_option:
1685
- openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
1552
+ if direct_account_save_buttons.get(account_id):
1553
+ uploaded_document = direct_document_uploads.get(account_id)
1554
+ if uploaded_document is None:
1555
+ st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
1686
1556
  else:
1687
- openai_model_name = selected_model
1557
+ try:
1558
+ document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name, session_info_override=account_session_info.strip())
1559
+ document["account_id"] = account_id
1560
+ document["email"] = str(batch_account.get("email", ""))
1561
+ save_credentials_document(STORAGE, batch_account, document)
1562
+ st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
1563
+ st.rerun()
1564
+ except ValueError as exc:
1565
+ st.error(str(exc))
1566
+ with st.form("add_batch_account_form"):
1567
+ st.markdown("**Credenciais do Upload directo desta conta Google**")
1568
+ st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
1569
+ add_cols = st.columns(4)
1570
+ with add_cols[0]:
1571
+ new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
1572
+ with add_cols[1]:
1573
+ new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
1574
+ with add_cols[2]:
1575
+ new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
1576
+ with add_cols[3]:
1577
+ new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
1578
+ 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, INNERTUBE_API_KEY, chunk_size e delegated_session_ids continuam apenas no documento JSON.")
1579
+ new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, INNERTUBE_API_KEY, chunk_size e delegated_session_ids. O sessionInfo pode ser preenchido no campo acima ou no documento.")
1580
+ st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. O campo sessionInfo é específico desta conta; os cookies e restantes credenciais continuam apenas no documento.")
1581
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
1582
+ if add_account:
1583
+ document_error = ""
1584
+ if "@" not in new_account_email.strip():
1585
+ st.error("Informe um e-mail Google válido.")
1586
+ elif not new_account_client_id.strip() or not new_account_client_secret.strip():
1587
+ st.error("Informe o Client ID e o Client Secret da nova conta.")
1588
+ elif new_account_document is not None:
1589
+ try:
1590
+ parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
1591
+ except ValueError as exc:
1592
+ document_error = str(exc)
1593
+ st.error(document_error)
1594
+ if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
1595
+ 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()}
1596
+ if new_account_document is not None:
1597
+ document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
1598
+ document["account_id"] = new_account["id"]
1599
+ document["email"] = new_account["email"]
1600
+ save_credentials_document(STORAGE, new_account, document)
1601
+ batch_accounts.append(new_account)
1602
+ settings["youtube_batch_accounts"] = batch_accounts
1603
+ settings["youtube_batch_selected_account_id"] = new_account["id"]
1604
+ write_json("settings.json", settings)
1605
+ if new_account_document is not None:
1606
+ st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
1688
1607
  else:
1689
- openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
1690
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
1691
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
1692
- st.warning(str(cached_catalog["error"]))
1693
- elif cached_models:
1694
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
1695
- else:
1696
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
1697
- llm_fields = [
1698
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
1699
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
1700
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
1701
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
1702
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
1703
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
1704
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
1705
- ]
1706
- for label, prefix, has_key in llm_fields:
1707
- st.markdown(f"**{label}**")
1708
- cols = st.columns(3)
1608
+ st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
1609
+ st.rerun()
1610
+
1611
+ with api_keys_tab:
1612
+ with st.form("settings_form"):
1613
+ st.subheader("API Keys")
1614
+ port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
1615
+ moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
1616
+ st.markdown("**YouTube OAuth 2.0 e consulta pública**")
1617
+ 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.")
1618
+ 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()}`.")
1619
+ youtube_cols = st.columns(2)
1620
+ with youtube_cols[0]:
1621
+ 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.")
1622
+ with youtube_cols[1]:
1623
+ 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.")
1624
+
1625
+ with st.expander("Niche Finder execução remota no Kaggle", expanded=True):
1626
+ 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.")
1627
+ kaggle_cols = st.columns(3)
1628
+ with kaggle_cols[0]:
1629
+ kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
1630
+ with kaggle_cols[1]:
1631
+ kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
1632
+ with kaggle_cols[2]:
1633
+ kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
1634
+
1635
+ with st.expander("Niche Finder — execução através da Apify", expanded=True):
1636
+ st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
1637
+ apify_cols = st.columns(4)
1638
+ with apify_cols[0]:
1639
+ apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
1640
+ with apify_cols[1]:
1641
+ apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
1642
+ with apify_cols[2]:
1643
+ apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
1644
+ with apify_cols[3]:
1645
+ 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)
1646
+
1647
+ with st.expander("Consulta oficial de métricas — opcional"):
1648
+ 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.")
1649
+ 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.")
1650
+
1651
+ st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
1652
+ direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
1653
+ direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
1654
+
1655
+ with st.expander("Serviço, materiais e rede"):
1656
+ cols = st.columns(2)
1709
1657
  with cols[0]:
1710
- if has_key:
1711
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
1658
+ log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
1659
+ listen_host = text_setting("API listen host", "listen_host")
1660
+ listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
1661
+ video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
1662
+ with cols[1]:
1663
+ endpoint = text_setting("Endpoint público", "endpoint")
1664
+ proxy_http = text_setting("Proxy HTTP", "proxy_http")
1665
+ proxy_https = text_setting("Proxy HTTPS", "proxy_https")
1666
+ match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
1667
+
1668
+ with st.expander("LLM — providers e modelos", expanded=True):
1669
+ provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
1670
+ llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
1671
+ st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
1672
+ st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
1673
+ openai_cols = st.columns(3)
1674
+ with openai_cols[0]:
1675
+ openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
1676
+ with openai_cols[1]:
1677
+ openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
1678
+ with openai_cols[2]:
1679
+ cached_catalog = st.session_state.get("openai_model_catalog", {})
1680
+ catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
1681
+ cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
1682
+ current_model_name = str(settings.get("openai_model_name", "") or "")
1683
+ manual_option = "__manual_model__"
1684
+ if cached_models:
1685
+ model_options = [manual_option, *cached_models]
1686
+ model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
1687
+ selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
1688
+ if selected_model == manual_option:
1689
+ openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
1690
+ else:
1691
+ openai_model_name = selected_model
1712
1692
  else:
1713
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
1693
+ openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
1694
+ refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
1695
+ if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
1696
+ st.warning(str(cached_catalog["error"]))
1697
+ elif cached_models:
1698
+ st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
1699
+ else:
1700
+ st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
1701
+ llm_fields = [
1702
+ ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
1703
+ ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
1704
+ ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
1705
+ ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
1706
+ ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
1707
+ ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
1708
+ ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
1709
+ ]
1710
+ for label, prefix, has_key in llm_fields:
1711
+ st.markdown(f"**{label}**")
1712
+ cols = st.columns(3)
1713
+ with cols[0]:
1714
+ if has_key:
1715
+ settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
1716
+ else:
1717
+ settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
1718
+ with cols[1]:
1719
+ settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
1720
+ with cols[2]:
1721
+ settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
1722
+
1723
+ with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
1724
+ cols = st.columns(2)
1725
+ with cols[0]:
1726
+ azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
1727
+ azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
1728
+ siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
1729
+ minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
1730
+ minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
1731
+ minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
1732
+ minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
1714
1733
  with cols[1]:
1715
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
1716
- with cols[2]:
1717
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
1718
-
1719
- with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
1720
- cols = st.columns(2)
1721
- with cols[0]:
1722
- azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
1723
- azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
1724
- siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
1725
- minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
1726
- minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
1727
- minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
1728
- minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
1729
- with cols[1]:
1730
- elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
1731
- elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
1732
- chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
1733
- chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
1734
- chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
1735
- sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
1736
- sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
1737
- st.markdown("**Suno agente musical opcional**")
1738
- suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
1739
- suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
1740
- suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
1741
-
1742
- with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
1743
- cols = st.columns(2)
1744
- with cols[0]:
1745
- pexels_api_keys = text_setting("Pexels API keys", "pexels_api_keys", secret=True, help_text="Separe várias chaves por vírgula para rotação.")
1746
- pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
1747
- coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
1748
- twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
1749
- material_directory = text_setting("Pasta de materiais", "material_directory")
1750
- with cols[1]:
1751
- subtitle_provider = st.selectbox("Subtitle provider", ["edge", "whisper", ""], index=["edge", "whisper", ""].index(settings.get("subtitle_provider", "edge")) if settings.get("subtitle_provider", "edge") in ["edge", "whisper", ""] else 0)
1752
- ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
1753
- video_codec = text_setting("Codec de vídeo", "video_codec")
1754
- whisper_model_size = text_setting("Whisper model", "whisper_model_size")
1755
- whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
1756
- whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
1757
-
1758
- with st.expander("TikTok for Developers Client ID e Client Secret", expanded=True):
1759
- st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
1760
- tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
1761
- tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
1762
-
1763
- with st.expander("Publicação através do Upload-Post"):
1764
- upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
1765
- upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
1766
- upload_post_username = text_setting("Upload-Post username", "upload_post_username")
1767
- upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
1768
- upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
1769
-
1770
- if refresh_openai_models:
1771
- try:
1772
- discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
1773
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
1774
- st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
1775
- st.rerun()
1776
- except ModelDiscoveryError as exc:
1777
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
1778
- st.rerun()
1734
+ elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
1735
+ elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
1736
+ chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
1737
+ chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
1738
+ chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
1739
+ sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
1740
+ sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
1741
+ st.markdown("**Suno agente musical opcional**")
1742
+ suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
1743
+ suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
1744
+ suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
1745
+
1746
+ with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
1747
+ cols = st.columns(2)
1748
+ with cols[0]:
1749
+ pexels_api_keys = text_setting("Pexels API keys", "pexels_api_keys", secret=True, help_text="Separe várias chaves por vírgula para rotação.")
1750
+ pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
1751
+ coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
1752
+ twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
1753
+ material_directory = text_setting("Pasta de materiais", "material_directory")
1754
+ with cols[1]:
1755
+ subtitle_provider = st.selectbox("Subtitle provider", ["edge", "whisper", ""], index=["edge", "whisper", ""].index(settings.get("subtitle_provider", "edge")) if settings.get("subtitle_provider", "edge") in ["edge", "whisper", ""] else 0)
1756
+ ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
1757
+ video_codec = text_setting("Codec de vídeo", "video_codec")
1758
+ whisper_model_size = text_setting("Whisper model", "whisper_model_size")
1759
+ whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
1760
+ whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
1761
+
1762
+ with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
1763
+ st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
1764
+ tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
1765
+ tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
1766
+
1767
+ with st.expander("Publicação através do Upload-Post"):
1768
+ upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
1769
+ upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
1770
+ upload_post_username = text_setting("Upload-Post username", "upload_post_username")
1771
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
1772
+ upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
1773
+
1774
+ if refresh_openai_models:
1775
+ try:
1776
+ discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
1777
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
1778
+ st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
1779
+ st.rerun()
1780
+ except ModelDiscoveryError as exc:
1781
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
1782
+ st.rerun()
1779
1783
 
1780
- save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
1781
- if save_all_settings:
1782
- settings.update({
1783
- "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1784
- "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1785
- "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1786
- "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),
1787
- "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1788
- "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1789
- "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
1790
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
1791
- "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
1792
- "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
1793
- "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
1794
- "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
1795
- "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
1796
- "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
1797
- "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
1798
- "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
1799
- "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
1800
- "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
1801
- "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
1802
- "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
1803
- "upload_post_auto_upload": upload_post_auto_upload,
1804
- })
1805
- write_json("settings.json", settings)
1784
+ save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
1785
+ if save_all_settings:
1786
+ settings.update({
1787
+ "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1788
+ "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1789
+ "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1790
+ "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),
1791
+ "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1792
+ "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1793
+ "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
1794
+ "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
1795
+ "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
1796
+ "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
1797
+ "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
1798
+ "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
1799
+ "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
1800
+ "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
1801
+ "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
1802
+ "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
1803
+ "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
1804
+ "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
1805
+ "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
1806
+ "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
1807
+ "upload_post_auto_upload": upload_post_auto_upload,
1808
+ })
1809
+ write_json("settings.json", settings)
1810
+ try:
1811
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
1812
+ if synced:
1813
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
1814
+ else:
1815
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1816
+ except Exception as exc:
1817
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1818
+
1819
+ with voice_test_tab:
1820
+ st.subheader("Teste de vozes")
1821
+ st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
1822
+ preview_cols = st.columns([1.1, 2.2, 1.2])
1823
+ with preview_cols[0]:
1824
+ preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
1825
+ with preview_cols[1]:
1826
+ if preview_provider in {"edge", "azure_speech"}:
1827
+ preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
1828
+ preview_voice = st.selectbox("Voz", preview_voice_options, index=preview_voice_options.index(settings.get("voice_preview_voice", "en-US-AriaNeural-Female")) if settings.get("voice_preview_voice", "en-US-AriaNeural-Female") in preview_voice_options else 0, format_func=lambda item: item or "Escolha uma voz", key="voice_preview_voice")
1829
+ else:
1830
+ preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
1831
+ with preview_cols[2]:
1832
+ preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
1833
+ preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
1834
+ if st.button("Testar voz", type="primary", key="voice_preview_generate"):
1835
+ st.session_state.pop("voice_preview_path", None)
1806
1836
  try:
1807
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
1808
- if synced:
1809
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
1810
- else:
1811
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1837
+ preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
1838
+ st.session_state["voice_preview_path"] = str(preview_path)
1839
+ st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
1812
1840
  except Exception as exc:
1813
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1814
-
1815
- st.divider()
1816
- st.subheader("Teste de vozes")
1817
- st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
1818
- preview_cols = st.columns([1.1, 2.2, 1.2])
1819
- with preview_cols[0]:
1820
- preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
1821
- with preview_cols[1]:
1822
- if preview_provider in {"edge", "azure_speech"}:
1823
- preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
1824
- preview_voice = st.selectbox("Voz", preview_voice_options, index=preview_voice_options.index(settings.get("voice_preview_voice", "en-US-AriaNeural-Female")) if settings.get("voice_preview_voice", "en-US-AriaNeural-Female") in preview_voice_options else 0, format_func=lambda item: item or "Escolha uma voz", key="voice_preview_voice")
1825
- else:
1826
- preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
1827
- with preview_cols[2]:
1828
- preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
1829
- preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
1830
- if st.button("Testar voz", type="primary", key="voice_preview_generate"):
1831
- st.session_state.pop("voice_preview_path", None)
1832
- try:
1833
- preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
1834
- st.session_state["voice_preview_path"] = str(preview_path)
1835
- st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
1836
- except Exception as exc:
1837
- st.error(f"Não foi possível gerar o preview: {exc}")
1838
- preview_value = str(st.session_state.get("voice_preview_path", "") or "").strip()
1839
- loaded_preview = load_preview_file(preview_value)
1840
- if loaded_preview:
1841
- preview_path, preview_data = loaded_preview
1842
- st.audio(preview_data, format="audio/mpeg")
1843
- st.download_button("Descarregar amostra", data=preview_data, file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
1844
- elif preview_value:
1845
- st.session_state.pop("voice_preview_path", None)
1846
- 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.")
1841
+ st.error(f"Não foi possível gerar o preview: {exc}")
1842
+ preview_value = str(st.session_state.get("voice_preview_path", "") or "").strip()
1843
+ loaded_preview = load_preview_file(preview_value)
1844
+ if loaded_preview:
1845
+ preview_path, preview_data = loaded_preview
1846
+ st.audio(preview_data, format="audio/mpeg")
1847
+ st.download_button("Descarregar amostra", data=preview_data, file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
1848
+ elif preview_value:
1849
+ st.session_state.pop("voice_preview_path", None)
1850
+ 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.")
1847
1851
 
1848
1852
 
1849
1853
  def render_mcp():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.48",
3
+ "version": "0.2.49",
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",