136 lines
4.7 KiB
Python
136 lines
4.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.
|
||
|
|
"""
|
||
|
|
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
|
||
|
|
|
||
|
|
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()
|
||
|
|
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:
|
||
|
|
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
|