@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.
- package/MANUAL-INSTALACAO.md +22 -6
- package/README.md +24 -7
- package/app/main.py +341 -19
- package/hermes_ui/domain.py +36 -0
- package/hermes_ui/music.py +82 -0
- package/hermes_ui/storage.py +14 -1
- package/hermes_ui/voice_preview.py +119 -0
- package/integrations/platforms.py +114 -44
- package/integrations/youtube_direct_upload.py +169 -0
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/scripts/cli.mjs +3 -1
- package/scripts/install.mjs +3 -1
|
@@ -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": {}}
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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
|
|
196
|
+
"""Fetch public channel metadata without requiring a YouTube Data API key."""
|
|
129
197
|
source = (value or "").strip()
|
|
130
198
|
if not source:
|
|
131
199
|
return IntegrationResult(False, "Introduza o nome, handle, URL ou ID do canal.", {})
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
initial_data = _extract_json_assignment(document, "ytInitialData")
|
|
153
|
-
metadata = _find_first_key(initial_data or {}, "channelMetadataRenderer") or {}
|
|
154
|
-
header = _find_first_key(initial_data or {}, "pageHeaderViewModel") or {}
|
|
155
|
-
if not metadata and not header:
|
|
156
|
-
return IntegrationResult(False, "O YouTube não disponibilizou dados públicos para este canal. Confirme o link ou tente a aba Cadastro manual.", {"url": page_url})
|
|
200
|
+
headers = {
|
|
201
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
|
|
202
|
+
"Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
|
|
203
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
204
|
+
}
|
|
205
|
+
last_url = source
|
|
206
|
+
last_error = ""
|
|
207
|
+
for page_url in _public_page_candidates(source):
|
|
208
|
+
last_url = page_url
|
|
209
|
+
try:
|
|
210
|
+
response = requests.get(page_url, headers=headers, timeout=20)
|
|
211
|
+
response.raise_for_status()
|
|
212
|
+
except requests.RequestException as exc:
|
|
213
|
+
last_error = str(exc)
|
|
214
|
+
continue
|
|
215
|
+
document = response.text or ""
|
|
216
|
+
initial_data = _extract_json_assignment(document, "ytInitialData") or {}
|
|
217
|
+
metadata = _find_first_key(initial_data, "channelMetadataRenderer") or {}
|
|
218
|
+
header = _find_first_key(initial_data, "pageHeaderViewModel") or {}
|
|
219
|
+
canonical_url = _meta_content(document, "og:url", "twitter:url") or page_url.split("?", 1)[0].rstrip("/")
|
|
157
220
|
owner_urls = metadata.get("ownerUrls", []) if isinstance(metadata, dict) else []
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
221
|
+
if owner_urls:
|
|
222
|
+
canonical_url = str(owner_urls[0])
|
|
223
|
+
canonical_url = canonical_url.replace("http://", "https://").removesuffix("/about")
|
|
224
|
+
youtube_id = str(metadata.get("externalId", "")) if isinstance(metadata, dict) else ""
|
|
225
|
+
if not youtube_id:
|
|
226
|
+
youtube_id = _channel_id_from_document(document, canonical_url)
|
|
227
|
+
feed = _public_feed_data(youtube_id, headers)
|
|
162
228
|
title = _text_from_node(metadata.get("title")) if isinstance(metadata, dict) else ""
|
|
163
229
|
if not title and isinstance(header, dict):
|
|
164
230
|
title = _text_from_node(header.get("title"))
|
|
165
231
|
if not title:
|
|
166
232
|
title = _text_from_node(_find_first_key(header, "dynamicTextViewModel"))
|
|
233
|
+
if not title:
|
|
234
|
+
title = _meta_content(document, "og:title", "twitter:title") or feed.get("name", "")
|
|
167
235
|
description = _text_from_node(metadata.get("description")) if isinstance(metadata, dict) else ""
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
236
|
+
description = description or _meta_content(document, "description", "og:description")
|
|
237
|
+
thumbnail_url = _thumbnail_from_metadata(metadata) or _meta_content(document, "og:image", "twitter:image")
|
|
238
|
+
handle_match = re.search(r"/@([^/?]+)", canonical_url)
|
|
239
|
+
handle = f"@{handle_match.group(1)}" if handle_match else ""
|
|
240
|
+
if not handle:
|
|
241
|
+
handle_match = re.search(r"/@([^/?]+)", document)
|
|
242
|
+
handle = f"@{handle_match.group(1)}" if handle_match else ""
|
|
243
|
+
subscriber_value = _find_first_key(initial_data, "subscriberCountText")
|
|
244
|
+
video_value = _find_first_key(initial_data, "videoCountText")
|
|
174
245
|
data = {
|
|
175
|
-
"youtube_id": youtube_id,
|
|
246
|
+
"youtube_id": youtube_id or feed.get("youtube_id", ""),
|
|
176
247
|
"name": title,
|
|
177
248
|
"handle": handle,
|
|
178
249
|
"url": canonical_url,
|
|
179
250
|
"description": description,
|
|
180
|
-
"thumbnail_url":
|
|
251
|
+
"thumbnail_url": thumbnail_url,
|
|
181
252
|
"subscriber_count": _parse_public_count(subscriber_value),
|
|
182
|
-
"video_count": _parse_public_count(video_value),
|
|
253
|
+
"video_count": _parse_public_count(video_value) or feed.get("video_count"),
|
|
183
254
|
"view_count": None,
|
|
184
255
|
"metrics_source": "youtube_public_page",
|
|
185
256
|
"public_lookup": True,
|
|
186
257
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return IntegrationResult(False, f"A página pública do YouTube mudou ou não pôde ser interpretada: {exc}", {"url": source})
|
|
258
|
+
if data["name"] or data["youtube_id"] or data["thumbnail_url"]:
|
|
259
|
+
return IntegrationResult(True, "Canal encontrado publicamente no YouTube, sem API Key. Reveja os dados antes de guardar.", data)
|
|
260
|
+
last_error = "A página respondeu sem metadados reconhecíveis."
|
|
261
|
+
return IntegrationResult(False, f"Não foi possível obter dados públicos do YouTube sem API Key. Confirme o URL/handle ou use Cadastro manual. {last_error}".strip(), {"url": last_url, "public_lookup": True})
|
|
192
262
|
|
|
193
263
|
def fetch_channel(self, value: str) -> IntegrationResult:
|
|
194
264
|
ref = self.extract_channel_ref(value)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CHUNK_GRANULARITY = 262144
|
|
17
|
+
SUPPORTED_EXTENSIONS = {".mp4", ".mov", ".webm", ".avi", ".mpeg", ".mpg", ".flv", ".wmv", ".3gpp"}
|
|
18
|
+
COOKIE_KEYS = ("SID", "SSID", "HSID", "APISID", "SAPISID")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DirectUploadResult:
|
|
23
|
+
ok: bool
|
|
24
|
+
message: str
|
|
25
|
+
data: dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cookie_settings(settings: dict[str, Any]) -> dict[str, str]:
|
|
29
|
+
return {key: str(settings.get(f"direct_cookie_{key.lower()}", "") or "").strip() for key in COOKIE_KEYS}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cookie_header(cookies: dict[str, str]) -> str:
|
|
33
|
+
return ";".join(["CONSENT=YES+cb"] + [f"{key}={value}" for key, value in cookies.items() if value])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sapishash(sapisid: str, origin: str = "https://studio.youtube.com") -> str:
|
|
37
|
+
timestamp = int(time.time())
|
|
38
|
+
digest = hashlib.sha1(f"{timestamp} {sapisid} {origin}".encode("utf-8")).hexdigest()
|
|
39
|
+
return f"{timestamp}_{digest}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _innertube_id() -> str:
|
|
43
|
+
return f"innertube_studio:{secrets.token_hex(18)}:0"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _header(response: requests.Response, name: str) -> str:
|
|
47
|
+
wanted = name.lower()
|
|
48
|
+
for key, value in response.headers.items():
|
|
49
|
+
if key.lower() == wanted:
|
|
50
|
+
return str(value)
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], settings: dict[str, Any]) -> str | None:
|
|
55
|
+
path = Path(video_path)
|
|
56
|
+
if not path.exists() or not path.is_file():
|
|
57
|
+
return "Ficheiro de vídeo não encontrado."
|
|
58
|
+
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
59
|
+
return "Formato de vídeo não suportado pelo upload directo."
|
|
60
|
+
if path.stat().st_size <= 0:
|
|
61
|
+
return "O ficheiro de vídeo está vazio."
|
|
62
|
+
missing_cookies = [key for key, value in _cookie_settings(settings).items() if not value]
|
|
63
|
+
if missing_cookies:
|
|
64
|
+
return f"Faltam cookies de sessão para upload directo: {', '.join(missing_cookies)}."
|
|
65
|
+
if not str(settings.get("direct_session_info", "") or "").strip():
|
|
66
|
+
return "Configure o token sessionInfo do upload directo."
|
|
67
|
+
if not str(settings.get("direct_innertube_api_key", "") or "").strip():
|
|
68
|
+
return "Configure o INNERTUBE_API_KEY do upload directo."
|
|
69
|
+
if not str(channel.get("delegated_session_id", "") or "").strip():
|
|
70
|
+
return "Este canal não tem DELEGATED_SESSION_ID configurado."
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class YouTubeDirectUploader:
|
|
75
|
+
def __init__(self, settings: dict[str, Any], channel: dict[str, Any], *, session: requests.Session | None = None):
|
|
76
|
+
self.settings = settings
|
|
77
|
+
self.channel = channel
|
|
78
|
+
self.session = session or requests.Session()
|
|
79
|
+
self.cookies = _cookie_settings(settings)
|
|
80
|
+
self.cookie_header = _cookie_header(self.cookies)
|
|
81
|
+
self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124 Safari/537.36"
|
|
82
|
+
self.inner_tube = _innertube_id()
|
|
83
|
+
self.google_upload: dict[str, str] = {}
|
|
84
|
+
self.video_id = ""
|
|
85
|
+
|
|
86
|
+
def _base_headers(self) -> dict[str, str]:
|
|
87
|
+
return {
|
|
88
|
+
"User-Agent": self.user_agent,
|
|
89
|
+
"Origin": "https://studio.youtube.com",
|
|
90
|
+
"Referer": "https://studio.youtube.com/",
|
|
91
|
+
"Cookie": self.cookie_header,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def describe_file(self, path: Path) -> None:
|
|
95
|
+
headers = {
|
|
96
|
+
**self._base_headers(),
|
|
97
|
+
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
98
|
+
"X-Goog-Upload-File-Name": path.name,
|
|
99
|
+
"X-Goog-Upload-Header-Content-Length": str(path.stat().st_size),
|
|
100
|
+
"X-Goog-Upload-Command": "start",
|
|
101
|
+
"X-Goog-Upload-Protocol": "resumable",
|
|
102
|
+
}
|
|
103
|
+
response = self.session.post("https://upload.youtube.com/upload/studio?authuser=0", headers=headers, data={"frontendUploadId": self.inner_tube}, timeout=60)
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
self.google_upload = {
|
|
106
|
+
"resource_id": _header(response, "x-goog-upload-header-scotty-resource-id"),
|
|
107
|
+
"upload_url": _header(response, "x-goog-upload-url"),
|
|
108
|
+
"upload_id": _header(response, "x-guploader-uploadid"),
|
|
109
|
+
}
|
|
110
|
+
if not self.google_upload["upload_url"] or not self.google_upload["resource_id"]:
|
|
111
|
+
raise RuntimeError("O YouTube não devolveu uma sessão de upload directo válida.")
|
|
112
|
+
|
|
113
|
+
def create_video(self, title: str, description: str, visibility: str) -> None:
|
|
114
|
+
payload = {
|
|
115
|
+
"resourceId": {"scottyResourceId": {"id": self.google_upload["resource_id"]}},
|
|
116
|
+
"frontendUploadId": self.inner_tube,
|
|
117
|
+
"initialMetadata": {
|
|
118
|
+
"title": {"newTitle": title},
|
|
119
|
+
"description": {"newDescription": description},
|
|
120
|
+
"privacy": {"newPrivacy": visibility},
|
|
121
|
+
"draftState": {"isDraft": False},
|
|
122
|
+
"targetedAudience": {"operation": "MDE_TARGETED_AUDIENCE_UPDATE_OPERATION_SET", "newTargetedAudience": "MDE_TARGETED_AUDIENCE_TYPE_ALL"},
|
|
123
|
+
},
|
|
124
|
+
"botguardClientResponse": f"${hashlib.sha1(os.urandom(16)).hexdigest()}",
|
|
125
|
+
"context": {
|
|
126
|
+
"client": {"clientName": 62, "clientVersion": "1.20210806.02.00", "hl": "pt-BR", "gl": "BR", "experimentsToken": "", "utcOffsetMinutes": 0},
|
|
127
|
+
"request": {"sessionInfo": {"token": str(self.settings.get("direct_session_info", ""))}},
|
|
128
|
+
"user": {"onBehalfOfUser": str(self.channel.get("delegated_session_id", ""))},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
api_key = str(self.settings.get("direct_innertube_api_key", "")).strip()
|
|
132
|
+
endpoint = f"https://studio.youtube.com/youtubei/v1/upload/createvideo?alt=json&key={quote(api_key)}"
|
|
133
|
+
headers = {**self._base_headers(), "Content-Type": "application/json", "X-Youtube-Client-Name": "62", "X-Youtube-Client-Version": "1.20210806.02.00", "X-Goog-PageId": str(self.channel.get("delegated_session_id", "")), "Authorization": f"SAPISIDHASH {_sapishash(self.cookies['SAPISID'])}"}
|
|
134
|
+
response = self.session.post(endpoint, headers=headers, data=json.dumps(payload), timeout=60)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
body = response.json() if response.content else {}
|
|
137
|
+
self.video_id = str(body.get("videoId") or body.get("video_id") or "")
|
|
138
|
+
if not self.video_id:
|
|
139
|
+
raise RuntimeError("O YouTube não devolveu o videoId após createvideo.")
|
|
140
|
+
|
|
141
|
+
def upload_chunks(self, path: Path, chunk_size: int = CHUNK_GRANULARITY) -> None:
|
|
142
|
+
chunk_size = max(CHUNK_GRANULARITY, int(chunk_size))
|
|
143
|
+
chunk_size -= chunk_size % CHUNK_GRANULARITY
|
|
144
|
+
if chunk_size == 0:
|
|
145
|
+
chunk_size = CHUNK_GRANULARITY
|
|
146
|
+
offset = 0
|
|
147
|
+
with path.open("rb") as handle:
|
|
148
|
+
while True:
|
|
149
|
+
chunk = handle.read(chunk_size)
|
|
150
|
+
if not chunk:
|
|
151
|
+
break
|
|
152
|
+
last = offset + len(chunk) >= path.stat().st_size
|
|
153
|
+
headers = {**self._base_headers(), "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", "X-Goog-Upload-Command": "upload, finalize" if last else "upload", "X-Goog-Upload-Offset": str(offset), "X-Goog-Upload-File-Name": quote(path.name)}
|
|
154
|
+
response = self.session.post(self.google_upload["upload_url"], headers=headers, data=chunk, timeout=180)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
offset += len(chunk)
|
|
157
|
+
|
|
158
|
+
def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int = CHUNK_GRANULARITY) -> DirectUploadResult:
|
|
159
|
+
path = Path(video_path)
|
|
160
|
+
validation_error = validate_direct_upload(path, self.channel, self.settings)
|
|
161
|
+
if validation_error:
|
|
162
|
+
return DirectUploadResult(False, validation_error, {"mechanism": "youtube-frontend-direct"})
|
|
163
|
+
try:
|
|
164
|
+
self.describe_file(path)
|
|
165
|
+
self.create_video(title, description, visibility)
|
|
166
|
+
self.upload_chunks(path, chunk_size)
|
|
167
|
+
return DirectUploadResult(True, f"Upload directo concluído: {self.video_id}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id, "page_id": self.channel.get("delegated_session_id", "")})
|
|
168
|
+
except (requests.RequestException, OSError, ValueError, RuntimeError) as exc:
|
|
169
|
+
return DirectUploadResult(False, f"Upload directo falhou: {exc}", {"mechanism": "youtube-frontend-direct", "video_id": self.video_id})
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
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();
|