@danhachuel/thunderbolt 0.5.71 → 0.5.73

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/app/main.py CHANGED
@@ -121,6 +121,7 @@ from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_co
121
121
  from integrations.youtube_direct_upload import YouTubeDirectUploader
122
122
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
123
123
  from integrations.session_info_health import check_account_session_info_health
124
+ from integrations.youtube_session_manager import renew_account_session
124
125
  from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
125
126
  from integrations.local_runtime import MoneyPrinterRuntime
126
127
  from integrations.moneyprinter_config import sync_moneyprinter_config
@@ -6570,6 +6571,8 @@ def render_google_accounts(*, include_innertube: bool = True):
6570
6571
  st.error(health_message, icon="⚠️")
6571
6572
  elif session_health.status == "unknown":
6572
6573
  st.warning(health_message, icon="⚠️")
6574
+ elif session_health.status in {"blocked_by_google", "invalid_format"}:
6575
+ st.error(health_message, icon="⚠️")
6573
6576
  with st.expander("Detalhes da conta Google", expanded=False):
6574
6577
  with st.form(f"batch_account_form_{account_id}"):
6575
6578
  account_cols = st.columns(2)
@@ -6687,6 +6690,30 @@ def render_google_accounts(*, include_innertube: bool = True):
6687
6690
  except ValueError as exc:
6688
6691
  st.error(str(exc))
6689
6692
 
6693
+ st.markdown("**Renovação de sessionInfo e cookies**")
6694
+ st.caption("A renovação é manual ou opt-in antes do upload; não há timer residente nem execução ao seleccionar a conta.")
6695
+ renewal_cols = st.columns([2, 1, 1])
6696
+ auto_renew = renewal_cols[0].checkbox(
6697
+ "Renovar automaticamente antes do upload",
6698
+ value=bool(batch_account.get("auto_renew_before_upload", False)),
6699
+ key=f"auto_renew_before_upload_{account_id}",
6700
+ )
6701
+ if renewal_cols[1].button("Renovar agora", key=f"renew_session_info_{account_id}", type="primary", use_container_width=True):
6702
+ result = renew_account_session(STORAGE, batch_account, settings, force=True, wait_seconds=10)
6703
+ (st.success if result.ok else st.error)(result.message)
6704
+ if result.ok:
6705
+ st.rerun()
6706
+ with renewal_cols[2]:
6707
+ _api_status_badge(
6708
+ "Configured" if session_health.status == "healthy" else ("Missing key" if session_health.status == "missing" else "Missing configuration"),
6709
+ "ready" if session_health.status == "healthy" else "missing",
6710
+ )
6711
+ if auto_renew != bool(batch_account.get("auto_renew_before_upload", False)):
6712
+ batch_account["auto_renew_before_upload"] = bool(auto_renew)
6713
+ settings["youtube_batch_accounts"] = batch_accounts
6714
+ write_json("settings.json", settings)
6715
+ st.success("Preferência de renovação guardada para esta conta.")
6716
+
6690
6717
  if youtube_accounts_missing_document:
6691
6718
  st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
6692
6719
 
@@ -1243,6 +1243,22 @@ def _valid_artifact_path(value: Any) -> Path | None:
1243
1243
  return None
1244
1244
 
1245
1245
 
1246
+ def _existing_thumbnail_path(task: dict[str, Any], artifacts: dict[str, Any], variant: dict[str, Any]) -> Path | None:
1247
+ """Find a thumbnail made previously by Pipeline Vídeos or the UI."""
1248
+ candidates: list[Any] = [
1249
+ artifacts.get("thumbnail"),
1250
+ task.get("thumbnail_path"),
1251
+ variant.get("image_path"),
1252
+ ]
1253
+ variants = task.get("thumbnail_variants") if isinstance(task.get("thumbnail_variants"), list) else []
1254
+ candidates.extend(item.get("image_path") for item in variants if isinstance(item, dict))
1255
+ for value in candidates:
1256
+ path = _valid_artifact_path(value)
1257
+ if path is not None:
1258
+ return path
1259
+ return None
1260
+
1261
+
1246
1262
  def _read_json_artifact(value: Any) -> dict[str, Any] | None:
1247
1263
  path = _valid_artifact_path(value)
1248
1264
  if path is None:
@@ -1482,7 +1498,8 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
1482
1498
 
1483
1499
  _update(task_id, stage="thumbnail", state="doing", progress=max(86, int(task.get("progress") or 0)), error=None)
1484
1500
  current_artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or artifacts)
1485
- existing_thumbnail = _valid_artifact_path(current_artifacts.get("thumbnail") or variant.get("image_path"))
1501
+ current_task = _task_by_id(task_id) or task
1502
+ existing_thumbnail = _existing_thumbnail_path(current_task, current_artifacts, variant)
1486
1503
  if existing_thumbnail is not None:
1487
1504
  thumbnail_path = existing_thumbnail
1488
1505
  else:
@@ -8,6 +8,7 @@ conta Google/YouTube.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  from dataclasses import dataclass
12
13
  from datetime import datetime, timedelta, timezone
13
14
  from pathlib import Path
@@ -15,10 +16,10 @@ from typing import Any
15
16
 
16
17
  from integrations.youtube_direct_credentials import credentials_document_path, load_credentials_document
17
18
 
18
- DEFAULT_SESSION_INFO_TTL_HOURS = 36
19
+ DEFAULT_SESSION_INFO_TTL_HOURS = 19
19
20
  DEFAULT_SESSION_INFO_ALERT_HOURS = 6
20
- MIN_SESSION_INFO_TTL_HOURS = 24
21
- MAX_SESSION_INFO_TTL_HOURS = 48
21
+ MIN_SESSION_INFO_TTL_HOURS = 1
22
+ MAX_SESSION_INFO_TTL_HOURS = 72
22
23
 
23
24
 
24
25
  def utc_now() -> datetime:
@@ -28,6 +29,11 @@ def utc_now() -> datetime:
28
29
 
29
30
  def parse_timestamp(value: Any) -> datetime | None:
30
31
  """Parse ISO-8601 timestamps used in local credential documents."""
32
+ if isinstance(value, (int, float)):
33
+ try:
34
+ return datetime.fromtimestamp(float(value), tz=timezone.utc)
35
+ except (TypeError, ValueError, OSError, OverflowError):
36
+ return None
31
37
  text = str(value or "").strip()
32
38
  if not text:
33
39
  return None
