siftlode/backend/app/youtube/client.py
npeter83 d02b540c60 feat(m5b): optional YouTube write scope via incremental OAuth
Default login now requests read-only (youtube.readonly); write (unsubscribe,
later playlist export) is an explicit opt-in.

- auth.py: split READ_SCOPES / WRITE_SCOPES; new GET /auth/upgrade (incremental
  consent, prompt=consent); has_write_scope() helper
- /api/me exposes can_write
- youtube/client.py: delete_subscription (50 units, OAuth-only)
- DELETE /api/channels/{id}/subscription, gated on write scope (403 otherwise)
- UI: Settings - Account 'Playlist editing & YouTube export' enable button;
  per-channel 'Unsubscribe on YouTube' (with confirm) shown only when can_write

Browser-facing; develop/test locally until the public HTTPS login lands. Needs a
one-time Console step: add youtube.readonly to the OAuth consent screen scopes.
2026-06-11 23:27:11 +02:00

185 lines
6.7 KiB
Python

"""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
import httpx
from app import quota
from app.config import settings
from app.models import User
from app.security import decrypt
log = logging.getLogger("subfeed.youtube")
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
API_BASE = "https://www.googleapis.com/youtube/v3"
class YouTubeError(Exception):
pass
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
self._http = httpx.Client(timeout=30.0)
def __enter__(self) -> "YouTubeClient":
return self
def __exit__(self, *exc) -> None:
self._http.close()
# --- 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)
if tok.access_token and tok.expiry and tok.expiry > now + timedelta(seconds=60):
return tok.access_token
refresh = decrypt(tok.refresh_token_enc)
if not refresh:
raise YouTubeError("No refresh token; user must re-authenticate")
resp = self._http.post(
GOOGLE_TOKEN_URL,
data={
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"refresh_token": refresh,
"grant_type": "refresh_token",
},
)
if resp.status_code != 200:
raise YouTubeError(f"Token refresh failed: {resp.status_code} {resp.text[:200]}")
data = resp.json()
tok.access_token = data["access_token"]
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 tok.access_token
# --- core request ---
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict:
p = dict(params)
headers = {}
if allow_key and settings.youtube_api_key:
p["key"] = settings.youtube_api_key
else:
headers["Authorization"] = f"Bearer {self._access_token()}"
resp = self._http.get(f"{API_BASE}/{path}", params=p, headers=headers)
quota.record_usage(self.db, cost)
if resp.status_code != 200:
log.warning("YouTube API %s -> %s: %s", path, resp.status_code, resp.text[:200])
raise YouTubeError(f"GET {path} -> {resp.status_code}: {resp.text[:300]}")
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 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",
"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._http.delete(
f"{API_BASE}/subscriptions",
params={"id": subscription_id},
headers={"Authorization": f"Bearer {self._access_token()}"},
)
quota.record_usage(self.db, 50)
if resp.status_code not in (200, 204):
log.warning(
"YouTube subscriptions.delete -> %s: %s",
resp.status_code,
resp.text[:200],
)
raise YouTubeError(
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}"
)
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",
"id": ",".join(batch),
"maxResults": 50,
},
)
items.extend(data.get("items", []))
return items