@danhachuel/thunderbolt 0.2.19 → 0.2.21

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.
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+ from . import storage
11
+
12
+ MUSIC_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
13
+
14
+
15
+ def music_directory() -> Path:
16
+ directory = storage.STORAGE / "music"
17
+ directory.mkdir(parents=True, exist_ok=True)
18
+ return directory
19
+
20
+
21
+ def list_music_files() -> list[Path]:
22
+ return sorted((path for path in music_directory().iterdir() if path.is_file() and path.suffix.lower() in MUSIC_EXTENSIONS), key=lambda path: path.stat().st_mtime, reverse=True)
23
+
24
+
25
+ def safe_music_name(name: str) -> str:
26
+ stem = re.sub(r"[^\w\-. ]+", "_", Path(name).stem, flags=re.UNICODE).strip() or "music"
27
+ suffix = Path(name).suffix.lower()
28
+ if suffix not in MUSIC_EXTENSIONS:
29
+ suffix = ".mp3"
30
+ return f"{stem}{suffix}"
31
+
32
+
33
+ def store_music_file(name: str, content: bytes) -> Path:
34
+ if not content:
35
+ raise ValueError("O ficheiro de música está vazio.")
36
+ target = music_directory() / safe_music_name(name)
37
+ target.write_bytes(content)
38
+ return target
39
+
40
+
41
+ def materialize_suno_audio(data: dict[str, Any], title: str = "suno-generated.mp3") -> Path | None:
42
+ candidates: list[str] = []
43
+ for key in ("audio_url", "audioUrl", "download_url", "downloadUrl", "url"):
44
+ value = data.get(key) if isinstance(data, dict) else None
45
+ if isinstance(value, str) and value.startswith("http"):
46
+ candidates.append(value)
47
+ clips = data.get("clips") if isinstance(data, dict) else None
48
+ if isinstance(clips, list):
49
+ for clip in clips:
50
+ if isinstance(clip, dict):
51
+ for key in ("audio_url", "audioUrl", "download_url", "downloadUrl", "url"):
52
+ value = clip.get(key)
53
+ if isinstance(value, str) and value.startswith("http"):
54
+ candidates.append(value)
55
+ if not candidates:
56
+ return None
57
+ response = requests.get(candidates[0], timeout=120)
58
+ response.raise_for_status()
59
+ return store_music_file(title, response.content)
60
+
61
+
62
+ def request_suno_generation(settings: dict[str, Any], prompt: str, title: str = "", duration_seconds: int = 120) -> dict[str, Any]:
63
+ """Request music from a configured Suno-compatible endpoint.
64
+
65
+ Suno-compatible deployments expose different endpoint paths; the UI therefore
66
+ requires an explicit base URL and never invents a public credential or endpoint.
67
+ """
68
+ api_key = str(settings.get("suno_api_key", "") or "").strip()
69
+ base_url = str(settings.get("suno_api_base_url", "") or "").strip().rstrip("/")
70
+ endpoint = str(settings.get("suno_api_endpoint", "/api/generate") or "/api/generate").strip()
71
+ if not api_key or not base_url:
72
+ return {"ok": False, "message": "Configure Suno API Key e Suno API Base URL em Configurações antes de solicitar uma música.", "data": {}}
73
+ url = endpoint if endpoint.startswith("http") else f"{base_url}/{endpoint.lstrip('/')}"
74
+ payload = {"prompt": prompt.strip(), "title": title.strip(), "duration": max(120, int(duration_seconds)), "make_instrumental": True}
75
+ try:
76
+ response = requests.post(url, json=payload, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=30)
77
+ if response.status_code >= 400:
78
+ return {"ok": False, "message": f"Suno devolveu HTTP {response.status_code}: {response.text[:240]}", "data": {"status_code": response.status_code}}
79
+ body = response.json() if response.content else {}
80
+ return {"ok": True, "message": "Pedido de música enviado ao endpoint Suno configurado.", "data": body if isinstance(body, dict) else {"response": body}}
81
+ except (requests.RequestException, ValueError) as exc:
82
+ return {"ok": False, "message": f"Não foi possível contactar o endpoint Suno configurado: {exc}", "data": {}}
@@ -177,6 +177,19 @@ DEFAULTS: dict[str, Any] = {
177
177
  "tiktok_scopes": "user.info.basic,video.publish,video.upload",
178
178
  "tiktok_access_token": "",
179
179
  "tiktok_connection_status": "not_configured",
180
+ "suno_api_key": "",
181
+ "suno_api_base_url": "",
182
+ "suno_api_endpoint": "/api/generate",
183
+ "voice_preview_provider": "edge",
184
+ "voice_preview_rate": "+0%",
185
+ "direct_cookie_sid": "",
186
+ "direct_cookie_ssid": "",
187
+ "direct_cookie_hsid": "",
188
+ "direct_cookie_apisid": "",
189
+ "direct_cookie_sapisid": "",
190
+ "direct_session_info": "",
191
+ "direct_innertube_api_key": "",
192
+ "direct_chunk_size": 262144,
180
193
  },
