feat: M2 (part 1) — subscription import, YouTube client, quota guard
- Channel/Subscription/Video/ApiQuotaUsage models + migration 0002 - Synchronous YouTube Data API client with OAuth token refresh and per-call quota accounting (API key preferred for public reads when configured) - Subscription import: upsert channels + subscription links, prune unsubscribed, fetch channel details (uploads playlist, topics, stats, handle, country) - Central quota guard tracking units per US-Pacific day against a daily budget - POST /api/sync/subscriptions and GET /api/sync/status endpoints
This commit is contained in:
parent
3a774cf7d6
commit
24b6e0026d
10 changed files with 603 additions and 3 deletions
0
backend/app/youtube/__init__.py
Normal file
0
backend/app/youtube/__init__.py
Normal file
135
backend/app/youtube/client.py
Normal file
135
backend/app/youtube/client.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue