@danhachuel/thunderbolt 0.2.41 → 0.2.43

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.41
5
+ > **Versão deste manual:** 0.2.43
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.41 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.43 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.41 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.43 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.
@@ -354,6 +354,9 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
354
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
355
  | YouTube OAuth Client ID | 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
+ | OpenAI/ NVIDIA NIM API key | Credencial do OpenAI ou NVIDIA Build/NIM, guardada apenas no storage local |
358
+ | OpenAI/ NVIDIA NIM Base URL | Endpoint OpenAI-compatible; por padrão `https://integrate.api.nvidia.com/v1` |
359
+ | OpenAI/ NVIDIA NIM modelo | Selector carregado de `/models` ou campo manual de fallback; guardado como `openai_model_name` |
357
360
  | YouTube upload principal | Lógica do `youtube-automation-agent` adaptada e executada dentro do Thunderbolt |
358
361
  | OAuth directo de redundância | Caminho alternativo accionado automaticamente se o agente falhar |
359
362
  | TikTok Client ID/Secret | Credenciais da aplicação; Redirect URI, scopes e autorização ficam no TikTok for Developers Playground |
@@ -366,6 +369,16 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
366
369
 
367
370
  As credenciais devem ser inseridas apenas na configuração local. A Kaggle API Key pertence exclusivamente à alternativa Kaggle. O Apify API Token pertence exclusivamente à alternativa Apify e não é usado para Kaggle; os resultados e histórico Apify ficam separados em `niche_apify_runs.json`. A Data API Key, o OAuth Client ID e o OAuth Client Secret são valores diferentes; Client ID + Secret não geram uma API Key nem um token OAuth até a conta ser autorizada. Não coloque nenhum deles no GitHub, no `package.json`, em blueprints ou em ficheiros de estado versionados.
368
371
 
372
+ ## OpenAI/ NVIDIA NIM — API key, Base URL e modelo
373
+
374
+ A secção **LLM — providers e modelos** mantém o provider técnico `openai`, mas a área visual passou a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. Isto permite usar a API oficial da OpenAI, a API pública NVIDIA NIM/NVIDIA Build ou qualquer servidor local OpenAI-compatible sem alterar o restante fluxo do MoneyPrinterTurbo.
375
+
376
+ Para usar NVIDIA NIM, abra **Configurações > Configurações Técnicas**, mantenha ou preencha a Base URL `https://integrate.api.nvidia.com/v1` e introduza a API key do NVIDIA Build. Clique em **Consultar/actualizar modelos NIM**. O Thunderbolt executa a consulta apenas nesse momento, acrescentando `/models` à Base URL, envia a API key como `Authorization: Bearer ...` e interpreta a resposta OpenAI-compatible `data[].id`. Os IDs devolvidos aparecem num selector; ao escolher um, o valor é guardado em `openai_model_name` e continua a ser enviado para o `config.toml` do MoneyPrinterTurbo.
377
+
378
+ Se utilizar um NIM local, substitua a Base URL pelo endereço do serviço, por exemplo `http://127.0.0.1:8000/v1`. Se o serviço não tiver `/models`, estiver indisponível ou recusar a credencial, o erro aparece na própria UI sem mostrar a API key. Nesse caso, seleccione **Escrever modelo manualmente** ou utilize directamente o campo de modelo manual. A consulta não é executada automaticamente ao abrir Configurações Técnicas.
379
+
380
+ A API key não é gravada em logs, não é incluída no GitHub e não é enviada ao abrir a página. Os identificadores mostrados no selector são os IDs retornados pelo endpoint; para NVIDIA Build, normalmente não devem receber o prefixo interno `nvidia_nim/`, pois o endpoint OpenAI-compatible espera o ID original do modelo.
381
+
369
382
  ## 9. Testar as áreas principais
370
383
 
371
384
  Após iniciar a aplicação, valide o seguinte percurso:
@@ -380,13 +393,14 @@ Após iniciar a aplicação, valide o seguinte percurso:
380
393
  8. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
381
394
  9. **Edição > Limpador de Metadados:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos das páginas de criação não são aceites nesta área.
382
395
  10. **Configurações > Configurações Técnicas:** confirme que os caminhos e credenciais estão locais e não aparecem no Git. Na secção **Contas Google/YouTube — canais em lote**, adicione cada conta com o seu e-mail/Gmail, OAuth Client ID e OAuth Client Secret próprios; o e-mail identifica a conta e não é usado para ler a caixa Gmail.
383
- 11. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
384
- 12. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
385
- 13. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
386
- 14. **Upload directo:** configure manualmente cookies, sessionInfo e INNERTUBE_API_KEY, atribua o `DELEGATED_SESSION_ID` no canal e teste apenas com um vídeo de validação. O método envia chunks de 256 KiB e não extrai cookies do navegador.
387
- 15. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
388
- 16. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
389
- 17. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
396
+ 11. **OpenAI/ NVIDIA NIM:** em **LLM — providers e modelos**, seleccione `openai`, confirme a Base URL, introduza a API key e clique em **Consultar/actualizar modelos NIM**. Escolha um modelo da lista ou utilize o fallback manual e guarde as configurações. Confirme que a consulta ocorre após o clique e que um erro de endpoint não interrompe a UI.
397
+ 12. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
398
+ 13. **Automação:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
399
+ 14. **Configurações > Configurações Técnicas > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
400
+ 15. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube canais em lote**, seleccione cada conta Google e carregue o seu ficheiro de cookies no painel **Upload directo — sessão YouTube Frontend API**. São aceites JSON de navegador, lista JSON e formato Netscape; o Thunderbolt valida `SID`, `SSID`, `HSID`, `APISID` e `SAPISID` e guarda o ficheiro em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`. Preencha também o `sessionInfo token` da mesma conta. Em **Canais > Upload directo conta e canal**, associe o canal à conta e preencha o `DELEGATED_SESSION_ID` individual. Configure `INNERTUBE_API_KEY`, teste apenas com um vídeo de validação e confirme que o botão só fica disponível quando os três dados — cookies, sessionInfo e ID delegado — estão configurados. O método envia chunks de 256 KiB e não extrai cookies do navegador.
401
+ 16. **Configurações > MCP > Client MCP:** confirme que Short Video Maker, AutoVio, OpenMontage e OpenCut aparecem com as portas padrão editáveis. O estado **Activo** é uma preferência local; a detecção deve indicar **Não detectado** quando os serviços externos não estiverem instalados ou iniciados.
402
+ 17. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
403
+ 18. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
390
404
 
391
405
  ## Canais em lote por conta Google/YouTube
392
406
 
@@ -404,7 +418,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
404
418
 
405
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.
406
420
 
407
- 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.41 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.43 install`; o instalador detecta e reutiliza o que já estiver válido.
408
422
 
