@danhachuel/thunderbolt 0.2.74 → 0.2.76
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 +23 -13
- package/README.md +20 -14
- package/THIRD-PARTY-NOTICES.md +4 -0
- package/app/main.py +344 -211
- package/hermes_ui/material_sources.py +64 -0
- package/hermes_ui/media_downloader.py +313 -0
- package/hermes_ui/notifications.py +2 -0
- package/hermes_ui/storage.py +6 -1
- package/integrations/moneyprinter_config.py +9 -3
- package/package.json +1 -1
- package/requirements.txt +1 -0
|
@@ -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"
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Iterable
|
|
9
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
from . import storage
|
|
12
|
+
from .notifications import record_notification
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import yt_dlp # type: ignore
|
|
16
|
+
except ImportError: # pragma: no cover - exercised when the optional dependency is absent
|
|
17
|
+
yt_dlp = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
HISTORY_FILE = "media_downloads.json"
|
|
21
|
+
VIDEO_QUALITY_OPTIONS = {
|
|
22
|
+
"Melhor qualidade": "bv*+ba/b",
|
|
23
|
+
"1080p ou inferior": "bv*[height<=1080]+ba/b[height<=1080]",
|
|
24
|
+
"720p ou inferior": "bv*[height<=720]+ba/b[height<=720]",
|
|
25
|
+
"480p ou inferior": "bv*[height<=480]+ba/b[height<=480]",
|
|
26
|
+
}
|
|
27
|
+
VIDEO_CONTAINERS = ("mp4", "mkv", "webm")
|
|
28
|
+
AUDIO_FORMATS = ("mp3", "m4a", "wav", "opus")
|
|
29
|
+
ProgressCallback = Callable[[dict[str, Any]], None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MediaDownloadError(RuntimeError):
|
|
33
|
+
"""Raised when a media download cannot be completed safely."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _now() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).isoformat()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _download_root() -> Path:
|
|
41
|
+
storage.ensure_storage()
|
|
42
|
+
storage.MEDIA_DOWNLOADS.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return storage.MEDIA_DOWNLOADS.resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _safe_download_path(value: str | Path) -> Path | None:
|
|
47
|
+
root = _download_root()
|
|
48
|
+
candidate = Path(value)
|
|
49
|
+
if not candidate.is_absolute():
|
|
50
|
+
candidate = root / candidate
|
|
51
|
+
try:
|
|
52
|
+
resolved = candidate.resolve()
|
|
53
|
+
resolved.relative_to(root)
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
return resolved
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _relative_name(path: Path) -> str:
|
|
60
|
+
root = _download_root()
|
|
61
|
+
try:
|
|
62
|
+
return path.resolve().relative_to(root).as_posix()
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
return path.name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _display_url(value: str) -> str:
|
|
68
|
+
parsed = urlsplit(value.strip())
|
|
69
|
+
if not parsed.scheme or not parsed.netloc:
|
|
70
|
+
return value.strip()[:120]
|
|
71
|
+
safe = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
|
|
72
|
+
return safe[:160]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _redact(value: Any) -> str:
|
|
76
|
+
text = str(value or "").strip()
|
|
77
|
+
text = re.sub(r"(?i)(authorization|bearer|api[_ -]?key|access[_ -]?token|cookie|session[_ -]?info)\s*[:=]?\s*[^\s,;]+", r"\1=[redacted]", text)
|
|
78
|
+
return text[:1200]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize_urls(value: str | Iterable[str]) -> list[str]:
|
|
82
|
+
"""Normalize one URL per line and reject unsafe/non-web inputs."""
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
candidates = value.splitlines()
|
|
85
|
+
else:
|
|
86
|
+
candidates = list(value)
|
|
87
|
+
urls: list[str] = []
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
for candidate in candidates:
|
|
90
|
+
url = str(candidate or "").strip()
|
|
91
|
+
if not url:
|
|
92
|
+
continue
|
|
93
|
+
if url.startswith("-"):
|
|
94
|
+
raise ValueError("Cada linha deve conter apenas uma URL; opções do yt-dlp não são aceites.")
|
|
95
|
+
parsed = urlsplit(url)
|
|
96
|
+
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc:
|
|
97
|
+
raise ValueError(f"URL inválida ou não suportada: {_display_url(url)}")
|
|
98
|
+
normalized = urlunsplit((parsed.scheme.lower(), parsed.netloc, parsed.path, parsed.query, parsed.fragment))
|
|
99
|
+
if normalized not in seen:
|
|
100
|
+
urls.append(normalized)
|
|
101
|
+
seen.add(normalized)
|
|
102
|
+
if not urls:
|
|
103
|
+
raise ValueError("Introduza pelo menos uma URL http(s) para descarregar.")
|
|
104
|
+
return urls
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_download_options(
|
|
108
|
+
*,
|
|
109
|
+
mode: str = "video",
|
|
110
|
+
quality: str = "Melhor qualidade",
|
|
111
|
+
container: str = "mp4",
|
|
112
|
+
audio_format: str = "mp3",
|
|
113
|
+
allow_playlist: bool = False,
|
|
114
|
+
download_subtitles: bool = False,
|
|
115
|
+
embed_metadata: bool = False,
|
|
116
|
+
progress_hook: Callable[[dict[str, Any]], None] | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Build a constrained YoutubeDL options dictionary without user CLI flags."""
|
|
119
|
+
normalized_mode = str(mode or "video").strip().lower()
|
|
120
|
+
if normalized_mode not in {"video", "audio"}:
|
|
121
|
+
raise ValueError("O modo deve ser Vídeo ou Áudio.")
|
|
122
|
+
normalized_container = str(container or "mp4").lower()
|
|
123
|
+
normalized_audio = str(audio_format or "mp3").lower()
|
|
124
|
+
if normalized_container not in VIDEO_CONTAINERS:
|
|
125
|
+
raise ValueError("Contentor de vídeo não suportado.")
|
|
126
|
+
if normalized_audio not in AUDIO_FORMATS:
|
|
127
|
+
raise ValueError("Formato de áudio não suportado.")
|
|
128
|
+
root = _download_root()
|
|
129
|
+
options: dict[str, Any] = {
|
|
130
|
+
"outtmpl": str(root / "%(title).200B [%(id)s].%(ext)s"),
|
|
131
|
+
"noplaylist": not bool(allow_playlist),
|
|
132
|
+
"quiet": True,
|
|
133
|
+
"no_warnings": True,
|
|
134
|
+
"ignoreerrors": False,
|
|
135
|
+
"windowsfilenames": True,
|
|
136
|
+
"overwrites": False,
|
|
137
|
+
"paths": {"home": str(root)},
|
|
138
|
+
}
|
|
139
|
+
if progress_hook is not None:
|
|
140
|
+
options["progress_hooks"] = [progress_hook]
|
|
141
|
+
if normalized_mode == "audio":
|
|
142
|
+
options.update({"format": "bestaudio/best", "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": normalized_audio, "preferredquality": "192"}]})
|
|
143
|
+
else:
|
|
144
|
+
options.update({"format": VIDEO_QUALITY_OPTIONS.get(quality, VIDEO_QUALITY_OPTIONS["Melhor qualidade"]), "merge_output_format": normalized_container})
|
|
145
|
+
if download_subtitles:
|
|
146
|
+
options.update({"writesubtitles": True, "writeautomaticsub": True, "subtitlesformat": "best", "subtitleslangs": ["all"]})
|
|
147
|
+
if embed_metadata:
|
|
148
|
+
options["addmetadata"] = True
|
|
149
|
+
return options
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_history() -> list[dict[str, Any]]:
|
|
153
|
+
records = storage.read_json(HISTORY_FILE, [])
|
|
154
|
+
return [item for item in records if isinstance(item, dict)] if isinstance(records, list) else []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _write_history(records: list[dict[str, Any]]) -> None:
|
|
158
|
+
storage.write_json(HISTORY_FILE, records[:200])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _upsert_history(record: dict[str, Any]) -> None:
|
|
162
|
+
history = [item for item in _read_history() if str(item.get("operation_id")) != str(record.get("operation_id"))]
|
|
163
|
+
_write_history([record, *history])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_media_downloads(limit: int = 50) -> list[dict[str, Any]]:
|
|
167
|
+
return _read_history()[: max(0, int(limit))]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def clear_media_download_history() -> int:
|
|
171
|
+
count = len(_read_history())
|
|
172
|
+
_write_history([])
|
|
173
|
+
return count
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def media_download_file(record: dict[str, Any], filename: str) -> Path | None:
|
|
177
|
+
"""Resolve a history filename strictly inside storage/downloads."""
|
|
178
|
+
allowed = {str(item) for item in record.get("files", []) if item}
|
|
179
|
+
if filename not in allowed:
|
|
180
|
+
return None
|
|
181
|
+
path = _safe_download_path(filename)
|
|
182
|
+
return path if path and path.is_file() else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def dependency_status() -> dict[str, Any]:
|
|
186
|
+
return {"yt_dlp": yt_dlp is not None, "ffmpeg_note": "A conversão/combinação de streams pode exigir FFmpeg."}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _files_from_info(info: Any, started_at: float) -> list[Path]:
|
|
190
|
+
candidates: list[str] = []
|
|
191
|
+
if isinstance(info, dict):
|
|
192
|
+
for key in ("filepath", "_filename", "filename"):
|
|
193
|
+
if info.get(key):
|
|
194
|
+
candidates.append(str(info[key]))
|
|
195
|
+
requested = info.get("requested_downloads")
|
|
196
|
+
if isinstance(requested, list):
|
|
197
|
+
for item in requested:
|
|
198
|
+
if isinstance(item, dict):
|
|
199
|
+
for key in ("filepath", "_filename", "filename"):
|
|
200
|
+
if item.get(key):
|
|
201
|
+
candidates.append(str(item[key]))
|
|
202
|
+
entries = info.get("entries")
|
|
203
|
+
if isinstance(entries, list):
|
|
204
|
+
for entry in entries:
|
|
205
|
+
candidates.extend(str(path) for path in _files_from_info(entry, started_at))
|
|
206
|
+
output: list[Path] = []
|
|
207
|
+
seen: set[str] = set()
|
|
208
|
+
for candidate in candidates:
|
|
209
|
+
path = _safe_download_path(candidate)
|
|
210
|
+
if path and path.is_file() and path.suffix.lower() not in {".part", ".ytdl"} and str(path) not in seen:
|
|
211
|
+
output.append(path)
|
|
212
|
+
seen.add(str(path))
|
|
213
|
+
root = _download_root()
|
|
214
|
+
try:
|
|
215
|
+
for path in root.rglob("*"):
|
|
216
|
+
if path.is_file() and path.suffix.lower() not in {".part", ".ytdl", ".json", ".description", ".vtt", ".srt", ".ass"} and path.stat().st_mtime >= started_at and str(path) not in seen:
|
|
217
|
+
output.append(path)
|
|
218
|
+
seen.add(str(path))
|
|
219
|
+
except OSError:
|
|
220
|
+
pass
|
|
221
|
+
return output
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _operation_id(url: str) -> str:
|
|
225
|
+
digest = hashlib.sha256(f"{url}|{_now()}|{uuid.uuid4().hex}".encode("utf-8")).hexdigest()[:16]
|
|
226
|
+
return f"media_{digest}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _notify(record: dict[str, Any]) -> None:
|
|
230
|
+
suffix = "concluído" if record.get("status") == "completed" else "falhou"
|
|
231
|
+
event_type = "media_download_completed" if record.get("status") == "completed" else "media_download_failed"
|
|
232
|
+
title = str(record.get("title") or record.get("display_url") or "Download de mídia")
|
|
233
|
+
message = f"O download de mídia {suffix}."
|
|
234
|
+
if record.get("status") == "failed" and record.get("error"):
|
|
235
|
+
message = f"O download de mídia falhou: {_redact(record['error'])}"
|
|
236
|
+
record_notification(
|
|
237
|
+
event_type,
|
|
238
|
+
title,
|
|
239
|
+
message,
|
|
240
|
+
metadata={"operation_id": record.get("operation_id"), "mode": record.get("mode"), "files": record.get("files", []), "display_url": record.get("display_url")},
|
|
241
|
+
dedupe_key=f"media:{record.get('operation_id')}:{record.get('status')}",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def download_media(
|
|
246
|
+
urls: str | Iterable[str],
|
|
247
|
+
*,
|
|
248
|
+
mode: str = "video",
|
|
249
|
+
quality: str = "Melhor qualidade",
|
|
250
|
+
container: str = "mp4",
|
|
251
|
+
audio_format: str = "mp3",
|
|
252
|
+
allow_playlist: bool = False,
|
|
253
|
+
download_subtitles: bool = False,
|
|
254
|
+
embed_metadata: bool = False,
|
|
255
|
+
progress_callback: ProgressCallback | None = None,
|
|
256
|
+
) -> list[dict[str, Any]]:
|
|
257
|
+
"""Download one or more public URLs and return one persisted record per URL."""
|
|
258
|
+
normalized_urls = normalize_urls(urls)
|
|
259
|
+
results: list[dict[str, Any]] = []
|
|
260
|
+
for url in normalized_urls:
|
|
261
|
+
operation_id = _operation_id(url)
|
|
262
|
+
record: dict[str, Any] = {
|
|
263
|
+
"operation_id": operation_id,
|
|
264
|
+
"url": _display_url(url),
|
|
265
|
+
"display_url": _display_url(url),
|
|
266
|
+
"mode": str(mode or "video").lower(),
|
|
267
|
+
"status": "processing",
|
|
268
|
+
"title": "",
|
|
269
|
+
"files": [],
|
|
270
|
+
"progress": 0.0,
|
|
271
|
+
"created_at": _now(),
|
|
272
|
+
"completed_at": "",
|
|
273
|
+
"error": "",
|
|
274
|
+
}
|
|
275
|
+
_upsert_history(record)
|
|
276
|
+
if progress_callback:
|
|
277
|
+
progress_callback({**record, "status": "processing"})
|
|
278
|
+
started_at = datetime.now().timestamp()
|
|
279
|
+
|
|
280
|
+
def progress_hook(payload: dict[str, Any]) -> None:
|
|
281
|
+
status = str(payload.get("status") or "")
|
|
282
|
+
downloaded = float(payload.get("downloaded_bytes") or 0)
|
|
283
|
+
total = float(payload.get("total_bytes") or payload.get("total_bytes_estimate") or 0)
|
|
284
|
+
progress = min(99.0, (downloaded / total) * 100) if total > 0 else (50.0 if status == "downloading" else 0.0)
|
|
285
|
+
record["progress"] = round(progress, 1)
|
|
286
|
+
if payload.get("filename"):
|
|
287
|
+
record["current_file"] = Path(str(payload["filename"])).name
|
|
288
|
+
if progress_callback:
|
|
289
|
+
progress_callback({**record, "hook_status": status})
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
if yt_dlp is None:
|
|
293
|
+
raise MediaDownloadError("yt-dlp não está instalado. Instale as dependências do Thunderbolt e tente novamente.")
|
|
294
|
+
options = build_download_options(mode=mode, quality=quality, container=container, audio_format=audio_format, allow_playlist=allow_playlist, download_subtitles=download_subtitles, embed_metadata=embed_metadata, progress_hook=progress_hook)
|
|
295
|
+
downloader = yt_dlp.YoutubeDL(options)
|
|
296
|
+
info = downloader.extract_info(url, download=True)
|
|
297
|
+
close = getattr(downloader, "close", None)
|
|
298
|
+
if callable(close):
|
|
299
|
+
close()
|
|
300
|
+
files = _files_from_info(info, started_at)
|
|
301
|
+
if not files:
|
|
302
|
+
raise MediaDownloadError("O yt-dlp terminou sem produzir um ficheiro local verificável.")
|
|
303
|
+
record.update({"status": "completed", "title": str(info.get("title") or "Download concluído") if isinstance(info, dict) else "Download concluído", "files": [_relative_name(path) for path in files], "progress": 100.0, "completed_at": _now(), "error": ""})
|
|
304
|
+
_upsert_history(record)
|
|
305
|
+
_notify(record)
|
|
306
|
+
except Exception as exc: # the UI receives a persisted failed record per URL
|
|
307
|
+
record.update({"status": "failed", "error": _redact(exc), "completed_at": _now()})
|
|
308
|
+
_upsert_history(record)
|
|
309
|
+
_notify(record)
|
|
310
|
+
results.append(dict(record))
|
|
311
|
+
if progress_callback:
|
|
312
|
+
progress_callback(dict(record))
|
|
313
|
+
return results
|
|
@@ -26,6 +26,8 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
|
|
|
26
26
|
{"code": "cuts_completed", "category": "Edição", "label": "Cortes concluídos", "description": "Quando a geração de cortes terminar com um manifesto completo."},
|
|
27
27
|
{"code": "metadata_cleaning_completed", "category": "Edição", "label": "Metadados limpos", "description": "Quando uma cópia com metadados limpos for criada."},
|
|
28
28
|
{"code": "python_edit_completed", "category": "Edição", "label": "Edição Python concluída", "description": "Quando uma operação do Editor Python guardar o artefacto."},
|
|
29
|
+
{"code": "media_download_completed", "category": "Edição", "label": "Download Mídia concluído", "description": "Quando um vídeo ou áudio terminar de ser descarregado com sucesso."},
|
|
30
|
+
{"code": "media_download_failed", "category": "Edição", "label": "Download Mídia falhou", "description": "Quando um download de vídeo ou áudio terminar com erro."},
|
|
29
31
|
{"code": "automation_completed", "category": "Automação", "label": "Automação concluída", "description": "Quando o worker concluir o lote agendado de um canal."},
|
|
30
32
|
{"code": "automation_failed", "category": "Automação", "label": "Automação falhou", "description": "Quando uma execução automática terminar com erro."},
|
|
31
33
|
{"code": "activity_failed", "category": "Sistema", "label": "Actividade falhou", "description": "Quando uma tarefa ou operação persistida terminar em erro."},
|
package/hermes_ui/storage.py
CHANGED
|
@@ -13,6 +13,7 @@ STORAGE = Path(os.getenv("THUNDERBOLT_STORAGE_DIR") or ROOT / "storage")
|
|
|
13
13
|
STATE = STORAGE / "state"
|
|
14
14
|
BLUEPRINTS = STORAGE / "blueprints"
|
|
15
15
|
TIKTOK_PROMPT_MASTERS = STORAGE / "tiktok" / "prompts_master"
|
|
16
|
+
MEDIA_DOWNLOADS = STORAGE / "downloads"
|
|
16
17
|
NICHES_DATA = STORAGE / "data" / "niches"
|
|
17
18
|
SEED_BLUEPRINTS = ROOT / "seed" / "blueprints"
|
|
18
19
|
SEED_TIKTOK_PROMPT_MASTERS = ROOT / "seed" / "prompt_masters"
|
|
@@ -25,6 +26,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
25
26
|
"batches.json": [],
|
|
26
27
|
"uploads.json": [],
|
|
27
28
|
"notifications.json": [],
|
|
29
|
+
"media_downloads.json": [],
|
|
28
30
|
"display_names.json": {"blueprints": {}, "prompt_masters": {}},
|
|
29
31
|
"niche_apify_runs.json": [],
|
|
30
32
|
"metadata_edits.json": [],
|
|
@@ -195,9 +197,12 @@ DEFAULTS: dict[str, Any] = {
|
|
|
195
197
|
"endpoint": "",
|
|
196
198
|
"proxy_http": "",
|
|
197
199
|
"proxy_https": "",
|
|
200
|
+
"material_api_keys": {},
|
|
198
201
|
"pexels_api_keys": "",
|
|
199
202
|
"pixabay_api_keys": "",
|
|
200
203
|
"coverr_api_keys": "",
|
|
204
|
+
"wavespeed_api_keys": "",
|
|
205
|
+
"loomloom_api_keys": "",
|
|
201
206
|
"twelvelabs_api_keys": "",
|
|
202
207
|
"sonilo_api_key": "",
|
|
203
208
|
"subtitle_provider": "edge",
|
|
@@ -283,7 +288,7 @@ def seed_prompt_masters() -> None:
|
|
|
283
288
|
|
|
284
289
|
|
|
285
290
|
def ensure_storage() -> None:
|
|
286
|
-
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
|
|
291
|
+
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
|
|
287
292
|
path.mkdir(parents=True, exist_ok=True)
|
|
288
293
|
seed_blueprints()
|
|
289
294
|
seed_prompt_masters()
|
|
@@ -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/package.json
CHANGED