@danhachuel/thunderbolt 0.2.76 → 0.2.77

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.
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.76 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.77 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.
@@ -436,7 +436,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
436
436
 
437
437
  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.
438
438
 
439
- 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.76 install`; o instalador detecta e reutiliza o que já estiver válido.
439
+ 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.77 install`; o instalador detecta e reutiliza o que já estiver válido.
440
440
 
441
441
  ### Niche Finder Apify
442
442
 
@@ -478,16 +478,24 @@ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista
478
478
 
479
479
  A área **Teste de vozes**, dentro de **Configurações > Configuração API**, é 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.
480
480
 
481
- ## Upload, Postiz e Upload directo
481
+ ## Upload, Postiz, Upload-Post e Upload directo
482
482
 
483
483
  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ção API > API Keys > Serviços e modelos**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
484
484
 
485
- 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.
485
+ O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. O **Upload-Post** é uma quarta subaba independente e não altera essa rota automática do YouTube. 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.
486
486
 
487
487
  Em **Configurações > Contas Google**, 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`.
488
488
 
489
489
  Em **Canais Youtube > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
490
490
 
491
+ ### Upload-Post
492
+
493
+ A subaba **Upload-Post** usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para publicar vídeos prontos em uma ou mais plataformas ligadas ao perfil configurado. Em **Configuração API > API Keys > Serviços e modelos > Publicação através do Upload-Post**, active a integração, guarde a API key, o username/perfil e as plataformas padrão. Depois abra **Upload > Upload-Post**, confirme ou altere as plataformas, escreva o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
494
+
495
+ A credencial não é duplicada na página de Upload. A integração envia o ficheiro local como `video` em `multipart/form-data`, usa `platform[]` repetido para cada destino e autentica com `Authorization: Apikey ...`. **Processar em segundo plano** envia `async_upload=true`; quando a API devolver `request_id`, este fica visível e é guardado em `uploads.json`. O histórico local também alimenta a notificação **Upload-Post concluído**. O Upload-Post é distinto do Postiz: não usa os endpoints nem a selecção de integrações do Postiz.
496
+
497
+ ### Upload directo
498
+
491
499
  A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
492
500
 
493
501
  Associe cada canal à conta Google correcta em **Canais Youtube**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
package/README.md CHANGED
@@ -25,6 +25,14 @@ A primeira versão implementa a camada UI independente com:
25
25
  | Configurações | **Contas Google**, **Configuração API** e **Notificações**; API Keys divididas entre **Serviços e modelos** e **Fontes de materiais**, com várias chaves independentes por fonte, além de contas Google/YouTube por cartão, `INNERTUBE_API_KEY` no documento da conta, providers, TTS, Nano Banana, Postiz, TikTok e Upload-Post |
26
26
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
27
27
 
28
+ ## Upload-Post — publicação para múltiplas plataformas
29
+
30
+ A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
31
+
32
+ A API key, o username/perfil e a lista inicial de plataformas continuam em **Configuração API > API Keys > Serviços e modelos**, no expander **Publicação através do Upload-Post**. A subaba não pede novamente a credencial. O cliente envia `multipart/form-data` para `https://api.upload-post.com/api/upload`, repete `platform[]` para cada destino e guarda a resposta, o `request_id` e o resultado no histórico local de uploads. A opção **Processar em segundo plano** usa `async_upload=true` quando a API estiver configurada para processamento assíncrono.
33
+
34
+ O Upload-Post é independente do Postiz: Postiz continua a usar o seu fluxo próprio de asset + post, enquanto Upload-Post publica directamente nas plataformas ligadas ao username configurado. Uma publicação aceite pelo Upload-Post também é reconciliada no centro de **Notificações**.
35
+
28
36
  ## Upload directo — credenciais por conta e por canal
29
37
 
30
38
  O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações > Contas Google > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
@@ -38,7 +46,7 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
38
46
 
39
47
  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
48
 
41
- ## Navegação da UI 0.2.76
49
+ ## Navegação da UI 0.2.77
42
50
 
43
51
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** 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**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais** e **Tutorial Meta**, 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**, **Contas Google**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
44
52
 
@@ -169,9 +177,9 @@ thunderbolt
169
177
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
170
178
 
171
179
  ```powershell
172
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install
173
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76 doctor
174
- npx.cmd --yes @danhachuel/thunderbolt@0.2.76
180
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install
181
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77 doctor
182
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.77
175
183
  ```