409
423
  ### Niche Finder Apify
410
424
 
@@ -434,7 +448,9 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
434
448
 
435
449
  ## Upload directo
436
450
 
437
- A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Em cada canal, preencha `DELEGATED_SESSION_ID`; em Configurações, preencha manualmente os cookies `SID`, `SSID`, `HSID`, `APISID`, `SAPISID`, o token `sessionInfo` e `INNERTUBE_API_KEY`. O adaptador valida o vídeo, cria a sessão interna e envia chunks múltiplos de 256 KiB. Estes valores são segredos locais e não devem ser enviados para o Git.
451
+ A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). As credenciais deixam de ser globais: cada conta Google/YouTube cadastrada em **Configurações Técnicas > Contas Google/YouTube canais em lote** tem um ficheiro de cookies próprio e um `sessionInfo token` próprio. O carregador aceita três representações: objecto JSON com os cookies, lista JSON de cookies e formato Netscape; os cinco nomes obrigatórios são `SID`, `SSID`, `HSID`, `APISID` e `SAPISID`. O ficheiro validado é guardado em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`, fora de `storage/state/`, com permissões locais restritas.
452
+
453
+ Em **Canais > Upload directo — conta e canal**, associe cada canal à conta Google que detém os cookies e o `sessionInfo token`, e preencha o `DELEGATED_SESSION_ID` individual desse canal. O upload lê os cookies e o token da conta associada e usa o ID delegado do canal como `pageId`/destino. No momento do upload, o Thunderbolt bloqueia a operação se não existir conta associada, ficheiro de cookies válido, `sessionInfo token`, `DELEGATED_SESSION_ID`, `INNERTUBE_API_KEY` ou vídeo elegível. O tamanho de chunk é validado e normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
438
454
 
439
455
  ## MCP e integrações externas
440
456
 
package/README.md CHANGED
@@ -20,17 +20,34 @@ A primeira versão implementa a camada UI independente com:
20
20
  | Models AI | Menu expansível com Personagens e Redes Sociais reservados para desenvolvimento futuro |
21
21
  | Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
22
22
  | Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
23
- | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo experimental, TikTok, Instagram e Facebook Pages no front end |
23
+ | Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
24
24
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
25
- | Configurações Técnicas | Provedores LLM, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID e Client Secret por conta, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
25
+ | Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID e Client Secret por conta, cookies SID/SSID/HSID/APISID/SAPISID e sessionInfo por conta, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
26
26
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
27
27
 
28
+ ## Upload directo — credenciais por conta e por canal
29
+
30
+ O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa dois níveis de configuração. Em **Configurações Técnicas > Upload directo — sessão YouTube Frontend API**, cada conta Google cadastrada possui o seu próprio ficheiro de cookies e o seu próprio `sessionInfo token`. O ficheiro pode ser um JSON exportado do navegador, uma lista JSON de cookies ou um ficheiro Netscape; o Thunderbolt valida e guarda apenas `SID`, `SSID`, `HSID`, `APISID` e `SAPISID` em `storage/youtube_direct_accounts/<id-da-conta>/cookies.json`.
31
+
32
+ Em **Canais > Upload directo — conta e canal**, cada canal é associado à conta Google que fornece os cookies e o `sessionInfo token`, e possui o seu próprio `DELEGATED_SESSION_ID`. O upload lê os cookies e o token da conta associada e envia o ID delegado individual do canal como `pageId`/`onBehalfOfUser`. O botão é bloqueado quando falta qualquer destes elementos. O `INNERTUBE_API_KEY` e o tamanho de chunk continuam nas configurações directas; o tamanho é normalizado para múltiplos de 262144 bytes.
33
+
34
+ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs, o ficheiro é escrito com permissões locais restritas e não é incluído no Git. O Thunderbolt não extrai cookies automaticamente do browser e não envia as credenciais para o GitHub. O método é não oficial e pode deixar de funcionar se o YouTube alterar os endpoints internos.
35
+
36
+
28
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.
29
38
 
30
- ## Navegação da UI 0.2.41
39
+ ## Navegação da UI 0.2.43
31
40
 
32
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.
33
42
 
43
+ ## OpenAI/ NVIDIA NIM — descoberta de modelos
44
+
45
+ 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 provider interno continua a ser `openai`, para preservar a compatibilidade com o MoneyPrinterTurbo e com os restantes endpoints OpenAI-compatible.
46
+
47
+ A Base URL predefinida para NVIDIA NIM é `https://integrate.api.nvidia.com/v1`. Depois de inserir a API key, clique em **Consultar/actualizar modelos NIM**. O Thunderbolt consulta a Base URL acrescentando `/models`, lê os identificadores do formato OpenAI (`data[].id`) e apresenta-os num selector. O modelo escolhido é guardado em `openai_model_name` e é sincronizado para o `config.toml` do motor.
48
+
49
+ A consulta só é executada quando o utilizador carrega no botão; a aplicação não envia a API key ao abrir a página. Se o endpoint estiver offline, recusar a credencial ou não disponibilizar `/models`, a UI mostra o erro sem expor a API key e mantém a opção **Escrever modelo manualmente**. Também é possível manter um endpoint local NIM ou outro servidor OpenAI-compatible substituindo a Base URL.
50
+
34
51
  ## Instalação
35
52
 
36
53
  Recomenda-se Python 3.11 ou superior e Node.js 18 ou superior.
@@ -116,9 +133,9 @@ thunderbolt
116
133
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
117
134
 
118
135
  ```powershell
119
- npx.cmd --yes @danhachuel/thunderbolt@0.2.41 install
120
- npx.cmd --yes @danhachuel/thunderbolt@0.2.41 doctor
121
- npx.cmd --yes @danhachuel/thunderbolt@0.2.41
136
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.43 install
137
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.43 doctor
138
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.43
122
139
  ```
123
140
 
124
141
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -142,7 +159,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
142
159
 
143
160
  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.
144
161
 
145
- **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.41 install`; o instalador detecta e reutiliza componentes já válidos.
162
+ **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.43 install`; o instalador detecta e reutiliza componentes já válidos.
146
163
 
147
164
  ## Editor Python baseado no PYEdit
148
165
 
package/app/main.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import json
4
5
  import re
5
6
  from datetime import date, datetime
@@ -34,9 +35,11 @@ from hermes_ui.music import list_music_files, materialize_suno_audio, request_su
34
35
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
35
36
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
36
37
  from integrations.youtube_direct_upload import YouTubeDirectUploader
38
+ from integrations.youtube_direct_credentials import COOKIE_KEYS as DIRECT_COOKIE_KEYS, direct_account_status, save_cookie_file
37
39
  from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels
38
40
  from integrations.local_runtime import MoneyPrinterRuntime
39
41
  from integrations.moneyprinter_config import sync_moneyprinter_config
42
+ from integrations.openai_model_discovery import DEFAULT_NVIDIA_NIM_BASE_URL, ModelDiscoveryError, fetch_openai_compatible_models
40
43
 
41
44
  AI_STYLE_OPTIONS = [
42
45
  "Natural Realista",
@@ -341,6 +344,11 @@ def render_channels():
341
344
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
342
345
  settings = read_json("settings.json", {})
343
346
  youtube = YouTubeAdapter(settings=settings)
347
+ youtube_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
348
+ youtube_account_ids = [""] + [str(account["id"]) for account in youtube_accounts]
349
+ youtube_account_labels = {"": "Sem conta Google associada"}
350
+ youtube_account_labels.update({str(account["id"]): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in youtube_accounts})
351
+ youtube_accounts_by_id = {str(account["id"]): account for account in youtube_accounts}
344
352
  import_tab, batch_tab, manual_tab = st.tabs(["Importar do YouTube", "Canais em lote gmail", "Cadastro manual"])
345
353
 
346
354
  with import_tab:
@@ -386,6 +394,8 @@ def render_channels():
386
394
  voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
387
395
  current_voice = imported.get("default_voice") or imported.get("voice", "")
388
396
  voice = st.selectbox("Voz padrão do canal", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
397
+ imported_account_id = str(imported.get("google_account_id", ""))
398
+ google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, index=youtube_account_ids.index(imported_account_id) if imported_account_id in youtube_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item), key="yt_import_google_account_id")
389
399
  delegated_session_id = st.text_input("DELEGATED_SESSION_ID", value=imported.get("delegated_session_id", ""), type="password", key="yt_import_delegated_session_id")
390
400
  automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
391
401
  automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
@@ -412,6 +422,8 @@ def render_channels():
412
422
  "default_voice": voice.strip(),
413
423
  "voice": voice.strip(),
414
424
  "delegated_session_id": delegated_session_id.strip(),
425
+ "google_account_id": google_account_id.strip(),
426
+ "google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
415
427
  "automation_on": bool(automation_on),
416
428
  "automation_time": automation_time.strip() if valid_hhmm(automation_time) else "00:00",
417
429
  "subscriber_count": int(subscriber_count) or None,
@@ -530,6 +542,7 @@ def render_channels():
530
542
  blueprint = st.selectbox("Blueprint padrão do canal", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
531
543
  voice_options = voice_catalog()
532
544
  voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
545
+ google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, format_func=lambda item: youtube_account_labels.get(item, item), key="manual_channel_google_account_id")
533
546
  delegated_session_id = st.text_input("DELEGATED_SESSION_ID", type="password", key="manual_channel_delegated_session_id")
534
547
  automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
535
548
  automation_time = st.text_input("Horário diário (HH:MM)", value="00:00", key="manual_channel_automation_time")
@@ -555,6 +568,8 @@ def render_channels():
555
568
  "default_voice": voice.strip(),
556
569
  "voice": voice.strip(),
557
570
  "delegated_session_id": delegated_session_id.strip(),
571
+ "google_account_id": google_account_id.strip(),
572
+ "google_account_email": str(youtube_accounts_by_id.get(google_account_id, {}).get("email", "")),
558
573
  "automation_on": bool(automation_on),
559
574
  "automation_time": automation_time.strip(),
560
575
  "thumbnail_url": thumbnail_url.strip(),
@@ -573,6 +588,20 @@ def render_channels():
573
588
  return
574
589
  for channel in channels:
575
590
  with st.container(border=True):
591
+ channel_account_ids = list(youtube_account_ids)
592
+ current_channel_account_id = str(channel.get("google_account_id", ""))
593
+ if current_channel_account_id and current_channel_account_id not in channel_account_ids:
594
+ channel_account_ids.append(current_channel_account_id)
595
+ youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
596
+ with st.expander("Upload directo — conta e canal", expanded=False):
597
+ with st.form(f"channel_direct_credentials_{channel['id']}"):
598
+ channel_account_id = st.selectbox("Conta Google dos cookies/sessionInfo", channel_account_ids, index=channel_account_ids.index(current_channel_account_id) if current_channel_account_id in channel_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"), key=f"channel_account_{channel['id']}")
599
+ channel_delegated_session_id = st.text_input("DELEGATED_SESSION_ID deste canal", value=str(channel.get("delegated_session_id", "")), type="password", key=f"channel_delegated_session_id_{channel['id']}")
600
+ save_channel_direct_credentials = st.form_submit_button("Guardar credenciais deste canal", type="primary")
601
+ if save_channel_direct_credentials:
602
+ update_channel(channel["id"], {"google_account_id": channel_account_id.strip(), "google_account_email": str(youtube_accounts_by_id.get(channel_account_id, {}).get("email", "")), "delegated_session_id": channel_delegated_session_id.strip()})
603
+ st.success("Conta Google e DELEGATED_SESSION_ID guardados neste canal.")
604
+ st.rerun()
576
605
  cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
577
606
  with cols[0]:
578
607
  if channel.get("thumbnail_url"):
@@ -1237,6 +1266,7 @@ def render_upload_direct():
1237
1266
  if not tasks:
1238
1267
  st.info("Não há vídeos prontos para upload directo.")
1239
1268
  return
1269
+ direct_accounts = {str(account.get("id")): account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")}
1240
1270
  channel_map = {channel.get("id"): channel for channel in channels}
1241
1271
  for task in tasks:
1242
1272
  channel = channel_map.get(task.get("channel_id"), {})
@@ -1244,8 +1274,15 @@ def render_upload_direct():
1244
1274
  with st.container(border=True):
1245
1275
  st.write(f"**{task.get('topic', 'Sem tópico')}** — {task.get('channel_name', 'Canal')}")
1246
1276
  st.caption(video_path or "Sem caminho de vídeo registado")
1277
+ account = direct_accounts.get(str(channel.get("google_account_id", "")))
1247
1278
  if not channel.get("delegated_session_id"):
1248
1279
  st.warning("Este canal não tem DELEGATED_SESSION_ID configurado na aba Canais.")
1280
+ if not account:
1281
+ st.warning("Este canal não tem uma conta Google associada. Configure-a em Canais > Upload directo — conta e canal.")
1282
+ elif not direct_account_status(STORAGE, account)["ready"]:
1283
+ st.warning("A conta Google deste canal não tem cookies completos e sessionInfo token configurados em Configurações Técnicas.")
1284
+ elif channel.get("delegated_session_id"):
1285
+ st.caption(f"Credenciais directas: {account.get('email', 'conta Google')} · DELEGATED_SESSION_ID configurado")
1249
1286
  direct_cols = st.columns([2.2, 1, 1])
1250
1287
  with direct_cols[0]:
1251
1288
  title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
@@ -1255,12 +1292,19 @@ def render_upload_direct():
1255
1292
  chunk_size = st.number_input("Chunk bytes", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)), key=f"direct_chunk_{task['id']}")
1256
1293
  description = st.text_area("Descrição", value=task.get("description", ""), key=f"direct_description_{task['id']}", height=90)
1257
1294
  if st.button("Enviar por Upload directo", type="primary", key=f"direct_upload_{task['id']}"):
1258
- result = YouTubeDirectUploader(settings, channel).upload(video_path, title=title, description=description, visibility=privacy, chunk_size=int(chunk_size))
1259
- record = {"task_id": task.get("id"), "channel_id": channel.get("id"), "destination": "YouTube direct frontend", "status": "published" if result.ok else "failed", "message": result.message, "data": result.data, "created_at": now()}
1260
- uploads = read_json("uploads.json", [])
1261
- uploads.append(record)
1262
- write_json("uploads.json", uploads)
1263
- (st.success if result.ok else st.error)(result.message)
1295
+ if not account:
1296
+ st.error("Associe primeiro este canal a uma conta Google em Canais.")
1297
+ elif not channel.get("delegated_session_id"):
1298
+ st.error("Configure primeiro o DELEGATED_SESSION_ID individual deste canal em Canais.")
1299
+ elif not direct_account_status(STORAGE, account)["ready"]:
1300
+ st.error("Configure primeiro o ficheiro de cookies e o sessionInfo token desta conta em Configurações Técnicas.")
1301
+ else:
1302
+ result = YouTubeDirectUploader(settings, channel, account=account, storage_root=STORAGE).upload(video_path, title=title, description=description, visibility=privacy, chunk_size=int(chunk_size))
1303
+ record = {"task_id": task.get("id"), "channel_id": channel.get("id"), "google_account_id": account.get("id", ""), "destination": "YouTube direct frontend", "status": "published" if result.ok else "failed", "message": result.message, "data": result.data, "created_at": now()}
1304
+ uploads = read_json("uploads.json", [])
1305
+ uploads.append(record)
1306
+ write_json("uploads.json", uploads)
1307
+ (st.success if result.ok else st.error)(result.message)
1264
1308
 
1265
1309
 
1266
1310
  def render_upload():
@@ -1494,18 +1538,46 @@ def render_settings():
1494
1538
  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.")
1495
1539
  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.")
1496
1540
 
1541
+ direct_account_session_fields = {}
1542
+ direct_cookie_uploads = {}
1497
1543
  with st.expander("Upload directo — sessão YouTube Frontend API"):
