2026-06-11 01:22:07 +02:00
|
|
|
"""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.
|
|
|
|
|
"""
|
2026-06-11 04:26:18 +02:00
|
|
|
import logging
|
2026-06-11 01:22:07 +02:00
|
|
|
from collections.abc import Iterator
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
2026-06-19 13:19:06 +02:00
|
|
|
from app import quota, sysconfig
|
2026-06-11 01:22:07 +02:00
|
|
|
from app.config import settings
|
|
|
|
|
from app.models import User
|
|
|
|
|
from app.security import decrypt
|
|
|
|
|
|
2026-06-21 06:53:12 +02:00
|
|
|
log = logging.getLogger("siftlode.youtube")
|
2026-06-11 04:26:18 +02:00
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
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
|
2026-07-01 12:46:50 +02:00
|
|
|
# Optionally route all YouTube traffic through a fixed-IP egress proxy so an
|
|
|
|
|
# IP-restricted API key keeps working from a host with a dynamic public IP.
|
2026-06-26 00:32:17 +02:00
|
|
|
proxy = sysconfig.get_str(db, "youtube_api_proxy") or None
|
|
|
|
|
self._http = httpx.Client(timeout=30.0, proxy=proxy)
|
2026-06-11 01:22:07 +02:00
|
|
|
|
|
|
|
|
def __enter__(self) -> "YouTubeClient":
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def __exit__(self, *exc) -> None:
|
|
|
|
|
self._http.close()
|
|
|
|
|
|
2026-07-09 09:24:09 +02:00
|
|
|
# --- transport ---
|
|
|
|
|
def _send(self, method: str, url: str, **kwargs) -> httpx.Response:
|
|
|
|
|
"""Issue an HTTP request, mapping any transport/network failure (egress proxy down,
|
|
|
|
|
DNS, timeout, connection reset) to YouTubeError. Every caller already handles
|
|
|
|
|
YouTubeError — degrade gracefully, log, and move on — so wrapping here keeps a
|
|
|
|
|
transient network fault (e.g. the fixed-IP egress proxy being unreachable) from
|
|
|
|
|
escaping as a raw httpx error and turning into an uncaught 500. HTTP status errors
|
|
|
|
|
are NOT touched here (we don't use raise_for_status): callers inspect resp.status_code
|
|
|
|
|
themselves and raise their own YouTubeError with the response body."""
|
|
|
|
|
try:
|
|
|
|
|
return self._http.request(method, url, **kwargs)
|
|
|
|
|
except httpx.HTTPError as exc:
|
|
|
|
|
raise YouTubeError(
|
|
|
|
|
f"YouTube API unreachable ({exc.__class__.__name__}: {exc})"
|
|
|
|
|
) from exc
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
# --- 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")
|
2026-07-09 09:24:09 +02:00
|
|
|
resp = self._send(
|
|
|
|
|
"POST",
|
2026-06-11 01:22:07 +02:00
|
|
|
GOOGLE_TOKEN_URL,
|
|
|
|
|
data={
|
2026-06-20 20:04:23 +02:00
|
|
|
"client_id": sysconfig.get_str(self.db, "google_client_id"),
|
|
|
|
|
"client_secret": sysconfig.get_str(self.db, "google_client_secret"),
|
2026-06-11 01:22:07 +02:00
|
|
|
"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()
|
2026-06-11 04:26:18 +02:00
|
|
|
log.info("Refreshed access token for user %s", self.user.id)
|
2026-06-11 01:22:07 +02:00
|
|
|
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 = {}
|
2026-06-19 13:19:06 +02:00
|
|
|
api_key = sysconfig.get_str(self.db, "youtube_api_key")
|
|
|
|
|
if allow_key and api_key:
|
|
|
|
|
p["key"] = api_key
|
2026-06-11 01:22:07 +02:00
|
|
|
else:
|
|
|
|
|
headers["Authorization"] = f"Bearer {self._access_token()}"
|
2026-07-09 09:24:09 +02:00
|
|
|
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
|
2026-06-11 01:22:07 +02:00
|
|
|
quota.record_usage(self.db, cost)
|
|
|
|
|
if resp.status_code != 200:
|
2026-06-11 04:26:18 +02:00
|
|
|
log.warning("YouTube API %s -> %s: %s", path, resp.status_code, resp.text[:200])
|
2026-06-11 01:22:07 +02:00
|
|
|
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
|
|
|
|
|
|
2026-06-15 19:37:03 +02:00
|
|
|
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
|
|
|
|
|
|
2026-06-19 02:16:42 +02:00
|
|
|
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
|
|
|
|
|
|
2026-06-11 01:22:07 +02:00
|
|
|
def get_channels(self, channel_ids: list[str]) -> list[dict]:
|
|
|
|
|
items: list[dict] = []
|
|
|
|
|
for batch in _chunks(channel_ids, 50):
|
|
|
|
|
data = self._get(
|
|
|
|
|
"channels",
|
|
|
|
|
{
|
feat(channels): channel-explore backend — about metadata, ephemeral provenance, cleanup
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
exploration, flags the channel ephemeral unless followed, ingests one page of uploads
(via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
unkept channels and their untouched ephemeral videos after a grace period.
2026-06-30 02:52:44 +02:00
|
|
|
"part": "snippet,contentDetails,statistics,topicDetails,brandingSettings",
|
2026-06-11 01:22:07 +02:00
|
|
|
"id": ",".join(batch),
|
|
|
|
|
"maxResults": 50,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
items.extend(data.get("items", []))
|
|
|
|
|
return items
|
feat: M2 (part 2) — RSS poller, backfill, enrichment, scheduler
- Free per-channel RSS reader for quota-less fresh-video detection
- Recent-first backfill (configurable: 100 videos / 1 year) plus resumable deep
backfill from the uploads playlist
- Enrichment via videos.list: duration, view/like counts, category, topics,
language, Shorts heuristic and livestream/premiere classification
- Reusable sync runners + APScheduler jobs (rss / enrich / backfill), all
quota-aware with a reserve so backfill never starves fresh enrichment
- Manual triggers: POST /api/sync/{rss,backfill,enrich}
- Exact insert counting via RETURNING with in-batch de-duplication
2026-06-11 01:36:41 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-06-11 23:27:11 +02:00
|
|
|
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."""
|
2026-07-09 09:24:09 +02:00
|
|
|
resp = self._send(
|
|
|
|
|
"DELETE",
|
2026-06-11 23:27:11 +02:00
|
|
|
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]}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-19 02:16:42 +02:00
|
|
|
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", "")
|
|
|
|
|
|
2026-06-15 21:22:51 +02:00
|
|
|
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()}"}
|
2026-07-09 09:24:09 +02:00
|
|
|
resp = self._send(
|
2026-06-15 21:22:51 +02:00
|
|
|
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 {}
|
|
|
|
|
|
2026-06-15 21:40:13 +02:00
|
|
|
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]}},
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-15 21:22:51 +02:00
|
|
|
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})
|
|
|
|
|
|
2026-06-29 02:01:31 +02:00
|
|
|
def search_videos(
|
|
|
|
|
self, q: str, page_token: str | None = None, order: str = "relevance"
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Live YouTube search (search.list) for embeddable videos. **100 quota units per
|
|
|
|
|
call** — by far the most expensive read, so callers must gate it (per-user cap +
|
|
|
|
|
budget pre-check). Public read: prefers the API key over OAuth.
|
|
|
|
|
|
|
|
|
|
Returns one page (up to 50) as ``{"items": [...], "next_page_token": str|None}``
|
|
|
|
|
where each item is a flat dict ready for stub insertion. `liveBroadcastContent` is
|
|
|
|
|
surfaced so the caller can drop currently-live/upcoming results (search can't filter
|
|
|
|
|
those out for us). Snippet carries no duration/stats — the caller enriches via
|
|
|
|
|
videos.list afterwards."""
|
|
|
|
|
params = {
|
|
|
|
|
"part": "snippet",
|
|
|
|
|
"q": q,
|
|
|
|
|
"type": "video",
|
|
|
|
|
"maxResults": 50,
|
|
|
|
|
"order": order,
|
|
|
|
|
"videoEmbeddable": "true",
|
|
|
|
|
"safeSearch": "none",
|
|
|
|
|
}
|
|
|
|
|
if page_token:
|
|
|
|
|
params["pageToken"] = page_token
|
|
|
|
|
data = self._get("search", params, cost=100)
|
|
|
|
|
items: list[dict] = []
|
|
|
|
|
for it in data.get("items", []):
|
|
|
|
|
vid = it.get("id", {}).get("videoId")
|
|
|
|
|
if not vid:
|
|
|
|
|
continue
|
|
|
|
|
sn = it.get("snippet", {})
|
|
|
|
|
items.append(
|
|
|
|
|
{
|
|
|
|
|
"id": vid,
|
|
|
|
|
"channel_id": sn.get("channelId"),
|
|
|
|
|
"channel_title": sn.get("channelTitle"),
|
|
|
|
|
"title": sn.get("title"),
|
|
|
|
|
"published_at": sn.get("publishedAt"),
|
|
|
|
|
"thumbnail_url": best_thumbnail(sn.get("thumbnails")),
|
|
|
|
|
"live_broadcast": sn.get("liveBroadcastContent") or "none",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return {"items": items, "next_page_token": data.get("nextPageToken")}
|
|
|
|
|
|
feat: M2 (part 2) — RSS poller, backfill, enrichment, scheduler
- Free per-channel RSS reader for quota-less fresh-video detection
- Recent-first backfill (configurable: 100 videos / 1 year) plus resumable deep
backfill from the uploads playlist
- Enrichment via videos.list: duration, view/like counts, category, topics,
language, Shorts heuristic and livestream/premiere classification
- Reusable sync runners + APScheduler jobs (rss / enrich / backfill), all
quota-aware with a reserve so backfill never starves fresh enrichment
- Manual triggers: POST /api/sync/{rss,backfill,enrich}
- Exact insert counting via RETURNING with in-batch de-duplication
2026-06-11 01:36:41 +02:00
|
|
|
def get_videos(self, video_ids: list[str]) -> list[dict]:
|
|
|
|
|
items: list[dict] = []
|
|
|
|
|
for batch in _chunks(video_ids, 50):
|
|
|
|
|
data = self._get(
|
|
|
|
|
"videos",
|
|
|
|
|
{
|
2026-06-18 03:20:28 +02:00
|
|
|
"part": "snippet,contentDetails,statistics,topicDetails,liveStreamingDetails,status",
|
feat: M2 (part 2) — RSS poller, backfill, enrichment, scheduler
- Free per-channel RSS reader for quota-less fresh-video detection
- Recent-first backfill (configurable: 100 videos / 1 year) plus resumable deep
backfill from the uploads playlist
- Enrichment via videos.list: duration, view/like counts, category, topics,
language, Shorts heuristic and livestream/premiere classification
- Reusable sync runners + APScheduler jobs (rss / enrich / backfill), all
quota-aware with a reserve so backfill never starves fresh enrichment
- Manual triggers: POST /api/sync/{rss,backfill,enrich}
- Exact insert counting via RETURNING with in-batch de-duplication
2026-06-11 01:36:41 +02:00
|
|
|
"id": ",".join(batch),
|
|
|
|
|
"maxResults": 50,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
items.extend(data.get("items", []))
|
|
|
|
|
return items
|