@danhachuel/thunderbolt 0.2.18 → 0.2.20
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 +28 -4
- package/README.md +33 -7
- package/app/main.py +472 -15
- package/hermes_ui/domain.py +36 -0
- package/hermes_ui/mcp.py +145 -0
- package/hermes_ui/music.py +82 -0
- package/hermes_ui/storage.py +56 -1
- package/hermes_ui/voice_preview.py +119 -0
- package/integrations/data/azure_voices.json +1326 -0
- package/integrations/platforms.py +114 -44
- package/integrations/youtube_direct_upload.py +169 -0
- package/package.json +3 -1
- package/requirements.txt +1 -0
- package/scripts/cli.mjs +4 -1
- package/scripts/install.mjs +4 -1
- package/seed/skills/moneyprinterturbo-video.md +132 -0
|
@@ -3,9 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
9
11
|
|
|
10
12
|
import requests
|
|
11
13
|
|
|
@@ -106,6 +108,72 @@ def _thumbnail_from_metadata(metadata: dict[str, Any]) -> str:
|
|
|
106
108
|
return ""
|
|
107
109
|
|
|
108
110
|
|
|
111
|
+
def _meta_content(document: str, *names: str) -> str:
|
|
112
|
+
for name in names:
|
|
113
|
+
pattern = rf'<meta[^>]+(?:name|property)=["\']{re.escape(name)}["\'][^>]+content=["\']([^"\']*)["\']'
|
|
114
|
+
match = re.search(pattern, document, flags=re.IGNORECASE)
|
|
115
|
+
if match:
|
|
116
|
+
return match.group(1).strip()
|
|
117
|
+
reverse_pattern = rf'<meta[^>]+content=["\']([^"\']*)["\'][^>]+(?:name|property)=["\']{re.escape(name)}["\']'
|
|
118
|
+
match = re.search(reverse_pattern, document, flags=re.IGNORECASE)
|
|
119
|
+
if match:
|
|
120
|
+
return match.group(1).strip()
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _public_page_candidates(source: str) -> list[str]:
|
|
125
|
+
source = source.strip()
|
|
126
|
+
if source.startswith(("http://", "https://")):
|
|
127
|
+
parsed = urlparse(source)
|
|
128
|
+
base = f"https://{parsed.netloc}{parsed.path}".rstrip("/")
|
|
129
|
+
if parsed.netloc.lower().endswith("youtube.com"):
|
|
130
|
+
base = base.replace("/about", "").replace("/videos", "")
|
|
131
|
+
elif source.startswith("UC"):
|
|
132
|
+
base = f"https://www.youtube.com/channel/{source}"
|
|
133
|
+
else:
|
|
134
|
+
handle = source if source.startswith("@") else f"@{source}"
|
|
135
|
+
base = f"https://www.youtube.com/{handle}"
|
|
136
|
+
return list(dict.fromkeys([
|
|
137
|
+
f"{base}/about?hl=pt-BR&gl=BR",
|
|
138
|
+
f"{base}?hl=pt-BR&gl=BR",
|
|
139
|
+
f"{base}/videos?hl=pt-BR&gl=BR",
|
|
140
|
+
]))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _channel_id_from_document(document: str, canonical_url: str = "") -> str:
|
|
144
|
+
patterns = [
|
|
145
|
+
r'"externalId"\s*:\s*"(UC[A-Za-z0-9_-]+)"',
|
|
146
|
+
r'"channelId"\s*:\s*"(UC[A-Za-z0-9_-]+)"',
|
|
147
|
+
r"/channel/(UC[A-Za-z0-9_-]+)",
|
|
148
|
+
]
|
|
149
|
+
for pattern in patterns:
|
|
150
|
+
match = re.search(pattern, document)
|
|
151
|
+
if match:
|
|
152
|
+
return match.group(1)
|
|
153
|
+
match = re.search(r"/channel/(UC[A-Za-z0-9_-]+)", canonical_url)
|
|
154
|
+
return match.group(1) if match else ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _public_feed_data(channel_id: str, headers: dict[str, str]) -> dict[str, Any]:
|
|
158
|
+
if not channel_id:
|
|
159
|
+
return {}
|
|
160
|
+
try:
|
|
161
|
+
response = requests.get(
|
|
162
|
+
f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}",
|
|
163
|
+
headers=headers,
|
|
164
|
+
timeout=12,
|
|
165
|
+
)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
root = ET.fromstring(response.text)
|
|
168
|
+
namespace = {"yt": "http://www.youtube.com/xml/schemas/2015", "atom": "http://www.w3.org/2005/Atom"}
|
|
169
|
+
title = root.findtext("atom:title", default="", namespaces=namespace).strip()
|
|
170
|
+
feed_id = root.findtext("yt:channelId", default=channel_id, namespaces=namespace).strip()
|
|
171
|
+
entries = root.findall("atom:entry", namespace)
|
|
172
|
+
return {"name": title, "youtube_id": feed_id, "video_count": len(entries) if entries else None}
|
|
173
|
+
except (requests.RequestException, ET.ParseError, ValueError):
|
|
174
|
+
return {}
|
|
175
|
+
|
|
176
|
+
|
|
109
177
|
@dataclass
|
|
110
178
|
class IntegrationResult:
|
|
111
179
|
ok: bool
|
|
@@ -125,70 +193,72 @@ class YouTubeAdapter:
|
|
|
125
193
|
return match.group(1) if match else value
|
|
126
194
|
|
|
127
195
|
def fetch_channel_public(self, value: str) -> IntegrationResult:
|
|
128
|
-
"""Fetch public channel metadata
|
|
196
|
+
"""Fetch public channel metadata without requiring a YouTube Data API key."""
|
|
129
197
|
source = (value or "").strip()
|
|
130
198
|
if not source:
|
|
131
199
|
return IntegrationResult(False, "Introduza o nome, handle, URL ou ID do canal.", {})
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
initial_data = _extract_json_assignment(document, "ytInitialData")
|
|
153
|
-
metadata = _find_first_key(initial_data or {}, "channelMetadataRenderer") or {}
|
|
154
|
-
header = _find_first_key(initial_data or {}, "pageHeaderViewModel") or {}
|
|
155
|
-
if not metadata and not header:
|
|
156
|
-
return IntegrationResult(False, "O YouTube não disponibilizou dados públicos para este canal. Confirme o link ou tente a aba Cadastro manual.", {"url": page_url})
|
|
200
|
+
headers = {
|
|
201
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
|
202
|
+
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
203
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
204
|
+
}
|
|
205
|
+
last_url = source
|
|
206
|
+
last_error = ""
|
|
207
|
+
for page_url in _public_page_candidates(source):
|
|
208
|
+
last_url = page_url
|
|
209
|
+
try:
|
|
210
|
+
response = requests.get(page_url, headers=headers, timeout=20)
|
|
211
|
+
response.raise_for_status()
|
|
212
|
+
except requests.RequestException as exc:
|
|
213
|
+
last_error = str(exc)
|
|
214
|
+
continue
|
|
215
|
+
document = response.text or ""
|
|
216
|
+
initial_data = _extract_json_assignment(document, "ytInitialData") or {}
|
|
217
|
+
metadata = _find_first_key(initial_data, "channelMetadataRenderer") or {}
|
|
218
|
+
header = _find_first_key(initial_data, "pageHeaderViewModel") or {}
|
|
219
|
+
canonical_url = _meta_content(document, "og:url", "twitter:url") or page_url.split("?", 1)[0].rstrip("/")
|
|
157
220
|
owner_urls = metadata.get("ownerUrls", []) if isinstance(metadata, dict) else []
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
221
|
+
if owner_urls:
|
|
222
|
+
canonical_url = str(owner_urls[0])
|
|
223
|
+
canonical_url = canonical_url.replace("http://", "https://").removesuffix("/about")
|
|
224
|
+
youtube_id = str(metadata.get("externalId", "")) if isinstance(metadata, dict) else ""
|
|
225
|
+
if not youtube_id:
|
|
226
|
+
youtube_id = _channel_id_from_document(document, canonical_url)
|
|
227
|
+
feed = _public_feed_data(youtube_id, headers)
|
|
162
228
|
title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
|
|
163
229
|
if not title and isinstance(header, dict):
|
|
164
230
|
title = _text_from_node(header.get("title"))
|
|
165
231
|
if not title:
|
|
166
232
|
title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
|
|
233
|
+
if not title:
|
|
234
|
+
title = _meta_content(document, "og:title", "twitter:title") or feed.get("name", "")
|
|
167
235
|
description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
236
|
+
description = description or _meta_content(document, "description", "og:description")
|
|
237
|
+
thumbnail_url = _thumbnail_from_metadata(metadata) or _meta_content(document, "og:image", "twitter:image")
|
|
238
|
+
handle_match = re.search(r"/@([^/?]+)", canonical_url)
|
|
239
|
+
handle = f"@{handle_match.group(1)}" if handle_match else ""
|
|
240
|
+
if not handle:
|
|
241
|
+
handle_match = re.search(r"/@([^/?]+)", document)
|
|
242
|
+
handle = f"@{handle_match.group(1)}" if handle_match else ""
|
|
243
|
+
subscriber_value = _find_first_key(initial_data, "subscriberCountText")
|
|
244
|
+
video_value = _find_first_key(initial_data, "videoCountText")
|
|
174
245
|
data = {
|
|
175
|
-
"youtube_id": youtube_id,
|
|
246
|
+
"youtube_id": youtube_id or feed.get("youtube_id", ""),
|
|
176
247
|
"name": title,
|
|
177
248
|
"handle": handle,
|
|
178
249
|
"url": canonical_url,
|
|
179
250
|
"description": description,
|
|
180
|
-
"thumbnail_url":
|
|
251
|
+
"thumbnail_url": thumbnail_url,
|
|
181
252
|
"subscriber_count": _parse_public_count(subscriber_value),
|
|
182
|
-
"video_count": _parse_public_count(video_value),
|
|
253
|
+
"video_count": _parse_public_count(video_value) or feed.get("video_count"),
|
|
183
254
|
"view_count": None,
|
|
184
255
|
"metrics_source": "youtube_public_page",
|
|
185
256
|
"public_lookup": True,
|
|
186
257
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return IntegrationResult(False, f"A página pública do YouTube mudou ou não pôde ser interpretada: {exc}", {"url": source})
|
|
258
|
+
if data["name"] or data["youtube_id"] or data["thumbnail_url"]:
|
|
259
|
+
return IntegrationResult(True, "Canal encontrado publicamente no YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
260
|
+
last_error = "A página respondeu sem metadados reconhecíveis."
|
|
261
|
+
return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. Confirme o URL/handle ou use Cadastro manual. {last_error}".strip(), {"url": last_url, "public_lookup": True})
|
|
192
262
|
|
|
193
263
|
def fetch_channel(self, value: str) -> IntegrationResult:
|
|
194
264
|
ref = self.extract_channel_ref(value)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CHUNK_GRANULARITY = 262144
|
|
17
|
+
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".webm", ".avi", ".mpeg", ".mpg", ".flv", ".wmv", ".3gpp"}
|
|
18
|
+
COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DirectUploadResult:
|
|
23
|
+
ok: bool
|
|
24
|
+
message: str
|
|
25
|
+
data: dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cookie_settings(settings: dict[str, Any]) -> dict[str, str]:
|
|
29
|
+
return {key: str(settings.get(f"direct_cookie_{key.lower()}", "") or "").strip() for key in COOKIE_KEYS}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cookie_header(cookies: dict[str, str]) -> str:
|
|
33
|
+
return ";".join(["CONSENT=YES+cb"] + [f"{key}={value}" for key, value in cookies.items() if value])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sapishash(sapisid: str, origin: str = "https://studio.youtube.com") -> str:
|
|
37
|
+
timestamp = int(time.time())
|
|
38
|
+
digest = hashlib.sha1(f"{timestamp} {sapisid} {origin}".encode("utf-8")).hexdigest()
|
|
39
|
+
return f"{timestamp}_{digest}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _innertube_id() -> str:
|
|
43
|
+
return f"innertube_studio:{secrets.token_hex(18)}:0"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _header(response: requests.Response, name: str) -> str:
|
|
47
|
+
wanted = name.lower()
|
|
48
|
+
for key, value in response.headers.items():
|
|
49
|
+
if key.lower() == wanted:
|
|
50
|
+
return str(value)
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], settings: dict[str, Any]) -> str | None:
|
|
55
|
+
path = Path(video_path)
|
|
56
|
+
if not path.exists() or not path.is_file():
|
|
57
|
+
return "Ficheiro de vídeo não encontrado."
|
|
58
|
+
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
59
|
+
return "Formato de vídeo não suportado pelo upload directo."
|
|
60
|
+
if path.stat().st_size <= 0:
|
|
61
|
+
return "O ficheiro de vídeo está vazio."
|
|
62
|
+
missing_cookies = [key for key, value in _cookie_settings(settings).items() if not value]
|
|
63
|
+
if missing_cookies:
|
|
64
|
+
return f"Faltam cookies de sessão para upload directo: {', '.join(missing_cookies)}."
|
|
65
|
+
if not str(settings.get("direct_session_info", "") or "").strip():
|
|
66
|
+
return "Configure o token sessionInfo do upload directo."
|
|
67
|
+
if not str(settings.get("direct_innertube_api_key", "") or "").strip():
|
|
68
|
+
return "Configure o INNERTUBE_API_KEY do upload directo."
|
|
69
|
+
if not str(channel.get("delegated_session_id", "") or "").strip():
|
|
70
|
+
return "Este canal não tem DELEGATED_SESSION_ID configurado."
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class YouTubeDirectUploader:
|
|
75
|
+
def __init__(self, settings: dict[str, Any], channel: dict[str, Any], *, session: requests.Session | None = None):
|
|
76
|
+
self.settings = settings
|
|
77
|
+
self.channel = channel
|
|
78
|
+
self.session = session or requests.Session()
|
|
79
|
+
self.cookies = _cookie_settings(settings)
|
|
80
|
+
self.cookie_header = _cookie_header(self.cookies)
|
|
81
|
+
self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36"
|
|
82
|
+
self.inner_tube = _innertube_id()
|
|
83
|
+
self.google_upload: dict[str, str] = {}
|
|
84
|
+
self.video_id = ""
|
|
85
|
+
|
|
86
|
+
def _base_headers(self) -> dict[str, str]:
|
|
87
|
+
return {
|
|
88
|
+
"User-Agent": self.user_agent,
|
|
89
|
+
"Origin": "https://studio.youtube.com",
|
|
90
|
+
"Referer": "https://studio.youtube.com/",
|
|
91
|
+
"Cookie": self.cookie_header,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def describe_file(self, path: Path) -> None:
|
|
95
|
+
headers = {
|
|
96
|
+
**self._base_headers(),
|
|
97
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
98
|
+
"X-Goog-Upload-File-Name": path.name,
|
|
99
|
+
"X-Goog-Upload-Header-Content-Length": str(path.stat().st_size),
|
|
100
|
+
"X-Goog-Upload-Command": "start",
|
|
101
|
+
"X-Goog-Upload-Protocol": "resumable",
|
|
102
|
+
}
|
|
103
|
+
response = self.session.post("https://upload.youtube.com/upload/studio?authuser=0", headers=headers, data={"frontendUploadId": self.inner_tube}, timeout=60)
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
self.google_upload = {
|
|
106
|
+
"resource_id": _header(response, "x-goog-upload-header-scotty-resource-id"),
|
|
107
|
+
"upload_url": _header(response, "x-goog-upload-url"),
|
|
108
|
+
"upload_id": _header(response, "x-guploader-uploadid"),
|
|
109
|
+
}
|
|
110
|
+
if not self.google_upload["upload_url"] or not self.google_upload["resource_id"]:
|
|
111
|
+
raise RuntimeError("O YouTube não devolveu uma sessão de upload directo válida.")
|
|
112
|
+
|
|
113
|
+
def create_video(self, title: str, description: str, visibility: str) -> None:
|
|
114
|
+
payload = {
|
|
115
|
+
"resourceId": {"scottyResourceId": {"id": self.google_upload["resource_id"]}},
|
|
116
|
+
"frontendUploadId": self.inner_tube,
|
|
117
|
+
"initialMetadata": {
|
|
118
|
+
"title": {"newTitle": title},
|
|
119
|
+
"description": {"newDescription": description},
|
|
120
|
+
"privacy": {"newPrivacy": visibility},
|
|
121
|
+
"draftState": {"isDraft": False},
|
|
122
|
+
"targetedAudience": {"operation": "MDE_TARGETED_AUDIENCE_UPDATE_OPERATION_SET", "newTargetedAudience": "MDE_TARGETED_AUDIENCE_TYPE_ALL"},
|
|
123
|
+
},
|
|
124
|
+
"botguardClientResponse": f"${hashlib.sha1(os.urandom(16)).hexdigest()}",
|
|
125
|
+
"context": {
|
|
126
|
+
"client": {"clientName": 62, "clientVersion": "1.20210806.02.00", "hl": "pt-BR", "gl": "BR", "experimentsToken": "", "utcOffsetMinutes": 0},
|
|
127
|
+
"request": {"sessionInfo": {"token": str(self.settings.get("direct_session_info", ""))}},
|
|
128
|
+
"user": {"onBehalfOfUser": str(self.channel.get("delegated_session_id", ""))},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
api_key = str(self.settings.get("direct_innertube_api_key", "")).strip()
|
|
132
|
+
endpoint = f"https://studio.youtube.com/youtubei/v1/upload/createvideo?alt=json&key={quote(api_key)}"
|
|
133
|
+
headers = {**self._base_headers(), "Content-Type": "application/json", "X-Youtube-Client-Name": "62", "X-Youtube-Client-Version": "1.20210806.02.00", "X-Goog-PageId": str(self.channel.get("delegated_session_id", "")), "Authorization": f"SAPISIDHASH {_sapishash(self.cookies['SAPISID'])}"}
|
|
134
|
+
response = self.session.post(endpoint, headers=headers, data=json.dumps(payload), timeout=60)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
body = response.json() if response.content else {}
|
|
137
|
+
self.video_id = str(body.get("videoId") or body.get("video_id") or "")
|
|
138
|
+
if not self.video_id:
|
|
139
|
+
raise RuntimeError("O YouTube não devolveu o videoId após createvideo.")
|
|
140
|
+
|
|
141
|
+
def upload_chunks(self, path: Path, chunk_size: int = CHUNK_GRANULARITY) -> None:
|
|
142
|
+
chunk_size = max(CHUNK_GRANULARITY, int(chunk_size))
|
|
143
|
+
chunk_size -= chunk_size % CHUNK_GRANULARITY
|
|
144
|
+
if chunk_size == 0:
|
|
145
|
+
chunk_size = CHUNK_GRANULARITY
|
|
146
|
+
offset = 0
|
|
147
|
+
with path.open("rb") as handle:
|
|
148
|
+
while True:
|
|
149
|
+
chunk = handle.read(chunk_size)
|
|
150
|
+
if not chunk:
|
|
151
|
+
break
|
|
152
|
+
last = offset + len(chunk) >= path.stat().st_size
|
|
153
|
+
headers = {**self._base_headers(), "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", "X-Goog-Upload-Command": "upload, finalize" if last else "upload", "X-Goog-Upload-Offset": str(offset), "X-Goog-Upload-File-Name": quote(path.name)}
|
|
154
|
+
response = self.session.post(self.google_upload["upload_url"], headers=headers, data=chunk, timeout=180)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
offset += len(chunk)
|
|
157
|
+
|
|
158
|
+
def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int = CHUNK_GRANULARITY) -> DirectUploadResult:
|
|
159
|
+
path = Path(video_path)
|
|
160
|
+
validation_error = validate_direct_upload(path, self.channel, self.settings)
|
|
161
|
+
if validation_error:
|
|
162
|
+
return DirectUploadResult(False, validation_error, {"mechanism": "youtube-frontend-direct"})
|
|
163
|
+
try:
|
|
164
|
+
self.describe_file(path)
|
|
165
|
+
self.create_video(title, description, visibility)
|
|
166
|
+
self.upload_chunks(path, chunk_size)
|
|
167
|
+
return DirectUploadResult(True, f"Upload directo concluído: {self.video_id}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id, "page_id": self.channel.get("delegated_session_id", "")})
|
|
168
|
+
except (requests.RequestException, OSError, ValueError, RuntimeError) as exc:
|
|
169
|
+
return DirectUploadResult(False, f"Upload directo falhou: {exc}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
4
4
|
"description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
|
|
5
5
|
"main": "scripts/cli.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
"app/main.py",
|
|
12
12
|
"hermes_ui/*.py",
|
|
13
13
|
"integrations/*.py",
|
|
14
|
+
"integrations/data/*.json",
|
|
14
15
|
"scripts/*.mjs",
|
|
15
16
|
"storage/blueprints/**/*.json",
|
|
16
17
|
"seed/blueprints/**/*.json",
|
|
18
|
+
"seed/skills/*.md",
|
|
17
19
|
"README.md",
|
|
18
20
|
"MANUAL-INSTALACAO.md",
|
|
19
21
|
"requirements.txt",
|
package/requirements.txt
CHANGED
package/scripts/cli.mjs
CHANGED
|
@@ -76,6 +76,9 @@ function ensureRuntimeStorage() {
|
|
|
76
76
|
join(storageRoot, "metadata_cleaner"),
|
|
77
77
|
join(storageRoot, "metadata_cleaner", "originals"),
|
|
78
78
|
join(storageRoot, "metadata_cleaner", "outputs"),
|
|
79
|
+
join(storageRoot, "skills"),
|
|
80
|
+
join(storageRoot, "music"),
|
|
81
|
+
join(storageRoot, "voice_previews"),
|
|
79
82
|
];
|
|
80
83
|
for (const directory of directories) mkdirSync(directory, { recursive: true });
|
|
81
84
|
const seedRoot = resolve(root, "seed", "blueprints");
|
|
@@ -95,7 +98,7 @@ function check() {
|
|
|
95
98
|
console.error(`Python não encontrado. Execute: npx.cmd --yes @danhachuel/thunderbolt install`);
|
|
96
99
|
process.exit(1);
|
|
97
100
|
}
|
|
98
|
-
const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg"];
|
|
101
|
+
const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts"];
|
|
99
102
|
const missing = requiredModules.filter((moduleName) => !moduleAvailable(moduleName));
|
|
100
103
|
const ffmpeg = moduleAvailable("imageio_ffmpeg");
|
|
101
104
|
const mptPath = configuredMoneyPrinterPath();
|
package/scripts/install.mjs
CHANGED
|
@@ -107,6 +107,9 @@ function ensureDirs() {
|
|
|
107
107
|
join(storageRoot, "metadata_cleaner", "originals"),
|
|
108
108
|
join(storageRoot, "metadata_cleaner", "outputs"),
|
|
109
109
|
join(storageRoot, "artifacts"),
|
|
110
|
+
join(storageRoot, "skills"),
|
|
111
|
+
join(storageRoot, "music"),
|
|
112
|
+
join(storageRoot, "voice_previews"),
|
|
110
113
|
];
|
|
111
114
|
for (const directory of directories) mkdirSync(directory, { recursive: true });
|
|
112
115
|
copySeedBlueprints(storageRoot);
|
|
@@ -287,7 +290,7 @@ function writeSettings(moneyprinterPath) {
|
|
|
287
290
|
|
|
288
291
|
function installThunderboltDependencies(python) {
|
|
289
292
|
if (!existsSync(pythonBin)) run(python.command, [...python.args, "-m", "venv", venvPath]);
|
|
290
|
-
installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "google.auth", "google_auth_oauthlib", "googleapiclient"], "Thunderbolt");
|
|
293
|
+
installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts", "google.auth", "google_auth_oauthlib", "googleapiclient"], "Thunderbolt");
|
|
291
294
|
}
|
|
292
295
|
|
|
293
296
|
function installMoneyPrinterDependencies(moneyprinterPath) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: moneyprinterturbo-video
|
|
3
|
+
description: Use this skill whenever the user wants to create a finished video from a topic, title, idea, prompt, or script with MoneyPrinterTurbo. This includes short-form, voice-over, educational, marketing, social-media, and stock-footage videos. Also use it when the user mentions MoneyPrinterTurbo, provides this Skill URL, asks an AI agent to install or configure MoneyPrinterTurbo, needs missing API keys identified, wants a failed generation repaired, or wants a generated MP4 located and delivered. Use this skill when the expected outcome is a final video file, not setup instructions.
|
|
4
|
+
compatibility: Requires an AI agent with terminal, network, filesystem, and long-running command support. Supports macOS and Windows and uses uv exclusively.
|
|
5
|
+
metadata:
|
|
6
|
+
author: "harry0703@hotmail.com"
|
|
7
|
+
version: "1.3.2"
|
|
8
|
+
upstream: "https://github.com/harry0703/MoneyPrinterTurbo"
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# MoneyPrinterTurbo Video Generation
|
|
12
|
+
|
|
13
|
+
The user only needs to provide a video topic or script. Complete installation, configuration reuse, generation, waiting, and final MP4 delivery automatically. Do not stop after giving instructions or commands.
|
|
14
|
+
|
|
15
|
+
## Required Behavior
|
|
16
|
+
|
|
17
|
+
1. Ask the user only for required API credentials that are missing, rejected, or unusable. Combine all required credentials into one request.
|
|
18
|
+
2. Do not ask for confirmation before installing, generating, waiting, using defaults, or returning the result.
|
|
19
|
+
3. Do not create or repeatedly update a detailed plan for a standard generation request. Send one short progress update and execute.
|
|
20
|
+
4. Run the helper as one foreground command with a timeout of at least 20 minutes.
|
|
21
|
+
5. Never poll with `sleep`, `echo`, `ps`, repeated `ls`, or repeated `tail`. If the terminal returns a resumable session ID, continue waiting on that same session.
|
|
22
|
+
6. Do not read the full log after success. Read only the short reported error or the relevant log tail after failure.
|
|
23
|
+
7. Never print API keys, tokens, the full `config.toml`, or credential-bearing configuration fragments.
|
|
24
|
+
|
|
25
|
+
## Defaults
|
|
26
|
+
|
|
27
|
+
Unless the user requests otherwise, generate one Chinese `9:16` portrait video with Pexels footage, the default Chinese Edge TTS voice, subtitles, and background music. Install MoneyPrinterTurbo under the user's home directory.
|
|
28
|
+
|
|
29
|
+
## Execution
|
|
30
|
+
|
|
31
|
+
### 1. Locate the helper
|
|
32
|
+
|
|
33
|
+
Resolve `SKILL_DIR` from this `SKILL.md` file. The helper is the adjacent `mpt_agent.py`. Set the terminal tool's working directory to `SKILL_DIR` and invoke the helper by its relative filename. Do not put the absolute helper path in the command, and do not run an extra `ls` or `dir` check.
|
|
34
|
+
|
|
35
|
+
This is required on Windows because some agent terminal validators remove backslashes from absolute paths embedded in commands. Using `mpt_agent.py` with `workdir=SKILL_DIR` avoids that failure and works on both macOS and Windows.
|
|
36
|
+
|
|
37
|
+
If the client loaded only the remote `SKILL.md`, download the helper from the official repository to a temporary directory, then use that temporary directory as the command working directory:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
https://raw.githubusercontent.com/harry0703/MoneyPrinterTurbo/main/docs/skill/mpt_agent.py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Run the helper
|
|
44
|
+
|
|
45
|
+
Do not run a separate `uv --version` preflight. Run the helper directly. If the shell explicitly reports that uv is missing, install uv and retry the same helper command once.
|
|
46
|
+
|
|
47
|
+
macOS uv installation:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Windows PowerShell uv installation:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Use this foreground command with `workdir=SKILL_DIR` and a timeout of at least 20 minutes:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv run --no-project --python 3.11 python mpt_agent.py --subject "<video topic>"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
On Windows, do not try absolute backslash paths, absolute forward-slash paths, or copies in the workspace before this relative command. If a terminal tool reports `referenced_script_path_missing`, verify that its working directory is exactly `SKILL_DIR` and retry the relative command once. Do not cycle through path variants.
|
|
66
|
+
|
|
67
|
+
Do not use Docker, Conda, system pip, or a manually managed virtual environment.
|
|
68
|
+
|
|
69
|
+
## Exit Handling
|
|
70
|
+
|
|
71
|
+
### Exit code 0: deliver the result
|
|
72
|
+
|
|
73
|
+
Successful output has this form:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
MPT_RESULT
|
|
77
|
+
VIDEO_FILE=<absolute path>/final-1.mp4
|
|
78
|
+
TASK_DIR=<absolute path>/storage/tasks/<task_id>
|
|
79
|
+
LOG_FILE=<absolute path>/run-<task_id>.log
|
|
80
|
+
RESULT_FILE=<absolute path>/latest-result.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`mpt_agent.py` emits `VIDEO_FILE` only after confirming that the file exists and is non-empty. Do not run another `ls`, `stat`, or file validation command.
|
|
84
|
+
|
|
85
|
+
If the terminal reports `exitCode=0` but truncates the output or returns a history-file reference without `MPT_RESULT`, do not infer failure and do not inspect old logs. Read this file once:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
~/MoneyPrinterTurbo/.agent-logs/moneyprinterturbo-video/latest-result.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Treat `status=completed` as success. Return only the absolute video path and a concise description, for example:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
The video is ready.
|
|
95
|
+
Topic: ...
|
|
96
|
+
Video file: /absolute/path/to/final-1.mp4
|
|
97
|
+
Summary: Chinese portrait video with voice-over, subtitles, and background music.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Exit code 10: request credentials once
|
|
101
|
+
|
|
102
|
+
`MPT_NEEDS_INPUT` includes only the required fields, recommended LLM providers and signup links, custom OpenAI-compatible requirements, and the Pexels signup link. Ask only for the listed values and do not request credentials already found in `config.toml`.
|
|
103
|
+
|
|
104
|
+
After the user responds, rerun the same foreground command with only the required environment variables:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
MPT_LLM_PROVIDER
|
|
108
|
+
MPT_LLM_API_KEY
|
|
109
|
+
MPT_LLM_BASE_URL
|
|
110
|
+
MPT_LLM_MODEL_NAME
|
|
111
|
+
MPT_PEXELS_API_KEY
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Exit code 1: repair or report
|
|
115
|
+
|
|
116
|
+
Use `MPT_ERROR` and `LOG_FILE` to repair a recoverable problem and retry once. Ask the user only if the repair requires a new API key. If the retry fails, report the failed stage, a short error, and the log path.
|
|
117
|
+
|
|
118
|
+
A terminal-tool path validation error is not a video-generation failure because the helper did not start. Correct the working directory and retry the relative command once. Never ask the user to copy `mpt_agent.py`, run commands manually, or confirm whether the agent should continue.
|
|
119
|
+
|
|
120
|
+
## Configuration and Background Fallback
|
|
121
|
+
|
|
122
|
+
The helper may read the complete local `config.toml` to reuse existing settings, but it must never print its contents. It reuses a working LLM provider automatically and validates configured Pexels keys through the authenticated My Collections endpoint before generation.
|
|
123
|
+
|
|
124
|
+
Use background mode only if the agent platform cannot wait for a foreground process. Wait for the platform's process-completion notification without polling, then read `latest-result.json` once.
|
|
125
|
+
|
|
126
|
+
## Scope
|
|
127
|
+
|
|
128
|
+
- Support macOS and Windows only.
|
|
129
|
+
- Use uv and the MoneyPrinterTurbo CLI only.
|
|
130
|
+
- Do not start Docker, WebUI, or API services.
|
|
131
|
+
- Do not run multiple video jobs concurrently.
|
|
132
|
+
- Pass additional video requirements after `--`. Run `cli.py --help` once only when an unfamiliar option must be verified.
|