176
184
 
177
185
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -195,7 +203,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
195
203
 
196
204
  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.
197
205
 
198
- **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ção API > API Keys > Serviços e modelos**. 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.76 install`; o instalador detecta e reutiliza componentes já válidos.
206
+ **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ção API > API Keys > Serviços e modelos**. 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.77 install`; o instalador detecta e reutiliza componentes já válidos.
199
207
 
200
208
  ## Editor Python baseado no PYEdit
201
209
 
package/app/main.py CHANGED
@@ -45,6 +45,7 @@ from hermes_ui.creative_generation import CreativeGenerationError, generate_crea
45
45
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
46
46
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
47
47
  from integrations.postiz import PostizAdapter
48
+ from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
48
49
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
49
50
  from integrations.youtube_direct_upload import YouTubeDirectUploader
50
51
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
@@ -2722,11 +2723,13 @@ def render_upload_direct():
2722
2723
 
2723
2724
  def render_upload():
2724
2725
  st.title("Upload")
2725
- upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
2726
+ upload_tab, direct_tab, postiz_tab, upload_post_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
2726
2727
  with direct_tab:
2727
2728
  render_upload_direct()
2728
2729
  with postiz_tab:
2729
2730
  render_upload_postiz()
2731
+ with upload_post_tab:
2732
+ render_upload_post()
2730
2733
  with upload_tab:
2731
2734
  render_upload_conventional()
2732
2735
 
@@ -2808,6 +2811,78 @@ def render_upload_postiz():
2808
2811
  (st.success if result.ok else st.error)(result.message)
2809
2812
 
2810
2813
 
2814
+ def render_upload_post():
2815
+ st.subheader("Upload-Post")
2816
+ st.caption("Envie um vídeo para uma ou mais plataformas ligadas ao seu perfil Upload-Post. A API key é lida da Configuração API e nunca é mostrada nesta aba.")
2817
+ settings = read_json("settings.json", {})
2818
+ uploader = UploadPostAdapter(settings)
2819
+ status = uploader.status()
2820
+ if not status.ok:
2821
+ st.warning(status.message)
2822
+ if not uploader.enabled:
2823
+ st.info("Active Upload-Post em Configuração API > API Keys > Serviços e modelos e guarde a configuração.")
2824
+ elif not uploader.api_key:
2825
+ st.info("Introduza a API key do Upload-Post em Configuração API > API Keys > Serviços e modelos.")
2826
+ return
2827
+
2828
+ configured_platforms = normalize_upload_post_platforms(uploader.platforms)
2829
+ selected_platforms = st.multiselect(
2830
+ "Plataformas Upload-Post",
2831
+ list(UPLOAD_POST_PLATFORM_OPTIONS),
2832
+ default=configured_platforms,
2833
+ format_func=lambda value: "Facebook Pages" if value == "facebook" else "X (Twitter)" if value == "x" else value.title(),
2834
+ key="upload_post_platforms_selector",
2835
+ help="Seleccione uma ou mais plataformas já ligadas ao perfil Upload-Post.",
2836
+ )
2837
+ async_upload = st.checkbox(
2838
+ "Processar em segundo plano",
2839
+ value=False,
2840
+ key="upload_post_async_upload",
2841
+ help="Envia async_upload=true para a API e mostra o request ID devolvido, quando existir.",
2842
+ )
2843
+ if not selected_platforms:
2844
+ st.info("Seleccione pelo menos uma plataforma antes de publicar.")
2845
+
2846
+ tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
2847
+ if not tasks:
2848
+ st.info("Não há vídeos prontos para enviar pelo Upload-Post.")
2849
+ return
2850
+ for task in tasks:
2851
+ artifacts = task.get("artifacts", {}) or {}
2852
+ video_path = artifacts.get("video", "")
2853
+ with st.container(border=True):
2854
+ st.write(f"**{task.get('topic', 'Vídeo Thunderbolt')}** — {uploader.username}")
2855
+ st.caption(video_path or "Sem caminho de vídeo registado")
2856
+ title = st.text_input("Título Upload-Post", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"upload_post_title_{task['id']}")
2857
+ description = st.text_area("Descrição Upload-Post", value=task.get("description", ""), key=f"upload_post_description_{task['id']}", height=90)
2858
+ if st.button("Enviar vídeo pelo Upload-Post", type="primary", key=f"upload_post_send_{task['id']}", disabled=not selected_platforms):
2859
+ result = uploader.upload_video(
2860
+ video_path,
2861
+ title=title,
2862
+ description=description,
2863
+ user=uploader.username,
2864
+ platforms=selected_platforms,
2865
+ async_upload=async_upload,
2866
+ )
2867
+ record = {
2868
+ "id": uuid.uuid4().hex,
2869
+ "task_id": task.get("id"),
2870
+ "destination": "Upload-Post",
2871
+ "target": {"username": uploader.username, "platforms": selected_platforms},
2872
+ "status": "published" if result.ok else "failed",
2873
+ "message": result.message,
2874
+ "data": result.data,
2875
+ "created_at": now(),
2876
+ }
2877
+ uploads = read_json("uploads.json", [])
2878
+ uploads.append(record)
2879
+ write_json("uploads.json", uploads)
2880
+ reconcile_persisted_notifications()
2881
+ (st.success if result.ok else st.error)(result.message)
2882
+ if result.ok and result.data.get("request_id"):
2883
+ st.caption(f"Request ID Upload-Post: {result.data['request_id']}")
2884
+
2885
+
2811
2886
  UPLOAD_DESTINATION_TARGET_KEYS = {
2812
2887
  "TikTok": "tiktok_accounts",
2813
2888
  "Instagram": "instagram_profiles",
@@ -37,7 +37,7 @@ def _json_response(response: requests.Response, action: str) -> Any:
37
37
  def _headers(token: str) -> dict[str, str]:
38
38
  token = str(token or "").strip()
39
39
  if not token:
40
- raise ApifyError("Configure o Apify API Token em Configurações Técnicas antes de iniciar.")
40
+ raise ApifyError("Configure o Apify API Token em Configuração API > API Keys > Serviços e modelos antes de iniciar.")
41
41
  return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
42
42
 
43
43
 
@@ -38,15 +38,15 @@ def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
38
38
  base_url = base_url or "http://127.0.0.1:11434/v1"
39
39
  if not base_url:
40
40
  raise CreativeGenerationError(
41
- f"O provider LLM '{provider}' não tem Base URL configurada em Configurações Técnicas > API Keys."
41
+ f"O provider LLM '{provider}' não tem Base URL configurada em Configuração API > API Keys > Serviços e modelos."
42
42
  )
43
43
  if not model:
44
44
  raise CreativeGenerationError(
45
- f"O provider LLM '{provider}' não tem modelo configurado em Configurações Técnicas > API Keys."
45
+ f"O provider LLM '{provider}' não tem modelo configurado em Configuração API > API Keys > Serviços e modelos."
46
46
  )
47
47
  if provider not in {"ollama", "litellm"} and not key:
48
48
  raise CreativeGenerationError(
49
- f"Configure a API key do provider LLM '{provider}' em Configurações Técnicas > API Keys."
49
+ f"Configure a API key do provider LLM '{provider}' em Configuração API > API Keys > Serviços e modelos."
50
50
  )
51
51
  return provider, key, base_url.rstrip("/"), model
52
52
 
@@ -36,6 +36,7 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
36
36
  {"code": "upload_instagram_success", "category": "Upload", "label": "Upload Instagram concluído", "description": "Quando um vídeo for publicado num perfil Instagram."},
37
37
  {"code": "upload_facebook_pages_success", "category": "Upload", "label": "Upload Facebook Pages concluído", "description": "Quando um vídeo for publicado numa Facebook Page."},
38
38
  {"code": "upload_postiz_success", "category": "Upload", "label": "Upload Postiz concluído", "description": "Quando um vídeo for enviado e publicado através do Postiz."},
39
+ {"code": "upload_upload_post_success", "category": "Upload", "label": "Upload-Post concluído", "description": "Quando um vídeo for aceite pelo Upload-Post para publicação nas plataformas seleccionadas."},
39
40
  {"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
40
41
  )
41
42
  EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
@@ -249,6 +250,8 @@ def _upload_notifications() -> int:
249
250
  "facebook pages": "upload_facebook_pages_success",
250
251
  "facebook_pages": "upload_facebook_pages_success",
251
252
  "postiz": "upload_postiz_success",
253
+ "upload-post": "upload_upload_post_success",
254
+ "upload_post": "upload_upload_post_success",
252
255
  "youtube direct frontend": "upload_youtube_success",
253
256
  }
254
257
  for upload in uploads:
@@ -115,6 +115,7 @@ DEFAULTS: dict[str, Any] = {
115
115
  "upload_instagram_success": True,
116
116
  "upload_facebook_pages_success": True,
117
117
  "upload_postiz_success": True,
118
+ "upload_upload_post_success": True,
118
119
  "mcp_operation_completed": True,
119
120
  },
120
121
  "kaggle_username": "",
@@ -87,7 +87,7 @@ def generate_thumbnail_image(
87
87
  ) -> Path:
88
88
  api_key = str(settings.get("gemini_image_api_key") or "").strip()
89
89
  if not api_key:
90
- raise ThumbnailGenerationError("Configure a API key Nano Banana em Configurações Técnicas > API Keys.")
90
+ raise ThumbnailGenerationError("Configure a API key Nano Banana em Configuração API > API Keys > Serviços e modelos.")
91
91
  clean_prompt = str(prompt or "").strip()
92
92
  if not clean_prompt:
93
93
  raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
@@ -33,7 +33,7 @@ class PostizAdapter:
33
33
 
34
34
  def status(self) -> IntegrationResult:
35
35
  if not self.api_key:
36
- return self._error("Postiz não configurado: adicione a API key em Configurações Técnicas.")
36
+ return self._error("Postiz não configurado: adicione a API key em Configuração API > API Keys > Serviços e modelos.")
37
37
  return IntegrationResult(True, f"Postiz configurado em {self.base_url}.", {"base_url": self.base_url, "mcp_url": self.mcp_url})
38
38
 
39
39
  def list_integrations(self) -> IntegrationResult:
@@ -0,0 +1,152 @@
1
+ """Upload-Post API adapter for publishing local video artefacts."""
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_UPLOAD_POST_BASE_URL = "https://api.upload-post.com/api"
15
+ UPLOAD_POST_PLATFORM_OPTIONS = (
16
+ "tiktok",
17
+ "instagram",
18
+ "youtube",
19
+ "facebook",
20
+ "linkedin",
21
+ "x",
22
+ "threads",
23
+ "pinterest",
24
+ "reddit",
25
+ "bluesky",
26
+ "discord",
27
+ "telegram",
28
+ )
29
+ _PLATFORM_ALIASES = {
30
+ "facebook pages": "facebook",
31
+ "facebook_pages": "facebook",
32
+ "twitter": "x",
33
+ "x (twitter)": "x",
34
+ }
35
+
36
+
37
+ def normalize_upload_post_platforms(value: Any) -> list[str]:
38
+ """Return stable Upload-Post platform slugs without duplicates."""
39
+ values = value if isinstance(value, (list, tuple, set)) else str(value or "").replace("\n", ",").split(",")
40
+ result: list[str] = []
41
+ seen: set[str] = set()
42
+ supported = set(UPLOAD_POST_PLATFORM_OPTIONS)
43
+ for item in values:
44
+ platform = str(item or "").strip().lower()
45
+ platform = _PLATFORM_ALIASES.get(platform, platform)
46
+ if platform in supported and platform not in seen:
47
+ result.append(platform)
48
+ seen.add(platform)
49
+ return result
50
+
51
+
52
+ class UploadPostAdapter:
53
+ """Small deterministic client for Upload-Post's ``POST /upload`` endpoint."""
54
+
55
+ def __init__(self, settings: dict[str, Any] | None = None):
56
+ self.settings = settings or {}
57
+ self.api_key = str(self.settings.get("upload_post_api_key") or "").strip()
58
+ self.enabled = bool(self.settings.get("upload_post_enabled", False))
59
+ self.username = str(self.settings.get("upload_post_username") or "").strip()
60
+ self.platforms = normalize_upload_post_platforms(self.settings.get("upload_post_platforms", ""))
61
+ self.base_url = str(self.settings.get("upload_post_base_url") or DEFAULT_UPLOAD_POST_BASE_URL).strip().rstrip("/")
62
+
63
+ def _headers(self) -> dict[str, str]:
64
+ return {"Authorization": f"Apikey {self.api_key}"}
65
+
66
+ def _error(self, message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
67
+ return IntegrationResult(False, message, data or {})
68
+
69
+ def status(self) -> IntegrationResult:
70
+ if not self.enabled:
71
+ return self._error("Upload-Post está desactivado em Configuração API.")
72
+ if not self.api_key:
73
+ return self._error("Upload-Post não configurado: adicione a API key em Configuração API > API Keys > Serviços e modelos.")
74
+ if not self.username:
75
+ return self._error("Upload-Post não configurado: adicione o username/perfil em Configuração API.")
76
+ if not self.platforms:
77
+ return self._error("Upload-Post não configurado: indique pelo menos uma plataforma em Configuração API.")
78
+ return IntegrationResult(
79
+ True,
80
+ f"Upload-Post configurado para {self.username} ({', '.join(self.platforms)}).",
81
+ {"base_url": self.base_url, "username": self.username, "platforms": self.platforms},
82
+ )
83
+
84
+ def upload_video(
85
+ self,
86
+ video_path: str | Path,
87
+ *,
88
+ title: str,
89
+ description: str = "",
90
+ user: str | None = None,
91
+ platforms: list[str] | None = None,
92
+ async_upload: bool = False,
93
+ ) -> IntegrationResult:
94
+ """Upload a local video to one or more connected Upload-Post platforms."""
95
+ if not self.enabled:
96
+ return self._error("Upload-Post está desactivado em Configuração API.")
97
+ if not self.api_key:
98
+ return self._error("Upload-Post não configurado: adicione a API key em Configuração API.")
99
+ path = Path(video_path)
100
+ if not path.is_file():
101
+ return self._error(f"Vídeo não encontrado para o Upload-Post: {path}")
102
+ selected_user = str(user or self.username).strip()
103
+ selected_platforms = normalize_upload_post_platforms(platforms or self.platforms)
104
+ if not selected_user:
105
+ return self._error("Indique o username/perfil ligado ao Upload-Post antes de publicar.")
106
+ if not selected_platforms:
107
+ return self._error("Seleccione pelo menos uma plataforma do Upload-Post antes de publicar.")
108
+
109
+ form_data: list[tuple[str, str]] = [
110
+ ("user", selected_user),
111
+ *[("platform[]", platform) for platform in selected_platforms],
112
+ ("title", str(title or "Vídeo Thunderbolt").strip()[:500] or "Vídeo Thunderbolt"),
113
+ ("description", str(description or "").strip()),
114
+ ("async_upload", "true" if async_upload else "false"),
115
+ ]
116
+ content_type = mimetypes.guess_type(path.name)[0] or "video/mp4"
117
+ try:
118
+ with path.open("rb") as handle:
119
+ response = requests.post(
120
+ f"{self.base_url}/upload",
121
+ headers=self._headers(),
122
+ data=form_data,
123
+ files={"video": (path.name, handle, content_type)},
124
+ timeout=240,
125
+ )
126
+ except requests.RequestException as exc:
127
+ return self._error(f"Não foi possível contactar o Upload-Post: {exc}", {"platforms": selected_platforms, "user": selected_user})
128
+ if response.status_code >= 400:
129
+ return self._error(
130
+ f"Upload-Post rejeitou o vídeo (HTTP {response.status_code}): {response.text[:500]}",
131
+ {"platforms": selected_platforms, "user": selected_user},
132
+ )
133
+ try:
134
+ payload: Any = response.json()
135
+ except ValueError:
136
+ return self._error("Upload-Post devolveu uma resposta que não é JSON.", {"response": response.text[:2000]})
137
+ payload_dict = payload if isinstance(payload, dict) else {"response": payload}
138
+ request_id = str(payload_dict.get("request_id") or payload_dict.get("requestId") or "").strip()
139
+ message = "Upload-Post aceitou o vídeo para publicação."
140
+ if request_id:
141
+ message += f" Request ID: {request_id}."
142
+ return IntegrationResult(
143
+ True,
144
+ message,
145
+ {
146
+ "payload": payload_dict,
147
+ "request_id": request_id,
148
+ "user": selected_user,
149
+ "platforms": selected_platforms,
150
+ "async_upload": bool(async_upload),
151
+ },
152
+ )
@@ -154,7 +154,7 @@ def upload_with_default_route(
154
154
 
155
155
  postiz = PostizAdapter(settings)
156
156
  if not bool(settings.get("postiz_enabled", False)):
157
- postiz_result = IntegrationResult(False, "Postiz está desactivado em Configurações Técnicas.", {})
157
+ postiz_result = IntegrationResult(False, "Postiz está desactivado em Configuração API > API Keys > Serviços e modelos.", {})
158
158
  attempts.append(_attempt_record("Postiz", postiz_result, skipped=True))
159
159
  else:
160
160
  postiz_publisher = postiz_publisher or postiz.publish_video
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.76",
3
+ "version": "0.2.77",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",