@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.
- package/MANUAL-INSTALACAO.md +29 -13
- package/README.md +25 -13
- package/app/main.py +302 -211
- package/app/modules/niche_finder/apify.py +1 -1
- package/hermes_ui/creative_generation.py +3 -3
- package/hermes_ui/material_sources.py +64 -0
- package/hermes_ui/notifications.py +3 -0
- package/hermes_ui/storage.py +4 -0
- package/hermes_ui/thumbnail_generation.py +1 -1
- package/integrations/moneyprinter_config.py +9 -3
- package/integrations/postiz.py +1 -1
- package/integrations/upload_post.py +152 -0
- package/integrations/upload_routing.py +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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:
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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"))
|
package/integrations/postiz.py
CHANGED
|
@@ -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
|
|
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
|
|
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