@danhachuel/thunderbolt 0.2.54 → 0.2.56
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 +12 -6
- package/README.md +18 -10
- package/app/main.py +269 -6
- package/hermes_ui/script_documents.py +82 -0
- package/hermes_ui/script_generation.py +91 -0
- package/hermes_ui/storage.py +10 -2
- package/integrations/postiz.py +173 -0
- package/integrations/upload_routing.py +177 -0
- 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.56
|
|
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.56 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.56 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.
|
|
@@ -428,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
428
428
|
|
|
429
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.
|
|
430
430
|
|
|
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.
|
|
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.56 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
432
432
|
|
|
433
433
|
### Niche Finder Apify
|
|
434
434
|
|
|
@@ -458,15 +458,21 @@ Depois do tópico, clique em **Gerar títulos e thumbnails com IA**. O Thunderbo
|
|
|
458
458
|
|
|
459
459
|
No modo **Lote geral**, todos os canais cadastrados são incluídos automaticamente. Não existe selector **Canais incluídos**. O Thunderbolt cria exactamente uma task por canal e cada task recebe briefing, título, thumbnail, Blueprint, voz e contexto próprios. O mesmo tópico não é replicado entre canais.
|
|
460
460
|
|
|
461
|
-
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação
|
|
461
|
+
## Pipeline: Criação de Vídeos, Criação de Músicas, Roteiros e Automação
|
|
462
462
|
|
|
463
463
|
O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`, o selector `Estilo IA` apresenta os 12 estilos disponíveis. Ao seleccionar `Apenas Música`, a UI obriga a escolher uma música existente, carregar um ficheiro ou solicitar uma música a um endpoint Suno configurado. O vídeo recebe `background_mode=none`, sem fundo Pexels/Pixabay ou IA.
|
|
464
464
|
|
|
465
|
+
A aba **Roteiros**, colocada entre **Criação de Músicas** e **Upload**, permite seleccionar um canal opcional, um Blueprint, **Roteiro de vídeo** ou **Letra de música**, idioma, tema e estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado e devolve um rascunho Markdown editável; o utilizador deve rever e clicar em **Guardar documento no storage**. Os ficheiros são guardados em `storage/scripts/` e o índice em `storage/state/scripts.json`; o caminho absoluto aparece no topo da página. Na subaba **Vídeos** de **Criação de Vídeos**, a aplicação mostra a frase `Os vídeos são guardados em <storage>/videos`, que identifica a pasta local dos vídeos.
|
|
466
|
+
|
|
465
467
|
A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, guarda **Automação ON** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
|
|
466
468
|
|
|
467
469
|
A área **Teste de vozes**, dentro de **Configurações > Configurações Técnicas**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
|
|
468
470
|
|
|
469
|
-
## Upload directo
|
|
471
|
+
## Upload, Postiz e Upload directo
|
|
472
|
+
|
|
473
|
+
A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações Postiz, seleccionar o canal ligado e enviar vídeos MP4 através da Public API. Configure **Activar Postiz como fallback final**, **Postiz API key**, **Postiz Public API Base URL**, **Postiz MCP URL** e, opcionalmente, o ID da integração padrão em **Configurações Técnicas > API Keys**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
|
|
474
|
+
|
|
475
|
+
O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. A API Oficial regista no storage local até cinco envios bem-sucedidos por dia por conta Gmail. Quando a quota é atingida ou o método falha, o Thunderbolt valida o `credentials.json` e tenta o Upload directo. Postiz só é tentado como último recurso e apenas quando está activo, com API key e integração válida.
|
|
470
476
|
|
|
471
477
|
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`.
|
|
472
478
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
11
11
|
| Área | Incluído |
|
|
12
12
|
|---|---|
|
|
13
13
|
| Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
|
|
14
|
-
| Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas e Upload |
|
|
14
|
+
| Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas, Roteiros e Upload |
|
|
15
15
|
| Blueprints Youtube | 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
17
|
| Canais Youtube | 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 |
|
|
@@ -20,9 +20,9 @@ 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, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
|
|
23
|
-
| Upload | YouTube via `youtube-automation-agent` adaptado internamente,
|
|
23
|
+
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz, upload de MP4 para Postiz via API key/MCP configurável, 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, 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 |
|
|
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, Postiz API key/Base URL/MCP URL/integração padrão, 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
|
|
@@ -38,9 +38,9 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
|
|
|
38
38
|
|
|
39
39
|
Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
|
|
40
40
|
|
|
41
|
-
## Navegação da UI 0.2.
|
|
41
|
+
## Navegação da UI 0.2.56
|
|
42
42
|
|
|
43
|
-
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
43
|
+
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
44
44
|
|
|
45
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
46
|
|
|
@@ -161,9 +161,9 @@ thunderbolt
|
|
|
161
161
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
162
162
|
|
|
163
163
|
```powershell
|
|
164
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
165
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
166
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
164
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install
|
|
165
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56 doctor
|
|
166
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.56
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -187,7 +187,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
|
|
|
187
187
|
|
|
188
188
|
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.
|
|
189
189
|
|
|
190
|
-
**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.
|
|
190
|
+
**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.56 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
191
191
|
|
|
192
192
|
## Editor Python baseado no PYEdit
|
|
193
193
|
|
|
@@ -254,7 +254,7 @@ A aba **Canais Youtube** está dividida em dois fluxos independentes. Em **Impor
|
|
|
254
254
|
|
|
255
255
|
Em **Cadastro manual**, nenhum pedido ao YouTube é feito e não existe qualquer dependência de API Key. O utilizador pode preencher nome, URL, handle, descrição, métricas, thumbnail, idioma, estilo, Blueprint padrão, voz padrão, `DELEGATED_SESSION_ID` e configuração de Automação. A importação pública nunca grava automaticamente: os dados aparecem num formulário de revisão antes de guardar. A resolução pública aceita URLs `/channel/UC...`, handles e subpáginas; quando existe um ID mas o HTML não traz todos os dados, tenta o feed RSS público. Se o YouTube responder que o canal não existe ou não fornecer metadados, a UI mostra uma mensagem clara e não mantém o formulário de uma pesquisa anterior. Cada cartão de canal tem **Activo** e, logo abaixo, **Apagar canal**, com confirmação; tarefas e artefactos não são apagados.
|
|
256
256
|
|
|
257
|
-
## Upload YouTube
|
|
257
|
+
## Upload YouTube e fallback Postiz
|
|
258
258
|
|
|
259
259
|
O Upload usa como caminho **principal** a lógica do `PublishingSchedulingAgent` do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent), adaptada para Python e executada dentro do processo Streamlit do Thunderbolt. Não é necessário instalar ou iniciar um segundo servidor Node. O fluxo valida o MP4 real, constrói `snippet/status`, faz upload resumível e tenta enviar thumbnail e legendas.
|
|
260
260
|
|
|
@@ -262,12 +262,20 @@ Na aba **Upload**, configure primeiro apenas o **YouTube OAuth Client ID** e o *
|
|
|
262
262
|
|
|
263
263
|
Use **Autorizar fallback OAuth** apenas se precisar de uma autorização separada para o caminho de redundância. Os resultados guardam no histórico local qual mecanismo foi utilizado e as tentativas realizadas, sem guardar segredos.
|
|
264
264
|
|
|
265
|
+
A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações ligadas através de `GET /integrations`, enviar um MP4 para `POST /upload` e criar o post YouTube em `POST /posts`. A API key é enviada como valor bruto do cabeçalho `Authorization`; a base cloud é `https://api.postiz.com/public/v1` e pode ser substituída por uma instalação self-hosted. O modo MCP guarda também a URL Streamable HTTP configurável para uso futuro/alternativo.
|
|
266
|
+
|
|
267
|
+
O botão principal de YouTube usa a ordem **API Oficial → Upload directo → Postiz**. A API Oficial tem um contador local de cinco envios bem-sucedidos por dia por conta Gmail; quando a quota é atingida, ou o método falha, o Thunderbolt tenta o documento de sessão do Upload directo. Só depois de esse caminho falhar tenta o Postiz, desde que esteja activo, tenha API key e tenha um ID de integração configurado.
|
|
268
|
+
|
|
265
269
|
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Ela usa cookies, `sessionInfo`, `INNERTUBE_API_KEY` e `DELEGATED_SESSION_ID` fornecidos manualmente pelo utilizador, cria o vídeo através do endpoint interno e envia o ficheiro em chunks de 256 KiB. Este caminho é experimental, não extrai cookies automaticamente e fica separado do agente YouTube principal.
|
|
266
270
|
|
|
267
271
|
## Pipeline: Criação de Vídeos, Criação de Músicas e Automação Youtube
|
|
268
272
|
|
|
269
273
|
Em **Criação de Vídeos**, o estilo visual `Pexels/Pixabay` é o modo de materiais, `full_ia` abre o selector **Estilo IA** com os 12 estilos solicitados e **Apenas Música** exige um áudio local, um upload musical ou um pedido ao endpoint Suno configurado. **Criação de Músicas** reutiliza o mesmo conteúdo e formulário com título próprio. Neste último modo, a task fica com `background_mode=none`: não são gerados fundos Pexels/Pixabay nem fundos IA.
|
|
270
274
|
|
|
275
|
+
A subaba **Roteiros**, entre **Criação de Músicas** e **Upload**, permite escolher um canal opcional, um Blueprint, o tipo **Roteiro de vídeo** ou **Letra de música**, idioma, briefing e notas de estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado, devolve um rascunho Markdown editável e só guarda o documento quando o utilizador confirmar. Os ficheiros ficam em `storage/scripts/` e o índice do histórico em `storage/state/scripts.json`; a própria página mostra o caminho absoluto correspondente ao storage local.
|
|
276
|
+
|
|
277
|
+
Na subaba **Vídeos** de **Criação de Vídeos**, a frase `Os vídeos são guardados em <storage>/videos` identifica a pasta real onde os artefactos de vídeo devem ser procurados.
|
|
278
|
+
|
|
271
279
|
O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais Youtube**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação Youtube**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação Youtube** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados; por definição, esta entrega não executa workers de fundo.
|
|
272
280
|
|
|
273
281
|
## Teste de vozes
|
package/app/main.py
CHANGED
|
@@ -34,9 +34,13 @@ from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips,
|
|
|
34
34
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
35
35
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
36
36
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
37
|
+
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
38
|
+
from hermes_ui.script_generation import generate_script_document
|
|
37
39
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
38
40
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
39
41
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
42
|
+
from integrations.postiz import PostizAdapter
|
|
43
|
+
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
40
44
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
41
45
|
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
|
|
42
46
|
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
|
|
@@ -1239,6 +1243,150 @@ def render_music_creation():
|
|
|
1239
1243
|
render_new_video(page_title="Criação de Músicas")
|
|
1240
1244
|
|
|
1241
1245
|
|
|
1246
|
+
def render_scripts():
|
|
1247
|
+
st.title("Roteiros")
|
|
1248
|
+
st.caption("Produza e guarde roteiros de vídeos ou letras de músicas a partir dos Blueprints do Thunderbolt.")
|
|
1249
|
+
script_dir = script_storage_path()
|
|
1250
|
+
st.info(f"**Ficheiros guardados em:** `{script_dir}` · o conteúdo fica no storage local do Thunderbolt e não é enviado automaticamente para plataformas.")
|
|
1251
|
+
|
|
1252
|
+
create_tab, history_tab = st.tabs(["Novo roteiro/letra", "Histórico guardado"])
|
|
1253
|
+
with create_tab:
|
|
1254
|
+
all_channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict)]
|
|
1255
|
+
active_channels = [channel for channel in all_channels if channel.get("active", True)]
|
|
1256
|
+
channel_options: list[dict[str, Any] | None] = [None] + active_channels
|
|
1257
|
+
selected_channel = st.selectbox(
|
|
1258
|
+
"Canal (opcional)",
|
|
1259
|
+
channel_options,
|
|
1260
|
+
format_func=lambda channel: "Documento independente" if channel is None else str(channel.get("name") or "Canal sem nome"),
|
|
1261
|
+
key="script_channel",
|
|
1262
|
+
)
|
|
1263
|
+
blueprint_options = blueprint_catalog()
|
|
1264
|
+
blueprint_ids = [identifier for identifier, _label in blueprint_options]
|
|
1265
|
+
blueprint_labels = {identifier: label for identifier, label in blueprint_options}
|
|
1266
|
+
selected_blueprint_id = st.selectbox(
|
|
1267
|
+
"Blueprint",
|
|
1268
|
+
blueprint_ids,
|
|
1269
|
+
format_func=lambda identifier: blueprint_labels.get(identifier, "Sem Blueprint padrão"),
|
|
1270
|
+
key="script_blueprint",
|
|
1271
|
+
)
|
|
1272
|
+
selected_blueprint: dict[str, Any] = {}
|
|
1273
|
+
if selected_blueprint_id:
|
|
1274
|
+
for path in list_blueprint_files():
|
|
1275
|
+
try:
|
|
1276
|
+
blueprint = load_blueprint_file(path)
|
|
1277
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
1278
|
+
continue
|
|
1279
|
+
identifiers = {str(blueprint.get("id") or ""), path.stem, str(blueprint.get("name") or "")}
|
|
1280
|
+
if selected_blueprint_id in identifiers:
|
|
1281
|
+
selected_blueprint = dict(blueprint)
|
|
1282
|
+
selected_blueprint.setdefault("id", selected_blueprint_id)
|
|
1283
|
+
selected_blueprint.setdefault("name", blueprint_labels.get(selected_blueprint_id, selected_blueprint_id))
|
|
1284
|
+
break
|
|
1285
|
+
if selected_blueprint:
|
|
1286
|
+
st.caption(f"Blueprint aplicado ao contexto: **{selected_blueprint.get('name', selected_blueprint_id)}**")
|
|
1287
|
+
else:
|
|
1288
|
+
st.warning("Sem Blueprint seleccionado: o documento pode ser criado, mas não terá contexto editorial de Blueprint.")
|
|
1289
|
+
|
|
1290
|
+
document_type = st.radio("Tipo de documento", ["Roteiro de vídeo", "Letra de música"], horizontal=True, key="script_document_type")
|
|
1291
|
+
title = st.text_input("Título", key="script_title", placeholder="Ex.: A verdade esquecida sobre…")
|
|
1292
|
+
brief = st.text_area(
|
|
1293
|
+
"Tema ou briefing",
|
|
1294
|
+
key="script_brief",
|
|
1295
|
+
height=120,
|
|
1296
|
+
placeholder="Descreva o tema, a mensagem, o conflito ou a ideia musical que o Blueprint deve orientar.",
|
|
1297
|
+
)
|
|
1298
|
+
language_options = VIDEO_LANGUAGE_OPTIONS
|
|
1299
|
+
language = st.selectbox("Idioma", language_options, key="script_language")
|
|
1300
|
+
structure_notes = st.text_area(
|
|
1301
|
+
"Estrutura e notas opcionais",
|
|
1302
|
+
key="script_structure_notes",
|
|
1303
|
+
height=90,
|
|
1304
|
+
placeholder="Ex.: 6 cenas, narração documental, refrão repetível, atmosfera sombria…",
|
|
1305
|
+
)
|
|
1306
|
+
generate_col, clear_col = st.columns([1.4, 1])
|
|
1307
|
+
with generate_col:
|
|
1308
|
+
generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", use_container_width=True, key="generate_script_document")
|
|
1309
|
+
with clear_col:
|
|
1310
|
+
clear_clicked = st.button("Limpar rascunho", use_container_width=True, key="clear_script_document")
|
|
1311
|
+
if clear_clicked:
|
|
1312
|
+
for key in ("script_draft", "script_draft_title", "script_draft_content", "script_draft_summary"):
|
|
1313
|
+
st.session_state.pop(key, None)
|
|
1314
|
+
st.rerun()
|
|
1315
|
+
if generate_clicked:
|
|
1316
|
+
try:
|
|
1317
|
+
with st.spinner("A gerar o documento com o provider LLM configurado…"):
|
|
1318
|
+
generated = generate_script_document(
|
|
1319
|
+
read_json("settings.json", {}),
|
|
1320
|
+
document_type=document_type,
|
|
1321
|
+
title=title,
|
|
1322
|
+
brief=brief,
|
|
1323
|
+
language=language,
|
|
1324
|
+
channel=selected_channel or {},
|
|
1325
|
+
blueprint=selected_blueprint,
|
|
1326
|
+
structure_notes=structure_notes,
|
|
1327
|
+
)
|
|
1328
|
+
st.session_state["script_draft"] = generated
|
|
1329
|
+
st.session_state["script_draft_title"] = generated["title"]
|
|
1330
|
+
st.session_state["script_draft_summary"] = generated.get("summary", "")
|
|
1331
|
+
st.session_state["script_draft_content"] = generated["content"]
|
|
1332
|
+
st.success("Rascunho gerado. Reveja e edite o texto antes de guardar.")
|
|
1333
|
+
except CreativeGenerationError as exc:
|
|
1334
|
+
st.error(str(exc))
|
|
1335
|
+
|
|
1336
|
+
draft = st.session_state.get("script_draft")
|
|
1337
|
+
if draft:
|
|
1338
|
+
st.divider()
|
|
1339
|
+
st.subheader("Rascunho editável")
|
|
1340
|
+
if "script_draft_title" not in st.session_state:
|
|
1341
|
+
st.session_state["script_draft_title"] = str(draft.get("title") or title or "Documento")
|
|
1342
|
+
if "script_draft_summary" not in st.session_state:
|
|
1343
|
+
st.session_state["script_draft_summary"] = str(draft.get("summary") or "")
|
|
1344
|
+
if "script_draft_content" not in st.session_state:
|
|
1345
|
+
st.session_state["script_draft_content"] = str(draft.get("content") or "")
|
|
1346
|
+
draft_title = st.text_input("Título do rascunho", key="script_draft_title")
|
|
1347
|
+
draft_summary = st.text_input("Resumo", key="script_draft_summary")
|
|
1348
|
+
draft_content = st.text_area("Conteúdo guardado", height=460, key="script_draft_content")
|
|
1349
|
+
if st.button("Guardar documento no storage", type="primary", use_container_width=True, key="save_script_document"):
|
|
1350
|
+
try:
|
|
1351
|
+
record = save_script_document(
|
|
1352
|
+
{
|
|
1353
|
+
**draft,
|
|
1354
|
+
"title": draft_title,
|
|
1355
|
+
"summary": draft_summary,
|
|
1356
|
+
"content": draft_content,
|
|
1357
|
+
"document_type": "video_script" if document_type == "Roteiro de vídeo" else "music_lyrics",
|
|
1358
|
+
"language": language,
|
|
1359
|
+
"channel_id": str((selected_channel or {}).get("id") or ""),
|
|
1360
|
+
"channel_name": str((selected_channel or {}).get("name") or "Documento independente"),
|
|
1361
|
+
"blueprint_id": str(selected_blueprint.get("id") or selected_blueprint_id or ""),
|
|
1362
|
+
"blueprint_name": str(selected_blueprint.get("name") or blueprint_labels.get(selected_blueprint_id, "SEM BLUEPRINT CONFIGURADO")),
|
|
1363
|
+
}
|
|
1364
|
+
)
|
|
1365
|
+
st.success(f"Documento guardado em `{record['path']}`.")
|
|
1366
|
+
except (OSError, ValueError) as exc:
|
|
1367
|
+
st.error(f"Não foi possível guardar o documento: {exc}")
|
|
1368
|
+
else:
|
|
1369
|
+
st.caption("Gere um rascunho com IA para o editar aqui, ou seleccione um Blueprint e preencha o briefing para começar.")
|
|
1370
|
+
|
|
1371
|
+
with history_tab:
|
|
1372
|
+
st.caption(f"Histórico persistente: `{script_dir}` · índice em `{STORAGE / 'state' / 'scripts.json'}`")
|
|
1373
|
+
records = list_script_documents()
|
|
1374
|
+
if not records:
|
|
1375
|
+
st.info("Ainda não existem roteiros ou letras guardados.")
|
|
1376
|
+
for record in records[:50]:
|
|
1377
|
+
label = f"{record.get('title', 'Documento')} · {record.get('document_type', 'documento')}"
|
|
1378
|
+
with st.expander(label, expanded=False):
|
|
1379
|
+
st.caption(f"{record.get('created_at', '—')} · {record.get('channel_name', 'Documento independente')} · Blueprint: {record.get('blueprint_name', '—')}")
|
|
1380
|
+
stored_path = Path(str(record.get("path") or ""))
|
|
1381
|
+
content = read_script_document(record)
|
|
1382
|
+
if content:
|
|
1383
|
+
st.text_area("Conteúdo", value=content, height=220, key=f"stored_script_{record.get('id')}")
|
|
1384
|
+
if stored_path.is_file():
|
|
1385
|
+
st.download_button("Descarregar Markdown", data=stored_path.read_bytes(), file_name=stored_path.name, mime="text/markdown", key=f"download_script_{record.get('id')}")
|
|
1386
|
+
else:
|
|
1387
|
+
st.warning("O ficheiro deste registo já não está disponível no storage.")
|
|
1388
|
+
|
|
1389
|
+
|
|
1242
1390
|
@st.cache_data(show_spinner=False)
|
|
1243
1391
|
def _cached_niche_download():
|
|
1244
1392
|
"""Download or reuse the dataset only after the user submits the form."""
|
|
@@ -1809,6 +1957,7 @@ def render_python_editor():
|
|
|
1809
1957
|
def render_videos():
|
|
1810
1958
|
st.subheader("Vídeos e backlog")
|
|
1811
1959
|
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
1960
|
+
st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
|
|
1812
1961
|
tasks = read_json("tasks.json", [])
|
|
1813
1962
|
if not tasks:
|
|
1814
1963
|
st.info("Nenhum vídeo criado.")
|
|
@@ -1989,17 +2138,99 @@ def render_upload_direct():
|
|
|
1989
2138
|
|
|
1990
2139
|
def render_upload():
|
|
1991
2140
|
st.title("Upload")
|
|
1992
|
-
upload_tab, direct_tab = st.tabs(["Upload convencional", "Upload directo"])
|
|
2141
|
+
upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
|
|
1993
2142
|
with direct_tab:
|
|
1994
2143
|
render_upload_direct()
|
|
2144
|
+
with postiz_tab:
|
|
2145
|
+
render_upload_postiz()
|
|
1995
2146
|
with upload_tab:
|
|
1996
2147
|
render_upload_conventional()
|
|
1997
2148
|
|
|
1998
2149
|
|
|
2150
|
+
def render_upload_postiz():
|
|
2151
|
+
st.subheader("Upload para Postiz")
|
|
2152
|
+
st.caption("O Thunderbolt envia primeiro o MP4 para o Postiz e cria um post na integração seleccionada. A API key e o servidor são configurados em Configurações Técnicas.")
|
|
2153
|
+
settings = read_json("settings.json", {})
|
|
2154
|
+
postiz = PostizAdapter(settings)
|
|
2155
|
+
if not settings.get("postiz_enabled"):
|
|
2156
|
+
st.warning("Postiz está desactivado. Active-o em Configurações Técnicas > API Keys e guarde a API key antes de enviar.")
|
|
2157
|
+
if not postiz.api_key:
|
|
2158
|
+
st.info("Nenhuma API key Postiz configurada.")
|
|
2159
|
+
return
|
|
2160
|
+
|
|
2161
|
+
integration_catalog = st.session_state.get("postiz_integrations", [])
|
|
2162
|
+
integration_ids = [str(item.get("id")) for item in integration_catalog if isinstance(item, dict) and item.get("id")]
|
|
2163
|
+
integration_labels = {
|
|
2164
|
+
str(item.get("id")): " — ".join(str(value) for value in [item.get("name") or item.get("provider") or item.get("type") or "Integração", item.get("username") or item.get("profile") or item.get("identifier") or ""] if value)
|
|
2165
|
+
for item in integration_catalog if isinstance(item, dict) and item.get("id")
|
|
2166
|
+
}
|
|
2167
|
+
integration_default = str(settings.get("postiz_integration_id", "") or "")
|
|
2168
|
+
if st.button("Carregar integrações Postiz", key="postiz_load_integrations"):
|
|
2169
|
+
result = postiz.list_integrations()
|
|
2170
|
+
if result.ok:
|
|
2171
|
+
st.session_state["postiz_integrations"] = result.data.get("integrations", [])
|
|
2172
|
+
st.success(result.message)
|
|
2173
|
+
st.rerun()
|
|
2174
|
+
st.error(result.message)
|
|
2175
|
+
if integration_ids:
|
|
2176
|
+
selected_integration = st.selectbox(
|
|
2177
|
+
"Canal/integração Postiz",
|
|
2178
|
+
integration_ids,
|
|
2179
|
+
index=integration_ids.index(integration_default) if integration_default in integration_ids else 0,
|
|
2180
|
+
format_func=lambda value: integration_labels.get(value, value),
|
|
2181
|
+
key="postiz_upload_integration",
|
|
2182
|
+
)
|
|
2183
|
+
st.caption("Para alterar a integração padrão, guarde o ID seleccionado em Configurações Técnicas.")
|
|
2184
|
+
else:
|
|
2185
|
+
selected_integration = st.text_input("ID da integração Postiz", value=integration_default, key="postiz_upload_integration_manual", help="Carregue as integrações para seleccionar uma conta ou introduza o ID devolvido pela API do Postiz.").strip()
|
|
2186
|
+
st.info("Carregue as integrações para descobrir os canais Postiz ligados à sua conta.")
|
|
2187
|
+
|
|
2188
|
+
tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
|
|
2189
|
+
if not tasks:
|
|
2190
|
+
st.info("Não há vídeos prontos para enviar para o Postiz.")
|
|
2191
|
+
return
|
|
2192
|
+
for task in tasks:
|
|
2193
|
+
artifacts = task.get("artifacts", {}) or {}
|
|
2194
|
+
video_path = artifacts.get("video", "")
|
|
2195
|
+
thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
|
|
2196
|
+
with st.container(border=True):
|
|
2197
|
+
st.write(f"**{task.get('topic', 'Vídeo Thunderbolt')}** — {task.get('channel_name', 'Canal')}")
|
|
2198
|
+
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
2199
|
+
title = st.text_input("Título Postiz", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"postiz_title_{task['id']}")
|
|
2200
|
+
description = st.text_area("Descrição Postiz", value=task.get("description", ""), key=f"postiz_description_{task['id']}", height=90)
|
|
2201
|
+
visibility = st.selectbox("Visibilidade YouTube no Postiz", ["private", "unlisted", "public"], key=f"postiz_visibility_{task['id']}")
|
|
2202
|
+
if st.button("Enviar vídeo para Postiz", type="primary", key=f"postiz_upload_{task['id']}"):
|
|
2203
|
+
result = postiz.publish_video(
|
|
2204
|
+
video_path,
|
|
2205
|
+
integration_id=selected_integration,
|
|
2206
|
+
title=title,
|
|
2207
|
+
description=description,
|
|
2208
|
+
visibility=visibility,
|
|
2209
|
+
tags=task.get("tags", []) if isinstance(task.get("tags", []), list) else [tag.strip() for tag in str(task.get("tags", "")).split(",") if tag.strip()],
|
|
2210
|
+
thumbnail_path=thumbnail_path,
|
|
2211
|
+
)
|
|
2212
|
+
record = {
|
|
2213
|
+
"task_id": task.get("id"),
|
|
2214
|
+
"destination": "Postiz",
|
|
2215
|
+
"status": "published" if result.ok else "failed",
|
|
2216
|
+
"message": result.message,
|
|
2217
|
+
"data": result.data,
|
|
2218
|
+
"created_at": now(),
|
|
2219
|
+
}
|
|
2220
|
+
uploads = read_json("uploads.json", [])
|
|
2221
|
+
uploads.append(record)
|
|
2222
|
+
write_json("uploads.json", uploads)
|
|
2223
|
+
(st.success if result.ok else st.error)(result.message)
|
|
2224
|
+
|
|
2225
|
+
|
|
1999
2226
|
def render_upload_conventional():
|
|
2000
2227
|
st.title("Upload")
|
|
2001
2228
|
settings = read_json("settings.json", {})
|
|
2002
2229
|
youtube = YouTubeAdapter(settings=settings)
|
|
2230
|
+
channels = read_json("channels.json", [])
|
|
2231
|
+
channel_map = {str(channel.get("id")): channel for channel in channels if channel.get("id")}
|
|
2232
|
+
direct_accounts = {str(account.get("id")): account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")}
|
|
2233
|
+
postiz = PostizAdapter(settings)
|
|
2003
2234
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
2004
2235
|
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
|
|
2005
2236
|
|
|
@@ -2009,9 +2240,14 @@ def render_upload_conventional():
|
|
|
2009
2240
|
st.info("Facebook Pages está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
2010
2241
|
|
|
2011
2242
|
if "YouTube" in destination:
|
|
2012
|
-
st.markdown("**YouTube —
|
|
2013
|
-
st.caption("
|
|
2243
|
+
st.markdown("**YouTube — fluxo recomendado de envio**")
|
|
2244
|
+
st.caption("Ordem automática: 1. API Oficial — até 5 envios bem-sucedidos por dia e por conta Gmail; 2. Upload directo — sessão interna YouTube; 3. Postiz — fallback final configurável.")
|
|
2014
2245
|
status = youtube.upload_status()
|
|
2246
|
+
if settings.get("postiz_enabled"):
|
|
2247
|
+
postiz_status = postiz.status()
|
|
2248
|
+
(st.success if postiz_status.ok else st.warning)(postiz_status.message)
|
|
2249
|
+
else:
|
|
2250
|
+
st.caption("Postiz está desactivado; active-o em Configurações Técnicas para o usar como fallback final.")
|
|
2015
2251
|
status_cols = st.columns(2)
|
|
2016
2252
|
with status_cols[0]:
|
|
2017
2253
|
(st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
|
|
@@ -2041,6 +2277,8 @@ def render_upload_conventional():
|
|
|
2041
2277
|
thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
|
|
2042
2278
|
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
2043
2279
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
2280
|
+
channel = channel_map.get(str(task.get("channel_id")), {})
|
|
2281
|
+
account = direct_accounts.get(str(channel.get("google_account_id", "")))
|
|
2044
2282
|
if "YouTube" in destination:
|
|
2045
2283
|
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
2046
2284
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
|
|
@@ -2052,10 +2290,16 @@ def render_upload_conventional():
|
|
|
2052
2290
|
category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
|
|
2053
2291
|
with yt_cols[2]:
|
|
2054
2292
|
language = st.text_input("Idioma", value="pt-BR", key=f"yt_language_{task['id']}")
|
|
2055
|
-
|
|
2293
|
+
quota_count = official_upload_count(channel, account)
|
|
2294
|
+
st.caption(f"API Oficial hoje: {quota_count}/{OFFICIAL_DAILY_LIMIT} envios nesta conta Gmail.")
|
|
2295
|
+
if st.button("Enviar pelo fluxo recomendado", type="primary", key=f"upload_youtube_{task['id']}"):
|
|
2056
2296
|
tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]
|
|
2057
|
-
result =
|
|
2058
|
-
|
|
2297
|
+
result = upload_with_default_route(
|
|
2298
|
+
settings,
|
|
2299
|
+
storage_root=STORAGE,
|
|
2300
|
+
channel=channel,
|
|
2301
|
+
account=account,
|
|
2302
|
+
video_path=video_path,
|
|
2059
2303
|
title=title,
|
|
2060
2304
|
description=description,
|
|
2061
2305
|
tags=tags,
|
|
@@ -2432,6 +2676,20 @@ def render_settings():
|
|
|
2432
2676
|
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
2433
2677
|
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
2434
2678
|
|
|
2679
|
+
with st.expander("Postiz — API key, integração e MCP", expanded=True):
|
|
2680
|
+
st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
|
|
2681
|
+
postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
|
|
2682
|
+
postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
|
|
2683
|
+
postiz_cols = st.columns(2)
|
|
2684
|
+
with postiz_cols[0]:
|
|
2685
|
+
postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
|
|
2686
|
+
postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
|
|
2687
|
+
postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
|
|
2688
|
+
with postiz_cols[1]:
|
|
2689
|
+
postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
|
|
2690
|
+
postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
|
|
2691
|
+
st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
|
|
2692
|
+
|
|
2435
2693
|
if refresh_openai_models:
|
|
2436
2694
|
try:
|
|
2437
2695
|
discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
|
|
@@ -2466,6 +2724,9 @@ def render_settings():
|
|
|
2466
2724
|
"upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
|
|
2467
2725
|
"upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
|
|
2468
2726
|
"upload_post_auto_upload": upload_post_auto_upload,
|
|
2727
|
+
"postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
|
|
2728
|
+
"postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
|
|
2729
|
+
"postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
|
|
2469
2730
|
})
|
|
2470
2731
|
write_json("settings.json", settings)
|
|
2471
2732
|
try:
|
|
@@ -2782,6 +3043,7 @@ def main():
|
|
|
2782
3043
|
pipeline_items = [
|
|
2783
3044
|
("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
|
|
2784
3045
|
("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
|
|
3046
|
+
("Roteiros", ":material/article:", "Roteiros"),
|
|
2785
3047
|
("Upload", ":material/cloud_upload:", "Upload"),
|
|
2786
3048
|
]
|
|
2787
3049
|
edition_items = [
|
|
@@ -2874,6 +3136,7 @@ def main():
|
|
|
2874
3136
|
"Início": render_dashboard,
|
|
2875
3137
|
"Criação de Vídeos": render_new_video,
|
|
2876
3138
|
"Criação de Músicas": render_music_creation,
|
|
3139
|
+
"Roteiros": render_scripts,
|
|
2877
3140
|
"Automação Youtube": render_automation,
|
|
2878
3141
|
"Niche Finder Kaggle": render_niche_finder,
|
|
2879
3142
|
"Niche Finder Apify": render_niche_finder_apify,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Local Markdown storage for Thunderbolt scripts and music lyrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .storage import STORAGE, append_json, ensure_storage, read_json, write_json
|
|
12
|
+
|
|
13
|
+
SCRIPT_HISTORY_FILE = "scripts.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _slug(value: str, fallback: str = "documento") -> str:
|
|
17
|
+
normalized = re.sub(r"[^a-zA-Z0-9À-ÿ]+", "-", str(value or "").strip(), flags=re.UNICODE).strip("-").lower()
|
|
18
|
+
return (normalized or fallback)[:90]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def script_storage_path() -> Path:
|
|
22
|
+
ensure_storage()
|
|
23
|
+
path = STORAGE / "scripts"
|
|
24
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_script_document(document: dict[str, Any]) -> dict[str, Any]:
|
|
29
|
+
content = str(document.get("content") or "").strip()
|
|
30
|
+
if not content:
|
|
31
|
+
raise ValueError("O documento não pode ser guardado vazio.")
|
|
32
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
33
|
+
document_id = str(document.get("id") or uuid.uuid4().hex[:12])
|
|
34
|
+
title = str(document.get("title") or "Documento").strip()
|
|
35
|
+
document_type = str(document.get("document_type") or "video_script").strip()
|
|
36
|
+
prefix = "roteiro" if document_type == "video_script" else "letra"
|
|
37
|
+
filename = f"{prefix}-{_slug(title)}-{document_id}.md"
|
|
38
|
+
path = script_storage_path() / filename
|
|
39
|
+
front_matter = [
|
|
40
|
+
"---",
|
|
41
|
+
f"id: {document_id}",
|
|
42
|
+
f"type: {document_type}",
|
|
43
|
+
f"title: {title}",
|
|
44
|
+
f"language: {str(document.get('language') or '')}",
|
|
45
|
+
f"channel: {str(document.get('channel_name') or '')}",
|
|
46
|
+
f"blueprint: {str(document.get('blueprint_name') or '')}",
|
|
47
|
+
f"created_at: {created_at}",
|
|
48
|
+
"---",
|
|
49
|
+
"",
|
|
50
|
+
]
|
|
51
|
+
summary = str(document.get("summary") or "").strip()
|
|
52
|
+
if summary:
|
|
53
|
+
front_matter.extend([f"> {summary}", ""])
|
|
54
|
+
path.write_text("\n".join(front_matter) + content.rstrip() + "\n", encoding="utf-8")
|
|
55
|
+
record = {
|
|
56
|
+
**document,
|
|
57
|
+
"id": document_id,
|
|
58
|
+
"title": title,
|
|
59
|
+
"document_type": document_type,
|
|
60
|
+
"path": str(path),
|
|
61
|
+
"filename": filename,
|
|
62
|
+
"created_at": created_at,
|
|
63
|
+
"content": content,
|
|
64
|
+
}
|
|
65
|
+
history = read_json(SCRIPT_HISTORY_FILE, [])
|
|
66
|
+
if not isinstance(history, list):
|
|
67
|
+
history = []
|
|
68
|
+
history.insert(0, {key: value for key, value in record.items() if key != "content"})
|
|
69
|
+
write_json(SCRIPT_HISTORY_FILE, history[:200])
|
|
70
|
+
return record
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_script_documents() -> list[dict[str, Any]]:
|
|
74
|
+
history = read_json(SCRIPT_HISTORY_FILE, [])
|
|
75
|
+
return [item for item in history if isinstance(item, dict)] if isinstance(history, list) else []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_script_document(record: dict[str, Any]) -> str:
|
|
79
|
+
path = Path(str(record.get("path") or ""))
|
|
80
|
+
if not path.is_file():
|
|
81
|
+
return ""
|
|
82
|
+
return path.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Blueprint-aware generation of video scripts and music lyrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .creative_generation import CreativeGenerationError, _chat_json, channel_context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DOCUMENT_TYPES = {
|
|
12
|
+
"Roteiro de vídeo": "video_script",
|
|
13
|
+
"Letra de música": "music_lyrics",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_script_document(
|
|
18
|
+
settings: dict[str, Any],
|
|
19
|
+
*,
|
|
20
|
+
document_type: str,
|
|
21
|
+
title: str,
|
|
22
|
+
brief: str,
|
|
23
|
+
language: str,
|
|
24
|
+
channel: dict[str, Any] | None = None,
|
|
25
|
+
blueprint: dict[str, Any] | None = None,
|
|
26
|
+
structure_notes: str = "",
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
"""Generate an editable Markdown document from the configured LLM.
|
|
29
|
+
|
|
30
|
+
The function deliberately returns text only. It never writes to storage; the UI
|
|
31
|
+
decides whether the generated draft should be saved after the user reviews it.
|
|
32
|
+
"""
|
|
33
|
+
normalized_type = DOCUMENT_TYPES.get(document_type, document_type)
|
|
34
|
+
if normalized_type not in {"video_script", "music_lyrics"}:
|
|
35
|
+
raise CreativeGenerationError("Escolha Roteiro de vídeo ou Letra de música.")
|
|
36
|
+
if not brief.strip():
|
|
37
|
+
raise CreativeGenerationError("Escreva um tema ou briefing antes de gerar o documento.")
|
|
38
|
+
|
|
39
|
+
channel_context_value = channel_context(channel or {}, blueprint or {})
|
|
40
|
+
blueprint_payload = blueprint or {}
|
|
41
|
+
if normalized_type == "video_script":
|
|
42
|
+
output_requirements = {
|
|
43
|
+
"content": "roteiro completo em Markdown, com título, gancho, cenas, narração e indicações visuais/sonoras",
|
|
44
|
+
"summary": "resumo editorial em uma frase",
|
|
45
|
+
}
|
|
46
|
+
system = (
|
|
47
|
+
"És um roteirista editorial de vídeos faceless. Cria um roteiro original, natural e executável, "
|
|
48
|
+
"alinhado exclusivamente ao nicho e às regras do Blueprint fornecido. Não inventes factos sensíveis, "
|
|
49
|
+
"não uses introduções genéricas, não escrevas comentários sobre IA e não incluas um CTA vazio. "
|
|
50
|
+
"Responde apenas com JSON válido nas chaves title, summary e content."
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
output_requirements = {
|
|
54
|
+
"content": "letra completa em Markdown, com secções [Verso], [Pré-refrão], [Refrão], [Ponte] quando fizer sentido",
|
|
55
|
+
"summary": "resumo da intenção musical em uma frase",
|
|
56
|
+
}
|
|
57
|
+
system = (
|
|
58
|
+
"És um compositor de letras originais. Cria uma letra cantável, coerente com o tema, idioma, "
|
|
59
|
+
"Blueprint e direcção musical fornecidos. Não copies letras existentes, não cites artistas sem pedido "
|
|
60
|
+
"e não acrescentes explicações dentro da letra. Responde apenas com JSON válido nas chaves title, summary e content."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
user = json.dumps(
|
|
64
|
+
{
|
|
65
|
+
"document_type": normalized_type,
|
|
66
|
+
"requested_title": title.strip(),
|
|
67
|
+
"brief": brief.strip(),
|
|
68
|
+
"language": language.strip(),
|
|
69
|
+
"channel": channel_context_value,
|
|
70
|
+
"blueprint": blueprint_payload,
|
|
71
|
+
"structure_notes": structure_notes.strip(),
|
|
72
|
+
"output_requirements": output_requirements,
|
|
73
|
+
},
|
|
74
|
+
ensure_ascii=False,
|
|
75
|
+
)
|
|
76
|
+
result = _chat_json(settings, system, user)
|
|
77
|
+
content = str(result.get("content") or "").strip()
|
|
78
|
+
if not content:
|
|
79
|
+
raise CreativeGenerationError("O provider LLM devolveu um documento sem conteúdo.")
|
|
80
|
+
return {
|
|
81
|
+
"document_type": normalized_type,
|
|
82
|
+
"title": str(result.get("title") or title or brief).strip(),
|
|
83
|
+
"summary": str(result.get("summary") or "").strip(),
|
|
84
|
+
"content": content,
|
|
85
|
+
"language": language.strip(),
|
|
86
|
+
"blueprint_id": str(blueprint_payload.get("id") or ""),
|
|
87
|
+
"blueprint_name": str(blueprint_payload.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
88
|
+
"channel_id": str((channel or {}).get("id") or ""),
|
|
89
|
+
"channel_name": str((channel or {}).get("name") or "Documento independente"),
|
|
90
|
+
"generated_by": "configured_llm",
|
|
91
|
+
}
|
package/hermes_ui/storage.py
CHANGED
|
@@ -24,8 +24,9 @@ DEFAULTS: dict[str, Any] = {
|
|
|
24
24
|
"uploads.json": [],
|
|
25
25
|
"niche_apify_runs.json": [],
|
|
26
26
|
"metadata_edits.json": [],
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
"python_editor_edits.json": [],
|
|
28
|
+
"scripts.json": [],
|
|
29
|
+
"mcp_server.json": {
|
|
29
30
|
"enabled": False,
|
|
30
31
|
"host": "127.0.0.1",
|
|
31
32
|
"port": 3031,
|
|
@@ -191,6 +192,13 @@ DEFAULTS: dict[str, Any] = {
|
|
|
191
192
|
"upload_post_username": "",
|
|
192
193
|
"upload_post_platforms": "tiktok,instagram",
|
|
193
194
|
"upload_post_auto_upload": False,
|
|
195
|
+
"postiz_enabled": False,
|
|
196
|
+
"postiz_api_key": "",
|
|
197
|
+
"postiz_base_url": "https://api.postiz.com/public/v1",
|
|
198
|
+
"postiz_mcp_url": "https://api.postiz.com/mcp",
|
|
199
|
+
"postiz_mode": "api",
|
|
200
|
+
"postiz_integration_id": "",
|
|
201
|
+
"postiz_auto_publish": False,
|
|
194
202
|
"tiktok_client_key": "",
|
|
195
203
|
"tiktok_client_secret": "",
|
|
196
204
|
"tiktok_redirect_uri": "http://localhost:3030/oauth/tiktok/callback",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Postiz Public API adapter used as the final upload fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .platforms import IntegrationResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_POSTIZ_BASE_URL = "https://api.postiz.com/public/v1"
|
|
15
|
+
DEFAULT_POSTIZ_MCP_URL = "https://api.postiz.com/mcp"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PostizAdapter:
|
|
19
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
20
|
+
self.settings = settings or {}
|
|
21
|
+
self.api_key = str(self.settings.get("postiz_api_key") or "").strip()
|
|
22
|
+
self.base_url = str(self.settings.get("postiz_base_url") or DEFAULT_POSTIZ_BASE_URL).strip().rstrip("/")
|
|
23
|
+
self.mcp_url = str(self.settings.get("postiz_mcp_url") or DEFAULT_POSTIZ_MCP_URL).strip().rstrip("/")
|
|
24
|
+
|
|
25
|
+
def _headers(self, *, json_body: bool = False) -> dict[str, str]:
|
|
26
|
+
headers = {"Authorization": self.api_key}
|
|
27
|
+
if json_body:
|
|
28
|
+
headers["Content-Type"] = "application/json"
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _error(self, message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
|
|
32
|
+
return IntegrationResult(False, message, data or {})
|
|
33
|
+
|
|
34
|
+
def status(self) -> IntegrationResult:
|
|
35
|
+
if not self.api_key:
|
|
36
|
+
return self._error("Postiz não configurado: adicione a API key em Configurações Técnicas.")
|
|
37
|
+
return IntegrationResult(True, f"Postiz configurado em {self.base_url}.", {"base_url": self.base_url, "mcp_url": self.mcp_url})
|
|
38
|
+
|
|
39
|
+
def list_integrations(self) -> IntegrationResult:
|
|
40
|
+
status = self.status()
|
|
41
|
+
if not status.ok:
|
|
42
|
+
return status
|
|
43
|
+
try:
|
|
44
|
+
response = requests.get(f"{self.base_url}/integrations", headers=self._headers(), timeout=30)
|
|
45
|
+
except requests.RequestException as exc:
|
|
46
|
+
return self._error(f"Não foi possível contactar o Postiz: {exc}")
|
|
47
|
+
if response.status_code >= 400:
|
|
48
|
+
return self._error(f"Postiz devolveu HTTP {response.status_code}: {response.text[:500]}")
|
|
49
|
+
try:
|
|
50
|
+
payload = response.json()
|
|
51
|
+
except ValueError:
|
|
52
|
+
return self._error("Postiz devolveu uma resposta que não é JSON.")
|
|
53
|
+
if isinstance(payload, dict):
|
|
54
|
+
integrations = payload.get("integrations") or payload.get("data") or payload.get("items") or []
|
|
55
|
+
else:
|
|
56
|
+
integrations = payload
|
|
57
|
+
if not isinstance(integrations, list):
|
|
58
|
+
return self._error("A resposta de integrações do Postiz não tem uma lista válida.", {"payload": payload})
|
|
59
|
+
normalized = [item for item in integrations if isinstance(item, dict) and item.get("id")]
|
|
60
|
+
return IntegrationResult(True, f"{len(normalized)} integração(ões) Postiz carregada(s).", {"integrations": normalized, "payload": payload})
|
|
61
|
+
|
|
62
|
+
def upload_file(self, video_path: str | Path, *, mime_type: str | None = None) -> IntegrationResult:
|
|
63
|
+
status = self.status()
|
|
64
|
+
if not status.ok:
|
|
65
|
+
return status
|
|
66
|
+
path = Path(video_path)
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
return self._error(f"Vídeo não encontrado para o Postiz: {path}")
|
|
69
|
+
content_type = mime_type or mimetypes.guess_type(path.name)[0] or "video/mp4"
|
|
70
|
+
try:
|
|
71
|
+
with path.open("rb") as handle:
|
|
72
|
+
response = requests.post(
|
|
73
|
+
f"{self.base_url}/upload",
|
|
74
|
+
headers=self._headers(),
|
|
75
|
+
files={"file": (path.name, handle, content_type)},
|
|
76
|
+
timeout=180,
|
|
77
|
+
)
|
|
78
|
+
except requests.RequestException as exc:
|
|
79
|
+
return self._error(f"Não foi possível enviar o vídeo para o Postiz: {exc}")
|
|
80
|
+
if response.status_code >= 400:
|
|
81
|
+
return self._error(f"Postiz rejeitou o upload (HTTP {response.status_code}): {response.text[:500]}")
|
|
82
|
+
try:
|
|
83
|
+
payload = response.json()
|
|
84
|
+
except ValueError:
|
|
85
|
+
return self._error("Postiz devolveu uma resposta de upload que não é JSON.")
|
|
86
|
+
asset_id = str(payload.get("id") or "").strip() if isinstance(payload, dict) else ""
|
|
87
|
+
asset_path = str(payload.get("path") or "").strip() if isinstance(payload, dict) else ""
|
|
88
|
+
if not asset_id or not asset_path:
|
|
89
|
+
return self._error("O upload Postiz não devolveu id e path do asset.", {"payload": payload})
|
|
90
|
+
return IntegrationResult(True, "Vídeo carregado no Postiz.", {"asset": {"id": asset_id, "path": asset_path}, "payload": payload})
|
|
91
|
+
|
|
92
|
+
def create_youtube_post(
|
|
93
|
+
self,
|
|
94
|
+
integration_id: str,
|
|
95
|
+
*,
|
|
96
|
+
asset: dict[str, str],
|
|
97
|
+
title: str,
|
|
98
|
+
description: str = "",
|
|
99
|
+
visibility: str = "private",
|
|
100
|
+
tags: list[str] | None = None,
|
|
101
|
+
thumbnail: dict[str, str] | None = None,
|
|
102
|
+
post_type: str = "now",
|
|
103
|
+
date: str | None = None,
|
|
104
|
+
) -> IntegrationResult:
|
|
105
|
+
status = self.status()
|
|
106
|
+
if not status.ok:
|
|
107
|
+
return status
|
|
108
|
+
integration_id = str(integration_id or "").strip()
|
|
109
|
+
if not integration_id:
|
|
110
|
+
return self._error("Seleccione uma integração YouTube do Postiz antes de publicar.")
|
|
111
|
+
normalized_visibility = visibility if visibility in {"public", "unlisted", "private"} else "private"
|
|
112
|
+
tag_values = [str(tag).strip() for tag in (tags or []) if str(tag).strip()]
|
|
113
|
+
body: dict[str, Any] = {
|
|
114
|
+
"type": post_type if post_type in {"now", "schedule", "draft"} else "now",
|
|
115
|
+
"shortLink": False,
|
|
116
|
+
"tags": [],
|
|
117
|
+
"posts": [
|
|
118
|
+
{
|
|
119
|
+
"integration": {"id": integration_id},
|
|
120
|
+
"value": [{"content": description, "image": [asset]}],
|
|
121
|
+
"settings": {
|
|
122
|
+
"__type": "youtube",
|
|
123
|
+
"title": title.strip()[:100] or "Vídeo Thunderbolt",
|
|
124
|
+
"type": normalized_visibility,
|
|
125
|
+
"selfDeclaredMadeForKids": "no",
|
|
126
|
+
"thumbnail": thumbnail,
|
|
127
|
+
"tags": [{"value": value, "label": value} for value in tag_values],
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
if post_type == "schedule" and date:
|
|
133
|
+
body["date"] = date
|
|
134
|
+
try:
|
|
135
|
+
response = requests.post(f"{self.base_url}/posts", headers=self._headers(json_body=True), json=body, timeout=60)
|
|
136
|
+
except requests.RequestException as exc:
|
|
137
|
+
return self._error(f"Não foi possível criar o post no Postiz: {exc}")
|
|
138
|
+
if response.status_code >= 400:
|
|
139
|
+
return self._error(f"Postiz rejeitou a publicação (HTTP {response.status_code}): {response.text[:500]}", {"request": body})
|
|
140
|
+
try:
|
|
141
|
+
payload = response.json()
|
|
142
|
+
except ValueError:
|
|
143
|
+
return self._error("Postiz devolveu uma resposta de publicação que não é JSON.", {"request": body})
|
|
144
|
+
return IntegrationResult(True, "Post criado no Postiz.", {"payload": payload, "request": body, "asset": asset, "integration_id": integration_id})
|
|
145
|
+
|
|
146
|
+
def publish_video(
|
|
147
|
+
self,
|
|
148
|
+
video_path: str | Path,
|
|
149
|
+
*,
|
|
150
|
+
integration_id: str,
|
|
151
|
+
title: str,
|
|
152
|
+
description: str = "",
|
|
153
|
+
visibility: str = "private",
|
|
154
|
+
tags: list[str] | None = None,
|
|
155
|
+
thumbnail_path: str | Path | None = None,
|
|
156
|
+
) -> IntegrationResult:
|
|
157
|
+
upload = self.upload_file(video_path)
|
|
158
|
+
if not upload.ok:
|
|
159
|
+
return upload
|
|
160
|
+
thumbnail_asset = None
|
|
161
|
+
if thumbnail_path and Path(thumbnail_path).is_file():
|
|
162
|
+
thumb_upload = self.upload_file(thumbnail_path, mime_type=mimetypes.guess_type(str(thumbnail_path))[0] or "image/jpeg")
|
|
163
|
+
if thumb_upload.ok:
|
|
164
|
+
thumbnail_asset = thumb_upload.data.get("asset")
|
|
165
|
+
return self.create_youtube_post(
|
|
166
|
+
integration_id,
|
|
167
|
+
asset=upload.data["asset"],
|
|
168
|
+
title=title,
|
|
169
|
+
description=description,
|
|
170
|
+
visibility=visibility,
|
|
171
|
+
tags=tags,
|
|
172
|
+
thumbnail=thumbnail_asset,
|
|
173
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Ordered YouTube upload routing for Thunderbolt.
|
|
2
|
+
|
|
3
|
+
The route is intentionally deterministic:
|
|
4
|
+
1. Official YouTube API, up to five successful sends per Google account/day.
|
|
5
|
+
2. Internal browser-session upload, when the account document is complete.
|
|
6
|
+
3. Postiz, when configured with an API key and integration ID.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from integrations.platforms import IntegrationResult, YouTubeAdapter
|
|
16
|
+
from integrations.postiz import PostizAdapter
|
|
17
|
+
from integrations.youtube_direct_credentials import document_status
|
|
18
|
+
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
19
|
+
from hermes_ui.storage import read_json, write_json
|
|
20
|
+
|
|
21
|
+
OFFICIAL_DAILY_LIMIT = 5
|
|
22
|
+
QUOTA_FILENAME = "official_upload_quota.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _attempt_record(name: str, result: IntegrationResult, *, skipped: bool = False) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"route": name,
|
|
28
|
+
"status": "skipped" if skipped else ("success" if result.ok else "failed"),
|
|
29
|
+
"message": result.message,
|
|
30
|
+
"data": result.data,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _quota_key(channel: dict[str, Any], account: dict[str, Any] | None) -> str:
|
|
35
|
+
if account and account.get("id"):
|
|
36
|
+
return f"account:{account['id']}"
|
|
37
|
+
if channel.get("google_account_id"):
|
|
38
|
+
return f"account:{channel['google_account_id']}"
|
|
39
|
+
return f"channel:{channel.get('id', 'unknown')}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def official_upload_count(channel: dict[str, Any], account: dict[str, Any] | None, *, today: str | None = None) -> int:
|
|
43
|
+
today = today or date.today().isoformat()
|
|
44
|
+
state = read_json(QUOTA_FILENAME, {})
|
|
45
|
+
if not isinstance(state, dict):
|
|
46
|
+
return 0
|
|
47
|
+
entry = state.get(_quota_key(channel, account), {})
|
|
48
|
+
if not isinstance(entry, dict) or entry.get("date") != today:
|
|
49
|
+
return 0
|
|
50
|
+
try:
|
|
51
|
+
return max(0, int(entry.get("count", 0)))
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def record_official_upload(channel: dict[str, Any], account: dict[str, Any] | None, *, today: str | None = None) -> int:
|
|
57
|
+
today = today or date.today().isoformat()
|
|
58
|
+
state = read_json(QUOTA_FILENAME, {})
|
|
59
|
+
if not isinstance(state, dict):
|
|
60
|
+
state = {}
|
|
61
|
+
key = _quota_key(channel, account)
|
|
62
|
+
current = official_upload_count(channel, account, today=today)
|
|
63
|
+
state[key] = {"date": today, "count": current + 1}
|
|
64
|
+
write_json(QUOTA_FILENAME, state)
|
|
65
|
+
return current + 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _result_with_attempts(result: IntegrationResult, attempts: list[dict[str, Any]], route: str) -> IntegrationResult:
|
|
69
|
+
data = dict(result.data or {})
|
|
70
|
+
data["route"] = route
|
|
71
|
+
data["attempts"] = attempts
|
|
72
|
+
return IntegrationResult(result.ok, result.message, data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def upload_with_default_route(
|
|
76
|
+
settings: dict[str, Any],
|
|
77
|
+
*,
|
|
78
|
+
storage_root: Path,
|
|
79
|
+
channel: dict[str, Any],
|
|
80
|
+
account: dict[str, Any] | None,
|
|
81
|
+
video_path: str,
|
|
82
|
+
title: str,
|
|
83
|
+
description: str = "",
|
|
84
|
+
tags: list[str] | None = None,
|
|
85
|
+
category_id: str = "22",
|
|
86
|
+
language: str = "pt-BR",
|
|
87
|
+
privacy_status: str = "private",
|
|
88
|
+
thumbnail_path: str = "",
|
|
89
|
+
captions_path: str = "",
|
|
90
|
+
official_uploader: Callable[..., IntegrationResult] | None = None,
|
|
91
|
+
direct_uploader: Callable[..., IntegrationResult] | None = None,
|
|
92
|
+
postiz_publisher: Callable[..., IntegrationResult] | None = None,
|
|
93
|
+
) -> IntegrationResult:
|
|
94
|
+
attempts: list[dict[str, Any]] = []
|
|
95
|
+
quota_count = official_upload_count(channel, account)
|
|
96
|
+
if quota_count >= OFFICIAL_DAILY_LIMIT:
|
|
97
|
+
quota_result = IntegrationResult(False, f"Limite local de {OFFICIAL_DAILY_LIMIT} envios oficiais por conta Google atingido hoje; a tentar o próximo método.", {"count": quota_count, "limit": OFFICIAL_DAILY_LIMIT})
|
|
98
|
+
attempts.append(_attempt_record("API Oficial", quota_result, skipped=True))
|
|
99
|
+
else:
|
|
100
|
+
official_uploader = official_uploader or YouTubeAdapter(settings).upload_video
|
|
101
|
+
try:
|
|
102
|
+
official_result = official_uploader(
|
|
103
|
+
video_path,
|
|
104
|
+
title=title,
|
|
105
|
+
description=description,
|
|
106
|
+
tags=tags or [],
|
|
107
|
+
category_id=category_id,
|
|
108
|
+
language=language,
|
|
109
|
+
privacy_status=privacy_status,
|
|
110
|
+
thumbnail_path=thumbnail_path,
|
|
111
|
+
captions_path=captions_path,
|
|
112
|
+
)
|
|
113
|
+
except Exception as exc: # Keep fallback actionable and deterministic.
|
|
114
|
+
official_result = IntegrationResult(False, f"API Oficial falhou: {exc}", {})
|
|
115
|
+
attempts.append(_attempt_record("API Oficial", official_result))
|
|
116
|
+
if official_result.ok:
|
|
117
|
+
used = record_official_upload(channel, account)
|
|
118
|
+
data = dict(official_result.data or {})
|
|
119
|
+
data["official_daily_count"] = used
|
|
120
|
+
data["official_daily_limit"] = OFFICIAL_DAILY_LIMIT
|
|
121
|
+
data["route"] = "API Oficial"
|
|
122
|
+
data["attempts"] = attempts
|
|
123
|
+
return IntegrationResult(True, official_result.message, data)
|
|
124
|
+
|
|
125
|
+
direct_ready = False
|
|
126
|
+
if account:
|
|
127
|
+
try:
|
|
128
|
+
status = document_status(storage_root, account, channel, settings, [channel])
|
|
129
|
+
direct_ready = bool(status.get("ready"))
|
|
130
|
+
if not direct_ready:
|
|
131
|
+
missing = list(status.get("missing_cookies", []))
|
|
132
|
+
if not status.get("has_session_info"):
|
|
133
|
+
missing.append("sessionInfo")
|
|
134
|
+
if not status.get("has_innertube_api_key"):
|
|
135
|
+
missing.append("INNERTUBE_API_KEY")
|
|
136
|
+
if not status.get("has_delegated_session_id"):
|
|
137
|
+
missing.append("DELEGATED_SESSION_ID")
|
|
138
|
+
direct_result = IntegrationResult(False, f"Upload directo indisponível: {', '.join(missing)}.", {"missing": missing})
|
|
139
|
+
else:
|
|
140
|
+
direct_result = None
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
direct_result = IntegrationResult(False, f"Não foi possível validar o documento do Upload directo: {exc}", {})
|
|
143
|
+
else:
|
|
144
|
+
direct_result = IntegrationResult(False, "Upload directo indisponível: o canal não tem uma conta Google associada.", {})
|
|
145
|
+
if direct_ready:
|
|
146
|
+
direct_uploader = direct_uploader or YouTubeDirectUploader(settings, channel, account=account, storage_root=storage_root).upload
|
|
147
|
+
try:
|
|
148
|
+
direct_result = direct_uploader(video_path, title=title, description=description, visibility=privacy_status)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
direct_result = IntegrationResult(False, f"Upload directo falhou: {exc}", {})
|
|
151
|
+
attempts.append(_attempt_record("Upload directo", direct_result))
|
|
152
|
+
if direct_result.ok:
|
|
153
|
+
return _result_with_attempts(direct_result, attempts, "Upload directo")
|
|
154
|
+
|
|
155
|
+
postiz = PostizAdapter(settings)
|
|
156
|
+
if not bool(settings.get("postiz_enabled", False)):
|
|
157
|
+
postiz_result = IntegrationResult(False, "Postiz está desactivado em Configurações Técnicas.", {})
|
|
158
|
+
attempts.append(_attempt_record("Postiz", postiz_result, skipped=True))
|
|
159
|
+
else:
|
|
160
|
+
postiz_publisher = postiz_publisher or postiz.publish_video
|
|
161
|
+
try:
|
|
162
|
+
postiz_result = postiz_publisher(
|
|
163
|
+
video_path,
|
|
164
|
+
integration_id=str(settings.get("postiz_integration_id", "") or ""),
|
|
165
|
+
title=title,
|
|
166
|
+
description=description,
|
|
167
|
+
visibility=privacy_status,
|
|
168
|
+
tags=tags or [],
|
|
169
|
+
thumbnail_path=thumbnail_path,
|
|
170
|
+
)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
postiz_result = IntegrationResult(False, f"Postiz falhou: {exc}", {})
|
|
173
|
+
attempts.append(_attempt_record("Postiz", postiz_result))
|
|
174
|
+
if postiz_result.ok:
|
|
175
|
+
return _result_with_attempts(postiz_result, attempts, "Postiz")
|
|
176
|
+
|
|
177
|
+
return IntegrationResult(False, "Nenhum método de envio conseguiu publicar o vídeo.", {"attempts": attempts, "route": "none"})
|
package/package.json
CHANGED