import logging import httpx from google.oauth2.credentials import Credentials from app.config import settings logger = logging.getLogger(__name__) API_BASE = "https://www.googleapis.com/youtube/v3" BATCH_SIZE = 50 class YouTubeQuotaExceeded(Exception): pass class YouTubeAPIError(Exception): pass class YouTubeInsufficientScope(Exception): """Raised when Google rejects a call because the stored token was granted under an older, narrower scope (e.g. readonly tokens issued before the unsubscribe feature needed write access) -- the fix is reconnecting.""" pass def _headers(credentials: Credentials) -> dict: return {"Authorization": f"Bearer {credentials.token}"} def _raise_for_status(response: httpx.Response) -> None: if response.status_code in (200, 204): return try: payload = response.json() reason = payload.get("error", {}).get("errors", [{}])[0].get("reason", "") message = payload.get("error", {}).get("message", response.text) except Exception: reason = "" message = response.text if response.status_code == 403 and reason in ("quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded"): raise YouTubeQuotaExceeded(message) if response.status_code in (401, 403) and ( reason == "insufficientPermissions" or "insufficient authentication scopes" in message.lower() ): raise YouTubeInsufficientScope(message) logger.error("YouTube API error %s: %s", response.status_code, message) raise YouTubeAPIError(f"{response.status_code}: {message}") def fetch_subscriptions(credentials: Credentials) -> list[dict]: subscriptions: list[dict] = [] page_token: str | None = None with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: while True: params = { "part": "snippet,contentDetails", "mine": "true", "maxResults": 50, } if page_token: params["pageToken"] = page_token response = client.get(f"{API_BASE}/subscriptions", params=params, headers=_headers(credentials)) _raise_for_status(response) data = response.json() for item in data.get("items", []): snippet = item.get("snippet", {}) resource_id = snippet.get("resourceId", {}) channel_id = resource_id.get("channelId") if not channel_id: continue thumbnails = snippet.get("thumbnails", {}) thumbnail = ( thumbnails.get("high") or thumbnails.get("medium") or thumbnails.get("default") or {} ).get("url") subscriptions.append( { "youtube_channel_id": channel_id, # The subscription resource's own id -- distinct from # the channel id, required to later call # subscriptions.delete (unsubscribe). "youtube_subscription_id": item.get("id"), "title": snippet.get("title", ""), "description": snippet.get("description", ""), "thumbnail_url": thumbnail, } ) page_token = data.get("nextPageToken") if not page_token: break return subscriptions def fetch_playlist_video_ids(credentials: Credentials, playlist_id: str, max_results: int) -> list[str]: with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: params = { "part": "contentDetails", "playlistId": playlist_id, "maxResults": min(max_results, 50), } response = client.get(f"{API_BASE}/playlistItems", params=params, headers=_headers(credentials)) if response.status_code == 404: return [] _raise_for_status(response) data = response.json() video_ids = [] for item in data.get("items", []): video_id = item.get("contentDetails", {}).get("videoId") if video_id: video_ids.append(video_id) return video_ids def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list[dict]: results: list[dict] = [] with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: for i in range(0, len(video_ids), BATCH_SIZE): batch = video_ids[i : i + BATCH_SIZE] params = { "part": "snippet,contentDetails,status", "id": ",".join(batch), "maxResults": BATCH_SIZE, } response = client.get(f"{API_BASE}/videos", params=params, headers=_headers(credentials)) _raise_for_status(response) data = response.json() for item in data.get("items", []): snippet = item.get("snippet", {}) thumbnails = snippet.get("thumbnails", {}) thumbnail = ( thumbnails.get("high") or thumbnails.get("medium") or thumbnails.get("default") or {} ).get("url") results.append( { "youtube_video_id": item.get("id"), "youtube_channel_id": snippet.get("channelId"), "title": snippet.get("title", ""), "description": snippet.get("description", ""), "thumbnail_url": thumbnail, "published_at": snippet.get("publishedAt"), "duration_iso8601": item.get("contentDetails", {}).get("duration"), } ) return results def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None: with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: response = client.delete( f"{API_BASE}/subscriptions", params={"id": youtube_subscription_id}, headers=_headers(credentials), ) if response.status_code == 404: # Already gone (unsubscribed elsewhere, or stale id) -- treat as success. return _raise_for_status(response) def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]: result: dict[str, str] = {} with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: for i in range(0, len(channel_ids), BATCH_SIZE): batch = channel_ids[i : i + BATCH_SIZE] params = { "part": "snippet,contentDetails", "id": ",".join(batch), "maxResults": BATCH_SIZE, } response = client.get(f"{API_BASE}/channels", params=params, headers=_headers(credentials)) _raise_for_status(response) data = response.json() for item in data.get("items", []): channel_id = item.get("id") uploads = ( item.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads") ) if channel_id and uploads: result[channel_id] = uploads return result