1498
- st.caption("Campos usados apenas pelo método Upload directo. Não são necessários para o youtube-automation-agent nem para a Data API pública.")
1499
- direct_cookie_cols = st.columns(3)
1500
- with direct_cookie_cols[0]:
1501
- direct_cookie_sid = text_setting("SID", "direct_cookie_sid", secret=True)
1502
- direct_cookie_ssid = text_setting("SSID", "direct_cookie_ssid", secret=True)
1503
- with direct_cookie_cols[1]:
1504
- direct_cookie_hsid = text_setting("HSID", "direct_cookie_hsid", secret=True)
1505
- direct_cookie_apisid = text_setting("APISID", "direct_cookie_apisid", secret=True)
1506
- with direct_cookie_cols[2]:
1507
- direct_cookie_sapisid = text_setting("SAPISID", "direct_cookie_sapisid", secret=True)
1508
- direct_session_info = text_setting("sessionInfo token", "direct_session_info", secret=True)
1544
+ st.caption("Os cookies e o sessionInfo token são configurados por conta Google/YouTube. O DELEGATED_SESSION_ID é configurado separadamente em cada canal.")
1545
+ direct_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1546
+ if not direct_accounts:
1547
+ st.info("Adicione primeiro uma conta em Contas Google/YouTube — canais em lote. Cada conta terá o seu ficheiro de cookies e sessionInfo token.")
1548
+ for direct_account in direct_accounts:
1549
+ direct_account_id = str(direct_account["id"])
1550
+ direct_status = direct_account_status(STORAGE, direct_account)
1551
+ with st.container(border=True):
1552
+ st.markdown(f"**{direct_account.get('label', 'Canais YouTube')}** — `{direct_account.get('email', 'sem e-mail')}`")
1553
+ direct_account_cols = st.columns([1.6, 1.6, 1])
1554
+ with direct_account_cols[0]:
1555
+ direct_session_info_value = st.text_input("sessionInfo token", value=str(direct_account.get("direct_session_info", "")), type="password", key=f"direct_session_info_{direct_account_id}")
1556
+ with direct_account_cols[1]:
1557
+ direct_cookie_uploads[direct_account_id] = st.file_uploader("Ficheiro de cookies", type=["json", "txt", "cookies", "tsv"], key=f"direct_cookie_file_{direct_account_id}", help="JSON de cookies do navegador ou ficheiro Netscape com SID, SSID, HSID, APISID e SAPISID.")
1558
+ with direct_account_cols[2]:
1559
+ if direct_status["cookie_file_exists"] and not direct_status["missing_cookies"]:
1560
+ st.success("Cookies guardados")
1561
+ elif direct_status["cookie_file_exists"]:
1562
+ st.warning(f"Faltam: {', '.join(direct_status['missing_cookies'])}")
1563
+ else:
1564
+ st.warning("Sem ficheiro de cookies")
1565
+ st.caption("O ficheiro é guardado em storage/youtube_direct_accounts/<conta>/cookies.json. Os valores não são mostrados nem enviados para o Git.")
1566
+ direct_account_session_fields[direct_account_id] = direct_session_info_value
1567
+
1568
+ with st.container(border=True):
1569
+ st.markdown("**Compatibilidade com instalação antiga — credenciais globais**")
1570
+ st.caption("Use apenas para manter instalações antigas. Quando houver uma conta Google configurada, o Upload directo usa o ficheiro e o sessionInfo dessa conta.")
1571
+ direct_cookie_cols = st.columns(3)
1572
+ with direct_cookie_cols[0]:
1573
+ direct_cookie_sid = text_setting("SID", "direct_cookie_sid", secret=True)
1574
+ direct_cookie_ssid = text_setting("SSID", "direct_cookie_ssid", secret=True)
1575
+ with direct_cookie_cols[1]:
1576
+ direct_cookie_hsid = text_setting("HSID", "direct_cookie_hsid", secret=True)
1577
+ direct_cookie_apisid = text_setting("APISID", "direct_cookie_apisid", secret=True)
1578
+ with direct_cookie_cols[2]:
1579
+ direct_cookie_sapisid = text_setting("SAPISID", "direct_cookie_sapisid", secret=True)
1580
+ direct_session_info = text_setting("sessionInfo token global", "direct_session_info", secret=True)
1509
1581
  direct_innertube_api_key = text_setting("INNERTUBE_API_KEY", "direct_innertube_api_key", secret=True)
1510
1582
  direct_chunk_size = st.number_input("Chunk size (múltiplo de 262144)", min_value=262144, step=262144, value=int(settings.get("direct_chunk_size", 262144)))
1511
1583
 
@@ -1525,14 +1597,36 @@ def render_settings():
1525
1597
  with st.expander("LLM — providers e modelos", expanded=True):
1526
1598
  provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
1527
1599
  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)
1528
- st.markdown("**OpenAI — API key, Base URL e modelo**")
1600
+ st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
1601
+ 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.")
1529
1602
  openai_cols = st.columns(3)
1530
1603
  with openai_cols[0]:
1531
- openai_api_key = text_setting("OpenAI API key", "openai_api_key", secret=True)
1604
+ 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.")
1532
1605
  with openai_cols[1]:
1533
- openai_base_url = text_setting("OpenAI Base URL", "openai_base_url", help_text="Opcional. Deixe vazio para a API oficial ou indique um endpoint compatível.")
1606
+ 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")
1534
1607
  with openai_cols[2]:
