fix(search): every related and live-search request takes a slot, and a failed one gives it back

A related list's cursor cost nothing, and a cursor is a free string: a replayed one read and
enriched without end. Every request now takes a slot of the user's daily cap — taken under a lock
before the work (`quota.claim_action`), so requests sent together cannot all pass the last free
one, and given back when the request fails outright (`quota.refund_action`). The live search's
scrape source takes its slots the same way.

A limit of 0 turns the feature off: a distinct 409 `related_disabled` / `search_disabled`, and
`/api/me` says `related_enabled` / `search_enabled` so the UI can leave it out.

A videos.list that fails no longer reads as an empty page: `ingest_candidates` raises, and the
search and the related list answer the refusal (the shared budget spent says "quota") instead of
"no results". The two routes' page loops are one (`_gather`).

`clear` deletes only what the caller found: a related list's rows carry no find, and any user
could name them.
This commit is contained in:
2026-10-25 00:32:26 +02:00
parent 7d45c4f59f
commit b04b51c909
14 changed files with 556 additions and 165 deletions
+21 -9
View File
@@ -75,7 +75,8 @@ def ingest_candidates(
) -> list[str]: ) -> list[str]:
"""Materialise one page of search candidates into the catalog (channel stubs + enrich, """Materialise one page of search candidates into the catalog (channel stubs + enrich,
video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
video ids — non-Short, non-live — in the input relevance order.""" video ids — non-Short, non-live — in the input relevance order. A videos.list that fails
raises its YouTubeError: with nothing enriched, nothing could be kept."""
ids = [it["id"] for it in candidates] ids = [it["id"] for it in candidates]
if not ids: if not ids:
return [] return []
@@ -132,13 +133,16 @@ def ingest_candidates(
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)} detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError: except YouTubeError:
# Every candidate then stays unenriched, and an unenriched video cannot be judged live or # An unenriched video cannot be judged live or Short, so none of this page could be kept:
# Short — so it is dropped. A working search returns nothing and reads as "no results". # swallowed, the failure answered as a page with nothing on it — "no results" for a search
# that never ran (the shared budget spent, no API key). The stubs stay (`insert_stubs`
# committed them), as the channels' do above; the caller answers the failure (its refusal
# says which).
log.warning( log.warning(
"the videos found by a search could not be enriched", "the videos found by a search could not be enriched",
extra=logs.event("search.video_enrich_failed", candidates=len(ids)), extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
) )
detail_by_id = {} raise
enriched_at = datetime.now( enriched_at = datetime.now(
timezone.utc timezone.utc
) # when THIS enrich ran (distinct from the stub placeholder) ) # when THIS enrich ran (distinct from the stub placeholder)
@@ -212,17 +216,25 @@ def clear(db: Session, user: User, video_ids: list[str]) -> int:
"""Discard these results "as if never added": drop this user's finds of them, then delete the """Discard these results "as if never added": drop this user's finds of them, then delete the
ones nobody kept — a `via_search` video no reader holds (`reader.queries.kept_by_a_reader`), ones nobody kept — a `via_search` video no reader holds (`reader.queries.kept_by_a_reader`),
whose channel nobody subscribes to, and that no other user still has as a find. Returns how whose channel nobody subscribes to, and that no other user still has as a find. Returns how
many videos went.""" many videos went.
db.execute(
delete(SearchFind).where( Only the ones this user found: a video in the catalog with no find at all (a related list's,
owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids) which records none) is in someone's list right now, and an id any user could name would let
them delete it from under that list."""
mine = list(
db.scalars(
delete(SearchFind)
.where(owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids))
.returning(SearchFind.video_id)
) )
) )
if not mine:
return 0
subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists() subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists() other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
result = db.execute( result = db.execute(
delete(Video).where( delete(Video).where(
Video.id.in_(video_ids), Video.id.in_(mine),
Video.via_search.is_(True), Video.via_search.is_(True),
~reader_queries.kept_by_a_reader(Video.id), ~reader_queries.kept_by_a_reader(Video.id),
~subscribed, ~subscribed,
+4 -2
View File
@@ -113,8 +113,10 @@ class Settings(BaseSettings):
# each search.list costs 100 units, so the shared budget only affords ~80-90/day total; with # each search.list costs 100 units, so the shared budget only affords ~80-90/day total; with
# the zero-quota scrape source it's a pure anti-abuse rate limit. Admin-tunable. # the zero-quota scrape source it's a pure anti-abuse rate limit. Admin-tunable.
search_daily_limit_per_user: int = 70 search_daily_limit_per_user: int = 70
# Per-user cap on related-video lists opened per day (the InnerTube watch page, no quota; each # Per-user cap on related-video requests per day — a list opened and each "Show more" (the
# list's enrich is 1 unit per 50 videos). A pure anti-abuse rate limit. Admin-tunable. # InnerTube watch page, no quota; its enrich is up to ~8 units: up to 4 pages, each a videos.list
# and a channels.list). A failed one gives its slot back. 0 turns related videos off.
# Admin-tunable.
related_daily_limit_per_user: int = 200 related_daily_limit_per_user: int = 200
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list, # Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list,
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks. # 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
+24
View File
@@ -166,6 +166,30 @@ def log_action(user_id: int, action: str) -> None:
qs.commit() qs.commit()
def claim_action(user_id: int, action: str, limit: int) -> int | None:
"""Take one of this user's `limit` daily `action` slots, or None when they are all taken — the
check and the take in one transaction, under a lock per user and action. Checked first and
logged after the work, N requests sent together at the last free slot all passed the check
(the work in between is seconds of scraping). Returns the event's id, for `refund_action` when
the work then produced nothing. Own session, as `log_action`."""
with SessionLocal() as qs:
qs.execute(select(func.pg_advisory_xact_lock(func.hashtext(action), user_id)))
if actions_today(qs, user_id, action) >= limit:
return None
event = QuotaEvent(user_id=user_id, action=action, units=0)
qs.add(event)
qs.commit()
return event.id
def refund_action(event_id: int) -> None:
"""Give a slot `claim_action` took back: the work it was taken for failed outright, and a
request that showed the user nothing must not count against them."""
with SessionLocal() as qs:
qs.execute(delete(QuotaEvent).where(QuotaEvent.id == event_id))
qs.commit()
def actions_today(db: Session, user_id: int, action: str) -> int: def actions_today(db: Session, user_id: int, action: str) -> int:
"""How many quota events of `action` this user has logged so far in the current Pacific """How many quota events of `action` this user has logged so far in the current Pacific
day — for per-user, per-action daily caps (e.g. the live-search limit). Counts events, day — for per-user, per-action daily caps (e.g. the live-search limit). Counts events,
+4
View File
@@ -79,6 +79,10 @@ def get_me(
# Which live-search source runs, so the search button can say what a search costs: "scrape" # Which live-search source runs, so the search button can say what a search costs: "scrape"
# spends no API quota, "api" spends 100 units a page. # spends no API quota, "api" spends 100 units a page.
"search_source": sysconfig.get_str(db, "search_source"), "search_source": sysconfig.get_str(db, "search_source"),
# Whether live search and related videos are on: an admin turns either off with a daily
# limit of 0, and the UI then leaves out what would only be refused.
"search_enabled": sysconfig.get_int(db, "search_daily_limit_per_user") > 0,
"related_enabled": sysconfig.get_int(db, "related_daily_limit_per_user") > 0,
"can_read": has_read_scope(user), "can_read": has_read_scope(user),
"can_write": has_write_scope(user), "can_write": has_write_scope(user),
"pending_invites": pending_invites, "pending_invites": pending_invites,
+164 -110
View File
@@ -20,6 +20,7 @@ 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 This is the HTTP side — the paging, the caps and the refusals; what a page of results leaves in the
catalog is `catalog/search`.""" catalog is `catalog/search`."""
from collections.abc import Callable
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, Path from fastapi import APIRouter, Depends, Path
@@ -34,6 +35,7 @@ from app.db import get_db
from app.models import User from app.models import User
from app.reader import queries as reader_queries from app.reader import queries as reader_queries
from app.youtube import related_scrape, search_scrape from app.youtube import related_scrape, search_scrape
from app.youtube.innertube import ScrapeError
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused from app.refusal import refused
from app.routes._common import youtube_refusal from app.routes._common import youtube_refusal
@@ -43,6 +45,8 @@ router = APIRouter(prefix="/api/search", tags=["search"])
# search.list costs this many quota units per page. # search.list costs this many quota units per page.
SEARCH_COST = 100 SEARCH_COST = 100
# The scrape source reads at most this many pages for one search.
SEARCH_MAX_PAGES = 12
# A related list is filled to this many unseen videos, reading at most this many pages (about 20 # 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. # videos each, measured) — the ones the user has seen are left out, so a page alone can fall short.
@@ -52,6 +56,66 @@ RELATED_MAX_PAGES = 4
RELATED_REGIONS = {"topical": ("en", "US"), "hu": ("hu", "HU")} RELATED_REGIONS = {"topical": ("en", "US"), "hu": ("hu", "HU")}
def _gather(
db: Session,
yt: YouTubeClient,
user: User,
*,
cursor: str | None,
max_pages: int,
fetch: Callable[[str | None], dict],
candidates_of: Callable[[list[dict], list[str]], list[dict]],
enough: Callable[[list[str]], bool],
scrape_failed: Callable[[Exception], None],
ingest_failed: Callable[[int, int], None],
) -> tuple[list[str], str | None]:
"""Page through a YouTube list into the catalog: fetch a page, keep its `candidates_of`, ingest
them, and go on from its continuation until `enough` or `max_pages`. Returns the kept ids and
the cursor to read further from. A page that fails after earlier ones gathered something ends
the list with what it has; a first page that fails raises the 422 refusal.
422, not 502: this is a JSON endpoint, and the FE treats 502/503/504 as a gateway dropout
(auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY), which hides the real
message and repeats the work. A Data API failure is logged where it happened; the scraper's is
not, and its text does not ride the answer — so `scrape_failed` says it. An ingest (DB writes +
enrich) fails the same way, guarded for any exception so a write or shape failure is a 422 with
a message too, not a bare 500; `ingest_failed` logs it inside the `except`, with the trace."""
collected: list[str] = []
next_cursor = cursor
for _ in range(max_pages):
try:
page = fetch(next_cursor)
except (YouTubeError, ScrapeError) as exc:
if collected:
break # keep what earlier pages already gathered
if isinstance(exc, ScrapeError):
scrape_failed(exc)
raise youtube_refusal(exc) from exc
candidates = candidates_of(page["items"], collected)
try:
collected.extend(catalog_search.ingest_candidates(db, yt, user, candidates))
except Exception as exc: # any ingest failure, not just YouTubeError
db.rollback()
ingest_failed(len(candidates), len(collected))
if collected:
break
raise youtube_refusal(exc) from exc
next_cursor = page["next_page_token"]
if enough(collected) or not next_cursor:
break
return collected, next_cursor
def _cap_spent(feature: str, limit: int) -> None:
"""Say that a request was refused for the per-user daily cap. The 429 is in the request line
either way; which of the caps produced it is not, and they are answered by different things —
this one by tomorrow, the shared budget by the admin."""
log.info(
"a request was refused: the per-user daily cap is spent",
extra=logs.event(f"{feature}.refused", reason="daily_cap", limit=limit),
)
@router.get("/youtube") @router.get("/youtube")
def search_youtube( def search_youtube(
q: str | None = None, q: str | None = None,
@@ -72,85 +136,73 @@ def search_youtube(
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page). # Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
source = sysconfig.get_str(db, "search_source") source = sysconfig.get_str(db, "search_source")
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard # Every request takes a slot of the per-user daily cap, a cursor's too (anti-abuse); with
# since the search itself spends no quota. # scrape it's the only guard since the search itself spends no quota. A limit of 0 turns live
# search off on this instance — a refusal tomorrow will not change.
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user") daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH) if daily_limit <= 0:
if used >= daily_limit: raise refused(409, "search_disabled")
# The 429 is in the request line either way; which of the two caps produced it is not, and claimed: int | None = None
# they are answered by different things — this one by tomorrow, the other by the admin. if source == "api":
log.info( # The one search.list call logs this action's event itself (record_usage, attributed
"a live search was refused: the per-user daily cap is spent", # below), so here the cap is only checked: a claimed slot would count it twice.
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit), if quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH) >= daily_limit:
) _cap_spent("search", daily_limit)
raise refused(429, "search_daily_limit", limit=daily_limit) raise refused(429, "search_daily_limit", limit=daily_limit)
# The 100-unit budget pre-check only applies to the API source. # The 100-unit budget pre-check only applies to the API source.
if source == "api" and not quota.can_spend(db, SEARCH_COST): if not quota.can_spend(db, SEARCH_COST):
log.info( log.info(
"a live search was refused: the shared YouTube budget is spent", "a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST), extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
) )
raise refused(429, "quota_spent") raise refused(429, "quota_spent")
else:
# The check and the take are one step (`quota.claim_action`); a search that fails
# outright gives the slot back below.
claimed = quota.claim_action(user.id, quota.QuotaAction.VIDEOS_SEARCH, daily_limit)
if claimed is None:
_cap_spent("search", daily_limit)
raise refused(429, "search_daily_limit", limit=daily_limit)
# Channels this user has blocked are dropped before ingestion — never shown, never stored. # Channels this user has blocked are dropped before ingestion — never shown, never stored.
blocked = queries.blocked_ids(db, user) blocked = queries.blocked_ids(db, user)
collected: list[str] = [] def fetch(token: str | None) -> dict:
next_cursor = cursor
MAX_PAGES = 12 # scrape safety bound
with YouTubeClient(db, user) as yt:
for _ in range(MAX_PAGES):
try:
if source == "api": if source == "api":
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
page = yt.search_videos(term, page_token=next_cursor) return yt.search_videos(term, page_token=token)
else: return search_scrape.search(term, continuation=token)
page = search_scrape.search(term, continuation=next_cursor)
except (YouTubeError, search_scrape.ScrapeError) as exc:
if collected:
break # keep what we already gathered
# 422, not 502: this is a JSON endpoint, and the FE treats 502/503/504 as a gateway
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
# which hides the real message and burns a second quota-costed search. Upstream failure
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
# A Data API failure is logged where it happened; the scraper's is not, and its text
# no longer rides the answer — so it is said here.
if isinstance(exc, search_scrape.ScrapeError):
log.warning(
"the YouTube search scrape failed",
extra=logs.event("search.scrape_failed", error=str(exc)),
)
raise youtube_refusal(exc) from exc
def candidates_of(items: list[dict], _collected: list[str]) -> list[dict]:
# Drop currently-live/upcoming, channel-less, and blocked-channel results. # Drop currently-live/upcoming, channel-less, and blocked-channel results.
candidates = [ return [
it it
for it in page["items"] for it in items
if it["live_broadcast"] == "none" if it["live_broadcast"] == "none"
and it["channel_id"] and it["channel_id"]
and it["channel_id"] not in blocked and it["channel_id"] not in blocked
] ]
# Ingest (DB writes + enrich) runs outside the page-fetch try above; guard it too so a
# write/shape failure degrades to a 422 with a message on this JSON endpoint instead of a def scrape_failed(exc: Exception) -> None:
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1). log.warning(
try: "the YouTube search scrape failed",
collected.extend(catalog_search.ingest_candidates(db, yt, user, candidates)) extra=logs.event("search.scrape_failed", error=str(exc)),
except Exception as exc: # any ingest failure, not just YouTubeError )
db.rollback()
# The term is NOT in this line. A search string is exactly what the privacy def ingest_failed(candidates: int, collected: int) -> None:
# policy keeps out of the log unless the user turned Teljes/TRACE on for # The term is NOT in this line. A search string is exactly what the privacy policy keeps
# themselves, and `redact_text` does not cover it — it scrubs addresses and # out of the log unless the user turned Teljes/TRACE on for themselves, and `redact_text`
# credential params, not what someone typed into the search box. What the # does not cover it — it scrubs addresses and credential params, not what someone typed
# ERROR line carries instead is the shape of the failure, which is what tells # into the search box. What the ERROR line carries instead is the shape of the failure,
# an ingest bug from an upstream one; the term follows at TRACE, and only # which is what tells an ingest bug from an upstream one; the term follows at TRACE, and
# under this user's own consent — not whenever the process runs at TRACE. # only under this user's own consent — not whenever the process runs at TRACE.
log.exception( log.exception(
"live search ingest failed", "live search ingest failed",
extra=logs.event( extra=logs.event(
"search.live.ingest_failed", "search.live.ingest_failed",
source=source, source=source,
candidates=len(candidates), candidates=candidates,
collected=len(collected), collected=collected,
), ),
) )
if logs.consented_trace(): if logs.consented_trace():
@@ -160,21 +212,27 @@ def search_youtube(
# log-pii-ok: only under this user's own Teljes consent # log-pii-ok: only under this user's own Teljes consent
extra=logs.event("search.live.term", term=term), extra=logs.event("search.live.term", term=term),
) )
if collected:
break # keep what earlier pages already gathered
raise youtube_refusal(exc) from exc
next_cursor = page["next_page_token"]
# The API source pages cost 100 units each — never auto-page; one page per request.
if source == "api" or len(collected) >= limit or not next_cursor:
break
# Count this as ONE per-user search against the daily cap. The scrape source spends no try:
# quota, so it never logs an event on its own; the API source already logged exactly one with YouTubeClient(db, user) as yt:
# (via record_usage — it never auto-pages). Logging once here, after a search that produced collected, next_cursor = _gather(
# at least one page (a total failure raises 502 above and never reaches this), keeps the cap db,
# counting user searches instead of internal continuation pages. yt,
if source != "api": user,
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH) cursor=cursor,
# The API source pages cost 100 units each — never auto-page; one page per request.
max_pages=1 if source == "api" else SEARCH_MAX_PAGES,
fetch=fetch,
candidates_of=candidates_of,
enough=lambda got: len(got) >= limit,
scrape_failed=scrape_failed,
ingest_failed=ingest_failed,
)
except Exception:
# A search that showed the user nothing does not count against them.
if claimed is not None:
quota.refund_action(claimed)
raise
ordered = collected[:limit] ordered = collected[:limit]
@@ -228,62 +286,58 @@ def related_videos(
without the ones this user has watched or hidden here or in their Takeout history, and without 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 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`).""" further. `region` picks the locale YouTube fills the list in (`RELATED_REGIONS`)."""
# Every request takes a slot, a cursor's too: each one scrapes pages and enriches what they
# hold, and a cursor is a free string — counting only first pages let a replayed cursor read
# without end. The check and the take are one step (`quota.claim_action`); a request that fails
# outright gives its slot back. A limit of 0 turns related videos off on this instance.
daily_limit = sysconfig.get_int(db, "related_daily_limit_per_user") daily_limit = sysconfig.get_int(db, "related_daily_limit_per_user")
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) if daily_limit <= 0:
if used >= daily_limit: raise refused(409, "related_disabled")
log.info( claimed = quota.claim_action(user.id, quota.QuotaAction.VIDEOS_RELATED, daily_limit)
"a related list was refused: the per-user daily cap is spent", if claimed is None:
extra=logs.event("related.refused", reason="daily_cap", limit=daily_limit), _cap_spent("related", daily_limit)
)
raise refused(429, "related_daily_limit", limit=daily_limit) raise refused(429, "related_daily_limit", limit=daily_limit)
hl, gl = RELATED_REGIONS[region] hl, gl = RELATED_REGIONS[region]
blocked = queries.blocked_ids(db, user) blocked = queries.blocked_ids(db, user)
collected: list[str] = []
next_cursor = cursor def candidates_of(items: list[dict], collected: list[str]) -> list[dict]:
with YouTubeClient(db, user) as yt: ids = [it["id"] for it in items]
for _ in range(RELATED_MAX_PAGES): drop = reader_queries.seen_by(db, user, ids) | set(collected) | {video_id}
try: return [it for it in items if it["id"] not in drop and it["channel_id"] not in blocked]
page = related_scrape.related(video_id, continuation=next_cursor, hl=hl, gl=gl)
except related_scrape.ScrapeError as exc: def scrape_failed(exc: Exception) -> None:
if collected:
break # keep what earlier pages already gathered
log.warning( log.warning(
"the related-videos scrape failed", "the related-videos scrape failed",
extra=logs.event("related.scrape_failed", error=str(exc)), extra=logs.event("related.scrape_failed", error=str(exc)),
) )
raise youtube_refusal(exc) from exc
ids = [it["id"] for it in page["items"]] def ingest_failed(candidates: int, collected: int) -> None:
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( log.exception(
"a related list's ingest failed", "a related list's ingest failed",
extra=logs.event( extra=logs.event("related.ingest_failed", candidates=candidates, collected=collected),
"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 try:
# (a cursor) is the same list. with YouTubeClient(db, user) as yt:
if cursor is None: collected, next_cursor = _gather(
quota.log_action(user.id, quota.QuotaAction.VIDEOS_RELATED) db,
yt,
user,
cursor=cursor,
max_pages=RELATED_MAX_PAGES,
fetch=lambda token: related_scrape.related(
video_id, continuation=token, hl=hl, gl=gl
),
candidates_of=candidates_of,
enough=lambda got: len(got) >= RELATED_TARGET,
scrape_failed=scrape_failed,
ingest_failed=ingest_failed,
)
except Exception:
quota.refund_action(claimed)
raise
rows = reader_queries.feed_rows_by_ids(db, user, collected) rows = reader_queries.feed_rows_by_ids(db, user, collected)
# A related list mixes channels the user follows with ones they never met: each row says which. # A related list mixes channels the user follows with ones they never met: each row says which.
followed = queries.followed_among(db, user, {r["channel_id"] for r in rows}) followed = queries.followed_among(db, user, {r["channel_id"] for r in rows})
+23 -1
View File
@@ -1,5 +1,6 @@
"""`/api/me` names the live-search source, so the search button's tooltip can say what a search costs """`/api/me` names the live-search source, so the search button's tooltip can say what a search costs
under the source that actually runs (the default website source spends no API quota).""" under the source that actually runs (the default website source spends no API quota), and says
whether live search and related videos are on at all."""
from app import sysconfig from app import sysconfig
@@ -24,3 +25,24 @@ def test_me_names_the_running_search_source(client, db, make_user):
sysconfig.set_value(db, "search_source", "api") sysconfig.set_value(db, "search_source", "api")
db.commit() db.commit()
assert _me(client)["search_source"] == "api" assert _me(client)["search_source"] == "api"
def test_me_says_whether_live_search_and_related_videos_are_on(client, db, make_user):
# A daily limit of 0 turns the feature off; the UI leaves it out instead of offering a refusal.
user = make_user(password=_PW)
login = client.post("/auth/password-login", json={"email": user.email, "password": _PW})
assert login.status_code == 200
me = _me(client)
assert (me["search_enabled"], me["related_enabled"]) == (True, True)
sysconfig.set_value(db, "search_daily_limit_per_user", 0)
db.commit()
me = _me(client)
assert (me["search_enabled"], me["related_enabled"]) == (False, True)
sysconfig.set_value(db, "search_daily_limit_per_user", 5)
sysconfig.set_value(db, "related_daily_limit_per_user", 0)
db.commit()
me = _me(client)
assert (me["search_enabled"], me["related_enabled"]) == (True, False)
+103 -5
View File
@@ -6,16 +6,18 @@ watched or hid — here or in their Takeout history, unless taken back to "new"
channels and the video itself; it pages on until it has enough; it lands in the catalog the way channels and the video itself; it pages on until it has enough; it lands in the catalog the way
search finds do; and a failure, or the day's cap, says so instead of answering an empty list.""" search finds do; and a failure, or the day's cap, says so instead of answering an empty list."""
import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import pytest import pytest
from sqlalchemy import select from sqlalchemy import func, select, text
from app import quota, sysconfig from app import quota, sysconfig
from app.catalog.models import BlockedChannel, Channel, Subscription, Video from app.catalog.models import BlockedChannel, Channel, Subscription, Video
from app.models import QuotaEvent from app.models import QuotaEvent
from app.reader.models import TakeoutItem, TakeoutKind, VideoState, VideoUnmark from app.reader.models import TakeoutItem, TakeoutKind, VideoState, VideoUnmark
from app.routes import search as search_routes from app.routes import search as search_routes
from app.youtube.client import YouTubeError
from app.youtube.innertube import ScrapeError from app.youtube.innertube import ScrapeError
T0 = datetime(2026, 10, 1, tzinfo=timezone.utc) T0 = datetime(2026, 10, 1, tzinfo=timezone.utc)
@@ -153,6 +155,8 @@ def test_what_the_takeout_history_shows_watched_is_left_out_unless_taken_back(
db.commit() db.commit()
pages["pages"] = [_page([_item(n) for n in range(1, 5)], token=None)] pages["pages"] = [_page([_item(n) for n in range(1, 5)], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(2), _vid(3), _vid(4)] assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(2), _vid(3), _vid(4)]
# Left out before the ingest: what the user has seen is not stored for a list that hides it.
assert db.get(Video, _vid(1)) is None
def test_an_unmark_older_than_the_history_row_does_not_bring_it_back(db, make_user, as_user, pages): def test_an_unmark_older_than_the_history_row_does_not_bring_it_back(db, make_user, as_user, pages):
@@ -288,14 +292,108 @@ def test_one_list_is_one_event_against_the_days_cap(db, make_user, as_user, page
assert len(pages["calls"]) == 2 # the refused one asked YouTube nothing assert len(pages["calls"]) == 2 # the refused one asked YouTube nothing
def test_reading_further_is_the_same_list_not_another_one(db, make_user, as_user, pages): def test_reading_further_takes_a_slot_too(db, make_user, as_user, pages):
# Each request scrapes and enriches; a cursor is a free string, so one that cost nothing let a
# replayed cursor read without end.
user = make_user() user = make_user()
sysconfig.set_value(db, "related_daily_limit_per_user", 2) sysconfig.set_value(db, "related_daily_limit_per_user", 1)
pages["pages"] = [_page([_item(1)], token=None), _page([_item(2)], token=None)] pages["pages"] = [_page([_item(1)], token=None), _page([_item(2)], token=None)]
client = as_user(user) client = as_user(user)
assert client.get(f"/api/search/related/{SOURCE}").status_code == 200 assert client.get(f"/api/search/related/{SOURCE}").status_code == 200
assert client.get(f"/api/search/related/{SOURCE}?cursor=more").status_code == 200 refused = client.get(f"/api/search/related/{SOURCE}?cursor=more")
assert quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) == 1 assert refused.status_code == 429
assert refused.json()["detail"]["reason"] == "related_daily_limit"
assert len(pages["calls"]) == 1
def test_a_list_that_failed_gives_its_slot_back(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "related_daily_limit_per_user", 1)
pages["pages"] = [ScrapeError("blocked"), _page([_item(1)], token=None)]
client = as_user(user)
assert client.get(f"/api/search/related/{SOURCE}").status_code == 422
assert quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) == 0
assert _ids(client.get(f"/api/search/related/{SOURCE}")) == [_vid(1)]
def test_a_zero_limit_turns_related_videos_off(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "related_daily_limit_per_user", 0)
resp = as_user(user).get(f"/api/search/related/{SOURCE}")
assert resp.status_code == 409
assert resp.json()["detail"] == {"code": "refused", "reason": "related_disabled"}
assert pages["calls"] == []
def test_the_last_slot_is_taken_under_a_lock(db, make_user):
# Checked first and logged after seconds of scraping, requests sent together at the last free
# slot all passed. The claim waits for one already taking a slot for this user and action.
user = make_user()
action = quota.QuotaAction.VIDEOS_RELATED
db.execute(select(func.pg_advisory_xact_lock(func.hashtext(action), user.id)))
got: list[int | None] = []
t = threading.Thread(target=lambda: got.append(quota.claim_action(user.id, action, 1)))
t.start()
t.join(timeout=1)
assert t.is_alive() # it waits for the one holding the lock
db.commit()
t.join(timeout=10)
assert got and got[0] is not None
assert quota.claim_action(user.id, action, 1) is None # the one slot is taken
def _videos_list_fails(monkeypatch, *, on_call: int = 1, reason: str = "quotaExceeded") -> None:
"""YouTube's videos.list refuses from its `on_call`-th call on."""
calls = {"n": 0}
real = _FakeYT.get_videos
def get_videos(self, ids):
calls["n"] += 1
if calls["n"] >= on_call:
raise YouTubeError("videos -> 403", reason=reason, path="videos")
return real(self, ids)
monkeypatch.setattr(_FakeYT, "get_videos", get_videos)
def test_a_lookup_that_fails_says_why_not_no_videos(db, make_user, as_user, pages, monkeypatch):
# Nothing it found can be kept unenriched; an empty list would read as "nothing related".
user = make_user()
_videos_list_fails(monkeypatch)
pages["pages"] = [_page([_item(1)], token=None)]
resp = as_user(user).get(f"/api/search/related/{SOURCE}")
assert resp.status_code == 422
assert resp.json()["detail"] == {"code": "youtube", "reason": "quota"}
assert quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) == 0
def test_a_lookup_failing_on_a_later_page_keeps_what_came_before(
db, make_user, as_user, pages, monkeypatch
):
user = make_user()
_videos_list_fails(monkeypatch, on_call=2)
pages["pages"] = [_page([_item(1)], token="page2"), _page([_item(2)], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(1)]
def test_an_ingest_that_breaks_the_session_is_rolled_back(
db, make_user, as_user, pages, monkeypatch
):
# A database error leaves the session unusable until it is rolled back: the rows of the pages
# before could not be read for the answer.
user = make_user()
real = search_routes.catalog_search.ingest_candidates
calls = {"n": 0}
def ingest(db_, yt, user_, candidates):
calls["n"] += 1
if calls["n"] == 2:
db_.execute(text("SELECT 1/0"))
return real(db_, yt, user_, candidates)
monkeypatch.setattr(search_routes.catalog_search, "ingest_candidates", ingest)
pages["pages"] = [_page([_item(1)], token="page2"), _page([_item(2)], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(1)]
def test_each_row_says_whether_the_user_follows_its_channel(db, make_user, as_user, pages): def test_each_row_says_whether_the_user_follows_its_channel(db, make_user, as_user, pages):
+17
View File
@@ -291,6 +291,23 @@ def test_search_results_cleared_through_the_route_are_gone(db, make_user, as_use
assert db.execute(select(SearchFind)).first() is None assert db.execute(select(SearchFind)).first() is None
def test_clearing_leaves_a_video_the_caller_never_found(db, make_user, as_user):
# A related list ingests its rows with no find: they are in someone's list right now, and an id
# any user could name must not delete them from under it.
user = make_user()
_video(db, "s1", via_search=True)
_video(db, "s2", via_search=True)
db.add(SearchFind(user_id=user.id, video_id="s1"))
db.commit()
r = as_user(user).post("/api/search/clear", json={"video_ids": ["s1", "s2"]})
assert r.json() == {"ok": True, "removed": 1}
db.expire_all()
assert db.get(Video, "s1") is None and db.get(Video, "s2") is not None
# Naming only ids it never found removes nothing.
r = as_user(user).post("/api/search/clear", json={"video_ids": ["s2"]})
assert r.json() == {"ok": True, "removed": 0}
# --- Plex show and season marks --------------------------------------------------------------- # --- Plex show and season marks ---------------------------------------------------------------
+132
View File
@@ -0,0 +1,132 @@
"""`GET /api/search/youtube` — the per-user daily cap of the live search (scrape source).
The scrape is replaced by pages the test hands out, and YouTube's videos.list by an offline enrich.
What is held: every request takes a slot, a cursor's too; a search that failed outright gives its
slot back; a zero limit turns live search off, which says so instead of "try again tomorrow"; and a
lookup that fails says why instead of answering "no results"."""
import pytest
from app import quota, sysconfig
from app.routes import search as search_routes
from app.youtube.client import YouTubeError
from app.youtube.innertube import ScrapeError
ACTION = quota.QuotaAction.VIDEOS_SEARCH
def _item(n: int) -> dict:
return {
"id": f"srch{n:07d}",
"channel_id": "UCsearch001",
"channel_title": "Chan",
"title": f"Found {n}",
"published_at": None,
"thumbnail_url": None,
"live_broadcast": "none",
}
class _FakeYT:
fail_videos = False
def __init__(self, *_a, **_kw):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def get_channels(self, ids):
return []
def get_videos(self, ids):
if _FakeYT.fail_videos:
raise YouTubeError("videos -> 403", reason="quotaExceeded", path="videos")
return [
{
"id": i,
"snippet": {"title": f"T {i}", "publishedAt": "2025-01-01T00:00:00Z"},
"contentDetails": {"duration": "PT30M"},
"statistics": {},
"status": {},
}
for i in ids
]
@pytest.fixture
def pages(monkeypatch, db):
"""The scrape answers with the pages in `state["pages"]`, one per call."""
sysconfig.set_value(db, "shorts_probe_max_seconds", 60)
sysconfig.set_value(db, "search_source", "scrape")
state: dict = {"pages": [], "calls": 0}
def search(term, continuation=None):
state["calls"] += 1
page = state["pages"].pop(0)
if isinstance(page, Exception):
raise page
return page
monkeypatch.setattr(search_routes.search_scrape, "search", search)
monkeypatch.setattr(search_routes, "YouTubeClient", _FakeYT)
monkeypatch.setattr(_FakeYT, "fail_videos", False)
return state
def _page(n: int) -> dict:
return {"items": [_item(n)], "next_page_token": None}
def test_every_request_takes_a_slot_a_cursors_too(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "search_daily_limit_per_user", 1)
pages["pages"] = [_page(1), _page(2)]
client = as_user(user)
assert client.get("/api/search/youtube?q=cats").status_code == 200
refused = client.get("/api/search/youtube?q=cats&cursor=more")
assert refused.status_code == 429
assert refused.json()["detail"]["reason"] == "search_daily_limit"
assert pages["calls"] == 1
def test_a_search_that_failed_gives_its_slot_back(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "search_daily_limit_per_user", 1)
pages["pages"] = [ScrapeError("blocked"), _page(1)]
client = as_user(user)
assert client.get("/api/search/youtube?q=cats").status_code == 422
assert quota.actions_today(db, user.id, ACTION) == 0
assert client.get("/api/search/youtube?q=cats").status_code == 200
assert quota.actions_today(db, user.id, ACTION) == 1
def test_a_zero_limit_turns_live_search_off(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "search_daily_limit_per_user", 0)
resp = as_user(user).get("/api/search/youtube?q=cats")
assert resp.status_code == 409
assert resp.json()["detail"] == {"code": "refused", "reason": "search_disabled"}
assert pages["calls"] == 0
def test_a_zero_limit_turns_the_api_source_off_too(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "search_source", "api")
sysconfig.set_value(db, "search_daily_limit_per_user", 0)
resp = as_user(user).get("/api/search/youtube?q=cats")
assert resp.status_code == 409
assert resp.json()["detail"]["reason"] == "search_disabled"
def test_a_lookup_that_fails_says_why_not_no_results(db, make_user, as_user, pages, monkeypatch):
user = make_user()
monkeypatch.setattr(_FakeYT, "fail_videos", True)
pages["pages"] = [_page(1)]
resp = as_user(user).get("/api/search/youtube?q=cats")
assert resp.status_code == 422
assert resp.json()["detail"] == {"code": "youtube", "reason": "quota"}
assert quota.actions_today(db, user.id, ACTION) == 0
@@ -13,10 +13,13 @@ a placeholder never becomes the video's permanent date.
from datetime import datetime, timezone from datetime import datetime, timezone
import pytest
from app import sysconfig from app import sysconfig
from app.models import User from app.models import User
from app.catalog.models import Video from app.catalog.models import Video
from app.catalog.search import ingest_candidates from app.catalog.search import ingest_candidates
from app.youtube.client import YouTubeError
class _FakeYT: class _FakeYT:
@@ -97,6 +100,25 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
assert c.enriched_at is None assert c.enriched_at is None
class _FailingYT(_FakeYT):
def get_videos(self, ids):
raise YouTubeError("videos -> 403", reason="quotaExceeded", path="videos")
def test_a_videos_list_failure_raises_and_keeps_the_stubs(db):
# Swallowed, it answered as a page with nothing on it — "no results" for a search whose lookup
# never ran. The stubs stay: the FK targets, backfilled later.
user = User(email="searcher3@example.com")
db.add(user)
db.commit()
with pytest.raises(YouTubeError):
ingest_candidates(db, _FailingYT({}), user, [_candidate("vid_ddddddd", "UC_chan_d")])
db.rollback() # what the caller does: the stubs were committed before the lookup
assert db.get(Video, "vid_ddddddd") is not None
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep): def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep):
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422 # Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry, # + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
+1 -1
View File
@@ -56,7 +56,7 @@
}, },
"related_daily_limit_per_user": { "related_daily_limit_per_user": {
"label": "Related videos — daily limit per user", "label": "Related videos — daily limit per user",
"hint": "Max related-video lists a user can open per day. They come from YouTube's website and spend no search quota; the videos in a list are looked up for 1 unit per 50. A pure anti-abuse limit. Set 0 to turn related videos off." "hint": "Max related-video requests a user can make per day — opening a list and each Show more count one. They come from YouTube's website and spend no search quota; looking up the videos in one costs up to about 8 units (up to 4 pages, each a video and a channel lookup). A failed one doesn't count. Set 0 to turn related videos off."
}, },
"search_daily_limit_per_user": { "search_daily_limit_per_user": {
"label": "Live search — daily limit per user", "label": "Live search — daily limit per user",
+2
View File
@@ -188,7 +188,9 @@
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.", "quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
"recipient_required": "A recipient email is required", "recipient_required": "A recipient email is required",
"registration_closed": "Registration is currently closed", "registration_closed": "Registration is currently closed",
"related_disabled": "Related videos are turned off on this instance.",
"related_daily_limit": "You've opened today's limit of related lists ({{limit}}). Try again tomorrow.", "related_daily_limit": "You've opened today's limit of related lists ({{limit}}). Try again tomorrow.",
"search_disabled": "Live YouTube search is turned off on this instance.",
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.", "search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
"search_term_required": "Enter something to search for", "search_term_required": "Enter something to search for",
"session_gone": "The playback session ended", "session_gone": "The playback session ended",
+1 -1
View File
@@ -56,7 +56,7 @@
}, },
"related_daily_limit_per_user": { "related_daily_limit_per_user": {
"label": "Kapcsolódó videók — napi limit / felhasználó", "label": "Kapcsolódó videók — napi limit / felhasználó",
"hint": "Hány kapcsolódóvideó-listát nyithat meg egy felhasználó naponta. A listák a YouTube weboldaláról jönnek, keresési kvótát nem fogyasztanak; a bennük lévő videók lekérése 50-enként 1 egység. Tiszta visszaélés-elleni limit. 0 = a kapcsolódó videók kikapcsolva." "hint": "Hányszor kérhet kapcsolódó videókat egy felhasználó naponta — egy lista megnyitása és minden „Mutass többet” egynek számít. A YouTube weboldaláról jönnek, keresési kvótát nem fogyasztanak; a bennük lévő videók lekérése kérésenként legfeljebb kb. 8 egység (legfeljebb 4 oldal, mindegyik egy videó- és egy csatornalekérés). A sikertelen nem számít. 0 = a kapcsolódó videók kikapcsolva."
}, },
"search_daily_limit_per_user": { "search_daily_limit_per_user": {
"label": "Élő keresés — napi limit / felhasználó", "label": "Élő keresés — napi limit / felhasználó",
+2
View File
@@ -188,7 +188,9 @@
"quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.", "quota_spent": "A mai közös YouTube-keret elfogyott. Próbáld holnap.",
"recipient_required": "Adj meg egy címzett e-mail címet", "recipient_required": "Adj meg egy címzett e-mail címet",
"registration_closed": "A regisztráció jelenleg zárva van", "registration_closed": "A regisztráció jelenleg zárva van",
"related_disabled": "A kapcsolódó videók ki vannak kapcsolva ezen a példányon.",
"related_daily_limit": "Elérted a mai kapcsolódó listák korlátját ({{limit}}). Próbáld holnap.", "related_daily_limit": "Elérted a mai kapcsolódó listák korlátját ({{limit}}). Próbáld holnap.",
"search_disabled": "Az élő YouTube-keresés ki van kapcsolva ezen a példányon.",
"search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.", "search_daily_limit": "Elérted a mai YouTube-keresési korlátot ({{limit}}). Próbáld holnap.",
"search_term_required": "Írj be egy keresőkifejezést", "search_term_required": "Írj be egy keresőkifejezést",
"session_gone": "A lejátszási munkamenet véget ért", "session_gone": "A lejátszási munkamenet véget ért",