New youtube_api_proxy setting (env fallback + admin Config UI, env YOUTUBE_API_PROXY): when set, the YouTubeClient routes all its httpx traffic through that HTTP(S) proxy. Lets a dynamic-IP host send API calls through a fixed-IP host (e.g. the server over private tunnel) so an IP-restricted API key keeps working. Empty = direct.
366 lines
15 KiB
Python
366 lines
15 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, sysconfig
|
|
from app.config import settings
|
|
from app.models import User
|
|
from app.security import decrypt
|
|
|
|
log = logging.getLogger("siftlode.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
|
|
# Optionally route all YouTube traffic through a fixed-IP egress proxy (e.g. the VPS
|
|
# over WireGuard) so an IP-restricted API key keeps working from a dynamic-IP host.
|
|
proxy = sysconfig.get_str(db, "youtube_api_proxy") or None
|
|
self._http = httpx.Client(timeout=30.0, proxy=proxy)
|
|
|
|
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": sysconfig.get_str(self.db, "google_client_id"),
|
|
"client_secret": sysconfig.get_str(self.db, "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 = {}
|
|
api_key = sysconfig.get_str(self.db, "youtube_api_key")
|
|
if allow_key and api_key:
|
|
p["key"] = 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 iter_my_playlists(self) -> Iterator[dict]:
|
|
"""Yield the authenticated user's own playlists (OAuth, so private ones are
|
|
included). Note: YouTube's special Watch Later / History playlists are NOT
|
|
returned by the Data API and cannot be synced."""
|
|
page_token = None
|
|
while True:
|
|
params = {"part": "snippet,contentDetails", "mine": "true", "maxResults": 50}
|
|
if page_token:
|
|
params["pageToken"] = page_token
|
|
data = self._get("playlists", params, cost=1, allow_key=False)
|
|
for item in data.get("items", []):
|
|
sn = item.get("snippet", {})
|
|
yield {
|
|
"id": item.get("id"),
|
|
"title": sn.get("title"),
|
|
"item_count": item.get("contentDetails", {}).get("itemCount"),
|
|
"thumbnail_url": best_thumbnail(sn.get("thumbnails")),
|
|
}
|
|
page_token = data.get("nextPageToken")
|
|
if not page_token:
|
|
break
|
|
|
|
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]:
|
|
"""Yield the video ids of one of the user's own playlists, in playlist order
|
|
(OAuth, so private/unlisted playlists work)."""
|
|
page_token = None
|
|
while True:
|
|
params = {
|
|
"part": "contentDetails",
|
|
"playlistId": playlist_id,
|
|
"maxResults": 50,
|
|
}
|
|
if page_token:
|
|
params["pageToken"] = page_token
|
|
data = self._get("playlistItems", params, cost=1, allow_key=False)
|
|
for item in data.get("items", []):
|
|
vid = item.get("contentDetails", {}).get("videoId")
|
|
if vid:
|
|
yield vid
|
|
page_token = data.get("nextPageToken")
|
|
if not page_token:
|
|
break
|
|
|
|
def get_my_channel_id(self) -> str | None:
|
|
"""The authenticated user's own channel id (channels.list?mine=true). OAuth only;
|
|
1 quota unit. Returns None if the account has no channel."""
|
|
data = self._get(
|
|
"channels", {"part": "id", "mine": "true", "maxResults": 1}, allow_key=False
|
|
)
|
|
items = data.get("items", [])
|
|
return items[0].get("id") if items else None
|
|
|
|
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 insert_subscription(self, channel_id: str) -> str:
|
|
"""Subscribe to a channel on YouTube. Requires the write scope and the user's
|
|
OAuth token (never the public API key). subscriptions.insert costs 50 quota
|
|
units. Returns the new subscription resource id — store it as
|
|
`yt_subscription_id` so the channel can later be unsubscribed."""
|
|
data = self._write(
|
|
"POST",
|
|
"subscriptions",
|
|
params={"part": "snippet"},
|
|
json={
|
|
"snippet": {
|
|
"resourceId": {"kind": "youtube#channel", "channelId": channel_id}
|
|
}
|
|
},
|
|
)
|
|
return data.get("id", "")
|
|
|
|
def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]:
|
|
"""Yield {item_id, video_id} for each item of one of the user's playlists, in
|
|
playlist order. `item_id` is the playlistItem resource id, needed to delete or
|
|
reposition the item via the write API. OAuth (private playlists work)."""
|
|
page_token = None
|
|
while True:
|
|
params = {
|
|
"part": "contentDetails",
|
|
"playlistId": playlist_id,
|
|
"maxResults": 50,
|
|
}
|
|
if page_token:
|
|
params["pageToken"] = page_token
|
|
data = self._get("playlistItems", params, cost=1, allow_key=False)
|
|
for item in data.get("items", []):
|
|
vid = item.get("contentDetails", {}).get("videoId")
|
|
if vid:
|
|
yield {"item_id": item.get("id"), "video_id": vid}
|
|
page_token = data.get("nextPageToken")
|
|
if not page_token:
|
|
break
|
|
|
|
# --- write endpoints (OAuth only, never the API key; each costs 50 quota units) ---
|
|
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
|
|
headers = {"Authorization": f"Bearer {self._access_token()}"}
|
|
resp = self._http.request(
|
|
method, f"{API_BASE}/{path}", params=params, json=json, headers=headers
|
|
)
|
|
quota.record_usage(self.db, 50)
|
|
if resp.status_code not in (200, 204):
|
|
log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200])
|
|
raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}")
|
|
return resp.json() if resp.content else {}
|
|
|
|
def get_playlist_snippet(self, playlist_id: str) -> dict | None:
|
|
"""The snippet (title/description) of one playlist, or None if it no longer
|
|
exists. 1 unit (OAuth, so private playlists work)."""
|
|
data = self._get(
|
|
"playlists",
|
|
{"part": "snippet", "id": playlist_id, "maxResults": 1},
|
|
cost=1,
|
|
allow_key=False,
|
|
)
|
|
items = data.get("items", [])
|
|
return items[0].get("snippet") if items else None
|
|
|
|
def update_playlist_title(self, playlist_id: str, title: str) -> None:
|
|
"""Rename a playlist on YouTube. 50 units."""
|
|
self._write(
|
|
"PUT",
|
|
"playlists",
|
|
params={"part": "snippet"},
|
|
json={"id": playlist_id, "snippet": {"title": title[:150]}},
|
|
)
|
|
|
|
def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str:
|
|
"""Create a new YouTube playlist; returns its id. 50 units."""
|
|
data = self._write(
|
|
"POST",
|
|
"playlists",
|
|
params={"part": "snippet,status"},
|
|
json={
|
|
"snippet": {"title": title[:150], "description": description[:5000]},
|
|
"status": {"privacyStatus": privacy},
|
|
},
|
|
)
|
|
return data["id"]
|
|
|
|
def add_playlist_item(self, playlist_id: str, video_id: str, position: int | None = None) -> str:
|
|
"""Append (or insert at `position`) a video into a playlist; returns the new
|
|
playlistItem id. 50 units."""
|
|
snippet = {
|
|
"playlistId": playlist_id,
|
|
"resourceId": {"kind": "youtube#video", "videoId": video_id},
|
|
}
|
|
if position is not None:
|
|
snippet["position"] = position
|
|
data = self._write(
|
|
"POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet}
|
|
)
|
|
return data["id"]
|
|
|
|
def move_playlist_item(
|
|
self, item_id: str, playlist_id: str, video_id: str, position: int
|
|
) -> None:
|
|
"""Reposition an existing playlistItem to absolute 0-based `position`. 50 units."""
|
|
self._write(
|
|
"PUT",
|
|
"playlistItems",
|
|
params={"part": "snippet"},
|
|
json={
|
|
"id": item_id,
|
|
"snippet": {
|
|
"playlistId": playlist_id,
|
|
"position": position,
|
|
"resourceId": {"kind": "youtube#video", "videoId": video_id},
|
|
},
|
|
},
|
|
)
|
|
|
|
def delete_playlist_item(self, item_id: str) -> None:
|
|
"""Remove a playlistItem from its playlist. 50 units."""
|
|
self._write("DELETE", "playlistItems", params={"id": item_id})
|
|
|
|
def delete_playlist(self, playlist_id: str) -> None:
|
|
"""Delete a whole playlist on YouTube. 50 units."""
|
|
self._write("DELETE", "playlists", params={"id": playlist_id})
|
|
|
|
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,status",
|
|
"id": ",".join(batch),
|
|
"maxResults": 50,
|
|
},
|
|
)
|
|
items.extend(data.get("items", []))
|
|
return items
|