@danhachuel/thunderbolt 0.2.75 → 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.
@@ -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
 
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
7
+ {"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
8
+ {"code": "pixabay", "label": "Pixabay", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pixabay_api_keys"},
9
+ {"code": "coverr", "label": "Coverr", "description": "Fonte de vídeos de stock com API própria.", "legacy_key": "coverr_api_keys"},
10
+ {"code": "wavespeed", "label": "WaveSpeed AI", "description": "Geração de clips por IA; requer um serviço configurado.", "legacy_key": "wavespeed_api_keys"},
11
+ {"code": "loomloom", "label": "LoomLoom", "description": "Fonte paga de materiais; requer confirmação no fluxo de criação.", "legacy_key": "loomloom_api_keys"},
12
+ {"code": "twelvelabs", "label": "TwelveLabs", "description": "Ranking e análise semântica opcional dos materiais.", "legacy_key": "twelvelabs_api_keys"},
13
+ )
14
+
15
+
16
+ def _as_key_list(value: Any) -> list[str]:
17
+ if isinstance(value, list):
18
+ values = value
19
+ else:
20
+ values = str(value or "").replace("\n", ",").split(",")
21
+ result: list[str] = []
22
+ seen: set[str] = set()
23
+ for item in values:
24
+ key = str(item or "").strip()
25
+ if key and key not in seen:
26
+ result.append(key)
27
+ seen.add(key)
28
+ return result
29
+
30
+
31
+ def material_source_catalog() -> list[dict[str, str]]:
32
+ return [dict(item) for item in MATERIAL_SOURCE_CATALOG]
33
+
34
+
35
+ def material_api_keys(settings: dict[str, Any], source: str) -> list[str]:
36
+ code = str(source or "").strip().lower()
37
+ saved = settings.get("material_api_keys", {})
38
+ if isinstance(saved, dict) and code in saved:
39
+ return _as_key_list(saved.get(code))
40
+ legacy_key = next((item["legacy_key"] for item in MATERIAL_SOURCE_CATALOG if item["code"] == code), f"{code}_api_keys")
41
+ return _as_key_list(settings.get(legacy_key, ""))
42
+
43
+
44
+ def all_material_api_keys(settings: dict[str, Any]) -> dict[str, list[str]]:
45
+ return {item["code"]: material_api_keys(settings, item["code"]) for item in MATERIAL_SOURCE_CATALOG}
46
+
47
+
48
+ def update_material_api_keys(settings: dict[str, Any], source: str, keys: list[str]) -> dict[str, Any]:
49
+ code = str(source or "").strip().lower()
50
+ if code not in {item["code"] for item in MATERIAL_SOURCE_CATALOG}:
51
+ raise ValueError("Fonte de materiais inválida.")
52
+ cleaned = _as_key_list(keys)
53
+ mapping = all_material_api_keys(settings)
54
+ mapping[code] = cleaned
55
+ settings["material_api_keys"] = mapping
56
+ source_entry = next(item for item in MATERIAL_SOURCE_CATALOG if item["code"] == code)
57
+ settings[source_entry["legacy_key"]] = cleaned
58
+ return settings
59
+
60
+
61
+ def selected_material_source(settings: dict[str, Any]) -> str:
62
+ source = str(settings.get("video_source") or "pexels").strip().lower()
63
+ valid = {item["code"] for item in MATERIAL_SOURCE_CATALOG} | {"local"}
64
+ return source if source in valid else "pexels"
@@ -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": "",
@@ -197,9 +198,12 @@ DEFAULTS: dict[str, Any] = {
197
198
  "endpoint": "",
198
199
  "proxy_http": "",
199
200
  "proxy_https": "",
201
+ "material_api_keys": {},
200
202
  "pexels_api_keys": "",
201
203
  "pixabay_api_keys": "",
202
204
  "coverr_api_keys": "",
205
+ "wavespeed_api_keys": "",
206
+ "loomloom_api_keys": "",
203
207
  "twelvelabs_api_keys": "",
204
208
  "sonilo_api_key": "",
205
209
  "subtitle_provider": "edge",
@@ -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.")
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
+ from hermes_ui.material_sources import all_material_api_keys, selected_material_source
7
+
6
8
  try:
7
9
  import toml
8
10
  except ImportError: # pragma: no cover - installation fallback
@@ -112,9 +114,13 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
112
114
  for settings_key, config_key in app_map.items():
113
115
  if settings_key in settings:
114
116
  app[config_key] = settings[settings_key]
115
- for key in ("pexels_api_keys", "pixabay_api_keys", "coverr_api_keys", "twelvelabs_api_keys"):
116
- if key in settings:
117
- app[key] = _list_value(settings[key])
117
+ # A UI can store the canonical mapping while older installations still
118
+ # expose one legacy field per source. Export every supported source as a
119
+ # TOML array so MoneyPrinterTurbo can rotate keys without CSV parsing.
120
+ canonical_material_keys = all_material_api_keys(settings)
121
+ for source, keys in canonical_material_keys.items():
122
+ app[f"{source}_api_keys"] = _list_value(keys)
123
+ app["video_source"] = selected_material_source(settings)
118
124
  config["app"] = app
119
125
 
120
126
  whisper["model_size"] = settings.get("whisper_model_size", whisper.get("model_size", "large-v3"))
@@ -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.75",
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",