@danhachuel/thunderbolt 0.2.17 → 0.2.19
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 +19 -7
- package/README.md +31 -10
- package/app/main.py +330 -57
- package/hermes_ui/mcp.py +145 -0
- package/hermes_ui/storage.py +43 -1
- package/integrations/data/azure_voices.json +1326 -0
- package/integrations/platforms.py +196 -2
- package/integrations/youtube_upload.py +367 -0
- package/package.json +3 -1
- package/requirements.txt +3 -0
- package/scripts/cli.mjs +1 -0
- package/scripts/install.mjs +2 -1
- package/seed/skills/moneyprinterturbo-video.md +132 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
from dataclasses import dataclass
|
|
@@ -9,6 +10,102 @@ from typing import Any
|
|
|
9
10
|
import requests
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
def _extract_json_assignment(document: str, variable: str) -> dict[str, Any] | None:
|
|
14
|
+
marker = re.search(rf"(?:var\s+)?{re.escape(variable)}\s*=", document)
|
|
15
|
+
if not marker:
|
|
16
|
+
return None
|
|
17
|
+
start = document.find("{", marker.end())
|
|
18
|
+
if start < 0:
|
|
19
|
+
return None
|
|
20
|
+
depth = 0
|
|
21
|
+
in_string = False
|
|
22
|
+
escaped = False
|
|
23
|
+
for index in range(start, len(document)):
|
|
24
|
+
character = document[index]
|
|
25
|
+
if in_string:
|
|
26
|
+
if escaped:
|
|
27
|
+
escaped = False
|
|
28
|
+
elif character == "\\":
|
|
29
|
+
escaped = True
|
|
30
|
+
elif character == '"':
|
|
31
|
+
in_string = False
|
|
32
|
+
continue
|
|
33
|
+
if character == '"':
|
|
34
|
+
in_string = True
|
|
35
|
+
elif character == "{":
|
|
36
|
+
depth += 1
|
|
37
|
+
elif character == "}":
|
|
38
|
+
depth -= 1
|
|
39
|
+
if depth == 0:
|
|
40
|
+
try:
|
|
41
|
+
parsed = json.loads(document[start:index + 1])
|
|
42
|
+
except json.JSONDecodeError:
|
|
43
|
+
return None
|
|
44
|
+
return parsed if isinstance(parsed, dict) else None
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _find_first_key(value: Any, key: str) -> Any:
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
if key in value:
|
|
51
|
+
return value[key]
|
|
52
|
+
for child in value.values():
|
|
53
|
+
found = _find_first_key(child, key)
|
|
54
|
+
if found is not None:
|
|
55
|
+
return found
|
|
56
|
+
elif isinstance(value, list):
|
|
57
|
+
for child in value:
|
|
58
|
+
found = _find_first_key(child, key)
|
|
59
|
+
if found is not None:
|
|
60
|
+
return found
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _text_from_node(value: Any) -> str:
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
return value.strip()
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
for key in ("simpleText", "content", "text"):
|
|
69
|
+
if isinstance(value.get(key), str):
|
|
70
|
+
return value[key].strip()
|
|
71
|
+
runs = value.get("runs")
|
|
72
|
+
if isinstance(runs, list):
|
|
73
|
+
return "".join(str(run.get("text", "")) for run in runs if isinstance(run, dict)).strip()
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_public_count(value: Any) -> int | None:
|
|
78
|
+
text = _text_from_node(value).lower().replace(" ", "")
|
|
79
|
+
if not text:
|
|
80
|
+
return None
|
|
81
|
+
match = re.search(r"([0-9][0-9.,]*)(mil|milhões|mi|bi|[kmb])?", text)
|
|
82
|
+
if not match:
|
|
83
|
+
return None
|
|
84
|
+
number = match.group(1)
|
|
85
|
+
suffix = match.group(2) or ""
|
|
86
|
+
if suffix == "milhões":
|
|
87
|
+
suffix = "m"
|
|
88
|
+
try:
|
|
89
|
+
if suffix in {"k", "mil"}:
|
|
90
|
+
return int(float(number.replace(",", ".")) * 1_000)
|
|
91
|
+
if suffix in {"m", "mi"}:
|
|
92
|
+
return int(float(number.replace(",", ".")) * 1_000_000)
|
|
93
|
+
if suffix == "b":
|
|
94
|
+
return int(float(number.replace(",", ".")) * 1_000_000_000)
|
|
95
|
+
return int(re.sub(r"[^0-9]", "", number))
|
|
96
|
+
except ValueError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _thumbnail_from_metadata(metadata: dict[str, Any]) -> str:
|
|
101
|
+
thumbnails = metadata.get("avatar", {}).get("thumbnails", []) if isinstance(metadata, dict) else []
|
|
102
|
+
if isinstance(thumbnails, list) and thumbnails:
|
|
103
|
+
last = thumbnails[-1]
|
|
104
|
+
if isinstance(last, dict):
|
|
105
|
+
return str(last.get("url", ""))
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
|
|
12
109
|
@dataclass
|
|
13
110
|
class IntegrationResult:
|
|
14
111
|
ok: bool
|
|
@@ -17,8 +114,9 @@ class IntegrationResult:
|
|
|
17
114
|
|
|
18
115
|
|
|
19
116
|
class YouTubeAdapter:
|
|
20
|
-
def __init__(self, api_key: str = ""):
|
|
21
|
-
self.
|
|
117
|
+
def __init__(self, api_key: str = "", settings: dict[str, Any] | None = None):
|
|
118
|
+
self.settings = settings or {}
|
|
119
|
+
self.api_key = api_key or self.settings.get("youtube_api_key", "") or os.getenv("YOUTUBE_API_KEY", "")
|
|
22
120
|
|
|
23
121
|
@staticmethod
|
|
24
122
|
def extract_channel_ref(value: str) -> str:
|
|
@@ -26,6 +124,72 @@ class YouTubeAdapter:
|
|
|
26
124
|
match = re.search(r"(?:channel/|@)([A-Za-z0-9_.-]+)", value)
|
|
27
125
|
return match.group(1) if match else value
|
|
28
126
|
|
|
127
|
+
def fetch_channel_public(self, value: str) -> IntegrationResult:
|
|
128
|
+
"""Fetch public channel metadata from YouTube HTML without an API key."""
|
|
129
|
+
source = (value or "").strip()
|
|
130
|
+
if not source:
|
|
131
|
+
return IntegrationResult(False, "Introduza o nome, handle, URL ou ID do canal.", {})
|
|
132
|
+
try:
|
|
133
|
+
if source.startswith("http://") or source.startswith("https://"):
|
|
134
|
+
page_url = source.rstrip("/")
|
|
135
|
+
elif source.startswith("UC"):
|
|
136
|
+
page_url = f"https://www.youtube.com/channel/{source}"
|
|
137
|
+
else:
|
|
138
|
+
handle = source if source.startswith("@") else f"@{source}"
|
|
139
|
+
page_url = f"https://www.youtube.com/{handle}"
|
|
140
|
+
if not page_url.endswith("/about"):
|
|
141
|
+
page_url = f"{page_url}/about"
|
|
142
|
+
response = requests.get(
|
|
143
|
+
page_url,
|
|
144
|
+
headers={
|
|
145
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
|
146
|
+
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
147
|
+
},
|
|
148
|
+
timeout=20,
|
|
149
|
+
)
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
document = response.text
|
|
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})
|
|
157
|
+
owner_urls = metadata.get("ownerUrls", []) if isinstance(metadata, dict) else []
|
|
158
|
+
canonical_url = owner_urls[0] if owner_urls else page_url.removesuffix("/about")
|
|
159
|
+
canonical_url = canonical_url.replace("http://", "https://")
|
|
160
|
+
handle_match = re.search(r"/@([^/?]+)", canonical_url)
|
|
161
|
+
handle = f"@{handle_match.group(1)}" if handle_match else ""
|
|
162
|
+
title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
|
|
163
|
+
if not title and isinstance(header, dict):
|
|
164
|
+
title = _text_from_node(header.get("title"))
|
|
165
|
+
if not title:
|
|
166
|
+
title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
|
|
167
|
+
description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
|
|
168
|
+
youtube_id = str(metadata.get("externalId", "")) if isinstance(metadata, dict) else ""
|
|
169
|
+
if not youtube_id:
|
|
170
|
+
id_match = re.search(r"/channel/(UC[A-Za-z0-9_-]+)", canonical_url)
|
|
171
|
+
youtube_id = id_match.group(1) if id_match else ""
|
|
172
|
+
subscriber_value = _find_first_key(initial_data or {}, "subscriberCountText")
|
|
173
|
+
video_value = _find_first_key(initial_data or {}, "videoCountText")
|
|
174
|
+
data = {
|
|
175
|
+
"youtube_id": youtube_id,
|
|
176
|
+
"name": title,
|
|
177
|
+
"handle": handle,
|
|
178
|
+
"url": canonical_url,
|
|
179
|
+
"description": description,
|
|
180
|
+
"thumbnail_url": _thumbnail_from_metadata(metadata),
|
|
181
|
+
"subscriber_count": _parse_public_count(subscriber_value),
|
|
182
|
+
"video_count": _parse_public_count(video_value),
|
|
183
|
+
"view_count": None,
|
|
184
|
+
"metrics_source": "youtube_public_page",
|
|
185
|
+
"public_lookup": True,
|
|
186
|
+
}
|
|
187
|
+
return IntegrationResult(True, "Canal encontrado publicamente no YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
188
|
+
except requests.RequestException as exc:
|
|
189
|
+
return IntegrationResult(False, f"Não foi possível consultar a página pública do YouTube: {exc}", {"url": source})
|
|
190
|
+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
191
|
+
return IntegrationResult(False, f"A página pública do YouTube mudou ou não pôde ser interpretada: {exc}", {"url": source})
|
|
192
|
+
|
|
29
193
|
def fetch_channel(self, value: str) -> IntegrationResult:
|
|
30
194
|
ref = self.extract_channel_ref(value)
|
|
31
195
|
if not self.api_key:
|
|
@@ -59,6 +223,36 @@ class YouTubeAdapter:
|
|
|
59
223
|
except requests.RequestException as exc:
|
|
60
224
|
return IntegrationResult(False, f"Falha ao consultar o YouTube: {exc}", {})
|
|
61
225
|
|
|
226
|
+
def upload_video(self, video_path: str, **kwargs: Any) -> IntegrationResult:
|
|
227
|
+
"""Publish through the adapted youtube-automation-agent, then OAuth fallback."""
|
|
228
|
+
from integrations.youtube_upload import upload_youtube_with_fallback
|
|
229
|
+
from hermes_ui.storage import STORAGE
|
|
230
|
+
|
|
231
|
+
return upload_youtube_with_fallback(
|
|
232
|
+
self.settings,
|
|
233
|
+
STORAGE,
|
|
234
|
+
video_path=video_path,
|
|
235
|
+
**kwargs,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def upload_status(self) -> dict[str, IntegrationResult]:
|
|
239
|
+
from integrations.youtube_upload import youtube_upload_status
|
|
240
|
+
from hermes_ui.storage import STORAGE
|
|
241
|
+
|
|
242
|
+
return youtube_upload_status(self.settings, STORAGE)
|
|
243
|
+
|
|
244
|
+
def authorize_agent(self) -> IntegrationResult:
|
|
245
|
+
from integrations.youtube_upload import authorize_youtube_agent
|
|
246
|
+
from hermes_ui.storage import STORAGE
|
|
247
|
+
|
|
248
|
+
return authorize_youtube_agent(self.settings, STORAGE)
|
|
249
|
+
|
|
250
|
+
def authorize_fallback(self) -> IntegrationResult:
|
|
251
|
+
from integrations.youtube_upload import authorize_youtube_fallback
|
|
252
|
+
from hermes_ui.storage import STORAGE
|
|
253
|
+
|
|
254
|
+
return authorize_youtube_fallback(self.settings, STORAGE)
|
|
255
|
+
|
|
62
256
|
|
|
63
257
|
class TikTokAdapter:
|
|
64
258
|
def __init__(self, settings: dict[str, Any]):
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from integrations.platforms import IntegrationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Mantemos os mesmos escopos usados pelo youtube-automation-agent.
|
|
14
|
+
AGENT_SCOPES = [
|
|
15
|
+
"https://www.googleapis.com/auth/youtube.upload",
|
|
16
|
+
"https://www.googleapis.com/auth/youtube",
|
|
17
|
+
"https://www.googleapis.com/auth/youtube.readonly",
|
|
18
|
+
"https://www.googleapis.com/auth/yt-analytics.readonly",
|
|
19
|
+
]
|
|
20
|
+
FALLBACK_SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class UploadAttempt:
|
|
25
|
+
mechanism: str
|
|
26
|
+
result: IntegrationResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _safe_text(value: Any, default: str = "") -> str:
|
|
30
|
+
return str(value if value is not None else default).strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _path_value(value: Any) -> Path | None:
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
value = value.get("path") or value.get("file") or value.get("filepath")
|
|
36
|
+
if not value:
|
|
37
|
+
return None
|
|
38
|
+
return Path(str(value)).expanduser()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_video_file(video_path: str | os.PathLike[str]) -> tuple[bool, str, Path]:
|
|
42
|
+
"""Validate the same real-video conditions used by the agent before publishing."""
|
|
43
|
+
path = Path(video_path).expanduser()
|
|
44
|
+
if not path.exists() or not path.is_file():
|
|
45
|
+
return False, "Ficheiro de vídeo não encontrado; o upload foi recusado.", path
|
|
46
|
+
if path.suffix.lower() != ".mp4":
|
|
47
|
+
return False, "O youtube-automation-agent só publica ficheiros MP4 reais.", path
|
|
48
|
+
if path.stat().st_size <= 0:
|
|
49
|
+
return False, "O ficheiro de vídeo está vazio; o upload foi recusado.", path
|
|
50
|
+
return True, "Vídeo válido.", path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_agent_video_metadata(
|
|
54
|
+
*,
|
|
55
|
+
title: str,
|
|
56
|
+
description: str = "",
|
|
57
|
+
tags: list[str] | None = None,
|
|
58
|
+
category_id: str = "22",
|
|
59
|
+
language: str = "pt-BR",
|
|
60
|
+
privacy_status: str = "private",
|
|
61
|
+
publish_at: str | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
"""Build the snippet/status payload used by PublishingSchedulingAgent."""
|
|
64
|
+
status: dict[str, Any] = {
|
|
65
|
+
"privacyStatus": privacy_status or "private",
|
|
66
|
+
"selfDeclaredMadeForKids": False,
|
|
67
|
+
}
|
|
68
|
+
if publish_at and status["privacyStatus"] == "private":
|
|
69
|
+
# YouTube requires a future publishAt with privacyStatus=private.
|
|
70
|
+
status["publishAt"] = publish_at
|
|
71
|
+
return {
|
|
72
|
+
"snippet": {
|
|
73
|
+
"title": _safe_text(title)[:100] or "Vídeo Thunderbolt",
|
|
74
|
+
"description": _safe_text(description),
|
|
75
|
+
"tags": [str(tag).strip() for tag in (tags or []) if str(tag).strip()],
|
|
76
|
+
"categoryId": _safe_text(category_id, "22"),
|
|
77
|
+
"defaultLanguage": _safe_text(language, "pt-BR"),
|
|
78
|
+
"defaultAudioLanguage": _safe_text(language, "pt-BR"),
|
|
79
|
+
},
|
|
80
|
+
"status": status,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _GoogleYouTubeBase:
|
|
85
|
+
def __init__(self, settings: dict[str, Any], token_path: Path, scopes: list[str], alternate_token_path: Path | None = None):
|
|
86
|
+
self.settings = settings
|
|
87
|
+
self.client_id = _safe_text(settings.get("youtube_client_id")) or os.getenv("YOUTUBE_CLIENT_ID", "")
|
|
88
|
+
self.client_secret = _safe_text(settings.get("youtube_client_secret")) or os.getenv("YOUTUBE_CLIENT_SECRET", "")
|
|
89
|
+
self.token_path = token_path
|
|
90
|
+
self.alternate_token_path = alternate_token_path
|
|
91
|
+
self.scopes = scopes
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def configured(self) -> bool:
|
|
95
|
+
return bool(self.client_id and self.client_secret)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def token_exists(self) -> bool:
|
|
99
|
+
return any(path.exists() and path.stat().st_size > 0 for path in self._token_candidates())
|
|
100
|
+
|
|
101
|
+
def _token_candidates(self) -> list[Path]:
|
|
102
|
+
paths = [self.token_path]
|
|
103
|
+
if self.alternate_token_path and self.alternate_token_path not in paths:
|
|
104
|
+
paths.append(self.alternate_token_path)
|
|
105
|
+
return paths
|
|
106
|
+
|
|
107
|
+
def _client_config(self) -> dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"installed": {
|
|
110
|
+
"client_id": self.client_id,
|
|
111
|
+
"client_secret": self.client_secret,
|
|
112
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
113
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
114
|
+
"redirect_uris": ["http://localhost"],
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def _load_credentials(self) -> Any:
|
|
119
|
+
try:
|
|
120
|
+
from google.auth.transport.requests import Request
|
|
121
|
+
from google.oauth2.credentials import Credentials
|
|
122
|
+
except ImportError as exc:
|
|
123
|
+
raise RuntimeError("As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.") from exc
|
|
124
|
+
token_source = next((path for path in self._token_candidates() if path.exists() and path.stat().st_size > 0), None)
|
|
125
|
+
if token_source is None:
|
|
126
|
+
return None
|
|
127
|
+
raw = json.loads(token_source.read_text(encoding="utf-8"))
|
|
128
|
+
if isinstance(raw, dict) and isinstance(raw.get("youtube"), dict):
|
|
129
|
+
raw = raw["youtube"]
|
|
130
|
+
credentials = Credentials.from_authorized_user_info(raw, self.scopes)
|
|
131
|
+
if credentials.expired and credentials.refresh_token:
|
|
132
|
+
credentials.refresh(Request())
|
|
133
|
+
self._save_credentials(credentials)
|
|
134
|
+
return credentials
|
|
135
|
+
|
|
136
|
+
def _save_credentials(self, credentials: Any, *, nested: bool = False) -> None:
|
|
137
|
+
self.token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
payload = json.loads(credentials.to_json())
|
|
139
|
+
if nested:
|
|
140
|
+
payload = {"youtube": payload}
|
|
141
|
+
temporary = self.token_path.with_name(f".{self.token_path.name}.{secrets.token_hex(6)}.tmp")
|
|
142
|
+
temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
143
|
+
try:
|
|
144
|
+
os.chmod(temporary, 0o600)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
temporary.replace(self.token_path)
|
|
148
|
+
try:
|
|
149
|
+
os.chmod(self.token_path, 0o600)
|
|
150
|
+
except OSError:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def _build_client(self, credentials: Any) -> Any:
|
|
154
|
+
try:
|
|
155
|
+
from googleapiclient.discovery import build
|
|
156
|
+
except ImportError as exc:
|
|
157
|
+
raise RuntimeError("A biblioteca Google API Client ainda não está instalada. Execute a instalação do Thunderbolt novamente.") from exc
|
|
158
|
+
return build("youtube", "v3", credentials=credentials, cache_discovery=False)
|
|
159
|
+
|
|
160
|
+
def authorize(self, *, open_browser: bool = True) -> IntegrationResult:
|
|
161
|
+
if not self.configured:
|
|
162
|
+
return IntegrationResult(False, "Preencha o YouTube OAuth Client ID e Client Secret nas Configurações.", {"status": "not_configured"})
|
|
163
|
+
try:
|
|
164
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
165
|
+
except ImportError as exc:
|
|
166
|
+
return IntegrationResult(False, "As dependências Google OAuth ainda não estão instaladas. Execute a instalação do Thunderbolt novamente.", {"status": "missing_dependencies", "error": str(exc)})
|
|
167
|
+
try:
|
|
168
|
+
flow = InstalledAppFlow.from_client_config(self._client_config(), self.scopes)
|
|
169
|
+
credentials = flow.run_local_server(
|
|
170
|
+
host="localhost",
|
|
171
|
+
port=0,
|
|
172
|
+
open_browser=open_browser,
|
|
173
|
+
access_type="offline",
|
|
174
|
+
prompt="consent",
|
|
175
|
+
include_granted_scopes="true",
|
|
176
|
+
)
|
|
177
|
+
self._save_credentials(credentials, nested=self.__class__.__name__ == "YouTubeAutomationAgentUploader")
|
|
178
|
+
return IntegrationResult(True, "Conta YouTube autorizada com sucesso.", {"status": "authorized", "token_path": str(self.token_path)})
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
return IntegrationResult(False, f"A autorização Google falhou: {exc}", {"status": "authorization_failed"})
|
|
181
|
+
|
|
182
|
+
def status(self) -> IntegrationResult:
|
|
183
|
+
if not self.configured:
|
|
184
|
+
return IntegrationResult(False, "YouTube OAuth ainda não configurado.", {"status": "not_configured", "authorized": False})
|
|
185
|
+
if not self.token_exists:
|
|
186
|
+
return IntegrationResult(False, "YouTube OAuth configurado, mas a conta ainda não foi autorizada.", {"status": "requires_authorization", "authorized": False})
|
|
187
|
+
try:
|
|
188
|
+
credentials = self._load_credentials()
|
|
189
|
+
if credentials and credentials.valid:
|
|
190
|
+
return IntegrationResult(True, "YouTube autorizado e pronto para publicar.", {"status": "ready", "authorized": True})
|
|
191
|
+
if credentials and credentials.refresh_token:
|
|
192
|
+
return IntegrationResult(True, "YouTube autorizado; o token será renovado no próximo uso.", {"status": "ready_refreshable", "authorized": True})
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
return IntegrationResult(False, f"Token YouTube inválido ou expirado: {exc}", {"status": "invalid_token", "authorized": False})
|
|
195
|
+
return IntegrationResult(False, "Token YouTube não está pronto para publicar.", {"status": "requires_authorization", "authorized": False})
|
|
196
|
+
|
|
197
|
+
def _upload_common(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
video_path: str,
|
|
201
|
+
title: str,
|
|
202
|
+
description: str,
|
|
203
|
+
tags: list[str],
|
|
204
|
+
category_id: str,
|
|
205
|
+
language: str,
|
|
206
|
+
privacy_status: str,
|
|
207
|
+
publish_at: str | None,
|
|
208
|
+
thumbnail_path: str | None,
|
|
209
|
+
captions_path: str | None,
|
|
210
|
+
) -> IntegrationResult:
|
|
211
|
+
valid, message, path = validate_video_file(video_path)
|
|
212
|
+
if not valid:
|
|
213
|
+
return IntegrationResult(False, message, {"status": "invalid_video", "path": str(path)})
|
|
214
|
+
try:
|
|
215
|
+
credentials = self._load_credentials()
|
|
216
|
+
if credentials is None:
|
|
217
|
+
return IntegrationResult(False, "Autorize primeiro a conta YouTube.", {"status": "requires_authorization", "path": str(path)})
|
|
218
|
+
youtube = self._build_client(credentials)
|
|
219
|
+
body = build_agent_video_metadata(
|
|
220
|
+
title=title,
|
|
221
|
+
description=description,
|
|
222
|
+
tags=tags,
|
|
223
|
+
category_id=category_id,
|
|
224
|
+
language=language,
|
|
225
|
+
privacy_status=privacy_status,
|
|
226
|
+
publish_at=publish_at,
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
from googleapiclient.http import MediaFileUpload
|
|
230
|
+
except ImportError as exc:
|
|
231
|
+
raise RuntimeError("A biblioteca de upload Google ainda não está instalada.") from exc
|
|
232
|
+
media = MediaFileUpload(str(path), mimetype="video/mp4", chunksize=8 * 1024 * 1024, resumable=True)
|
|
233
|
+
request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)
|
|
234
|
+
response = None
|
|
235
|
+
while response is None:
|
|
236
|
+
_, response = request.next_chunk()
|
|
237
|
+
video_id = response.get("id") if isinstance(response, dict) else None
|
|
238
|
+
if not video_id:
|
|
239
|
+
return IntegrationResult(False, "O YouTube não devolveu um ID de vídeo após o upload.", {"status": "upload_failed", "response": response or {}})
|
|
240
|
+
|
|
241
|
+
extras: dict[str, Any] = {}
|
|
242
|
+
thumbnail = _path_value(thumbnail_path)
|
|
243
|
+
if thumbnail and thumbnail.exists() and thumbnail.is_file():
|
|
244
|
+
try:
|
|
245
|
+
with thumbnail.open("rb") as handle:
|
|
246
|
+
youtube.thumbnails().set(videoId=video_id, media_body=handle).execute()
|
|
247
|
+
extras["thumbnail_uploaded"] = True
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
extras["thumbnail_warning"] = str(exc)
|
|
250
|
+
captions = _path_value(captions_path)
|
|
251
|
+
if captions and captions.exists() and captions.is_file():
|
|
252
|
+
try:
|
|
253
|
+
with captions.open("rb") as handle:
|
|
254
|
+
youtube.captions().insert(
|
|
255
|
+
part="snippet",
|
|
256
|
+
body={"snippet": {"videoId": video_id, "language": language or "pt", "name": "Thunderbolt", "isDraft": False}},
|
|
257
|
+
media_body=handle,
|
|
258
|
+
).execute()
|
|
259
|
+
extras["captions_uploaded"] = True
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
extras["captions_warning"] = str(exc)
|
|
262
|
+
return IntegrationResult(
|
|
263
|
+
True,
|
|
264
|
+
f"Vídeo publicado no YouTube: https://www.youtube.com/watch?v={video_id}",
|
|
265
|
+
{"status": "published", "video_id": video_id, "url": f"https://www.youtube.com/watch?v={video_id}", "mechanism": self.__class__.__name__, **extras},
|
|
266
|
+
)
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
return IntegrationResult(False, f"Falha no upload YouTube: {exc}", {"status": "upload_failed", "error": str(exc), "mechanism": self.__class__.__name__})
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class YouTubeAutomationAgentUploader(_GoogleYouTubeBase):
|
|
272
|
+
"""Adaptation of the source agent's PublishingSchedulingAgent inside Thunderbolt."""
|
|
273
|
+
|
|
274
|
+
def __init__(self, settings: dict[str, Any], storage_root: Path):
|
|
275
|
+
super().__init__(settings, storage_root / "state" / "youtube_agent_tokens.json", AGENT_SCOPES)
|
|
276
|
+
|
|
277
|
+
def upload(self, **kwargs: Any) -> IntegrationResult:
|
|
278
|
+
result = self._upload_common(**kwargs)
|
|
279
|
+
result.data.setdefault("mechanism", "youtube-automation-agent-adaptado")
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class DirectYouTubeOAuthUploader(_GoogleYouTubeBase):
|
|
284
|
+
"""Independent minimal OAuth upload path used only after the primary path fails."""
|
|
285
|
+
|
|
286
|
+
def __init__(self, settings: dict[str, Any], storage_root: Path):
|
|
287
|
+
super().__init__(
|
|
288
|
+
settings,
|
|
289
|
+
storage_root / "state" / "youtube_oauth_fallback_token.json",
|
|
290
|
+
FALLBACK_SCOPES,
|
|
291
|
+
alternate_token_path=storage_root / "state" / "youtube_agent_tokens.json",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def upload(self, **kwargs: Any) -> IntegrationResult:
|
|
295
|
+
result = self._upload_common(**kwargs)
|
|
296
|
+
result.data.setdefault("mechanism", "oauth-direct-fallback")
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def upload_youtube_with_fallback(
|
|
301
|
+
settings: dict[str, Any],
|
|
302
|
+
storage_root: Path,
|
|
303
|
+
*,
|
|
304
|
+
video_path: str,
|
|
305
|
+
title: str,
|
|
306
|
+
description: str = "",
|
|
307
|
+
tags: list[str] | None = None,
|
|
308
|
+
category_id: str = "22",
|
|
309
|
+
language: str = "pt-BR",
|
|
310
|
+
privacy_status: str = "private",
|
|
311
|
+
publish_at: str | None = None,
|
|
312
|
+
thumbnail_path: str | None = None,
|
|
313
|
+
captions_path: str | None = None,
|
|
314
|
+
on_attempt: Callable[[UploadAttempt], None] | None = None,
|
|
315
|
+
) -> IntegrationResult:
|
|
316
|
+
"""Run the adapted agent path first and OAuth direct only as redundancy."""
|
|
317
|
+
kwargs = {
|
|
318
|
+
"video_path": video_path,
|
|
319
|
+
"title": title,
|
|
320
|
+
"description": description,
|
|
321
|
+
"tags": tags or [],
|
|
322
|
+
"category_id": category_id,
|
|
323
|
+
"language": language,
|
|
324
|
+
"privacy_status": privacy_status,
|
|
325
|
+
"publish_at": publish_at,
|
|
326
|
+
"thumbnail_path": thumbnail_path,
|
|
327
|
+
"captions_path": captions_path,
|
|
328
|
+
}
|
|
329
|
+
attempts: list[dict[str, Any]] = []
|
|
330
|
+
primary = YouTubeAutomationAgentUploader(settings, storage_root)
|
|
331
|
+
primary_result = primary.upload(**kwargs)
|
|
332
|
+
primary_result.data.setdefault("mechanism", "youtube-automation-agent-adaptado")
|
|
333
|
+
primary_attempt = UploadAttempt("youtube-automation-agent-adaptado", primary_result)
|
|
334
|
+
attempts.append({"mechanism": primary_attempt.mechanism, "ok": primary_result.ok, "message": primary_result.message, "data": primary_result.data})
|
|
335
|
+
if on_attempt:
|
|
336
|
+
on_attempt(primary_attempt)
|
|
337
|
+
if primary_result.ok:
|
|
338
|
+
primary_result.data["attempts"] = attempts
|
|
339
|
+
return primary_result
|
|
340
|
+
|
|
341
|
+
fallback = DirectYouTubeOAuthUploader(settings, storage_root)
|
|
342
|
+
fallback_result = fallback.upload(**kwargs)
|
|
343
|
+
fallback_result.data.setdefault("mechanism", "oauth-direct-fallback")
|
|
344
|
+
fallback_attempt = UploadAttempt("oauth-direct-fallback", fallback_result)
|
|
345
|
+
attempts.append({"mechanism": fallback_attempt.mechanism, "ok": fallback_result.ok, "message": fallback_result.message, "data": fallback_result.data})
|
|
346
|
+
if on_attempt:
|
|
347
|
+
on_attempt(fallback_attempt)
|
|
348
|
+
fallback_result.data["attempts"] = attempts
|
|
349
|
+
if fallback_result.ok:
|
|
350
|
+
fallback_result.message = f"Upload concluído pelo fallback OAuth directo após falha do agente: {fallback_result.message}"
|
|
351
|
+
return fallback_result
|
|
352
|
+
return IntegrationResult(False, f"O agente de upload falhou e o fallback OAuth também falhou. Agente: {primary_result.message} Fallback: {fallback_result.message}", {"status": "all_upload_mechanisms_failed", "attempts": attempts})
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def authorize_youtube_agent(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
|
|
356
|
+
return YouTubeAutomationAgentUploader(settings, storage_root).authorize(open_browser=open_browser)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def authorize_youtube_fallback(settings: dict[str, Any], storage_root: Path, *, open_browser: bool = True) -> IntegrationResult:
|
|
360
|
+
return DirectYouTubeOAuthUploader(settings, storage_root).authorize(open_browser=open_browser)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def youtube_upload_status(settings: dict[str, Any], storage_root: Path) -> dict[str, IntegrationResult]:
|
|
364
|
+
return {
|
|
365
|
+
"agent": YouTubeAutomationAgentUploader(settings, storage_root).status(),
|
|
366
|
+
"fallback": DirectYouTubeOAuthUploader(settings, storage_root).status(),
|
|
367
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
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,7 @@ 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"),
|
|
79
80
|
];
|
|
80
81
|
for (const directory of directories) mkdirSync(directory, { recursive: true });
|
|
81
82
|
const seedRoot = resolve(root, "seed", "blueprints");
|
package/scripts/install.mjs
CHANGED
|
@@ -107,6 +107,7 @@ 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"),
|
|
110
111
|
];
|
|
111
112
|
for (const directory of directories) mkdirSync(directory, { recursive: true });
|
|
112
113
|
copySeedBlueprints(storageRoot);
|
|
@@ -287,7 +288,7 @@ function writeSettings(moneyprinterPath) {
|
|
|
287
288
|
|
|
288
289
|
function installThunderboltDependencies(python) {
|
|
289
290
|
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"], "Thunderbolt");
|
|
291
|
+
installRequirementIfNeeded(join(root, "requirements.txt"), "thunderbolt_requirements_sha256", ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "google.auth", "google_auth_oauthlib", "googleapiclient"], "Thunderbolt");
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
function installMoneyPrinterDependencies(moneyprinterPath) {
|