- uvicorn --log-config (log_config.json): timestamped formatters for uvicorn access/error and the app's own "subfeed" loggers (ms precision) - Log key activity: startup/shutdown, login/denied, token refresh, YouTube API errors, subscription imports, sync pause/resume - Background sync jobs no longer swallow errors silently — failures in RSS poll, backfill, enrichment, autotag and resync are logged with tracebacks
166 lines
5.9 KiB
Python
166 lines
5.9 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 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
|