@danhachuel/thunderbolt 0.2.47 → 0.2.49
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 +10 -10
- package/README.md +15 -7
- package/app/main.py +391 -361
- package/integrations/youtube_batch.py +34 -4
- package/integrations/youtube_direct_credentials.py +35 -4
- package/integrations/youtube_upload.py +15 -4
- package/package.json +1 -1
|
@@ -11,6 +11,36 @@ from typing import Any
|
|
|
11
11
|
from integrations.platforms import IntegrationResult
|
|
12
12
|
|
|
13
13
|
BATCH_SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
|
|
14
|
+
DEFAULT_LOOPBACK_HOST = "127.0.0.1"
|
|
15
|
+
DEFAULT_LOOPBACK_PORT = 8765
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def loopback_host() -> str:
|
|
19
|
+
return _text(os.getenv("THUNDERBOLT_OAUTH_LOOPBACK_HOST")) or DEFAULT_LOOPBACK_HOST
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def loopback_port() -> int:
|
|
23
|
+
try:
|
|
24
|
+
port = int(os.getenv("THUNDERBOLT_OAUTH_LOOPBACK_PORT", str(DEFAULT_LOOPBACK_PORT)))
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
port = DEFAULT_LOOPBACK_PORT
|
|
27
|
+
return port if 1024 <= port <= 65535 else DEFAULT_LOOPBACK_PORT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def loopback_redirect_uri() -> str:
|
|
31
|
+
return f"http://{loopback_host()}:{loopback_port()}/"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _authorization_error_message(email: str, exc: Exception) -> str:
|
|
35
|
+
detail = str(exc)
|
|
36
|
+
if "redirect_uri_mismatch" in detail.lower():
|
|
37
|
+
return (
|
|
38
|
+
f"A autorização da conta {email} foi rejeitada pelo Google (redirect_uri_mismatch). "
|
|
39
|
+
f"Use um cliente OAuth do tipo Desktop app ou adicione exactamente {loopback_redirect_uri()} "
|
|
40
|
+
"em Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados. "
|
|
41
|
+
"Não use uma URI sem a porta, com localhost diferente ou sem a barra final."
|
|
42
|
+
)
|
|
43
|
+
return f"A autorização da conta {email} falhou: {detail}"
|
|
14
44
|
|
|
15
45
|
|
|
16
46
|
def _text(value: Any) -> str:
|
|
@@ -35,7 +65,7 @@ def _client_config(account: dict[str, Any]) -> dict[str, Any]:
|
|
|
35
65
|
"client_secret": _text(account.get("client_secret")),
|
|
36
66
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
37
67
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
38
|
-
"redirect_uris": [
|
|
68
|
+
"redirect_uris": [loopback_redirect_uri()],
|
|
39
69
|
}
|
|
40
70
|
}
|
|
41
71
|
|
|
@@ -84,8 +114,8 @@ def authorize_account(account: dict[str, Any], storage_root: Path, *, open_brows
|
|
|
84
114
|
try:
|
|
85
115
|
flow = InstalledAppFlow.from_client_config(_client_config(account), BATCH_SCOPES)
|
|
86
116
|
credentials = flow.run_local_server(
|
|
87
|
-
host=
|
|
88
|
-
port=
|
|
117
|
+
host=loopback_host(),
|
|
118
|
+
port=loopback_port(),
|
|
89
119
|
open_browser=open_browser,
|
|
90
120
|
access_type="offline",
|
|
91
121
|
prompt="consent",
|
|
@@ -95,7 +125,7 @@ def authorize_account(account: dict[str, Any], storage_root: Path, *, open_brows
|
|
|
95
125
|
_save_credentials(path, credentials)
|
|
96
126
|
return IntegrationResult(True, f"Conta Google autorizada para listagem de canais: {email}.", {"status": "authorized", "email": email, "token_path": str(path)})
|
|
97
127
|
except Exception as exc:
|
|
98
|
-
return IntegrationResult(False,
|
|
128
|
+
return IntegrationResult(False, _authorization_error_message(email, exc), {"status": "authorization_failed", "email": email, "redirect_uri": loopback_redirect_uri()})
|
|
99
129
|
|
|
100
130
|
|
|
101
131
|
def delete_account_token(account: dict[str, Any], storage_root: Path) -> None:
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import secrets
|
|
7
|
+
import shutil
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -78,14 +79,16 @@ def parse_cookie_file(content: bytes, filename: str = "cookies.json") -> dict[st
|
|
|
78
79
|
return {key: pairs[key] for key in COOKIE_KEYS}
|
|
79
80
|
|
|
80
81
|
|
|
81
|
-
def parse_credentials_document(content: bytes, filename: str = DIRECT_DOCUMENT_NAME) -> dict[str, Any]:
|
|
82
|
+
def parse_credentials_document(content: bytes, filename: str = DIRECT_DOCUMENT_NAME, *, session_info_override: str = "") -> dict[str, Any]:
|
|
82
83
|
try:
|
|
83
84
|
raw = json.loads(content.decode("utf-8-sig", errors="replace"))
|
|
84
85
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
85
86
|
raise ValueError(f"O documento de credenciais {filename} deve ser JSON válido.") from exc
|
|
86
87
|
if not isinstance(raw, dict):
|
|
87
88
|
raise ValueError(f"O documento de credenciais {filename} deve conter um objecto JSON.")
|
|
88
|
-
document = _normalise_document(raw, {"id": raw.get("account_id", ""), "email": raw.get("email", "")})
|
|
89
|
+
document = _normalise_document(raw, {"id": raw.get("account_id", ""), "email": raw.get("email", ""), "sessionInfo": session_info_override})
|
|
90
|
+
if session_info_override.strip():
|
|
91
|
+
document["sessionInfo"] = session_info_override.strip()
|
|
89
92
|
missing = [key for key in COOKIE_KEYS if not document["cookies"].get(key)]
|
|
90
93
|
if missing:
|
|
91
94
|
raise ValueError(f"Faltam cookies obrigatórios no documento {filename}: {', '.join(missing)}.")
|
|
@@ -114,6 +117,10 @@ def _channel_keys(channel: dict[str, Any]) -> list[str]:
|
|
|
114
117
|
return keys
|
|
115
118
|
|
|
116
119
|
|
|
120
|
+
def _account_session_info(account: dict[str, Any]) -> str:
|
|
121
|
+
return str(account.get("sessionInfo") or account.get("session_info") or account.get("direct_session_info") or "").strip()
|
|
122
|
+
|
|
123
|
+
|
|
117
124
|
def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
|
|
118
125
|
raw = raw if isinstance(raw, dict) else {}
|
|
119
126
|
cookies = _normalise_pairs(raw.get("cookies", raw))
|
|
@@ -129,7 +136,7 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
|
|
|
129
136
|
return {
|
|
130
137
|
"account_id": str(raw.get("account_id") or account.get("id") or "").strip(),
|
|
131
138
|
"email": str(raw.get("email") or account.get("email") or "").strip(),
|
|
132
|
-
"sessionInfo": str(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or
|
|
139
|
+
"sessionInfo": str(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)).strip(),
|
|
133
140
|
"cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
|
|
134
141
|
"INNERTUBE_API_KEY": str(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or "").strip(),
|
|
135
142
|
"chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
|
|
@@ -156,7 +163,7 @@ def _legacy_document(storage_root: Path, account: dict[str, Any], settings: dict
|
|
|
156
163
|
return _normalise_document({
|
|
157
164
|
"account_id": account.get("id"),
|
|
158
165
|
"email": account.get("email"),
|
|
159
|
-
"sessionInfo": account
|
|
166
|
+
"sessionInfo": _account_session_info(account) or settings.get("direct_session_info"),
|
|
160
167
|
"cookies": legacy_cookies,
|
|
161
168
|
"INNERTUBE_API_KEY": settings.get("direct_innertube_api_key"),
|
|
162
169
|
"chunk_size": settings.get("direct_chunk_size", DEFAULT_CHUNK_SIZE),
|
|
@@ -191,6 +198,30 @@ def save_credentials_document(storage_root: Path, account: dict[str, Any], docum
|
|
|
191
198
|
return destination
|
|
192
199
|
|
|
193
200
|
|
|
201
|
+
def update_credentials_document_session_info(storage_root: Path, account: dict[str, Any], session_info: str) -> Path | None:
|
|
202
|
+
"""Update only sessionInfo in an existing credentials document."""
|
|
203
|
+
path = credentials_document_path(storage_root, account)
|
|
204
|
+
if not path.exists():
|
|
205
|
+
return None
|
|
206
|
+
raw = _read_json_document(path) or {}
|
|
207
|
+
document = _normalise_document(raw, {**account, "sessionInfo": session_info})
|
|
208
|
+
document["sessionInfo"] = str(session_info or "").strip()
|
|
209
|
+
return save_credentials_document(storage_root, account, document)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def delete_credentials_document(storage_root: Path, account: dict[str, Any]) -> None:
|
|
213
|
+
"""Remove all direct-upload credentials belonging only to one Google account."""
|
|
214
|
+
if not str(account.get("id") or "").strip():
|
|
215
|
+
return
|
|
216
|
+
directory = account_directory(storage_root, account)
|
|
217
|
+
try:
|
|
218
|
+
shutil.rmtree(directory)
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
pass
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
194
225
|
def _read_json_document(path: Path) -> dict[str, Any] | None:
|
|
195
226
|
try:
|
|
196
227
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any, Callable
|
|
9
9
|
|
|
10
10
|
from integrations.platforms import IntegrationResult
|
|
11
|
+
from integrations.youtube_batch import loopback_host, loopback_port, loopback_redirect_uri
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
# Mantemos os mesmos escopos usados pelo youtube-automation-agent.
|
|
@@ -111,7 +112,7 @@ class _GoogleYouTubeBase:
|
|
|
111
112
|
"client_secret": self.client_secret,
|
|
112
113
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
113
114
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
114
|
-
"redirect_uris": [
|
|
115
|
+
"redirect_uris": [loopback_redirect_uri()],
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -167,8 +168,8 @@ class _GoogleYouTubeBase:
|
|
|
167
168
|
try:
|
|
168
169
|
flow = InstalledAppFlow.from_client_config(self._client_config(), self.scopes)
|
|
169
170
|
credentials = flow.run_local_server(
|
|
170
|
-
host=
|
|
171
|
-
port=
|
|
171
|
+
host=loopback_host(),
|
|
172
|
+
port=loopback_port(),
|
|
172
173
|
open_browser=open_browser,
|
|
173
174
|
access_type="offline",
|
|
174
175
|
prompt="consent",
|
|
@@ -177,7 +178,17 @@ class _GoogleYouTubeBase:
|
|
|
177
178
|
self._save_credentials(credentials, nested=self.__class__.__name__ == "YouTubeAutomationAgentUploader")
|
|
178
179
|
return IntegrationResult(True, "Conta YouTube autorizada com sucesso.", {"status": "authorized", "token_path": str(self.token_path)})
|
|
179
180
|
except Exception as exc:
|
|
180
|
-
|
|
181
|
+
detail = str(exc)
|
|
182
|
+
if "redirect_uri_mismatch" in detail.lower():
|
|
183
|
+
message = (
|
|
184
|
+
"A autorização Google foi rejeitada (redirect_uri_mismatch). "
|
|
185
|
+
f"Use um cliente OAuth do tipo Desktop app ou adicione exactamente {loopback_redirect_uri()} "
|
|
186
|
+
"em Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados. "
|
|
187
|
+
"Não use uma URI sem a porta, com localhost diferente ou sem a barra final."
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
message = f"A autorização Google falhou: {detail}"
|
|
191
|
+
return IntegrationResult(False, message, {"status": "authorization_failed", "redirect_uri": loopback_redirect_uri()})
|
|
181
192
|
|
|
182
193
|
def status(self) -> IntegrationResult:
|
|
183
194
|
if not self.configured:
|
package/package.json
CHANGED