181
194
  }
182
195
 
@@ -198,7 +211,7 @@ def seed_blueprints() -> None:
198
211
 
199
212
 
200
213
  def ensure_storage() -> None:
201
- for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs"]:
214
+ for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews"]:
202
215
  path.mkdir(parents=True, exist_ok=True)
203
216
  seed_blueprints()
204
217
  for filename, default in DEFAULTS.items():
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import inspect
6
+ import json
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import requests
12
+
13
+ from . import storage
14
+
15
+ DEFAULT_SAMPLE = "Esta é uma amostra de voz do Thunderbolt. O resultado é apenas um teste e não altera nenhum vídeo ou tarefa da pipeline."
16
+
17
+
18
+ def preview_directory() -> Path:
19
+ directory = storage.STORAGE / "voice_previews"
20
+ directory.mkdir(parents=True, exist_ok=True)
21
+ return directory
22
+
23
+
24
+ def _edge_voice_name(value: str) -> str:
25
+ return str(value or "en-US-AriaNeural-Female").split("-Female", 1)[0].split("-Male", 1)[0]
26
+
27
+
28
+ async def _save_edge_async(text: str, voice: str, output: Path, rate: str) -> None:
29
+ import edge_tts
30
+
31
+ kwargs: dict[str, Any] = {"rate": rate}
32
+ if "boundary" in inspect.signature(edge_tts.Communicate).parameters:
33
+ kwargs["boundary"] = "WordBoundary"
34
+ communicate = edge_tts.Communicate(text, _edge_voice_name(voice), **kwargs)
35
+ if hasattr(communicate, "save"):
36
+ result = communicate.save(str(output))
37
+ if inspect.isawaitable(result):
38
+ await result
39
+ return
40
+ raise RuntimeError("A versão instalada de edge-tts não possui save().")
41
+
42
+
43
+ def _save_edge(text: str, voice: str, output: Path, rate: str) -> None:
44
+ asyncio.run(_save_edge_async(text, voice, output, rate))
45
+
46
+
47
+ def _save_azure_speech(text: str, voice: str, output: Path, settings: dict[str, Any], rate: str) -> None:
48
+ key = str(settings.get("azure_speech_key", "") or "").strip()
49
+ region = str(settings.get("azure_speech_region", "") or "").strip()
50
+ if not key or not region:
51
+ raise RuntimeError("Configure Azure Speech key e região antes de testar Azure Speech.")
52
+ endpoint = f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1"
53
+ ssml = f"<speak version='1.0' xml:lang='pt-BR'><voice name='{_edge_voice_name(voice)}'><prosody rate='{rate}'>{text}</prosody></voice></speak>"
54
+ response = requests.post(endpoint, data=ssml.encode("utf-8"), headers={"Ocp-Apim-Subscription-Key": key, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "audio-24khz-96kbitrate-mono-mp3"}, timeout=45)
55
+ response.raise_for_status()
56
+ output.write_bytes(response.content)
57
+
58
+
59
+ def _save_openai_compatible(text: str, voice: str, output: Path, settings: dict[str, Any], provider: str) -> None:
60
+ if provider == "elevenlabs":
61
+ api_key = str(settings.get("elevenlabs_api_key", "") or "").strip()
62
+ base = "https://api.elevenlabs.io/v1/text-to-speech"
63
+ voice_id = voice.split("|", 1)[-1] if "|" in voice else voice
64
+ endpoint = f"{base}/{voice_id or '21m00Tcm4TlvDq8ikWAM'}"
65
+ headers = {"xi-api-key": api_key, "Content-Type": "application/json"}
66
+ payload = {"text": text, "model_id": settings.get("elevenlabs_model_id", "eleven_multilingual_v2")}
67
+ elif provider == "minimax":
68
+ api_key = str(settings.get("minimax_tts_api_key", "") or "").strip()
69
+ endpoint = str(settings.get("minimax_tts_base_url", "") or "").strip().rstrip("/")
70
+ endpoint = f"{endpoint}/v1/t2a_v2" if endpoint and not endpoint.endswith("/t2a_v2") else endpoint
71
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
72
+ payload = {"model": settings.get("minimax_tts_model_id", "speech-2.8-hd"), "text": text, "voice_setting": {"voice_id": voice or settings.get("minimax_tts_voice_id", "English_expressive_narrator"), "speed": 1, "vol": 1, "pitch": 0}}
73
+ else:
74
+ api_key = str(settings.get(f"{provider}_tts_api_key", "") or settings.get(f"{provider}_api_key", "") or "").strip()
75
+ endpoint = str(settings.get(f"{provider}_tts_base_url", "") or settings.get(f"{provider}_base_url", "") or "").strip().rstrip("/")
76
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
77
+ payload = {"model": settings.get(f"{provider}_model_id", settings.get(f"{provider}_model_name", "")), "input": text, "voice": voice}
78
+ if not api_key or not endpoint:
79
+ raise RuntimeError(f"Configure a API key e o endpoint de {provider} antes do teste.")
80
+ response = requests.post(endpoint, json=payload, headers=headers, timeout=60)
81
+ response.raise_for_status()
82
+ content_type = response.headers.get("content-type", "")
83
+ if "application/json" in content_type:
84
+ body = response.json()
85
+ audio = body.get("audio") or body.get("audio_base64") or body.get("data")
86
+ if isinstance(audio, dict):
87
+ audio = audio.get("audio") or audio.get("url")
88
+ if isinstance(audio, str) and audio.startswith("http"):
89
+ audio_response = requests.get(audio, timeout=60)
90
+ audio_response.raise_for_status()
91
+ output.write_bytes(audio_response.content)
92
+ elif isinstance(audio, str):
93
+ output.write_bytes(base64.b64decode(audio))
94
+ else:
95
+ raise RuntimeError("O provider devolveu JSON sem áudio reconhecível.")
96
+ else:
97
+ output.write_bytes(response.content)
98
+
99
+
100
+ def synthesize_preview(text: str, provider: str, voice: str, settings: dict[str, Any], rate: str = "+0%") -> Path:
101
+ text = (text or "").strip()
102
+ if not text:
103
+ raise ValueError("Introduza um texto para testar a voz.")
104
+ if len(text) > 1000:
105
+ raise ValueError("O texto de teste deve ter no máximo 1000 caracteres.")
106
+ provider = provider.lower().strip()
107
+ extension = ".mp3"
108
+ output = preview_directory() / f"voice-preview-{uuid.uuid4().hex[:10]}{extension}"
109
+ if provider in {"edge", "azure_v1"}:
110
+ _save_edge(text, voice, output, rate)
111
+ elif provider == "azure_speech":
112
+ _save_azure_speech(text, voice, output, settings, rate)
113
+ elif provider in {"elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"}:
114
+ _save_openai_compatible(text, voice, output, settings, provider)
115
+ else:
116
+ raise ValueError(f"Provider de preview não suportado: {provider}")
117
+ if not output.exists() or output.stat().st_size == 0:
118
+ raise RuntimeError("O provider não gerou um ficheiro de áudio válido.")
119
+ return output
@@ -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 from YouTube HTML without an API key."""
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
- 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})
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
- 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 ""
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
- 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")
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": _thumbnail_from_metadata(metadata),
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
- 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})
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.19",
3
+ "version": "0.2.21",
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",
package/requirements.txt CHANGED
@@ -6,3 +6,4 @@ toml>=0.10,<1
6
6
  google-auth>=2.29,<3
7
7
  google-auth-oauthlib>=1.2,<2
8
8
  google-api-python-client>=2.120,<3
9
+ edge-tts>=7.0,<8
package/scripts/cli.mjs CHANGED
@@ -77,6 +77,8 @@ function ensureRuntimeStorage() {
77
77
  join(storageRoot, "metadata_cleaner", "originals"),
78
78
  join(storageRoot, "metadata_cleaner", "outputs"),
79
79
  join(storageRoot, "skills"),
80
+ join(storageRoot, "music"),
81
+ join(storageRoot, "voice_previews"),
80
82
  ];
81
83
  for (const directory of directories) mkdirSync(directory, { recursive: true });
82
84
  const seedRoot = resolve(root, "seed", "blueprints");
@@ -96,7 +98,7 @@ function check() {
96
98
  console.error(`Python não encontrado. Execute: npx.cmd --yes @danhachuel/thunderbolt install`);
97
99
  process.exit(1);
98
100
  }
99
- const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg"];
101
+ const requiredModules = ["streamlit", "requests", "pandas", "toml", "imageio_ffmpeg", "edge_tts"];
100
102
  const missing = requiredModules.filter((moduleName) => !moduleAvailable(moduleName));
101
103
  const ffmpeg = moduleAvailable("imageio_ffmpeg");
102
104
  const mptPath = configuredMoneyPrinterPath();