1535
- openai_model_name = text_setting("OpenAI model", "openai_model_name", help_text="Ex.: gpt-4o, gpt-4.1 ou o modelo disponibilizado pelo seu endpoint.")
1608
+ cached_catalog = st.session_state.get("openai_model_catalog", {})
1609
+ catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
1610
+ cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
1611
+ current_model_name = str(settings.get("openai_model_name", "") or "")
1612
+ manual_option = "__manual_model__"
1613
+ if cached_models:
1614
+ model_options = [manual_option, *cached_models]
1615
+ model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
1616
+ 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")
1617
+ if selected_model == manual_option:
1618
+ openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help_text="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
1619
+ else:
1620
+ openai_model_name = selected_model
1621
+ else:
1622
+ 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")
1623
+ refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
1624
+ if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
1625
+ st.warning(str(cached_catalog["error"]))
1626
+ elif cached_models:
1627
+ st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
1628
+ else:
1629
+ st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
1536
1630
  llm_fields = [
1537
1631
  ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
1538
1632
  ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
@@ -1606,7 +1700,31 @@ def render_settings():
1606
1700
  upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
1607
1701
  upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
1608
1702
 
1703
+ if refresh_openai_models:
1704
+ try:
1705
+ discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
1706
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
1707
+ st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
1708
+ st.rerun()
1709
+ except ModelDiscoveryError as exc:
1710
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
1711
+ st.rerun()
1712
+
1609
1713
  if st.form_submit_button("Guardar configurações do Thunderbolt", type="primary"):
1714
+ direct_cookie_error = ""
1715
+ configured_direct_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1716
+ for direct_account in configured_direct_accounts:
1717
+ direct_account_id = str(direct_account["id"])
1718
+ direct_account["direct_session_info"] = str(direct_account_session_fields.get(direct_account_id, direct_account.get("direct_session_info", "")) or "").strip()
1719
+ uploaded_cookie_file = direct_cookie_uploads.get(direct_account_id)
1720
+ if uploaded_cookie_file is not None:
1721
+ try:
1722
+ save_cookie_file(STORAGE, direct_account, uploaded_cookie_file.getvalue(), uploaded_cookie_file.name)
1723
+ except ValueError as exc:
1724
+ direct_cookie_error = str(exc)
1725
+ settings["youtube_batch_accounts"] = configured_direct_accounts
1726
+ if direct_cookie_error:
1727
+ st.error(direct_cookie_error)
1610
1728
  settings.update({
1611
1729
  "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1612
1730
  "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
@@ -1631,14 +1749,17 @@ def render_settings():
1631
1749
  "upload_post_auto_upload": upload_post_auto_upload,
1632
1750
  })
1633
1751
  write_json("settings.json", settings)
1634
- try:
1635
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
1636
- if synced:
1637
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
1638
- else:
1639
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1640
- except Exception as exc:
1641
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1752
+ if direct_cookie_error:
1753
+ st.warning("As restantes configurações foram guardadas, mas o ficheiro de cookies não foi substituído. Corrija o ficheiro e tente novamente.")
1754
+ else:
1755
+ try:
1756
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
1757
+ if synced:
1758
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
1759
+ else:
1760
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
1761
+ except Exception as exc:
1762
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1642
1763
 
1643
1764
  st.divider()
1644
1765
  st.subheader("Teste de vozes")
@@ -100,7 +100,7 @@ DEFAULTS: dict[str, Any] = {
100
100
  "shengsuanyun_base_url": "",
101
101
  "shengsuanyun_model_name": "",
102
102
  "openai_api_key": "",
103
- "openai_base_url": "",
103
+ "openai_base_url": "https://integrate.api.nvidia.com/v1",
104
104
  "openai_model_name": "",
105
105
  "gemini_api_key": "",
106
106
  "gemini_model_name": "",
@@ -0,0 +1,88 @@
1
+ """Discovery of models from OpenAI-compatible providers such as NVIDIA NIM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+
10
+ DEFAULT_NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
11
+ DEFAULT_TIMEOUT_SECONDS = 12
12
+ MAX_MODEL_IDS = 2000
13
+
14
+
15
+ class ModelDiscoveryError(ValueError):
16
+ """Raised when an OpenAI-compatible model list cannot be loaded safely."""
17
+
18
+
19
+ def models_endpoint(base_url: str) -> str:
20
+ """Return the ``/models`` endpoint for an OpenAI-compatible base URL."""
21
+ value = str(base_url or "").strip().rstrip("/")
22
+ if not value:
23
+ raise ModelDiscoveryError("Informe a Base URL antes de consultar os modelos.")
24
+ if value.endswith("/models"):
25
+ return value
26
+ return f"{value}/models"
27
+
28
+
29
+ def normalize_model_ids(payload: Any) -> list[str]:
30
+ """Extract unique model IDs from the standard OpenAI response shape."""
31
+ if isinstance(payload, dict):
32
+ collection = payload.get("data")
33
+ if collection is None:
34
+ collection = payload.get("models")
35
+ elif isinstance(payload, list):
36
+ collection = payload
37
+ else:
38
+ collection = None
39
+
40
+ if not isinstance(collection, list):
41
+ raise ModelDiscoveryError("A resposta de modelos não tem uma lista data válida.")
42
+
43
+ model_ids: set[str] = set()
44
+ for item in collection:
45
+ if isinstance(item, dict):
46
+ model_id = item.get("id") or item.get("name")
47
+ elif isinstance(item, str):
48
+ model_id = item
49
+ else:
50
+ model_id = None
51
+ if isinstance(model_id, str) and model_id.strip():
52
+ model_ids.add(model_id.strip())
53
+
54
+ if not model_ids:
55
+ raise ModelDiscoveryError("O endpoint não devolveu nenhum identificador de modelo.")
56
+ return sorted(model_ids, key=str.casefold)[:MAX_MODEL_IDS]
57
+
58
+
59
+ def fetch_openai_compatible_models(
60
+ api_key: str,
61
+ base_url: str,
62
+ *,
63
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
64
+ ) -> list[str]:
65
+ """Fetch model IDs without exposing the API key in errors or logs."""
66
+ endpoint = models_endpoint(base_url)
67
+ headers = {"Accept": "application/json"}
68
+ token = str(api_key or "").strip()
69
+ if token:
70
+ headers["Authorization"] = f"Bearer {token}"
71
+
72
+ try:
73
+ response = requests.get(endpoint, headers=headers, timeout=timeout)
74
+ except requests.RequestException as exc:
75
+ raise ModelDiscoveryError(f"Não foi possível consultar {endpoint}: {exc}") from exc
76
+
77
+ if response.status_code >= 400:
78
+ if response.status_code in (401, 403):
79
+ raise ModelDiscoveryError(
80
+ f"O endpoint recusou a credencial (HTTP {response.status_code}). Verifique a API key."
81
+ )
82
+ raise ModelDiscoveryError(f"O endpoint devolveu HTTP {response.status_code}.")
83
+
84
+ try:
85
+ payload = response.json()
86
+ except ValueError as exc:
87
+ raise ModelDiscoveryError("O endpoint devolveu uma resposta que não é JSON.") from exc
88
+ return normalize_model_ids(payload)
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
11
+
12
+
13
+ def account_key(account: dict[str, Any]) -> str:
14
+ raw = str(account.get("id") or "").strip()
15
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-")
16
+ return safe[:80] or "google-account"
17
+
18
+
19
+ def account_directory(storage_root: Path, account: dict[str, Any]) -> Path:
20
+ return Path(storage_root) / "youtube_direct_accounts" / account_key(account)
21
+
22
+
23
+ def cookie_file_path(storage_root: Path, account: dict[str, Any]) -> Path:
24
+ return account_directory(storage_root, account) / "cookies.json"
25
+
26
+
27
+ def _normalise_pairs(value: Any) -> dict[str, str]:
28
+ pairs: dict[str, str] = {}
29
+ if isinstance(value, dict):
30
+ if isinstance(value.get("cookies"), list):
31
+ return _normalise_pairs(value["cookies"])
32
+ for key, item in value.items():
33
+ key_text = str(key).strip()
34
+ if key_text in COOKIE_KEYS:
35
+ pairs[key_text] = str(item or "").strip()
36
+ return pairs
37
+ if isinstance(value, list):
38
+ for item in value:
39
+ if isinstance(item, dict):
40
+ name = str(item.get("name") or item.get("key") or "").strip()
41
+ content = str(item.get("value") or "").strip()
42
+ if name in COOKIE_KEYS:
43
+ pairs[name] = content
44
+ return pairs
45
+ return pairs
46
+
47
+
48
+ def parse_cookie_file(content: bytes, filename: str = "cookies.json") -> dict[str, str]:
49
+ text = content.decode("utf-8-sig", errors="replace").strip()
50
+ pairs: dict[str, str] = {}
51
+ try:
52
+ parsed = json.loads(text)
53
+ pairs.update(_normalise_pairs(parsed))
54
+ except json.JSONDecodeError:
55
+ for line in text.splitlines():
56
+ line = line.strip()
57
+ if not line or line.startswith("#"):
58
+ continue
59
+ fields = line.split("\t")
60
+ if len(fields) >= 7 and fields[5].strip() in COOKIE_KEYS:
61
+ pairs[fields[5].strip()] = fields[6].strip()
62
+ continue
63
+ if "=" in line:
64
+ name, value = line.split("=", 1)
65
+ name = name.strip()
66
+ if name in COOKIE_KEYS:
67
+ pairs[name] = value.strip().rstrip(";")
68
+ missing = [key for key in COOKIE_KEYS if not pairs.get(key)]
69
+ if missing:
70
+ raise ValueError(f"Faltam cookies obrigatórios no ficheiro {filename}: {', '.join(missing)}.")
71
+ return {key: pairs[key] for key in COOKIE_KEYS}
72
+
73
+
74
+ def save_cookie_file(storage_root: Path, account: dict[str, Any], content: bytes, filename: str = "cookies.json") -> Path:
75
+ cookies = parse_cookie_file(content, filename)
76
+ destination = cookie_file_path(storage_root, account)
77
+ destination.parent.mkdir(parents=True, exist_ok=True)
78
+ temporary = destination.with_name(f".{destination.name}.{secrets.token_hex(6)}.tmp")
79
+ temporary.write_text(json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8")
80
+ try:
81
+ os.chmod(temporary, 0o600)
82
+ except OSError:
83
+ pass
84
+ temporary.replace(destination)
85
+ try:
86
+ os.chmod(destination, 0o600)
87
+ except OSError:
88
+ pass
89
+ return destination
90
+
91
+
92
+ def load_cookie_file(storage_root: Path, account: dict[str, Any]) -> dict[str, str]:
93
+ path = cookie_file_path(storage_root, account)
94
+ if not path.exists():
95
+ return {}
96
+ try:
97
+ raw = json.loads(path.read_text(encoding="utf-8"))
98
+ except (OSError, json.JSONDecodeError):
99
+ return {}
100
+ return _normalise_pairs(raw)
101
+
102
+
103
+ def direct_account_status(storage_root: Path, account: dict[str, Any]) -> dict[str, Any]:
104
+ cookies = load_cookie_file(storage_root, account)
105
+ missing = [key for key in COOKIE_KEYS if not cookies.get(key)]
106
+ session_info = str(account.get("direct_session_info") or "").strip()
107
+ return {
108
+ "cookie_file": str(cookie_file_path(storage_root, account)),
109
+ "cookie_file_exists": cookie_file_path(storage_root, account).exists(),
110
+ "missing_cookies": missing,
111
+ "has_session_info": bool(session_info),
112
+ "ready": not missing and bool(session_info),
113
+ }
@@ -8,6 +8,8 @@ import time
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Any
11
+
12
+ from integrations.youtube_direct_credentials import COOKIE_KEYS, load_cookie_file
11
13
  from urllib.parse import quote
12
14
 
13
15
  import requests
@@ -15,7 +17,6 @@ import requests
15
17
 
16
18
  CHUNK_GRANULARITY = 262144
17
19
  SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".webm", ".avi", ".mpeg", ".mpg", ".flv", ".wmv", ".3gpp"}
18
- COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
19
20
 
20
21
 
21
22
  @dataclass
@@ -25,10 +26,20 @@ class DirectUploadResult:
25
26
  data: dict[str, Any]
26
27
 
27
28
 
28
- def _cookie_settings(settings: dict[str, Any]) -> dict[str, str]:
29
+ def _cookie_settings(settings: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> dict[str, str]:
30
+ if account is not None and storage_root is not None:
31
+ account_cookies = load_cookie_file(storage_root, account)
32
+ if account_cookies:
33
+ return {key: str(account_cookies.get(key, "") or "").strip() for key in COOKIE_KEYS}
29
34
  return {key: str(settings.get(f"direct_cookie_{key.lower()}", "") or "").strip() for key in COOKIE_KEYS}
30
35
 
31
36
 
37
+ def _session_info(settings: dict[str, Any], account: dict[str, Any] | None = None) -> str:
38
+ if account is not None:
39
+ return str(account.get("direct_session_info", "") or "").strip()
40
+ return str(settings.get("direct_session_info", "") or "").strip()
41
+
42
+
32
43
  def _cookie_header(cookies: dict[str, str]) -> str:
33
44
  return ";".join(["CONSENT=YES+cb"] + [f"{key}={value}" for key, value in cookies.items() if value])
34
45
 
@@ -51,7 +62,7 @@ def _header(response: requests.Response, name: str) -> str:
51
62
  return ""
52
63
 
53
64
 
54
- def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], settings: dict[str, Any]) -> str | None:
65
+ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], settings: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str | None:
55
66
  path = Path(video_path)
56
67
  if not path.exists() or not path.is_file():
57
68
  return "Ficheiro de vídeo não encontrado."
@@ -59,10 +70,10 @@ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], sett
59
70
  return "Formato de vídeo não suportado pelo upload directo."
60
71
  if path.stat().st_size <= 0:
61
72
  return "O ficheiro de vídeo está vazio."
62
- missing_cookies = [key for key, value in _cookie_settings(settings).items() if not value]
73
+ missing_cookies = [key for key, value in _cookie_settings(settings, account, storage_root).items() if not value]
63
74
  if missing_cookies:
64
75
  return f"Faltam cookies de sessão para upload directo: {', '.join(missing_cookies)}."
65
- if not str(settings.get("direct_session_info", "") or "").strip():
76
+ if not _session_info(settings, account):
66
77
  return "Configure o token sessionInfo do upload directo."
67
78
  if not str(settings.get("direct_innertube_api_key", "") or "").strip():
68
79
  return "Configure o INNERTUBE_API_KEY do upload directo."
@@ -72,11 +83,14 @@ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], sett
72
83
 
73
84
 
74
85
  class YouTubeDirectUploader:
75
- def __init__(self, settings: dict[str, Any], channel: dict[str, Any], *, session: requests.Session | None = None):
86
+ def __init__(self, settings: dict[str, Any], channel: dict[str, Any], *, account: dict[str, Any] | None = None, storage_root: Path | None = None, session: requests.Session | None = None):
76
87
  self.settings = settings
77
88
  self.channel = channel
89
+ self.account = account
90
+ self.storage_root = storage_root
78
91
  self.session = session or requests.Session()
79
- self.cookies = _cookie_settings(settings)
92
+ self.cookies = _cookie_settings(settings, account, storage_root)
93
+ self.session_info = _session_info(settings, account)
80
94
  self.cookie_header = _cookie_header(self.cookies)
81
95
  self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36"
82
96
  self.inner_tube = _innertube_id()
@@ -124,7 +138,7 @@ class YouTubeDirectUploader:
124
138
  "botguardClientResponse": f"${hashlib.sha1(os.urandom(16)).hexdigest()}",
125
139
  "context": {
126
140
  "client": {"clientName": 62, "clientVersion": "1.20210806.02.00", "hl": "pt-BR", "gl": "BR", "experimentsToken": "", "utcOffsetMinutes": 0},
127
- "request": {"sessionInfo": {"token": str(self.settings.get("direct_session_info", ""))}},
141
+ "request": {"sessionInfo": {"token": self.session_info}},
128
142
  "user": {"onBehalfOfUser": str(self.channel.get("delegated_session_id", ""))},
129
143
  },
130
144
  }
@@ -157,13 +171,13 @@ class YouTubeDirectUploader:
157
171
 
158
172
  def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int = CHUNK_GRANULARITY) -> DirectUploadResult:
159
173
  path = Path(video_path)
160
- validation_error = validate_direct_upload(path, self.channel, self.settings)
174
+ validation_error = validate_direct_upload(path, self.channel, self.settings, self.account, self.storage_root)
161
175
  if validation_error:
162
176
  return DirectUploadResult(False, validation_error, {"mechanism": "youtube-frontend-direct"})
163
177
  try:
164
178
  self.describe_file(path)
165
179
  self.create_video(title, description, visibility)
166
180
  self.upload_chunks(path, chunk_size)
167
- return DirectUploadResult(True, f"Upload directo concluído: {self.video_id}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id, "page_id": self.channel.get("delegated_session_id", "")})
181
+ return DirectUploadResult(True, f"Upload directo concluído: {self.video_id}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id, "page_id": self.channel.get("delegated_session_id", ""), "google_account_id": (self.account or {}).get("id", "")})
168
182
  except (requests.RequestException, OSError, ValueError, RuntimeError) as exc:
169
183
  return DirectUploadResult(False, f"Upload directo falhou: {exc}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.41",
3
+ "version": "0.2.43",
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",