"""Thin synchronous YouTube Data API v3 client bound to a user's OAuth token. Each list call costs 1 quota unit and is recorded via the central quota guard. Public reads (channels/videos/playlistItems) use the configured API key when available so they don't depend on a specific user's token; subscriptions.list?mine=true always uses OAuth. """ import logging from collections.abc import Iterator from datetime import datetime, timedelta, timezone from typing import Literal import httpx from app import logs, quota, sysconfig from app.models import User from app.security import decrypt, decrypt_optional, encrypt log = logs.get_logger(__name__) GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" API_BASE = "https://www.googleapis.com/youtube/v3" class YouTubeError(Exception): """An upstream YouTube Data API failure. `reason` is the API's own error reason code (`quotaExceeded`, `subscriptionDuplicate`, `videoNotFound`, …) when the body carried one. Branch on THAT, never on the message: the message is prose, it changes under us, and it is deliberately the poorer of the two here. `path` is the API endpoint that ANSWERED with the failure (`playlistItems`); empty when no endpoint did — the request never got through (`transport`) or the token step refused first.""" def __init__(self, message: str, *, reason: str = "", path: str = ""): super().__init__(message) self.reason = reason self.path = path def _error_reason(resp: httpx.Response) -> str: """The `reason` YouTube puts on an API error. That is the whole diagnostic value of an error body; the `message` beside it is prose, and upstream prose can quote the request back — a search term, a playlist title.""" try: error = resp.json().get("error") # The OAuth token endpoint's shape: `{"error": "invalid_grant", "error_description": …}`. if isinstance(error, str): return error or "" errors = (error or {}).get("errors") or [] reasons = ",".join(e.get("reason", "") for e in errors if e.get("reason")) # silent-ok: the sentinel it returns goes straight into the caller's warning line, which # is the report. (Any non-JSON body — an HTML error page from a proxy.) except Exception: # noqa: BLE001 return "" return reasons or "" def _upstream_failure( method: str, path: str, resp: httpx.Response, expected: frozenset[str] = frozenset() ) -> YouTubeError: """Report one upstream failure and build the error to raise. Every YouTube response that is not a success goes through here — the Data API's reads, writes and deletes and the OAuth token refresh — so what may appear in a log line cannot drift apart between them, and the error a caller raises carries the reason code rather than the body, because callers put it in a log line (`routes/channels.py`) and in a 422 answered to the user. (A request that got no response at all is `_send`'s, with the reason `transport`.) The body is logged only under the requesting user's own Teljes consent: it is the request quoted back.""" reason = _error_reason(resp) # A reason the caller named as an answer rather than a failure (`channelNotFound` on a `mine` # read: the account simply has no channel) is still raised, but it is not a warning — it came # back every six hours for an account that will never have one. log.log( logging.DEBUG if reason in expected else logging.WARNING, "YouTube %s %s -> %s (%s)", method, path, resp.status_code, reason, extra=logs.event( "youtube.api.error", method=method, path=path, status=resp.status_code, reason=reason, ), ) if logs.consented_trace(): log.log( logs.TRACE, "the upstream body behind it", # log-pii-ok: only under this user's own Teljes consent, which is what it is for extra=logs.event("youtube.api.body", body=resp.text[:500]), ) return YouTubeError( f"{method} {path} -> {resp.status_code}: {reason}", reason=reason, path=path ) # The `mine` reads' answer for a Google account that has no YouTube channel. _NO_CHANNEL = frozenset({"channelNotFound"}) # The reasons that mean the project's quota is spent: every further call today gets the same answer. _QUOTA_REASONS = frozenset({"quotaExceeded", "dailyLimitExceeded"}) def is_quota_refusal(exc: YouTubeError) -> bool: """Whether the API refused for want of quota. `reason` can list several codes, comma-joined.""" return any(r in _QUOTA_REASONS for r in exc.reason.split(",")) # The token endpoint's answer for a revoked or expired grant — the one refusal a reconnect fixes. _DEAD_GRANT = "invalid_grant" # What a refused YouTube action means to the person who asked for it — the three things they can # act on differently: wait for tomorrow's budget, reconnect the account, or try again later. YouTubeRefusal = Literal["quota", "reconnect", "unavailable", "gone"] def is_playlist_gone(exc: YouTubeError) -> bool: """Whether YouTube answered that the playlist read or written does not exist. Only the caller knows which playlist that was: a user's list deleted on YouTube, or a channel's uploads list, which YouTube also answers so for a channel with no uploads at all.""" return "playlistNotFound" in exc.reason.split(",") def refusal_reason(exc: YouTubeError, *, playlist: bool = False) -> YouTubeRefusal: """Sort a failure by what the user can do about it. A dead grant (Google refused the refresh with `invalid_grant`, which also drops the grant — see `_drop_dead_grant`) or no usable token at all (raised before any endpoint answered, so with no path and no transport reason) means the account must be reconnected; retrying cannot help. Any other token-endpoint failure — a 5xx, a 429, a misconfigured client — leaves the grant as it was: reconnecting would not fix it, and the account would still read as connected, with no connect button to press. `playlist`: the action was on ONE playlist the user picked, so a missing playlist is that list, gone (deleted on YouTube) — neither a retry nor a reconnect helps. Anywhere else the playlist is one the user never named (a channel's uploads, one list of a bulk pull), and "this playlist no longer exists" would describe nothing on their screen.""" if is_quota_refusal(exc): return "quota" if playlist and is_playlist_gone(exc): return "gone" if exc.path == "oauth2/token": return "reconnect" if exc.reason == _DEAD_GRANT else "unavailable" if not exc.path and exc.reason != "transport": return "reconnect" return "unavailable" def _chunks(items: list, size: int) -> Iterator[list]: for i in range(0, len(items), size): yield items[i : i + size] def best_thumbnail(thumbnails: dict | None) -> str | None: if not thumbnails: return None for key in ("maxres", "standard", "high", "medium", "default"): if key in thumbnails and thumbnails[key].get("url"): return thumbnails[key]["url"] return None class YouTubeClient: def __init__(self, db, user: User): self.db = db self.user = user # Optionally route all YouTube traffic through a fixed-IP egress proxy so an # IP-restricted API key keeps working from a host with a dynamic public IP. proxy = sysconfig.get_str(db, "youtube_api_proxy") or None self._http = httpx.Client(timeout=30.0, proxy=proxy) def __enter__(self) -> "YouTubeClient": return self def __exit__(self, *exc) -> None: self._http.close() # --- transport --- def _send(self, method: str, url: str, **kwargs) -> httpx.Response: """Issue an HTTP request, mapping any transport/network failure (egress proxy down, DNS, timeout, connection reset) to YouTubeError. Every caller already handles YouTubeError — degrade gracefully, log, and move on — so wrapping here keeps a transient network fault (e.g. the fixed-IP egress proxy being unreachable) from escaping as a raw httpx error and turning into an uncaught 500. HTTP status errors are NOT touched here (we don't use raise_for_status): each call site inspects resp.status_code itself and hands the response to `_upstream_failure`.""" try: return self._http.request(method, url, **kwargs) except httpx.HTTPError as exc: raise YouTubeError( f"YouTube API unreachable ({exc.__class__.__name__}: {exc})", reason="transport" ) from exc # --- auth --- def _access_token(self) -> str: tok = self.user.token if tok is None: raise YouTubeError("User has no stored OAuth token") now = datetime.now(timezone.utc) cached = decrypt_optional(tok.access_token) if cached and tok.expiry and tok.expiry > now + timedelta(seconds=60): return cached try: refresh = decrypt(tok.refresh_token_enc) except Exception as exc: # Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed) # A key mismatch must degrade like any other token problem (caller handles YouTubeError), # not surface as a raw 500 on every scheduler job / connect check. (B-core-2) raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc if not refresh: raise YouTubeError("No refresh token; user must re-authenticate") resp = self._send( "POST", GOOGLE_TOKEN_URL, data={ "client_id": sysconfig.get_str(self.db, "google_client_id"), "client_secret": sysconfig.get_str(self.db, "google_client_secret"), "refresh_token": refresh, "grant_type": "refresh_token", }, ) if resp.status_code != 200: exc = _upstream_failure("POST", "oauth2/token", resp) if exc.reason == _DEAD_GRANT: self._drop_dead_grant(tok) raise exc data = resp.json() access = data["access_token"] tok.access_token = encrypt(access) tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600))) self.db.add(tok) self.db.commit() log.info("Refreshed access token for user %s", self.user.id) return access def _drop_dead_grant(self, tok) -> None: """Google answered the refresh with `invalid_grant`: the grant was revoked or expired, and no retry will revive it. Forget it, so the account reads as NOT connected — Settings and the onboarding wizard then offer the connect button again (a fresh consent issues a new refresh token). Kept as "granted", the UI showed a live connection with no way to renew it while every call failed. Committed like the refresh above: the caller rolls back on the error.""" tok.scopes = None tok.access_token = None tok.refresh_token_enc = None tok.expiry = None self.db.add(tok) self.db.commit() # INFO: the refusal itself is already the warning (`youtube.api.error`, invalid_grant). log.info( "cleared the dead YouTube grant of user %s", self.user.id, extra=logs.event("youtube.grant.cleared"), ) # --- core request --- def _get( self, path: str, params: dict, cost: int = 1, allow_key: bool = True, expected: frozenset[str] = frozenset(), ) -> dict: p = dict(params) headers = {} api_key = sysconfig.get_str(self.db, "youtube_api_key") if allow_key and api_key: p["key"] = api_key else: headers["Authorization"] = f"Bearer {self._access_token()}" resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers) quota.record_usage(cost) if resp.status_code != 200: raise _upstream_failure("GET", path, resp, expected) return resp.json() # --- endpoints --- def iter_subscriptions(self) -> Iterator[dict]: """Yield the authenticated user's subscriptions (requires OAuth).""" page_token = None while True: params = { "part": "snippet", "mine": "true", "maxResults": 50, "order": "alphabetical", } if page_token: params["pageToken"] = page_token data = self._get("subscriptions", params, cost=1, allow_key=False) for item in data.get("items", []): snippet = item.get("snippet", {}) resource = snippet.get("resourceId", {}) yield { "channel_id": resource.get("channelId"), "title": snippet.get("title"), "description": snippet.get("description"), "thumbnail_url": best_thumbnail(snippet.get("thumbnails")), "yt_subscription_id": item.get("id"), } page_token = data.get("nextPageToken") if not page_token: break def iter_my_playlists(self) -> Iterator[dict]: """Yield the authenticated user's own playlists (OAuth, so private ones are included). Note: YouTube's special Watch Later / History playlists are NOT returned by the Data API and cannot be synced.""" page_token = None while True: params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50} if page_token: params["pageToken"] = page_token try: data = self._get("playlists", params, cost=1, allow_key=False, expected=_NO_CHANNEL) except YouTubeError as exc: # A Google account without a YouTube channel has no playlists, and YouTube says # so as a 404. That is an empty list, not a failed sync. if exc.reason in _NO_CHANNEL: return raise for item in data.get("items", []): sn = item.get("snippet", {}) yield { "id": item.get("id"), "title": sn.get("title"), "item_count": item.get("contentDetails", {}).get("itemCount"), "thumbnail_url": best_thumbnail(sn.get("thumbnails")), } page_token = data.get("nextPageToken") if not page_token: break def _iter_playlist_items(self, playlist_id: str) -> Iterator[dict]: """Paginate one of the user's playlists' items (playlistItems, contentDetails part; OAuth so private/unlisted work), yielding each raw item in playlist order.""" page_token = None while True: params = { "part": "contentDetails", "playlistId": playlist_id, "maxResults": 50, } if page_token: params["pageToken"] = page_token data = self._get("playlistItems", params, cost=1, allow_key=False) yield from data.get("items", []) page_token = data.get("nextPageToken") if not page_token: break def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]: """Yield the video ids of one of the user's own playlists, in playlist order (OAuth, so private/unlisted playlists work).""" for item in self._iter_playlist_items(playlist_id): vid = item.get("contentDetails", {}).get("videoId") if vid: yield vid def get_my_channel_id(self) -> str | None: """The authenticated user's own channel id (channels.list?mine=true). OAuth only; 1 quota unit. Returns None if the account has no channel.""" data = self._get( "channels", {"part": "id", "mine": "true", "maxResults": 1}, allow_key=False ) items = data.get("items", []) return items[0].get("id") if items else None def get_channels(self, channel_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(channel_ids, 50): data = self._get( "channels", { "part": "snippet,contentDetails,statistics,topicDetails,brandingSettings", "id": ",".join(batch), "maxResults": 50, }, ) items.extend(data.get("items", [])) return items def get_playlist_items(self, playlist_id: str, page_token: str | None = None) -> dict: """One page (up to 50) of a playlist's items, most-recent-first for uploads playlists. Returns the raw response (items + nextPageToken).""" params = { "part": "contentDetails,snippet", "playlistId": playlist_id, "maxResults": 50, } if page_token: params["pageToken"] = page_token return self._get("playlistItems", params) def delete_subscription(self, subscription_id: str) -> None: """Unsubscribe on YouTube. Requires the write scope and the user's OAuth token (never the public API key). subscriptions.delete costs 50 quota units.""" resp = self._send( "DELETE", f"{API_BASE}/subscriptions", params={"id": subscription_id}, headers={"Authorization": f"Bearer {self._access_token()}"}, ) quota.record_usage(50) if resp.status_code not in (200, 204): raise _upstream_failure("DELETE", "subscriptions", resp) def insert_subscription(self, channel_id: str) -> str: """Subscribe to a channel on YouTube. Requires the write scope and the user's OAuth token (never the public API key). subscriptions.insert costs 50 quota units. Returns the new subscription resource id — store it as `yt_subscription_id` so the channel can later be unsubscribed.""" data = self._write( "POST", "subscriptions", params={"part": "snippet"}, json={"snippet": {"resourceId": {"kind": "youtube#channel", "channelId": channel_id}}}, ) return data.get("id", "") def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]: """Yield {item_id, video_id} for each item of one of the user's playlists, in playlist order. `item_id` is the playlistItem resource id, needed to delete or reposition the item via the write API. OAuth (private playlists work).""" for item in self._iter_playlist_items(playlist_id): vid = item.get("contentDetails", {}).get("videoId") if vid: yield {"item_id": item.get("id"), "video_id": vid} # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: headers = {"Authorization": f"Bearer {self._access_token()}"} resp = self._send(method, f"{API_BASE}/{path}", params=params, json=json, headers=headers) quota.record_usage(50) if resp.status_code not in (200, 204): raise _upstream_failure(method, path, resp) return resp.json() if resp.content else {} def get_playlist_snippet(self, playlist_id: str) -> dict | None: """The snippet (title/description) of one playlist plus its `item_count` (what an empty item listing is checked against), or None if it no longer exists. 1 unit (OAuth, so private playlists work).""" data = self._get( "playlists", {"part": "snippet,contentDetails", "id": playlist_id, "maxResults": 1}, cost=1, allow_key=False, ) items = data.get("items", []) if not items: return None return { **(items[0].get("snippet") or {}), "item_count": items[0].get("contentDetails", {}).get("itemCount"), } def update_playlist_title(self, playlist_id: str, title: str) -> None: """Rename a playlist on YouTube. 50 units.""" self._write( "PUT", "playlists", params={"part": "snippet"}, json={"id": playlist_id, "snippet": {"title": title[:150]}}, ) def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: """Create a new YouTube playlist; returns its id. 50 units.""" data = self._write( "POST", "playlists", params={"part": "snippet,status"}, json={ "snippet": {"title": title[:150], "description": description[:5000]}, "status": {"privacyStatus": privacy}, }, ) return data["id"] def add_playlist_item( self, playlist_id: str, video_id: str, position: int | None = None ) -> str: """Append (or insert at `position`) a video into a playlist; returns the new playlistItem id. 50 units.""" snippet = { "playlistId": playlist_id, "resourceId": {"kind": "youtube#video", "videoId": video_id}, } if position is not None: snippet["position"] = position data = self._write( "POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet} ) return data["id"] def move_playlist_item( self, item_id: str, playlist_id: str, video_id: str, position: int ) -> None: """Reposition an existing playlistItem to absolute 0-based `position`. 50 units.""" self._write( "PUT", "playlistItems", params={"part": "snippet"}, json={ "id": item_id, "snippet": { "playlistId": playlist_id, "position": position, "resourceId": {"kind": "youtube#video", "videoId": video_id}, }, }, ) def delete_playlist_item(self, item_id: str) -> None: """Remove a playlistItem from its playlist. 50 units.""" self._write("DELETE", "playlistItems", params={"id": item_id}) def delete_playlist(self, playlist_id: str) -> None: """Delete a whole playlist on YouTube. 50 units.""" self._write("DELETE", "playlists", params={"id": playlist_id}) def search_videos( self, q: str, page_token: str | None = None, order: str = "relevance" ) -> dict: """Live YouTube search (search.list) for embeddable videos. **100 quota units per call** — by far the most expensive read, so callers must gate it (per-user cap + budget pre-check). Public read: prefers the API key over OAuth. Returns one page (up to 50) as ``{"items": [...], "next_page_token": str|None}`` where each item is a flat dict ready for stub insertion. `liveBroadcastContent` is surfaced so the caller can drop currently-live/upcoming results (search can't filter those out for us). Snippet carries no duration/stats — the caller enriches via videos.list afterwards.""" params = { "part": "snippet", "q": q, "type": "video", "maxResults": 50, "order": order, "videoEmbeddable": "true", "safeSearch": "none", } if page_token: params["pageToken"] = page_token data = self._get("search", params, cost=100) items: list[dict] = [] for it in data.get("items", []): vid = it.get("id", {}).get("videoId") if not vid: continue sn = it.get("snippet", {}) items.append( { "id": vid, "channel_id": sn.get("channelId"), "channel_title": sn.get("channelTitle"), "title": sn.get("title"), "published_at": sn.get("publishedAt"), "thumbnail_url": best_thumbnail(sn.get("thumbnails")), "live_broadcast": sn.get("liveBroadcastContent") or "none", } ) return {"items": items, "next_page_token": data.get("nextPageToken")} def get_videos(self, video_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(video_ids, 50): data = self._get( "videos", { "part": "snippet,contentDetails,statistics,topicDetails,liveStreamingDetails,status", "id": ",".join(batch), "maxResults": 50, }, ) items.extend(data.get("items", [])) return items