feat(catalog): a video's related videos, without what the user has seen
GET /api/search/related/{video_id} reads the list YouTube shows beside a
video from the website's InnerTube "next" endpoint (no quota), and brings
it into the catalog the way live-search results come in. It leaves out
what the user watched or hid, here or in their Takeout history (unless
taken back to "new" since), blocked channels and the video itself, and
pages on until 20 are gathered (at most 4 pages). region=topical|hu picks
the locale the list is filled in. Its own per-user daily cap
(related_daily_limit_per_user, one event per opened list); a failed
scrape answers 422 youtube/unavailable.
The InnerTube request (live key, consent cookie, errors) moves into
youtube/innertube.py, shared by the search scrape and the related one.
This commit is contained in:
@@ -13,10 +13,16 @@ this search-discovered "noise" by default. Shorts and currently-live/upcoming vi
|
||||
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
|
||||
dropped from the search response up front).
|
||||
|
||||
A video's related videos (`/related/{video_id}`, the list YouTube shows beside it) come in the same
|
||||
way: read from the website (`related_scrape`), ingested as search finds are, under their own daily
|
||||
cap — with what this user has already seen left out.
|
||||
|
||||
This is the HTTP side — the paging, the caps and the refusals; what a page of results leaves in the
|
||||
catalog is `catalog/search`."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -27,7 +33,7 @@ from app.catalog import search as catalog_search
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.reader import queries as reader_queries
|
||||
from app.youtube import search_scrape
|
||||
from app.youtube import related_scrape, search_scrape
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.refusal import refused
|
||||
from app.routes._common import youtube_refusal
|
||||
@@ -38,6 +44,13 @@ router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
# search.list costs this many quota units per page.
|
||||
SEARCH_COST = 100
|
||||
|
||||
# A related list is filled to this many unseen videos, reading at most this many pages (about 20
|
||||
# videos each, measured) — the ones the user has seen are left out, so a page alone can fall short.
|
||||
RELATED_TARGET = 20
|
||||
RELATED_MAX_PAGES = 4
|
||||
# The locale YouTube fills a related list in: on the video's topic, or with what trends in Hungary.
|
||||
RELATED_REGIONS = {"topical": ("en", "US"), "hu": ("hu", "HU")}
|
||||
|
||||
|
||||
@router.get("/youtube")
|
||||
def search_youtube(
|
||||
@@ -201,3 +214,78 @@ def clear_search(
|
||||
removed = catalog_search.clear(db, user, ids)
|
||||
db.commit()
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.get("/related/{video_id}")
|
||||
def related_videos(
|
||||
video_id: str = Path(max_length=16),
|
||||
cursor: str | None = None,
|
||||
region: Literal["topical", "hu"] = "topical",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The videos YouTube shows beside this one, materialised into the catalog like search results,
|
||||
without the ones this user has watched or hidden here or in their Takeout history, and without
|
||||
blocked channels. Pages on (`cursor`) until `RELATED_TARGET` are gathered; `next_cursor` reads
|
||||
further. `region` picks the locale YouTube fills the list in (`RELATED_REGIONS`)."""
|
||||
daily_limit = sysconfig.get_int(db, "related_daily_limit_per_user")
|
||||
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED)
|
||||
if used >= daily_limit:
|
||||
log.info(
|
||||
"a related list was refused: the per-user daily cap is spent",
|
||||
extra=logs.event("related.refused", reason="daily_cap", limit=daily_limit),
|
||||
)
|
||||
raise refused(429, "related_daily_limit", limit=daily_limit)
|
||||
|
||||
hl, gl = RELATED_REGIONS[region]
|
||||
blocked = queries.blocked_ids(db, user)
|
||||
collected: list[str] = []
|
||||
next_cursor = cursor
|
||||
with YouTubeClient(db, user) as yt:
|
||||
for _ in range(RELATED_MAX_PAGES):
|
||||
try:
|
||||
page = related_scrape.related(video_id, continuation=next_cursor, hl=hl, gl=gl)
|
||||
except related_scrape.ScrapeError as exc:
|
||||
if collected:
|
||||
break # keep what earlier pages already gathered
|
||||
log.warning(
|
||||
"the related-videos scrape failed",
|
||||
extra=logs.event("related.scrape_failed", error=str(exc)),
|
||||
)
|
||||
raise youtube_refusal(exc) from exc
|
||||
ids = [it["id"] for it in page["items"]]
|
||||
drop = reader_queries.seen_by(db, user, ids) | set(collected) | {video_id}
|
||||
candidates = [
|
||||
it
|
||||
for it in page["items"]
|
||||
if it["id"] not in drop and it["channel_id"] not in blocked
|
||||
]
|
||||
# As in the search above: an ingest failure answers 422, not a bare 500.
|
||||
try:
|
||||
collected.extend(catalog_search.ingest_candidates(db, yt, user, candidates))
|
||||
except Exception as exc: # any ingest failure, not just YouTubeError
|
||||
db.rollback()
|
||||
log.exception(
|
||||
"a related list's ingest failed",
|
||||
extra=logs.event(
|
||||
"related.ingest_failed",
|
||||
candidates=len(candidates),
|
||||
collected=len(collected),
|
||||
),
|
||||
)
|
||||
if collected:
|
||||
break
|
||||
raise youtube_refusal(exc) from exc
|
||||
next_cursor = page["next_page_token"]
|
||||
if len(collected) >= RELATED_TARGET or not next_cursor:
|
||||
break
|
||||
|
||||
# One opened list is one event against the cap, however many pages it read; reading further
|
||||
# (a cursor) is the same list.
|
||||
if cursor is None:
|
||||
quota.log_action(user.id, quota.QuotaAction.VIDEOS_RELATED)
|
||||
return {
|
||||
"items": reader_queries.feed_rows_by_ids(db, user, collected),
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": bool(next_cursor),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user