@@ -118,11 +124,17 @@ def health_check_session_info(
118
124
  file_text = str(credential_file or "")
119
125
  if not session_info:
120
126
  return SessionInfoHealth("missing", "Falta sessionInfo nesta conta Google/YouTube.", None, None, None, None, account_id, account_label, file_text, False, True)
127
+ if str(document.get("sessionInfoHealthStatus") or "").strip() == "blocked_by_google":
128
+ return SessionInfoHealth("blocked_by_google", "Sessão bloqueada pelo Google; é necessário login manual.", None, None, None, captured.isoformat() if captured else None, account_id, account_label, file_text, True, True)
129
+ if str(document.get("sessionInfoHealthStatus") or "").strip() == "invalid_format":
130
+ return SessionInfoHealth("invalid_format", "sessionInfo capturado, mas o formato foi rejeitado.", None, None, None, captured.isoformat() if captured else None, account_id, account_label, file_text, True, True)
121
131
  if captured is None:
122
- return SessionInfoHealth("unknown", "sessionInfo existe, mas a data de captura não é conhecida; guarde novamente o token para activar o alerta de expiração.", None, None, None, None, account_id, account_label, file_text, True, True)
132
+ # Legacy documents without a capture date remain compatible with the
133
+ # existing uploader, while the UI still asks for a manual renewal.
134
+ return SessionInfoHealth("unknown", "sessionInfo existe, mas a data de captura não é conhecida; renove a sessão para activar o alerta de expiração.", None, None, None, None, account_id, account_label, file_text, True, True)
123
135
  age = max(0.0, (current - captured.astimezone(timezone.utc)).total_seconds() / 3600)
124
- remaining = float(ttl) - age
125
- expires = captured.astimezone(timezone.utc) + timedelta(hours=ttl)
136
+ expires = parse_timestamp(document.get("expires_at")) or (captured.astimezone(timezone.utc) + timedelta(hours=ttl))
137
+ remaining = (expires - current.astimezone(timezone.utc)).total_seconds() / 3600
126
138
  if remaining <= 0:
127
139
  return SessionInfoHealth("expired", f"sessionInfo expirou há {abs(remaining):.1f} horas; renove-o manualmente antes de criar ou enviar conteúdos.", age, remaining, expires.isoformat(), captured.isoformat(), account_id, account_label, file_text, True, True)
128
140
  if remaining <= alert:
@@ -160,6 +172,14 @@ def check_account_session_info_health(
160
172
  """Load one account document and return its safe SessionInfo health state."""
161
173
  settings = settings or {}
162
174
  document = load_credentials_document(storage_root, account, settings, create=False)
175
+ state_path = credentials_document_path(storage_root, account).with_name("renewal_state.json")
176
+ try:
177
+ state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
178
+ except (OSError, json.JSONDecodeError):
179
+ state = {}
180
+ if isinstance(state, dict) and state.get("status") in {"blocked_by_google", "invalid_format"}:
181
+ document = dict(document)
182
+ document["sessionInfoHealthStatus"] = state["status"]
163
183
  return session_info_health_from_settings(
164
184
  account,
165
185
  document,
@@ -18,6 +18,7 @@ from integrations.postiz import PostizAdapter
18
18
  from integrations.session_info_health import check_account_session_info_health, emit_session_info_health_alerts
19
19
  from integrations.youtube_direct_credentials import document_status
20
20
  from integrations.youtube_direct_upload import YouTubeDirectUploader
21
+ from integrations.youtube_session_manager import renew_account_session
21
22
  from integrations.composio_upload import ComposioUploadError, execute_upload
22
23
  from hermes_ui.storage import read_json, write_json
23
24
  from hermes_ui.languages import language_locale
@@ -157,6 +158,16 @@ def upload_with_default_route(
157
158
  direct_ready = False
158
159
  if account:
159
160
  try:
161
+ renewal_warning = ""
162
+ if bool(account.get("auto_renew_before_upload", settings.get("auto_renew_before_upload", False))):
163
+ current_health = check_account_session_info_health(storage_root, account, settings)
164
+ if current_health.status in {"expiring", "expired"}:
165
+ renewal = renew_account_session(storage_root, account, settings, wait_seconds=10)
166
+ if not renewal.ok:
167
+ renewal_warning = f"Renovação automática não concluída ({renewal.status}); o upload pode falhar."
168
+ attempts.append({"route": "Renovação sessionInfo", "status": "failed", "message": renewal_warning, "data": {"status": renewal.status}})
169
+ else:
170
+ attempts.append({"route": "Renovação sessionInfo", "status": "success", "message": renewal.message, "data": {"status": renewal.status}})
160
171
  status = document_status(storage_root, account, channel, settings, [channel])
161
172
  health = check_account_session_info_health(storage_root, account, settings)
162
173
  emit_session_info_health_alerts([health])
@@ -165,7 +176,7 @@ def upload_with_default_route(
165
176
  direct_result = IntegrationResult(
166
177
  False,
167
178
  f"Upload directo indisponível: {health.message}",
168
- {"missing": ["sessionInfo"], "session_info_health": health.as_dict()},
179
+ {"missing": ["sessionInfo"], "session_info_health": health.as_dict(), "renewal_warning": renewal_warning},
169
180
  )
170
181
  elif not direct_ready:
171
182
  missing = list(status.get("missing_cookies", []))
@@ -175,7 +186,7 @@ def upload_with_default_route(
175
186
  missing.append("INNERTUBE_API_KEY")
176
187
  if not status.get("has_delegated_session_id"):
177
188
  missing.append("DELEGATED_SESSION_ID")
178
- direct_result = IntegrationResult(False, f"Upload directo indisponível: {', '.join(missing)}.", {"missing": missing})
189
+ direct_result = IntegrationResult(False, f"Upload directo indisponível: {', '.join(missing)}. {renewal_warning}".strip(), {"missing": missing, "renewal_warning": renewal_warning})
179
190
  else:
180
191
  direct_result = None
181
192
  except Exception as exc:
@@ -185,6 +185,10 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
185
185
  "email": str(raw.get("email") or account.get("email") or "").strip(),
186
186
  "sessionInfo": _placeholder_to_empty(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)),
187
187
  "sessionInfoCapturedAt": str(raw.get("sessionInfoCapturedAt") or raw.get("session_info_captured_at") or account.get("sessionInfoCapturedAt") or account.get("session_info_captured_at") or "").strip(),
188
+ "expires_at": raw.get("expires_at") or account.get("expires_at") or "",
189
+ "sessionInfoHealthStatus": str(raw.get("sessionInfoHealthStatus") or "").strip(),
190
+ "renewalLastAttemptAt": str(raw.get("renewalLastAttemptAt") or "").strip(),
191
+ "renewalLastSuccessAt": str(raw.get("renewalLastSuccessAt") or "").strip(),
188
192
  "cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
189
193
  "INNERTUBE_API_KEY": _placeholder_to_empty(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or ""),
190
194
  "chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
@@ -229,6 +233,10 @@ def save_credentials_document(storage_root: Path, account: dict[str, Any], docum
229
233
  "email": normalised["email"],
230
234
  "sessionInfo": normalised["sessionInfo"],
231
235
  "sessionInfoCapturedAt": normalised.get("sessionInfoCapturedAt", ""),
236
+ "expires_at": normalised.get("expires_at", ""),
237
+ "sessionInfoHealthStatus": normalised.get("sessionInfoHealthStatus", ""),
238
+ "renewalLastAttemptAt": normalised.get("renewalLastAttemptAt", ""),
239
+ "renewalLastSuccessAt": normalised.get("renewalLastSuccessAt", ""),
232
240
  "cookies": normalised["cookies"],
233
241
  # INNERTUBE_API_KEY não pertence ao documento de cookies/credenciais.
234
242
  "chunk_size": normalised["chunk_size"],
@@ -0,0 +1,371 @@
1
+ """Safe, opt-in renewal of YouTube cookies and sessionInfo per account.
2
+
3
+ The module is deliberately one-shot: it never starts Streamlit, workers, or a
4
+ resident polling loop. Browser automation is best-effort and refuses Google
5
+ verification/CAPTCHA pages instead of attempting to bypass them.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import base64
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import shutil
16
+ import signal
17
+ import time
18
+ from contextlib import contextmanager
19
+ from dataclasses import dataclass
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+ from typing import Any, Iterator
23
+
24
+ from integrations.session_info_health import check_account_session_info_health
25
+ from integrations.youtube_direct_credentials import (
26
+ COOKIE_KEYS,
27
+ account_directory,
28
+ account_key,
29
+ credentials_document_path,
30
+ load_credentials_document,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+ LOCK_TIMEOUT_SECONDS = 300
35
+ LOCK_WAIT_SECONDS = 10
36
+ MAX_CAPTURE_ATTEMPTS = 2
37
+ TITLE_SELECTORS = [
38
+ 'input[aria-label="Título"]',
39
+ 'input[aria-label="Title"]',
40
+ 'input[name="title"]',
41
+ 'input[placeholder*="Título"]',
42
+ 'input[placeholder*="Title"]',
43
+ 'ytcp-text-input#title-text-input',
44
+ ]
45
+ DESCRIPTION_SELECTORS = [
46
+ 'textarea[aria-label="Descrição"]',
47
+ 'textarea[aria-label="Description"]',
48
+ 'textarea[name="description"]',
49
+ 'textarea[placeholder*="Descrição"]',
50
+ 'textarea[placeholder*="Description"]',
51
+ 'ytcp-mention-drawer textarea',
52
+ ]
53
+ BLOCKED_TEXT = ("verify", "verificação", "verification", "captcha", "confirme que você é humano", "confirm it's you", "confirme que é você")
54
+
55
+ try:
56
+ from yt_cm import YouTubeCookieManager # type: ignore
57
+ YT_CM_AVAILABLE = True
58
+ except ImportError: # optional by design
59
+ YouTubeCookieManager = None # type: ignore
60
+ YT_CM_AVAILABLE = False
61
+
62
+
63
+ def is_yt_cm_available() -> bool:
64
+ """Return whether optional yt-cm can be imported."""
65
+ return bool(YT_CM_AVAILABLE)
66
+
67
+
68
+ def validate_session_info(token: str | None) -> bool:
69
+ """Validate the conservative Base64-like shape required by the plan."""
70
+ value = str(token or "").strip()
71
+ return bool(value and len(value) >= 50 and re.fullmatch(r"[A-Za-z0-9+/=]+", value))
72
+
73
+
74
+ def _safe_error(exc: BaseException) -> str:
75
+ text = str(exc)
76
+ for secret in ("sessionInfo", "SID", "SSID", "HSID", "APISID", "SAPISID"):
77
+ text = text.replace(secret, "[redacted]")
78
+ return text[:500]
79
+
80
+
81
+ def _pid_alive(pid: int) -> bool:
82
+ if pid <= 0:
83
+ return False
84
+ try:
85
+ os.kill(pid, 0)
86
+ except (OSError, ProcessLookupError):
87
+ return False
88
+ except PermissionError:
89
+ return True
90
+ return True
91
+
92
+
93
+ @contextmanager
94
+ def account_lock(account_dir: Path, account_id: str, *, wait_seconds: int = 0) -> Iterator[None]:
95
+ """Acquire an atomic per-account lock, removing only dead/expired locks."""
96
+ account_dir.mkdir(parents=True, exist_ok=True)
97
+ path = account_dir / f"{account_key({'id': account_id})}.lock"
98
+ deadline = time.monotonic() + max(0, wait_seconds)
99
+ acquired = False
100
+ while not acquired:
101
+ payload = {"pid": os.getpid(), "acquired_at": datetime.now(timezone.utc).isoformat(), "timeout_seconds": LOCK_TIMEOUT_SECONDS}
102
+ try:
103
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
104
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
105
+ json.dump(payload, handle)
106
+ acquired = True
107
+ except FileExistsError:
108
+ stale = False
109
+ try:
110
+ current = json.loads(path.read_text(encoding="utf-8"))
111
+ pid = int(current.get("pid", 0))
112
+ acquired_at = datetime.fromisoformat(str(current.get("acquired_at", "")).replace("Z", "+00:00"))
113
+ age = (datetime.now(timezone.utc) - (acquired_at if acquired_at.tzinfo else acquired_at.replace(tzinfo=timezone.utc))).total_seconds()
114
+ stale = age > LOCK_TIMEOUT_SECONDS or not _pid_alive(pid)
115
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
116
+ stale = True
117
+ if stale:
118
+ try:
119
+ path.unlink()
120
+ except FileNotFoundError:
121
+ pass
122
+ continue
123
+ if time.monotonic() >= deadline:
124
+ raise TimeoutError("lock de renovação já está activo")
125
+ time.sleep(0.25)
126
+ try:
127
+ yield
128
+ finally:
129
+ try:
130
+ current = json.loads(path.read_text(encoding="utf-8"))
131
+ if int(current.get("pid", -1)) == os.getpid():
132
+ path.unlink(missing_ok=True)
133
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
134
+ pass
135
+
136
+
137
+ def _rotate_backups(destination: Path) -> None:
138
+ bak, bak1, bak2 = (destination.with_name(destination.name + suffix) for suffix in (".bak", ".bak1", ".bak2"))
139
+ if bak1.exists():
140
+ os.replace(bak1, bak2)
141
+ if bak.exists():
142
+ os.replace(bak, bak1)
143
+ if destination.exists():
144
+ shutil.copy2(destination, bak)
145
+
146
+
147
+ def _write_renewal_state(directory: Path, status: str) -> None:
148
+ """Persist only non-secret renewal state for the UI/health check."""
149
+ path = directory / "renewal_state.json"
150
+ path.write_text(json.dumps({"status": status, "updated_at": datetime.now(timezone.utc).isoformat()}), encoding="utf-8")
151
+
152
+
153
+ def atomic_save_credentials(destination: Path, document: dict[str, Any]) -> None:
154
+ """Rotate three backups, atomically replace, and restore on failure."""
155
+ destination.parent.mkdir(parents=True, exist_ok=True)
156
+ _rotate_backups(destination)
157
+ temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
158
+ try:
159
+ temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
160
+ try:
161
+ os.chmod(temporary, 0o600)
162
+ except OSError:
163
+ pass
164
+ os.replace(temporary, destination)
165
+ except Exception:
166
+ temporary.unlink(missing_ok=True)
167
+ latest = destination.with_name(destination.name + ".bak")
168
+ if latest.exists():
169
+ os.replace(latest, destination)
170
+ raise
171
+
172
+
173
+ def _blocked_page(page: Any) -> bool:
174
+ try:
175
+ body = str(page.locator("body").inner_text(timeout=2000)).lower()
176
+ if any(marker in body for marker in BLOCKED_TEXT):
177
+ return True
178
+ if page.locator('input[type="email"], input[type="password"], iframe[src*="captcha"], iframe[title*="captcha" i]').count() > 0:
179
+ return True
180
+ except Exception:
181
+ return False
182
+ return False
183
+
184
+
185
+ def _first_selector(page: Any, selectors: list[str]) -> str | None:
186
+ for selector in selectors:
187
+ try:
188
+ page.wait_for_selector(selector, timeout=3000)
189
+ logger.debug("Selector YouTube utilizado: %s", selector)
190
+ return selector
191
+ except Exception:
192
+ continue
193
+ return None
194
+
195
+
196
+ def _capture_with_playwright(document: dict[str, Any], *, logs_dir: Path, video_id: str = "") -> tuple[str, str | None]:
197
+ try:
198
+ from playwright.sync_api import sync_playwright # type: ignore
199
+ except ImportError:
200
+ logger.warning("Playwright não instalado; captura sessionInfo indisponível.")
201
+ return "browser_unavailable", None
202
+ captured: list[str] = []
203
+ logs_dir.mkdir(parents=True, exist_ok=True)
204
+ cookies = [{"name": key, "value": str(document["cookies"][key]), "domain": ".youtube.com", "path": "/"} for key in COOKIE_KEYS if document.get("cookies", {}).get(key)]
205
+ with sync_playwright() as playwright:
206
+ browser = playwright.chromium.launch(headless=True)
207
+ context = browser.new_context()
208
+ context.add_cookies(cookies)
209
+ page = context.new_page()
210
+
211
+ def on_response(response: Any) -> None:
212
+ try:
213
+ text = response.text()
214
+ for match in re.findall(r"[A-Za-z0-9+/=]{50,}", text):
215
+ if validate_session_info(match):
216
+ captured.append(match)
217
+ break
218
+ except Exception:
219
+ pass
220
+
221
+ page.on("response", on_response)
222
+ try:
223
+ url = "https://studio.youtube.com/"
224
+ if video_id:
225
+ url += f"video/{video_id}/edit"
226
+ page.goto(url, wait_until="domcontentloaded", timeout=30000)
227
+ if _blocked_page(page):
228
+ logger.warning("🔒 Sessão bloqueada pelo Google. Renovação manual necessária.")
229
+ return "blocked_by_google", None
230
+ selector = _first_selector(page, TITLE_SELECTORS)
231
+ if selector is None:
232
+ selector = _first_selector(page, DESCRIPTION_SELECTORS)
233
+ if selector is None:
234
+ screenshot = logs_dir / f"failure_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}.png"
235
+ page.screenshot(path=str(screenshot), full_page=True)
236
+ logger.error("Nenhum selector de título ou descrição do YouTube foi encontrado; screenshot: %s", screenshot)
237
+ return "selector_failure", None
238
+ try:
239
+ page.locator(selector).focus()
240
+ page.keyboard.press("Escape")
241
+ except Exception:
242
+ pass
243
+ page.wait_for_timeout(1500)
244
+ for token in captured:
245
+ if validate_session_info(token):
246
+ return "captured", token
247
+ return "invalid_format", None
248
+ finally:
249
+ context.close()
250
+ browser.close()
251
+
252
+
253
+ def _try_renew_cookies(document: dict[str, Any], account: dict[str, Any]) -> dict[str, Any]:
254
+ if not is_yt_cm_available():
255
+ logger.warning("⚠️ yt-cm não encontrado. A renovação de cookies será pulada. Instale com: pip install yt-cm")
256
+ return document
257
+ manager = YouTubeCookieManager() # type: ignore[operator]
258
+ for method_name in ("renew", "refresh", "validate"):
259
+ method = getattr(manager, method_name, None)
260
+ if callable(method):
261
+ result = method(document.get("cookies", {}), account=account)
262
+ if isinstance(result, dict):
263
+ document["cookies"] = {key: str(result.get(key, document["cookies"].get(key, ""))) for key in COOKIE_KEYS}
264
+ break
265
+ return document
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class RenewalResult:
270
+ ok: bool
271
+ status: str
272
+ message: str
273
+ account_id: str
274
+ attempts: int = 0
275
+
276
+ def as_dict(self) -> dict[str, Any]:
277
+ return {"ok": self.ok, "status": self.status, "message": self.message, "account_id": self.account_id, "attempts": self.attempts}
278
+
279
+
280
+ def renew_account_session(storage_root: Path, account: dict[str, Any], settings: dict[str, Any] | None = None, *, force: bool = False, wait_seconds: int = 0, logs_dir: Path | None = None) -> RenewalResult:
281
+ settings = settings or {}
282
+ account_id = str(account.get("id") or "").strip()
283
+ if not account_id:
284
+ return RenewalResult(False, "invalid_account", "Conta Google sem ID.", "")
285
+ health = check_account_session_info_health(storage_root, account, settings)
286
+ if not force and health.status not in {"expiring", "expired", "missing", "invalid_format"}:
287
+ return RenewalResult(True, health.status, "Renovação não necessária.", account_id)
288
+ directory = account_directory(storage_root, account)
289
+ destination = credentials_document_path(storage_root, account)
290
+ logs_dir = logs_dir or directory / "logs"
291
+ try:
292
+ with account_lock(directory, account_id, wait_seconds=wait_seconds):
293
+ document = load_credentials_document(storage_root, account, settings, create=False)
294
+ document = dict(document)
295
+ try:
296
+ document = _try_renew_cookies(document, account)
297
+ except Exception as exc:
298
+ return RenewalResult(False, "cookie_renewal_failed", f"Falha na renovação de cookies: {_safe_error(exc)}", account_id)
299
+ for attempt in range(1, MAX_CAPTURE_ATTEMPTS + 1):
300
+ capture_status, token = _capture_with_playwright(document, logs_dir=logs_dir, video_id=str(account.get("video_id") or ""))
301
+ if capture_status == "blocked_by_google":
302
+ _write_renewal_state(directory, "blocked_by_google")
303
+ return RenewalResult(False, "blocked_by_google", "🔒 Sessão bloqueada pelo Google. Renovação manual necessária.", account_id, attempt)
304
+ if validate_session_info(token):
305
+ document["sessionInfo"] = token
306
+ now = datetime.now(timezone.utc).isoformat()
307
+ document["sessionInfoCapturedAt"] = now
308
+ document["expires_at"] = (datetime.now(timezone.utc) + timedelta(hours=19)).isoformat()
309
+ document["sessionInfoHealthStatus"] = ""
310
+ atomic_save_credentials(destination, document)
311
+ _write_renewal_state(directory, "healthy")
312
+ return RenewalResult(True, "healthy", "sessionInfo renovado com sucesso.", account_id, attempt)
313
+ logger.warning("sessionInfo capturado, mas formato inválido. Descartando.")
314
+ _write_renewal_state(directory, "invalid_format")
315
+ return RenewalResult(False, "invalid_format", "Não foi possível capturar um sessionInfo válido.", account_id, MAX_CAPTURE_ATTEMPTS)
316
+ except TimeoutError as exc:
317
+ return RenewalResult(False, "lock_timeout", str(exc), account_id)
318
+ except Exception as exc:
319
+ return RenewalResult(False, "renewal_failed", f"Renovação falhou: {_safe_error(exc)}", account_id)
320
+
321
+
322
+ def _accounts_from_settings(settings: dict[str, Any]) -> list[dict[str, Any]]:
323
+ accounts = settings.get("youtube_batch_accounts", [])
324
+ return [item for item in accounts if isinstance(item, dict) and str(item.get("id") or "").strip()] if isinstance(accounts, list) else []
325
+
326
+
327
+ def run_all_accounts(storage_root: Path, settings: dict[str, Any]) -> int:
328
+ renewed = failed = ignored = 0
329
+ for account in _accounts_from_settings(settings):
330
+ enabled = bool(account.get("auto_renew_enabled", settings.get("auto_renew_enabled", False)))
331
+ if not enabled:
332
+ ignored += 1
333
+ continue
334
+ health = check_account_session_info_health(storage_root, account, settings)
335
+ if health.status not in {"expiring", "expired"}:
336
+ ignored += 1
337
+ continue
338
+ result = renew_account_session(storage_root, account, settings)
339
+ if result.ok:
340
+ renewed += 1
341
+ else:
342
+ failed += 1
343
+ logger.info("Renovadas: %s, Falhas: %s, Ignoradas: %s", renewed, failed, ignored)
344
+ return 1 if failed else 0
345
+
346
+
347
+ def main(argv: list[str] | None = None) -> int:
348
+ parser = argparse.ArgumentParser(description="Renovação única de sessões YouTube")
349
+ parser.add_argument("--all-accounts", action="store_true")
350
+ parser.add_argument("--account-id")
351
+ parser.add_argument("--storage-root", default=os.environ.get("THUNDERBOLT_STORAGE_DIR", "storage"))
352
+ args = parser.parse_args(argv)
353
+ root = Path(args.storage_root)
354
+ settings_path = root / "state" / "settings.json"
355
+ try:
356
+ settings = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {}
357
+ except (OSError, json.JSONDecodeError):
358
+ settings = {}
359
+ if args.all_accounts:
360
+ return run_all_accounts(root, settings)
361
+ accounts = [a for a in _accounts_from_settings(settings) if str(a.get("id")) == str(args.account_id)]
362
+ if not accounts:
363
+ return 1
364
+ result = renew_account_session(root, accounts[0], settings, force=True)
365
+ return 0 if result.ok else 1
366
+
367
+
368
+ if __name__ == "__main__":
369
+ raise SystemExit(main())
370
+
371
+ __all__ = ["RenewalResult", "TITLE_SELECTORS", "account_lock", "atomic_save_credentials", "is_yt_cm_available", "renew_account_session", "run_all_accounts", "validate_session_info"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.71",
3
+ "version": "0.5.73",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",