@danhachuel/thunderbolt 0.2.50 → 0.2.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +22 -13
- package/README.md +17 -7
- package/app/main.py +336 -163
- package/hermes_ui/domain.py +14 -0
- package/hermes_ui/storage.py +3 -2
- package/integrations/platforms.py +77 -0
- package/integrations/youtube_direct_credentials.py +55 -5
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
5
|
+
> **Versão deste manual:** 0.2.52
|
|
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.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.52 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.52 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.
|
|
@@ -396,12 +396,21 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
396
396
|
11. **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, OAuth Client Secret e `sessionInfo` próprios; use **Repetir campos para nova conta** para preparar contas adicionais e o ícone **Apagar conta** para eliminar individualmente a conta, tokens, documento JSON e associações de canais.
|
|
397
397
|
12. **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 só ocorre após o clique e que um erro de endpoint não interrompe a UI.
|
|
398
398
|
13. **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`.
|
|
399
|
-
14. **
|
|
400
|
-
15. **
|
|
401
|
-
16. **
|
|
402
|
-
17. **Configurações >
|
|
403
|
-
18. **Configurações > MCP >
|
|
404
|
-
19. **Configurações > MCP >
|
|
399
|
+
14. **Canais:** abra **Configurações > Canais**, clique em **Editar** num cartão, altere o nicho, Blueprint/Prompts do Canal, Narrador/Voz padrão e horário, guarde e confirme que o cartão é actualizado sem duplicar o canal. Clique em **Actualizar últimos 10 vídeos**, valide a vista Lista, mude para Kanban e abra **Editar vídeo** para testar título, estado, data, URL e notas.
|
|
400
|
+
15. **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.
|
|
401
|
+
16. **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.
|
|
402
|
+
17. **Upload directo:** em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada cartão Gmail apresenta o uploader **Documento de credenciais desta conta Google** e o campo **sessionInfo token desta conta Google**. O sessionInfo é guardado por conta e sincronizado no documento JSON, que reúne `SID`, `SSID`, `HSID`, `APISID`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` por canal. Use **Repetir campos para nova conta** para preencher o formulário seguinte e o ícone **Apagar conta** para remover a conta e os dados privados associados. Em **Canais > Canais cadastrados**, associe cada canal à conta Google do documento; a UI não mostra nem edita o `DELEGATED_SESSION_ID`. O documento é guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, e o uploader lê os valores directos exclusivamente desse documento. Não existem campos técnicos de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na parte inferior da UI e o método não extrai cookies automaticamente do navegador.
|
|
403
|
+
18. **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.
|
|
404
|
+
19. **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.
|
|
405
|
+
20. **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.
|
|
406
|
+
|
|
407
|
+
## Gestão de Canais, edição e vídeos recentes
|
|
408
|
+
|
|
409
|
+
Na página **Configurações > Canais**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (Voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
|
|
410
|
+
|
|
411
|
+
O cartão mostra o nicho ou Canais de Referência imediatamente abaixo do nome. Os três blocos de gestão usam os rótulos **Prompts do Canal**, **Canais de Referência** e **Narrador**, com botões que abrem o editor do próprio canal.
|
|
412
|
+
|
|
413
|
+
A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal e não dentro de Criação de Vídeos. Clique em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A vista **Lista** apresenta título, data, URL, estado e botão **Editar vídeo**; a vista **Kanban** separa Planejamento, Produção, Finalizado e Agendado/Publicado. A edição local permite alterar título, estado, data, URL e notas. Essas alterações são overrides de gestão local e não publicam automaticamente no YouTube.
|
|
405
414
|
|
|
406
415
|
## Canais em lote por conta Google/YouTube
|
|
407
416
|
|
|
@@ -419,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
419
428
|
|
|
420
429
|
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.
|
|
421
430
|
|
|
422
|
-
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.
|
|
431
|
+
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.52 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
423
432
|
|
|
424
433
|
### Niche Finder Apify
|
|
425
434
|
|
|
@@ -457,13 +466,13 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
|
|
|
457
466
|
|
|
458
467
|
## Upload directo
|
|
459
468
|
|
|
460
|
-
Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada
|
|
469
|
+
Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um expander identificado por nome e e-mail. Dentro dele existe o uploader **Subir documento de cookies/credenciais** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
|
|
461
470
|
|
|
462
471
|
Em **Canais > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
|
|
463
472
|
|
|
464
|
-
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O
|
|
473
|
+
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
|
|
465
474
|
|
|
466
|
-
Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é 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.
|
|
475
|
+
Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é 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.
|
|
467
476
|
|
|
468
477
|
## MCP e integrações externas
|
|
469
478
|
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
14
14
|
| Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas e Upload |
|
|
15
15
|
| Blueprints | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
|
|
16
16
|
| Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
|
|
17
|
-
| Canais | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente |
|
|
17
|
+
| Canais | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
|
|
18
18
|
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; lotes; 51 rótulos de idioma; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
|
|
19
19
|
| Automação | Lista de vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
20
20
|
| Models AI | Menu expansível com Personagens e Redes Sociais reservados para desenvolvimento futuro |
|
|
@@ -22,25 +22,35 @@ A primeira versão implementa a camada UI independente com:
|
|
|
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
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, 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, Client Secret e sessionInfo por conta,
|
|
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, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, 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
28
|
## Upload directo — credenciais por conta e por canal
|
|
29
29
|
|
|
30
|
-
O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada
|
|
30
|
+
O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
|
|
31
31
|
|
|
32
|
-
O
|
|
32
|
+
O botão **Adicionar outra conta Gmail** fica abaixo de uma divisória, fora dos cartões existentes. Ao criar uma conta, o Thunderbolt cria imediatamente `credentials.json` com placeholders vazios. Um upload completo ou parcial é incorporado por merge: os cookies enviados actualizam apenas os cookies fornecidos e valores como `sessionInfo`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` existentes não são apagados. O alerta **Documento incompleto: SID, SSID, HSID, APISID, SAPISID, sessionInfo, INNERTUBE_API_KEY** permanece visível por conta quando necessário.
|
|
33
|
+
|
|
34
|
+
O documento é guardado por Gmail em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, com permissões locais restritas. Em **Canais > Canais cadastrados**, a UI apenas associa o canal à conta Google; essa associação continua permitida mesmo quando o documento está incompleto. O uploader procura o ID do canal dentro do mapa `delegated_session_ids` do documento e usa-o como `pageId`/`onBehalfOfUser`; só a operação de Upload directo é bloqueada quando faltarem os dados técnicos. Não existem campos separados de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na UI.
|
|
33
35
|
|
|
34
36
|
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
37
|
|
|
36
38
|
|
|
37
39
|
Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
|
|
38
40
|
|
|
39
|
-
## Navegação da UI 0.2.
|
|
41
|
+
## Navegação da UI 0.2.52
|
|
40
42
|
|
|
41
43
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** 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
44
|
|
|
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.
|
|
45
|
+
Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém cartões expansíveis de contas Google, sessionInfo e documentos de Upload directo; o formulário **Adicionar outra conta Gmail** fica fora dos cartões. **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
|
|
46
|
+
|
|
47
|
+
## Canais — edição por cartão e vídeos recentes
|
|
48
|
+
|
|
49
|
+
A página **Canais** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
|
|
50
|
+
|
|
51
|
+
Os blocos do cartão usam a nomenclatura solicitada: **Prompts do Canal**, **Canais de Referência** e **Narrador**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
|
|
52
|
+
|
|
53
|
+
Abaixo do cartão, a secção **Últimos 10 vídeos publicados** usa o feed público RSS do YouTube, sem Data API Key. O carregamento ocorre quando se clica em **Actualizar últimos 10 vídeos**, evitando chamadas automáticas ao abrir a página. Os vídeos ficam guardados em `storage/state/channel_videos.json` e podem ser apresentados em **Lista** ou **Kanban**, nos grupos Planejamento, Produção, Finalizado e Agendado/Publicado. Cada vídeo tem **Editar vídeo** para alterar localmente o título, estado, data, URL e notas. A fonte pública não substitui o vídeo nem publica alterações no YouTube; os campos editáveis são overrides locais de gestão.
|
|
44
54
|
|
|
45
55
|
## Criação de Vídeos — geração editorial por canal
|
|
46
56
|
|
|
@@ -64,7 +74,7 @@ A consulta só é executada quando o utilizador carrega no botão; a aplicação
|
|
|
64
74
|
|
|
65
75
|
Para autorizar uma conta, use no Google Cloud um cliente OAuth do tipo **Desktop app**. O Thunderbolt usa o loopback local determinístico `http://127.0.0.1:8765/`, abre o browser e guarda o token por conta. Se estiver a usar um cliente do tipo Web application, adicione exactamente essa URI em **Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados**; a URI deve incluir a porta e a barra final. O erro `400: redirect_uri_mismatch` significa que a URI registada no cliente Google não coincide exactamente com a enviada pela aplicação.
|
|
66
76
|
|
|
67
|
-
A área **Contas Google/YouTube — canais em lote** permite manter várias contas. Cada cartão tem
|
|
77
|
+
A área **Contas Google/YouTube — canais em lote** permite manter várias contas em cartões expansíveis com nome e e-mail. Cada cartão tem OAuth Client ID, OAuth Client Secret, sessionInfo e documento de Upload directo; o formulário separado **Adicionar outra conta Gmail** cria o documento padrão automaticamente. Use **Apagar conta** para remover a conta, os tokens e os dados directos associados. O sessionInfo é sincronizado com o documento JSON da própria conta; cookies e os restantes parâmetros de Upload directo permanecem apenas nesse documento.
|
|
68
78
|
|
|
69
79
|
## Instalação
|
|
70
80
|
|
package/app/main.py
CHANGED
|
@@ -20,7 +20,7 @@ try:
|
|
|
20
20
|
except (OSError, json.JSONDecodeError):
|
|
21
21
|
APP_VERSION = ""
|
|
22
22
|
|
|
23
|
-
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel
|
|
23
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
24
24
|
from hermes_ui.automation_worker import load_worker_status
|
|
25
25
|
from hermes_ui.storage import BLUEPRINTS, STORAGE, ensure_storage, list_blueprint_files, load_blueprint_file, now, read_json, write_json
|
|
26
26
|
from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
|
|
@@ -35,9 +35,9 @@ from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
|
35
35
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
36
36
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
37
37
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
38
|
-
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
38
|
+
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
39
39
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
40
|
-
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
40
|
+
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
41
41
|
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, loopback_redirect_uri
|
|
42
42
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
43
43
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
@@ -300,6 +300,172 @@ def channel_default_options(channel: dict) -> tuple[list[str], dict[str, str], s
|
|
|
300
300
|
return blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice
|
|
301
301
|
|
|
302
302
|
|
|
303
|
+
def channel_niche_label(channel: dict) -> str:
|
|
304
|
+
"""Return the channel niche/reference label shown under the channel name."""
|
|
305
|
+
niche = str(channel.get("niche") or "").strip()
|
|
306
|
+
if niche:
|
|
307
|
+
return niche
|
|
308
|
+
references = channel.get("reference_channels")
|
|
309
|
+
if isinstance(references, list):
|
|
310
|
+
values = [str(item).strip() for item in references if str(item).strip()]
|
|
311
|
+
if values:
|
|
312
|
+
return ", ".join(values)
|
|
313
|
+
blueprint = blueprint_for_channel(channel)
|
|
314
|
+
metadata = blueprint.get("metadata") if isinstance(blueprint.get("metadata"), dict) else {}
|
|
315
|
+
return str(blueprint.get("target_niche") or blueprint.get("niche") or metadata.get("target_niche") or metadata.get("niche") or "SEM NICHO CONFIGURADO").strip()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _merge_channel_videos(channel_id: str, videos: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
319
|
+
existing = read_json("channel_videos.json", [])
|
|
320
|
+
if not isinstance(existing, list):
|
|
321
|
+
existing = []
|
|
322
|
+
other_channels = [item for item in existing if isinstance(item, dict) and str(item.get("channel_id")) != str(channel_id)]
|
|
323
|
+
current_by_id = {str(item.get("id")): item for item in existing if isinstance(item, dict) and str(item.get("channel_id")) == str(channel_id)}
|
|
324
|
+
merged: list[dict[str, Any]] = []
|
|
325
|
+
for video in videos:
|
|
326
|
+
item = dict(current_by_id.get(str(video.get("id")), {}))
|
|
327
|
+
item.update(video)
|
|
328
|
+
item["channel_id"] = str(channel_id)
|
|
329
|
+
item.setdefault("status", "publicado")
|
|
330
|
+
merged.append(item)
|
|
331
|
+
write_json("channel_videos.json", other_channels + merged)
|
|
332
|
+
return merged
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def channel_videos_for(channel: dict, limit: int = 10) -> list[dict[str, Any]]:
|
|
336
|
+
channel_id = str(channel.get("id") or "")
|
|
337
|
+
stored = read_json("channel_videos.json", [])
|
|
338
|
+
videos = [item for item in stored if isinstance(item, dict) and str(item.get("channel_id")) == channel_id]
|
|
339
|
+
videos.sort(key=lambda item: str(item.get("published_at") or item.get("created_at") or ""), reverse=True)
|
|
340
|
+
return videos[:limit]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def render_channel_video_editor(video: dict, channel_id: str) -> None:
|
|
344
|
+
video_id = str(video.get("id") or "")
|
|
345
|
+
edit_key = f"channel_video_edit_{video_id}"
|
|
346
|
+
if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", use_container_width=True):
|
|
347
|
+
st.session_state[edit_key] = True
|
|
348
|
+
st.rerun()
|
|
349
|
+
if st.session_state.get(edit_key):
|
|
350
|
+
with st.form(f"channel_video_form_{video_id}"):
|
|
351
|
+
edited_title = st.text_input("Título", value=str(video.get("title") or ""))
|
|
352
|
+
edited_status = st.selectbox("Estado", ["planejamento", "produção", "finalizado", "agendado", "publicado"], index=["planejamento", "produção", "finalizado", "agendado", "publicado"].index(str(video.get("status") or "publicado")) if str(video.get("status") or "publicado") in {"planejamento", "produção", "finalizado", "agendado", "publicado"} else 4)
|
|
353
|
+
edited_date = st.text_input("Data de publicação", value=str(video.get("published_at") or ""))
|
|
354
|
+
edited_url = st.text_input("URL", value=str(video.get("url") or ""))
|
|
355
|
+
edited_notes = st.text_area("Notas", value=str(video.get("notes") or ""), height=80)
|
|
356
|
+
save_video = st.form_submit_button("Guardar alteração", type="primary")
|
|
357
|
+
if save_video:
|
|
358
|
+
update_channel_video(video_id, {"title": edited_title.strip(), "status": edited_status, "published_at": edited_date.strip(), "url": edited_url.strip(), "notes": edited_notes.strip()})
|
|
359
|
+
st.session_state.pop(edit_key, None)
|
|
360
|
+
st.success("Vídeo actualizado.")
|
|
361
|
+
st.rerun()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_channel_videos(channel: dict) -> None:
|
|
365
|
+
channel_id = str(channel.get("id") or "")
|
|
366
|
+
st.markdown("### Últimos 10 vídeos publicados")
|
|
367
|
+
st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
|
|
368
|
+
refresh_col, view_col = st.columns([1.4, 1])
|
|
369
|
+
with refresh_col:
|
|
370
|
+
if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
|
|
371
|
+
result = fetch_channel_videos_public(channel, limit=10)
|
|
372
|
+
if result.ok:
|
|
373
|
+
videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
|
|
374
|
+
st.session_state[f"channel_videos_{channel_id}"] = videos
|
|
375
|
+
st.success(result.message)
|
|
376
|
+
st.rerun()
|
|
377
|
+
else:
|
|
378
|
+
st.warning(result.message)
|
|
379
|
+
with view_col:
|
|
380
|
+
view_mode = st.radio("Vista", ["Lista", "Kanban"], horizontal=True, key=f"channel_videos_view_{channel_id}", label_visibility="collapsed")
|
|
381
|
+
videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
|
|
382
|
+
if not videos:
|
|
383
|
+
st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
|
|
384
|
+
return
|
|
385
|
+
if view_mode == "Lista":
|
|
386
|
+
for video in videos[:10]:
|
|
387
|
+
with st.container(border=True):
|
|
388
|
+
cols = st.columns([0.7, 3.4, 1.4, 1.1])
|
|
389
|
+
with cols[0]:
|
|
390
|
+
if video.get("thumbnail_url"):
|
|
391
|
+
st.image(video["thumbnail_url"], width=64)
|
|
392
|
+
else:
|
|
393
|
+
st.markdown("### YT")
|
|
394
|
+
with cols[1]:
|
|
395
|
+
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
396
|
+
st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
|
|
397
|
+
with cols[2]:
|
|
398
|
+
st.caption(str(video.get("status") or "publicado").title())
|
|
399
|
+
with cols[3]:
|
|
400
|
+
render_channel_video_editor(video, channel_id)
|
|
401
|
+
else:
|
|
402
|
+
columns = st.columns(4)
|
|
403
|
+
groups = [("planejamento", "Planejamento"), ("produção", "Produção"), ("finalizado", "Finalizado"), ("publicado", "Agendado/Publicado")]
|
|
404
|
+
for column, (status_key, label) in zip(columns, groups):
|
|
405
|
+
with column:
|
|
406
|
+
st.markdown(f"**{label}**")
|
|
407
|
+
group = [video for video in videos[:10] if str(video.get("status") or "publicado").lower() in ({status_key} if status_key != "publicado" else {"publicado", "agendado"})]
|
|
408
|
+
if not group:
|
|
409
|
+
st.caption("Sem vídeos")
|
|
410
|
+
for video in group:
|
|
411
|
+
with st.container(border=True):
|
|
412
|
+
st.write(f"**{video.get('title', 'Vídeo sem título')}**")
|
|
413
|
+
st.caption(video.get("published_at") or "Sem data")
|
|
414
|
+
render_channel_video_editor(video, channel_id)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], youtube_account_labels: dict[str, str], youtube_accounts_by_id: dict[str, dict[str, Any]]) -> None:
|
|
418
|
+
channel_id = str(channel["id"])
|
|
419
|
+
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
420
|
+
account_ids = list(youtube_account_ids)
|
|
421
|
+
current_account = str(channel.get("google_account_id") or "")
|
|
422
|
+
if current_account and current_account not in account_ids:
|
|
423
|
+
account_ids.append(current_account)
|
|
424
|
+
youtube_account_labels[current_account] = "Conta Google não configurada"
|
|
425
|
+
with st.form(f"channel_edit_form_{channel_id}"):
|
|
426
|
+
st.subheader("Editar canal")
|
|
427
|
+
edit_cols = st.columns(2)
|
|
428
|
+
with edit_cols[0]:
|
|
429
|
+
edited_name = st.text_input("Nome do canal", value=str(channel.get("name") or ""))
|
|
430
|
+
edited_url = st.text_input("URL", value=str(channel.get("url") or ""))
|
|
431
|
+
edited_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
|
|
432
|
+
edited_language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=["Português", "English", "Español", "Français", "Deutsch"].index(channel.get("language")) if channel.get("language") in {"Português", "English", "Español", "Français", "Deutsch"} else 0)
|
|
433
|
+
style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
|
|
434
|
+
style_value = {"pexels": "Pexels/Pixabay", "music": "Apenas Música"}.get(str(channel.get("style_wide") or "pexels"), str(channel.get("style_wide") or "Pexels/Pixabay"))
|
|
435
|
+
edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
|
|
436
|
+
edited_niche = st.text_input("Canais de Referência / Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
|
|
437
|
+
with edit_cols[1]:
|
|
438
|
+
edited_blueprint = st.selectbox("Prompts do Canal", blueprint_ids, index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"))
|
|
439
|
+
edited_voice = st.selectbox("Narrador", 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")
|
|
440
|
+
edited_account = st.selectbox("Conta Google para Upload directo", account_ids, index=account_ids.index(current_account) if current_account in account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"))
|
|
441
|
+
edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
|
|
442
|
+
edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
|
|
443
|
+
edited_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"))
|
|
444
|
+
save_channel = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
|
|
445
|
+
cancel_edit = st.form_submit_button("Cancelar edição")
|
|
446
|
+
if cancel_edit:
|
|
447
|
+
st.session_state.pop(f"edit_channel_{channel_id}", None)
|
|
448
|
+
st.rerun()
|
|
449
|
+
if save_channel:
|
|
450
|
+
if not edited_name.strip():
|
|
451
|
+
st.error("Informe o nome do canal.")
|
|
452
|
+
elif not valid_hhmm(edited_time):
|
|
453
|
+
st.error("O horário diário deve estar no formato HH:MM.")
|
|
454
|
+
else:
|
|
455
|
+
update_channel(channel_id, {
|
|
456
|
+
"name": edited_name.strip(), "url": edited_url.strip(), "handle": edited_handle.strip(), "language": edited_language,
|
|
457
|
+
"style_wide": {"Pexels/Pixabay": "pexels", "Apenas Música": "music"}.get(edited_style, edited_style),
|
|
458
|
+
"niche": edited_niche.strip(), "reference_channels": [item.strip() for item in re.split(r"[,|]", edited_niche) if item.strip()],
|
|
459
|
+
"default_blueprint_id": edited_blueprint.strip(), "blueprint_id": edited_blueprint.strip(),
|
|
460
|
+
"default_voice": edited_voice.strip(), "voice": edited_voice.strip(),
|
|
461
|
+
"google_account_id": edited_account.strip(), "google_account_email": str(youtube_accounts_by_id.get(edited_account, {}).get("email", "")),
|
|
462
|
+
"description": edited_description.strip(), "automation_on": bool(edited_automation), "automation_time": edited_time.strip(),
|
|
463
|
+
})
|
|
464
|
+
st.session_state.pop(f"edit_channel_{channel_id}", None)
|
|
465
|
+
st.success("Canal actualizado.")
|
|
466
|
+
st.rerun()
|
|
467
|
+
|
|
468
|
+
|
|
303
469
|
def render_dashboard():
|
|
304
470
|
st.title("Thunderbolt")
|
|
305
471
|
st.caption("Interface local para operação e automação de conteúdo faceless")
|
|
@@ -482,6 +648,7 @@ def render_channels():
|
|
|
482
648
|
automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
|
|
483
649
|
automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
|
|
484
650
|
description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
|
|
651
|
+
niche = st.text_input("Canais de Referência / Nicho", value=imported.get("niche", ""), key="yt_import_niche")
|
|
485
652
|
metrics = st.columns(3)
|
|
486
653
|
with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
|
|
487
654
|
with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=int(imported.get("video_count") or 0), key="yt_import_videos")
|
|
@@ -497,6 +664,8 @@ def render_channels():
|
|
|
497
664
|
**imported,
|
|
498
665
|
"handle": handle.strip(),
|
|
499
666
|
"description": description.strip(),
|
|
667
|
+
"niche": niche.strip(),
|
|
668
|
+
"reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
|
|
500
669
|
"language": language,
|
|
501
670
|
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
502
671
|
"blueprint_id": blueprint.strip(),
|
|
@@ -615,6 +784,7 @@ def render_channels():
|
|
|
615
784
|
url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
|
|
616
785
|
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
617
786
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
787
|
+
niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
|
|
618
788
|
language = st.selectbox("Idioma", ["Português", "English", "Español", "Français", "Deutsch"], index=0, key="manual_channel_language")
|
|
619
789
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
620
790
|
manual_blueprint_items = blueprint_catalog()
|
|
@@ -642,6 +812,8 @@ def render_channels():
|
|
|
642
812
|
channel = create_channel(name, url, {
|
|
643
813
|
"handle": handle.strip(),
|
|
644
814
|
"description": description.strip(),
|
|
815
|
+
"niche": niche.strip(),
|
|
816
|
+
"reference_channels": [item.strip() for item in re.split(r"[,|]", niche) if item.strip()],
|
|
645
817
|
"language": language,
|
|
646
818
|
"style_wide": {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style, style),
|
|
647
819
|
"blueprint_id": blueprint.strip(),
|
|
@@ -667,99 +839,94 @@ def render_channels():
|
|
|
667
839
|
st.info("Nenhum canal cadastrado.")
|
|
668
840
|
return
|
|
669
841
|
for channel in channels:
|
|
842
|
+
channel_id = str(channel["id"])
|
|
843
|
+
edit_key = f"edit_channel_{channel_id}"
|
|
670
844
|
with st.container(border=True):
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if current_channel_account_id and current_channel_account_id not in channel_account_ids:
|
|
674
|
-
channel_account_ids.append(current_channel_account_id)
|
|
675
|
-
youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
|
|
676
|
-
st.markdown("**Upload directo — documento da conta deste canal**")
|
|
677
|
-
st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
|
|
678
|
-
with st.form(f"channel_direct_credentials_{channel['id']}"):
|
|
679
|
-
channel_account_id = st.selectbox("Conta Google do documento deste canal", 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']}")
|
|
680
|
-
save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
|
|
681
|
-
selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
|
|
682
|
-
if selected_channel_account:
|
|
683
|
-
selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
|
|
684
|
-
if selected_account_status["ready"]:
|
|
685
|
-
st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
|
|
686
|
-
elif not selected_account_status["document_exists"]:
|
|
687
|
-
st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
|
|
688
|
-
else:
|
|
689
|
-
missing_channel_parts = list(selected_account_status["missing_cookies"])
|
|
690
|
-
if not selected_account_status["has_session_info"]:
|
|
691
|
-
missing_channel_parts.append("sessionInfo")
|
|
692
|
-
if not selected_account_status["has_innertube_api_key"]:
|
|
693
|
-
missing_channel_parts.append("INNERTUBE_API_KEY")
|
|
694
|
-
if not selected_account_status["has_delegated_session_id"]:
|
|
695
|
-
missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
|
|
696
|
-
st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
|
|
697
|
-
else:
|
|
698
|
-
st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
|
|
699
|
-
if save_channel_direct_credentials:
|
|
700
|
-
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", ""))})
|
|
701
|
-
st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
|
|
702
|
-
st.rerun()
|
|
703
|
-
cols = st.columns([0.6, 2.2, 1.2, 1.2, 1.2, 1])
|
|
704
|
-
with cols[0]:
|
|
845
|
+
header_cols = st.columns([0.7, 3.5, 1.3, 1.3, 1.5])
|
|
846
|
+
with header_cols[0]:
|
|
705
847
|
if channel.get("thumbnail_url"):
|
|
706
|
-
st.image(channel["thumbnail_url"], width=
|
|
848
|
+
st.image(channel["thumbnail_url"], width=64)
|
|
707
849
|
else:
|
|
708
850
|
st.markdown("### YT")
|
|
709
|
-
with
|
|
851
|
+
with header_cols[1]:
|
|
710
852
|
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
853
|
+
st.caption(channel_niche_label(channel))
|
|
711
854
|
st.caption(f"{channel.get('handle') or channel.get('url') or 'sem URL'} · {channel.get('metrics_source', 'manual')}")
|
|
712
|
-
with
|
|
713
|
-
|
|
714
|
-
with
|
|
715
|
-
|
|
716
|
-
|
|
855
|
+
with header_cols[2]:
|
|
856
|
+
st.metric("Inscritos", channel.get("subscriber_count") if channel.get("subscriber_count") is not None else "—")
|
|
857
|
+
with header_cols[3]:
|
|
858
|
+
st.metric("Vídeos", channel.get("video_count") if channel.get("video_count") is not None else "—")
|
|
859
|
+
with header_cols[4]:
|
|
860
|
+
active = st.toggle("Activo", value=channel.get("active", True), key=f"active_{channel_id}")
|
|
717
861
|
if active != channel.get("active"):
|
|
718
|
-
update_channel(
|
|
862
|
+
update_channel(channel_id, {"active": active})
|
|
719
863
|
st.rerun()
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if st.button("
|
|
864
|
+
action_cols = st.columns(2)
|
|
865
|
+
with action_cols[0]:
|
|
866
|
+
if st.button("Editar", key=f"edit_channel_button_{channel_id}", use_container_width=True):
|
|
867
|
+
st.session_state[edit_key] = True
|
|
868
|
+
st.rerun()
|
|
869
|
+
with action_cols[1]:
|
|
870
|
+
delete_key = f"delete_pending_{channel_id}"
|
|
871
|
+
if st.button("Apagar", key=f"delete_{channel_id}", use_container_width=True):
|
|
723
872
|
st.session_state[delete_key] = True
|
|
724
873
|
st.rerun()
|
|
874
|
+
if st.session_state.get(edit_key):
|
|
875
|
+
render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
|
|
876
|
+
else:
|
|
877
|
+
summary = channel_blueprint_summary(channel)
|
|
878
|
+
block_cols = st.columns(3)
|
|
879
|
+
with block_cols[0]:
|
|
880
|
+
st.markdown(f"**Prompts do Canal**\n\n{summary['name']}")
|
|
881
|
+
if st.button("Editar Prompts", key=f"edit_prompts_{channel_id}", use_container_width=True):
|
|
882
|
+
st.session_state[edit_key] = True
|
|
883
|
+
st.rerun()
|
|
884
|
+
with block_cols[1]:
|
|
885
|
+
st.markdown(f"**Canais de Referência**\n\n{channel_niche_label(channel)}")
|
|
886
|
+
if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
|
|
887
|
+
st.session_state[edit_key] = True
|
|
888
|
+
st.rerun()
|
|
889
|
+
with block_cols[2]:
|
|
890
|
+
st.markdown(f"**Narrador**\n\n{summary['voice'] or 'Sem voz padrão'}")
|
|
891
|
+
if st.button("Configurar Narrador", key=f"edit_voice_{channel_id}", use_container_width=True):
|
|
892
|
+
st.session_state[edit_key] = True
|
|
893
|
+
st.rerun()
|
|
894
|
+
|
|
895
|
+
channel_account_ids = list(youtube_account_ids)
|
|
896
|
+
current_channel_account_id = str(channel.get("google_account_id", ""))
|
|
897
|
+
if current_channel_account_id and current_channel_account_id not in channel_account_ids:
|
|
898
|
+
channel_account_ids.append(current_channel_account_id)
|
|
899
|
+
youtube_account_labels[current_channel_account_id] = "Conta Google não configurada"
|
|
900
|
+
with st.expander("Upload directo — documento da conta deste canal", expanded=False):
|
|
901
|
+
st.caption("O DELEGATED_SESSION_ID deste canal é individual, mas fica apenas no documento JSON da conta Google. A UI não mostra nem edita esse valor; associe apenas o canal à conta que contém o documento.")
|
|
902
|
+
with st.form(f"channel_direct_credentials_{channel_id}"):
|
|
903
|
+
channel_account_id = st.selectbox("Conta Google do documento deste canal", 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}")
|
|
904
|
+
save_channel_direct_credentials = st.form_submit_button("Associar conta Google ao canal", type="primary", use_container_width=True)
|
|
905
|
+
selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
|
|
906
|
+
if selected_channel_account:
|
|
907
|
+
selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
|
|
908
|
+
if selected_account_status["ready"]:
|
|
909
|
+
st.success("Documento da conta completo e DELEGATED_SESSION_ID deste canal encontrado no documento.")
|
|
910
|
+
elif not selected_account_status["document_exists"]:
|
|
911
|
+
st.warning("A conta seleccionada ainda não tem documento JSON de credenciais.")
|
|
912
|
+
else:
|
|
913
|
+
missing_channel_parts = list(selected_account_status["missing_cookies"])
|
|
914
|
+
if not selected_account_status["has_session_info"]:
|
|
915
|
+
missing_channel_parts.append("sessionInfo")
|
|
916
|
+
if not selected_account_status["has_innertube_api_key"]:
|
|
917
|
+
missing_channel_parts.append("INNERTUBE_API_KEY")
|
|
918
|
+
if not selected_account_status["has_delegated_session_id"]:
|
|
919
|
+
missing_channel_parts.append("DELEGATED_SESSION_ID deste canal")
|
|
920
|
+
st.warning(f"Documento incompleto: {', '.join(missing_channel_parts)}")
|
|
725
921
|
else:
|
|
726
|
-
st.
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
removed = delete_channel(channel["id"])
|
|
731
|
-
st.session_state.pop(delete_key, None)
|
|
732
|
-
if removed:
|
|
733
|
-
st.success(f"Canal {removed.get('name', 'seleccionado')} apagado.")
|
|
734
|
-
st.rerun()
|
|
735
|
-
with cancel_col:
|
|
736
|
-
if st.button("Cancelar", key=f"cancel_delete_{channel['id']}", use_container_width=True):
|
|
737
|
-
st.session_state.pop(delete_key, None)
|
|
738
|
-
st.rerun()
|
|
739
|
-
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
740
|
-
with st.expander("Definir Blueprint e voz padrão", expanded=False):
|
|
741
|
-
editor_cols = st.columns(2)
|
|
742
|
-
with editor_cols[0]:
|
|
743
|
-
channel_blueprint = st.selectbox(
|
|
744
|
-
"Blueprint padrão",
|
|
745
|
-
blueprint_ids,
|
|
746
|
-
index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
|
|
747
|
-
format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
|
|
748
|
-
key=f"channel_default_blueprint_{channel['id']}",
|
|
749
|
-
)
|
|
750
|
-
with editor_cols[1]:
|
|
751
|
-
channel_voice = st.selectbox(
|
|
752
|
-
"Voz padrão",
|
|
753
|
-
voice_options,
|
|
754
|
-
index=voice_options.index(current_voice) if current_voice in voice_options else 0,
|
|
755
|
-
format_func=lambda item: item or "Sem voz padrão",
|
|
756
|
-
key=f"channel_default_voice_{channel['id']}",
|
|
757
|
-
)
|
|
758
|
-
if st.button("Guardar Blueprint e voz", key=f"save_channel_defaults_{channel['id']}", type="primary"):
|
|
759
|
-
set_channel_defaults(channel["id"], channel_blueprint, channel_voice)
|
|
760
|
-
st.success("Blueprint padrão e voz padrão guardados para este canal.")
|
|
922
|
+
st.info("Associe este canal a uma conta Google para validar o documento de credenciais.")
|
|
923
|
+
if save_channel_direct_credentials:
|
|
924
|
+
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", ""))})
|
|
925
|
+
st.success("Conta Google associada ao canal. O DELEGATED_SESSION_ID continua exclusivamente no documento da conta.")
|
|
761
926
|
st.rerun()
|
|
762
927
|
|
|
928
|
+
render_channel_videos(channel)
|
|
929
|
+
|
|
763
930
|
|
|
764
931
|
def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
765
932
|
st.title(page_title)
|
|
@@ -1747,53 +1914,60 @@ def render_settings():
|
|
|
1747
1914
|
|
|
1748
1915
|
with google_accounts_tab:
|
|
1749
1916
|
st.subheader("Contas Google/YouTube — canais em lote")
|
|
1750
|
-
st.caption("
|
|
1917
|
+
st.caption("Abra uma conta para ver e editar os detalhes. O e-mail identifica a conta Google; esta área não lê a caixa Gmail.")
|
|
1751
1918
|
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1752
|
-
|
|
1753
|
-
|
|
1919
|
+
channel_state = read_json("channels.json", [])
|
|
1920
|
+
youtube_accounts_missing_document: list[str] = []
|
|
1921
|
+
|
|
1754
1922
|
for batch_account in batch_accounts:
|
|
1755
1923
|
account_id = str(batch_account["id"])
|
|
1756
|
-
|
|
1924
|
+
account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
|
|
1925
|
+
account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
|
|
1926
|
+
ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
|
|
1927
|
+
direct_status = direct_account_status(STORAGE, batch_account)
|
|
1928
|
+
missing_document_parts = list(direct_status.get("missing_cookies", []))
|
|
1929
|
+
if not direct_status.get("has_session_info"):
|
|
1930
|
+
missing_document_parts.append("sessionInfo")
|
|
1931
|
+
if not direct_status.get("has_innertube_api_key"):
|
|
1932
|
+
missing_document_parts.append("INNERTUBE_API_KEY")
|
|
1933
|
+
if missing_document_parts:
|
|
1934
|
+
youtube_accounts_missing_document.append(account_email_snapshot)
|
|
1935
|
+
|
|
1936
|
+
with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
|
|
1757
1937
|
with st.form(f"batch_account_form_{account_id}"):
|
|
1758
|
-
account_cols = st.columns(
|
|
1938
|
+
account_cols = st.columns(2)
|
|
1759
1939
|
with account_cols[0]:
|
|
1760
|
-
account_label = st.text_input("Nome da conta", value=
|
|
1761
|
-
|
|
1762
|
-
account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
|
|
1763
|
-
with account_cols[2]:
|
|
1940
|
+
account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
|
|
1941
|
+
account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
|
|
1764
1942
|
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
1765
|
-
with account_cols[
|
|
1943
|
+
with account_cols[1]:
|
|
1766
1944
|
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}")
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1945
|
+
account_session_info = st.text_input(
|
|
1946
|
+
"sessionInfo token desta conta Google",
|
|
1947
|
+
value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
|
|
1948
|
+
type="password",
|
|
1949
|
+
key=f"batch_session_info_{account_id}",
|
|
1950
|
+
help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
|
|
1951
|
+
)
|
|
1774
1952
|
save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
|
|
1775
|
-
|
|
1776
|
-
st.markdown("**
|
|
1777
|
-
st.caption("
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
else:
|
|
1792
|
-
st.warning("Sem documento de credenciais")
|
|
1793
|
-
st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
|
|
1794
|
-
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}")
|
|
1953
|
+
|
|
1954
|
+
st.markdown("**Documento de cookies/credenciais desta conta Google**")
|
|
1955
|
+
st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
|
|
1956
|
+
document_upload = st.file_uploader(
|
|
1957
|
+
"Subir documento de cookies/credenciais",
|
|
1958
|
+
type=["json"],
|
|
1959
|
+
key=f"direct_credentials_document_{account_id}",
|
|
1960
|
+
help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
|
|
1961
|
+
)
|
|
1962
|
+
if missing_document_parts:
|
|
1963
|
+
st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
|
|
1964
|
+
else:
|
|
1965
|
+
st.success("Documento completo para a conta Google")
|
|
1966
|
+
st.caption(f"Documento guardado em: {direct_status['document_file']}")
|
|
1967
|
+
document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
|
|
1968
|
+
|
|
1795
1969
|
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
1796
|
-
status_cols = st.columns(
|
|
1970
|
+
status_cols = st.columns(3)
|
|
1797
1971
|
with status_cols[0]:
|
|
1798
1972
|
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
1799
1973
|
with status_cols[1]:
|
|
@@ -1810,23 +1984,13 @@ def render_settings():
|
|
|
1810
1984
|
settings["youtube_batch_accounts"] = remaining_accounts
|
|
1811
1985
|
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
1812
1986
|
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
1813
|
-
|
|
1814
|
-
channels_changed = False
|
|
1815
|
-
for channel in channels:
|
|
1987
|
+
for channel in channel_state:
|
|
1816
1988
|
if str(channel.get("google_account_id") or "") == account_id:
|
|
1817
1989
|
channel.update({"google_account_id": "", "google_account_email": ""})
|
|
1818
|
-
|
|
1819
|
-
if channels_changed:
|
|
1820
|
-
write_json("channels.json", channels)
|
|
1990
|
+
write_json("channels.json", channel_state)
|
|
1821
1991
|
write_json("settings.json", settings)
|
|
1822
1992
|
st.rerun()
|
|
1823
|
-
|
|
1824
|
-
st.session_state["new_batch_account_label"] = account_label
|
|
1825
|
-
st.session_state["new_batch_account_email"] = account_email
|
|
1826
|
-
st.session_state["new_batch_account_client_id"] = account_client_id
|
|
1827
|
-
st.session_state["new_batch_account_client_secret"] = account_client_secret
|
|
1828
|
-
st.session_state["new_batch_account_session_info"] = account_session_info
|
|
1829
|
-
st.rerun()
|
|
1993
|
+
|
|
1830
1994
|
if save_account:
|
|
1831
1995
|
if "@" not in account_email.strip():
|
|
1832
1996
|
st.error("Informe um e-mail Google válido.")
|
|
@@ -1840,40 +2004,47 @@ def render_settings():
|
|
|
1840
2004
|
delete_youtube_batch_token(existing, STORAGE)
|
|
1841
2005
|
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()})
|
|
1842
2006
|
update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
|
|
2007
|
+
ensure_credentials_document(STORAGE, existing, settings, channel_state)
|
|
1843
2008
|
settings["youtube_batch_accounts"] = batch_accounts
|
|
1844
2009
|
write_json("settings.json", settings)
|
|
1845
2010
|
st.success("Conta Google/YouTube guardada.")
|
|
1846
2011
|
st.rerun()
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
if
|
|
1850
|
-
st.error("Seleccione
|
|
2012
|
+
|
|
2013
|
+
if document_save:
|
|
2014
|
+
if document_upload is None:
|
|
2015
|
+
st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
|
|
1851
2016
|
else:
|
|
1852
2017
|
try:
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2018
|
+
merge_credentials_document(
|
|
2019
|
+
STORAGE,
|
|
2020
|
+
batch_account,
|
|
2021
|
+
document_upload.getvalue(),
|
|
2022
|
+
document_upload.name,
|
|
2023
|
+
session_info_override=str(batch_account.get("sessionInfo") or ""),
|
|
2024
|
+
channels=channel_state,
|
|
2025
|
+
)
|
|
2026
|
+
st.success("Documento incorporado e guardado nesta conta Google.")
|
|
1858
2027
|
st.rerun()
|
|
1859
2028
|
except ValueError as exc:
|
|
1860
2029
|
st.error(str(exc))
|
|
2030
|
+
|
|
2031
|
+
if youtube_accounts_missing_document:
|
|
2032
|
+
st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
|
|
2033
|
+
|
|
2034
|
+
st.divider()
|
|
2035
|
+
st.markdown("### Adicionar outra conta Gmail")
|
|
2036
|
+
st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
|
|
1861
2037
|
with st.form("add_batch_account_form"):
|
|
1862
|
-
st.
|
|
1863
|
-
st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
|
|
1864
|
-
add_cols = st.columns(4)
|
|
2038
|
+
add_cols = st.columns(2)
|
|
1865
2039
|
with add_cols[0]:
|
|
1866
2040
|
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
1867
|
-
with add_cols[1]:
|
|
1868
2041
|
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
1869
|
-
with add_cols[2]:
|
|
1870
2042
|
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
1871
|
-
with add_cols[
|
|
2043
|
+
with add_cols[1]:
|
|
1872
2044
|
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
st.
|
|
1876
|
-
add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
|
|
2045
|
+
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.")
|
|
2046
|
+
new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
|
|
2047
|
+
add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
|
|
1877
2048
|
if add_account:
|
|
1878
2049
|
document_error = ""
|
|
1879
2050
|
if "@" not in new_account_email.strip():
|
|
@@ -1882,25 +2053,27 @@ def render_settings():
|
|
|
1882
2053
|
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
1883
2054
|
elif new_account_document is not None:
|
|
1884
2055
|
try:
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2056
|
+
raw_document = new_account_document.getvalue()
|
|
2057
|
+
json.loads(raw_document.decode("utf-8-sig", errors="replace"))
|
|
2058
|
+
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
|
|
2059
|
+
document_error = f"O documento {new_account_document.name} deve ser JSON válido."
|
|
1888
2060
|
st.error(document_error)
|
|
1889
2061
|
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
1890
2062
|
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()}
|
|
1891
|
-
if new_account_document is not None:
|
|
1892
|
-
document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
|
|
1893
|
-
document["account_id"] = new_account["id"]
|
|
1894
|
-
document["email"] = new_account["email"]
|
|
1895
|
-
save_credentials_document(STORAGE, new_account, document)
|
|
1896
2063
|
batch_accounts.append(new_account)
|
|
1897
2064
|
settings["youtube_batch_accounts"] = batch_accounts
|
|
1898
2065
|
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
1899
2066
|
write_json("settings.json", settings)
|
|
2067
|
+
ensure_credentials_document(STORAGE, new_account, settings, channel_state)
|
|
1900
2068
|
if new_account_document is not None:
|
|
1901
|
-
|
|
2069
|
+
try:
|
|
2070
|
+
merge_credentials_document(STORAGE, new_account, new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip(), channels=channel_state)
|
|
2071
|
+
except ValueError as exc:
|
|
2072
|
+
st.error(str(exc))
|
|
2073
|
+
st.stop()
|
|
2074
|
+
st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
|
|
1902
2075
|
else:
|
|
1903
|
-
st.warning(f"Conta {new_account['email']} adicionada.
|
|
2076
|
+
st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
|
|
1904
2077
|
st.rerun()
|
|
1905
2078
|
|
|
1906
2079
|
with api_keys_tab:
|
package/hermes_ui/domain.py
CHANGED
|
@@ -29,6 +29,8 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
29
29
|
"url": url.strip(),
|
|
30
30
|
"handle": "",
|
|
31
31
|
"description": "",
|
|
32
|
+
"niche": "",
|
|
33
|
+
"reference_channels": [],
|
|
32
34
|
"thumbnail_url": "",
|
|
33
35
|
"subscriber_count": None,
|
|
34
36
|
"video_count": None,
|
|
@@ -184,6 +186,18 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
184
186
|
return created
|
|
185
187
|
|
|
186
188
|
|
|
189
|
+
def update_channel_video(video_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
190
|
+
"""Actualizar um vídeo remoto sincronizado ou um override local de vídeo do canal."""
|
|
191
|
+
videos = read_json("channel_videos.json", [])
|
|
192
|
+
for video in videos:
|
|
193
|
+
if video.get("id") == video_id:
|
|
194
|
+
video.update(updates)
|
|
195
|
+
video["updated_at"] = now()
|
|
196
|
+
write_json("channel_videos.json", videos)
|
|
197
|
+
return video
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
187
201
|
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
188
202
|
tasks = read_json("tasks.json", [])
|
|
189
203
|
for task in tasks:
|
package/hermes_ui/storage.py
CHANGED
|
@@ -16,8 +16,9 @@ NICHES_DATA = STORAGE / "data" / "niches"
|
|
|
16
16
|
SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
|
|
17
17
|
|
|
18
18
|
DEFAULTS: dict[str, Any] = {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
"channels.json": [],
|
|
20
|
+
"channel_videos.json": [],
|
|
21
|
+
"tasks.json": [],
|
|
21
22
|
"queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
|
|
22
23
|
"batches.json": [],
|
|
23
24
|
"uploads.json": [],
|
|
@@ -238,6 +238,80 @@ def _public_feed_data(channel_id: str, headers: dict[str, str]) -> dict[str, Any
|
|
|
238
238
|
return {}
|
|
239
239
|
|
|
240
240
|
|
|
241
|
+
def fetch_channel_videos_public(channel_ref: str | dict[str, Any], limit: int = 10) -> "IntegrationResult":
|
|
242
|
+
"""Fetch the latest public channel videos from YouTube's Atom feed without an API key."""
|
|
243
|
+
if isinstance(channel_ref, dict):
|
|
244
|
+
source = str(channel_ref.get("youtube_channel_id") or channel_ref.get("url") or "").strip()
|
|
245
|
+
else:
|
|
246
|
+
source = str(channel_ref or "").strip()
|
|
247
|
+
if not source:
|
|
248
|
+
return IntegrationResult(False, "Este canal ainda não tem ID ou URL pública do YouTube.", {"videos": []})
|
|
249
|
+
headers = {
|
|
250
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
|
251
|
+
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
252
|
+
"Accept": "application/atom+xml,application/xml,text/xml;q=0.9,*/*;q=0.8",
|
|
253
|
+
}
|
|
254
|
+
channel_id = _channel_id_from_source(source)
|
|
255
|
+
if not channel_id and source.startswith(("http://", "https://")):
|
|
256
|
+
for page_url in _public_page_candidates(source):
|
|
257
|
+
try:
|
|
258
|
+
response = requests.get(page_url, headers=headers, timeout=12, allow_redirects=True)
|
|
259
|
+
response.raise_for_status()
|
|
260
|
+
except requests.RequestException:
|
|
261
|
+
continue
|
|
262
|
+
document = response.text or ""
|
|
263
|
+
channel_id = _channel_id_from_document(document, str(getattr(response, "url", "") or page_url))
|
|
264
|
+
if channel_id:
|
|
265
|
+
break
|
|
266
|
+
if not channel_id:
|
|
267
|
+
return IntegrationResult(False, "Não foi possível resolver o ID público do canal para carregar os vídeos.", {"videos": []})
|
|
268
|
+
feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
|
269
|
+
try:
|
|
270
|
+
response = requests.get(feed_url, headers=headers, timeout=12)
|
|
271
|
+
response.raise_for_status()
|
|
272
|
+
root = ET.fromstring(response.text)
|
|
273
|
+
except (requests.RequestException, ET.ParseError, ValueError) as exc:
|
|
274
|
+
return IntegrationResult(False, f"Não foi possível carregar os vídeos públicos do canal: {exc}", {"channel_id": channel_id, "videos": []})
|
|
275
|
+
namespace = {
|
|
276
|
+
"yt": "http://www.youtube.com/xml/schemas/2015",
|
|
277
|
+
"atom": "http://www.w3.org/2005/Atom",
|
|
278
|
+
"media": "http://search.yahoo.com/mrss/",
|
|
279
|
+
}
|
|
280
|
+
videos: list[dict[str, Any]] = []
|
|
281
|
+
max_items = max(1, min(10, int(limit or 10)))
|
|
282
|
+
for entry in root.findall("atom:entry", namespace)[:max_items]:
|
|
283
|
+
video_id = entry.findtext("yt:videoId", default="", namespaces=namespace).strip()
|
|
284
|
+
title = entry.findtext("atom:title", default="", namespaces=namespace).strip()
|
|
285
|
+
published_at = entry.findtext("atom:published", default="", namespaces=namespace).strip()
|
|
286
|
+
updated_at = entry.findtext("atom:updated", default="", namespaces=namespace).strip()
|
|
287
|
+
link = ""
|
|
288
|
+
for candidate in entry.findall("atom:link", namespace):
|
|
289
|
+
if candidate.attrib.get("rel", "alternate") == "alternate":
|
|
290
|
+
link = candidate.attrib.get("href", "")
|
|
291
|
+
break
|
|
292
|
+
if not link and video_id:
|
|
293
|
+
link = f"https://www.youtube.com/watch?v={video_id}"
|
|
294
|
+
thumbnail = ""
|
|
295
|
+
media_group = entry.find("media:group", namespace)
|
|
296
|
+
if media_group is not None:
|
|
297
|
+
media_thumbnail = media_group.find("media:thumbnail", namespace)
|
|
298
|
+
if media_thumbnail is not None:
|
|
299
|
+
thumbnail = str(media_thumbnail.attrib.get("url", ""))
|
|
300
|
+
videos.append({
|
|
301
|
+
"id": f"youtube_{video_id}" if video_id else f"youtube_{len(videos)}",
|
|
302
|
+
"youtube_video_id": video_id,
|
|
303
|
+
"channel_id": channel_id,
|
|
304
|
+
"title": title or "Vídeo sem título",
|
|
305
|
+
"published_at": published_at,
|
|
306
|
+
"updated_at": updated_at,
|
|
307
|
+
"url": link,
|
|
308
|
+
"thumbnail_url": thumbnail,
|
|
309
|
+
"source": "youtube_public_rss",
|
|
310
|
+
"status": "publicado",
|
|
311
|
+
})
|
|
312
|
+
return IntegrationResult(True, f"{len(videos)} vídeo(s) público(s) carregado(s) sem API Key.", {"channel_id": channel_id, "videos": videos})
|
|
313
|
+
|
|
314
|
+
|
|
241
315
|
@dataclass
|
|
242
316
|
class IntegrationResult:
|
|
243
317
|
ok: bool
|
|
@@ -385,6 +459,9 @@ class YouTubeAdapter:
|
|
|
385
459
|
data["youtube_id"] = direct_id
|
|
386
460
|
return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. {last_error or 'Confirme o URL/handle ou use Cadastro manual.'}".strip(), data)
|
|
387
461
|
|
|
462
|
+
def fetch_channel_videos_public(self, channel_ref: str | dict[str, Any], limit: int = 10) -> IntegrationResult:
|
|
463
|
+
return fetch_channel_videos_public(channel_ref, limit=limit)
|
|
464
|
+
|
|
388
465
|
def fetch_channel(self, value: str) -> IntegrationResult:
|
|
389
466
|
ref = self.extract_channel_ref(value)
|
|
390
467
|
if not self.api_key:
|
|
@@ -32,6 +32,13 @@ def cookie_file_path(storage_root: Path, account: dict[str, Any]) -> Path:
|
|
|
32
32
|
return account_directory(storage_root, account) / "cookies.json"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _placeholder_to_empty(value: Any) -> str:
|
|
36
|
+
text = str(value or "").strip()
|
|
37
|
+
if text in {"...", "…", "<preencher>", "<valor>"}:
|
|
38
|
+
return ""
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
35
42
|
def _normalise_pairs(value: Any) -> dict[str, str]:
|
|
36
43
|
pairs: dict[str, str] = {}
|
|
37
44
|
if isinstance(value, dict):
|
|
@@ -40,13 +47,13 @@ def _normalise_pairs(value: Any) -> dict[str, str]:
|
|
|
40
47
|
for key, item in value.items():
|
|
41
48
|
key_text = str(key).strip()
|
|
42
49
|
if key_text in COOKIE_KEYS:
|
|
43
|
-
pairs[key_text] =
|
|
50
|
+
pairs[key_text] = _placeholder_to_empty(item)
|
|
44
51
|
return pairs
|
|
45
52
|
if isinstance(value, list):
|
|
46
53
|
for item in value:
|
|
47
54
|
if isinstance(item, dict):
|
|
48
55
|
name = str(item.get("name") or item.get("key") or "").strip()
|
|
49
|
-
content =
|
|
56
|
+
content = _placeholder_to_empty(item.get("value"))
|
|
50
57
|
if name in COOKIE_KEYS:
|
|
51
58
|
pairs[name] = content
|
|
52
59
|
return pairs
|
|
@@ -130,15 +137,15 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
|
|
|
130
137
|
for key, value in delegated_raw.items():
|
|
131
138
|
if isinstance(value, dict):
|
|
132
139
|
value = value.get("DELEGATED_SESSION_ID") or value.get("delegated_session_id") or ""
|
|
133
|
-
value =
|
|
140
|
+
value = _placeholder_to_empty(value)
|
|
134
141
|
if value:
|
|
135
142
|
delegated[str(key)] = value
|
|
136
143
|
return {
|
|
137
144
|
"account_id": str(raw.get("account_id") or account.get("id") or "").strip(),
|
|
138
145
|
"email": str(raw.get("email") or account.get("email") or "").strip(),
|
|
139
|
-
"sessionInfo":
|
|
146
|
+
"sessionInfo": _placeholder_to_empty(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)),
|
|
140
147
|
"cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
|
|
141
|
-
"INNERTUBE_API_KEY":
|
|
148
|
+
"INNERTUBE_API_KEY": _placeholder_to_empty(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or ""),
|
|
142
149
|
"chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
|
|
143
150
|
"delegated_session_ids": delegated,
|
|
144
151
|
}
|
|
@@ -257,6 +264,49 @@ def ensure_credentials_document(storage_root: Path, account: dict[str, Any], set
|
|
|
257
264
|
return credentials_document_path(storage_root, account)
|
|
258
265
|
|
|
259
266
|
|
|
267
|
+
def merge_credentials_document(
|
|
268
|
+
storage_root: Path,
|
|
269
|
+
account: dict[str, Any],
|
|
270
|
+
content: bytes,
|
|
271
|
+
filename: str = DIRECT_DOCUMENT_NAME,
|
|
272
|
+
*,
|
|
273
|
+
session_info_override: str = "",
|
|
274
|
+
channels: list[dict[str, Any]] | None = None,
|
|
275
|
+
) -> Path:
|
|
276
|
+
"""Merge a complete or partial credentials JSON into the account document.
|
|
277
|
+
|
|
278
|
+
The upload may contain only cookies or the full Frontend API document. Existing
|
|
279
|
+
non-empty values are preserved, so uploading cookies never discards sessionInfo,
|
|
280
|
+
INNERTUBE_API_KEY, chunk_size or delegated channel IDs.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
raw = json.loads(content.decode("utf-8-sig", errors="replace"))
|
|
284
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
285
|
+
raise ValueError(f"O documento de credenciais {filename} deve ser JSON válido.") from exc
|
|
286
|
+
if not isinstance(raw, dict):
|
|
287
|
+
raise ValueError(f"O documento de credenciais {filename} deve conter um objecto JSON.")
|
|
288
|
+
|
|
289
|
+
current = load_credentials_document(storage_root, account, channels=channels, create=True)
|
|
290
|
+
incoming = _normalise_document(raw, {**account, "sessionInfo": session_info_override})
|
|
291
|
+
merged = _normalise_document(current, account)
|
|
292
|
+
for key in COOKIE_KEYS:
|
|
293
|
+
if incoming["cookies"].get(key):
|
|
294
|
+
merged["cookies"][key] = incoming["cookies"][key]
|
|
295
|
+
incoming_session = _placeholder_to_empty(session_info_override) or incoming.get("sessionInfo", "")
|
|
296
|
+
if incoming_session:
|
|
297
|
+
merged["sessionInfo"] = incoming_session
|
|
298
|
+
if incoming.get("INNERTUBE_API_KEY"):
|
|
299
|
+
merged["INNERTUBE_API_KEY"] = incoming["INNERTUBE_API_KEY"]
|
|
300
|
+
if "chunk_size" in raw:
|
|
301
|
+
merged["chunk_size"] = incoming["chunk_size"]
|
|
302
|
+
for key, value in incoming.get("delegated_session_ids", {}).items():
|
|
303
|
+
if value:
|
|
304
|
+
merged["delegated_session_ids"][str(key)] = value
|
|
305
|
+
merged["account_id"] = str(account.get("id") or merged.get("account_id") or "")
|
|
306
|
+
merged["email"] = str(account.get("email") or merged.get("email") or "")
|
|
307
|
+
return save_credentials_document(storage_root, account, merged)
|
|
308
|
+
|
|
309
|
+
|
|
260
310
|
def delegated_session_id(document: dict[str, Any], channel: dict[str, Any]) -> str:
|
|
261
311
|
mapping = document.get("delegated_session_ids", {}) if isinstance(document, dict) else {}
|
|
262
312
|
if not isinstance(mapping, dict):
|
package/package.json
CHANGED