feat(search): live YouTube search backend
Add a live YouTube search that materialises results into the shared catalog so they render with the normal feed cards + in-app player and gain per-user state. - YouTubeClient.search_videos(): search.list (100 units), embeddable-only, returns flat stubs + nextPageToken; surfaces liveBroadcastContent for live filtering. - routes/search.py GET /api/search/youtube: require_human + per-user daily cap (search_daily_limit_per_user, default 70) + can_spend pre-check (429 on either); drops live/upcoming, upserts channel stubs (channels.list) + video stubs, enriches (videos.list), runs the youtube.com/shorts probe, then excludes Shorts/live and returns feed cards in relevance order with the YouTube pageToken as the cursor. - Provenance: videos.via_search / channels.from_search (migration 0028) flag search-discovered rows; the feed hides them from the Library (scope=all) by default via exclude_search_discovered, leaving the Mine feed untouched. - quota.actions_today() counts a user's per-action events today for the cap; only the search.list call is attributed VIDEOS_SEARCH so the counter is exactly 1 per search.
This commit is contained in:
parent
b1ed706cab
commit
9b1bdb6b42
9 changed files with 341 additions and 0 deletions
|
|
@ -351,6 +351,49 @@ class YouTubeClient:
|
|||
"""Delete a whole playlist on YouTube. 50 units."""
|
||||
self._write("DELETE", "playlists", params={"id": playlist_id})
|
||||
|
||||
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")}
|
||||
|
||||
def get_videos(self, video_ids: list[str]) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for batch in _chunks(video_ids, 50):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue