@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.
@@ -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.54
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.54 install
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.54 install
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.54 install`; o instalador detecta e reutiliza o que já estiver válido.
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, 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 |
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.54
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.54 install
165
- npx.cmd --yes @danhachuel/thunderbolt@0.2.54 doctor
166
- npx.cmd --yes @danhachuel/thunderbolt@0.2.54
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.54 install`; o instalador detecta e reutiliza componentes já válidos.
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 — youtube-automation-agent (principal)**")
2013
- st.caption("O Thunderbolt executa internamente a lógica do PublishingSchedulingAgent: OAuth, upload resumível, metadados, thumbnail e legendas. O OAuth directo é usado apenas como redundância se o caminho principal falhar.")
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
- if st.button("Publicar no YouTube", type="primary", key=f"upload_youtube_{task['id']}"):
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 = youtube.upload_video(
2058
- video_path,
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
+ }
@@ -24,8 +24,9 @@ DEFAULTS: dict[str, Any] = {
24
24
  "uploads.json": [],
25
25
  "niche_apify_runs.json": [],
26
26
  "metadata_edits.json": [],
27
- "python_editor_edits.json": [],
28
- "mcp_server.json": {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.54",
3
+ "version": "0.2.56",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "main": "scripts/cli.mjs",
6
6
  "type": "module",