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:
2026-10-24 16:00:37 +02:00
parent 4695e1a62f
commit 8b37c470ba
19 changed files with 2263 additions and 108 deletions
+90 -2
View File
@@ -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),
}