@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.
@@ -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.api_key = api_key or os.getenv("YOUTUBE_API_KEY", "")
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.17",
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
@@ -3,3 +3,6 @@ requests>=2.31,<3
3
3
  pandas>=2.0,<3
4
4
  imageio-ffmpeg>=0.6,<1
5
5
  toml>=0.10,<1
6
+ google-auth>=2.29,<3
7
+ google-auth-oauthlib>=1.2,<2
8
+ google-api-python-client>=2.120,<3
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");
@@ -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) {