@danhachuel/thunderbolt 0.2.55 → 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.55
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.55 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.55 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.55 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
 
@@ -468,7 +468,11 @@ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista
468
468
 
469
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.
470
470
 
471
- ## 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.
472
476
 
473
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`.
474
478
 
package/README.md CHANGED
@@ -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,7 +38,7 @@ 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.55
41
+ ## Navegação da UI 0.2.56
42
42
 
43
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
 
@@ -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.55 install
165
- npx.cmd --yes @danhachuel/thunderbolt@0.2.55 doctor
166
- npx.cmd --yes @danhachuel/thunderbolt@0.2.55
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.55 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,6 +262,10 @@ 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
package/app/main.py CHANGED
@@ -39,6 +39,8 @@ from hermes_ui.script_generation import generate_script_document
39
39
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
40
40
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
41
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
42
44
  from integrations.youtube_direct_upload import YouTubeDirectUploader
43
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
44
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
@@ -2136,17 +2138,99 @@ def render_upload_direct():
2136
2138
 
2137
2139
  def render_upload():
2138
2140
  st.title("Upload")
2139
- upload_tab, direct_tab = st.tabs(["Upload convencional", "Upload directo"])
2141
+ upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
2140
2142
  with direct_tab:
2141
2143
  render_upload_direct()
2144
+ with postiz_tab:
2145
+ render_upload_postiz()
2142
2146
  with upload_tab:
2143
2147
  render_upload_conventional()
2144
2148
 
2145
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
+
2146
2226
  def render_upload_conventional():
2147
2227
  st.title("Upload")
2148
2228
  settings = read_json("settings.json", {})
2149
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)
2150
2234
  tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
2151
2235
  destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
2152
2236
 
@@ -2156,9 +2240,14 @@ def render_upload_conventional():
2156
2240
  st.info("Facebook Pages está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
2157
2241
 
2158
2242
  if "YouTube" in destination:
2159
- st.markdown("**YouTube — youtube-automation-agent (principal)**")
2160
- 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.")
2161
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.")
2162
2251
  status_cols = st.columns(2)
2163
2252
  with status_cols[0]:
2164
2253
  (st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
@@ -2188,6 +2277,8 @@ def render_upload_conventional():
2188
2277
  thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
2189
2278
  captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
2190
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", "")))
2191
2282
  if "YouTube" in destination:
2192
2283
  title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
2193
2284
  description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
@@ -2199,10 +2290,16 @@ def render_upload_conventional():
2199
2290
  category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
2200
2291
  with yt_cols[2]:
2201
2292
  language = st.text_input("Idioma", value="pt-BR", key=f"yt_language_{task['id']}")
2202
- 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']}"):
2203
2296
  tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]
2204
- result = youtube.upload_video(
2205
- 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,
2206
2303
  title=title,
2207
2304
  description=description,
2208
2305
  tags=tags,
@@ -2579,6 +2676,20 @@ def render_settings():
2579
2676
  upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
2580
2677
  upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
2581
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
+
2582
2693
  if refresh_openai_models:
2583
2694
  try:
2584
2695
  discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
@@ -2613,6 +2724,9 @@ def render_settings():
2613
2724
  "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
2614
2725
  "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
2615
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),
2616
2730
  })
2617
2731
  write_json("settings.json", settings)
2618
2732
  try:
@@ -192,6 +192,13 @@ DEFAULTS: dict[str, Any] = {
192
192
  "upload_post_username": "",
193
193
  "upload_post_platforms": "tiktok,instagram",
194
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,
195
202
  "tiktok_client_key": "",
196
203
  "tiktok_client_secret": "",
197
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.55",
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",