release: 0.112.0 (feature/e45-related-videos)

This commit is contained in:
2026-10-25 17:31:11 +01:00
103 changed files with 7003 additions and 637 deletions
+14
View File
@@ -7,6 +7,20 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.112.0 — 2026-10-25
### New
- Player: a Related tab lists videos related to the one playing, leaving out what you have already watched (your uploaded YouTube history included). Channels you follow are marked, and you can switch between On topic and Trending.
- Play a related video in place; Back returns to the one you opened. Admins set a daily per-user limit in Config › Quota (default 200, 0 turns it off).
- Side panel: the list controls and the watched toggle are glyphs on one line, and Auto and Loop show when they are on, also under the pointer.
### Fixed
- Live search: a search that never reached YouTube no longer uses up a daily slot, a busy moment says so after a few seconds instead of waiting on, and a first page that failed offers Retry.
- Menus: Play from the beginning wears the same ↺ glyph everywhere, and Reset its own eraser glyph.
- On the day of this update, API searches made earlier that day stop counting toward the daily search limit, until Pacific midnight.
## 0.111.0 — 2026-10-24
### New
+1 -1
View File
@@ -1 +1 @@
0.111.0
0.112.0
+13
View File
@@ -158,6 +158,19 @@ def blocked_ids(db: Session, user: User) -> set[str]:
)
def followed_among(db: Session, user: User, channel_ids: set[str]) -> set[str]:
"""Which of these channels the user subscribes to."""
if not channel_ids:
return set()
return set(
db.scalars(
select(Subscription.channel_id).where(
owned_by(Subscription.user_id, user), Subscription.channel_id.in_(channel_ids)
)
)
)
def blocked_channels(db: Session, user: User, lang: str) -> list[Channel]:
return list(
db.execute(
+21 -9
View File
@@ -75,7 +75,8 @@ def ingest_candidates(
) -> list[str]:
"""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 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]
if not ids:
return []
@@ -132,13 +133,16 @@ def ingest_candidates(
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
except YouTubeError:
# Every candidate then stays unenriched, and an unenriched video cannot be judged live or
# Short — so it is dropped. A working search returns nothing and reads as "no results".
# An unenriched video cannot be judged live or Short, so none of this page could be kept:
# 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(
"the videos found by a search could not be enriched",
extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
)
detail_by_id = {}
raise
enriched_at = datetime.now(
timezone.utc
) # 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
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
many videos went."""
db.execute(
delete(SearchFind).where(
owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids)
many videos went.
Only the ones this user found: a video in the catalog with no find at all (a related list's,
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()
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
result = db.execute(
delete(Video).where(
Video.id.in_(video_ids),
Video.id.in_(mine),
Video.via_search.is_(True),
~reader_queries.kept_by_a_reader(Video.id),
~subscribed,
+5
View File
@@ -113,6 +113,11 @@ class Settings(BaseSettings):
# 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.
search_daily_limit_per_user: int = 70
# Per-user cap on related-video requests per day — a list opened and each "Show more" (the
# 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
# 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.
search_source: SearchSource = "scrape"
+31 -13
View File
@@ -44,6 +44,7 @@ class QuotaAction:
VIDEOS_ENRICH = "videos_enrich"
VIDEOS_LOOKUP = "videos_lookup"
VIDEOS_SEARCH = "videos_search"
VIDEOS_RELATED = "videos_related"
CHANNELS_DISCOVER = "channels_discover"
CHANNELS_EXPLORE = "channels_explore"
CHANNELS_SUBSCRIBE = "channels_subscribe"
@@ -142,7 +143,7 @@ def backfill_reserve_reached() -> bool:
condition for every backfill/explore path (recent, deep, immediate full-history, explore, and
rolling revalidation).
Reads in its OWN short-lived session (like record_usage / log_action), for two reasons: it
Reads in its OWN short-lived session (like record_usage / claim_action), for two reasons: it
replaces the remaining/reserve comparison that was open-coded at every call site, and — critically
for the per-page guards in paging loops — a guard must not hold the caller's transaction open
across the network fetch that follows it. Quota is committed independently of any caller, so a
@@ -151,24 +152,40 @@ def backfill_reserve_reached() -> bool:
return remaining_today(qs) <= sysconfig.get_int(qs, "backfill_quota_reserve")
def log_action(user_id: int, action: str) -> None:
"""Record a zero-cost action event so per-user daily caps still count it.
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.
`record_usage` only logs when units are actually charged; a scrape-based search spends no
quota yet must still be rate-limited per user, so it logs its event here directly.
Writes in its OWN session (like record_usage) — quota accounting is durable independent of the
caller's transaction and must never commit the caller's session (that would flush a job's
partial state mid-run, defeating its own rollback)."""
The ONE way to count a capped action: a claim is a zero-unit event, and `actions_today` counts
those alone, so a call the action then pays for (search.list's own `record_usage` event, under
the same action) is accounting, never a second slot. Own session, committed at once — quota
accounting is durable whatever the caller's transaction does next, and must never commit it."""
with SessionLocal() as qs:
qs.add(QuotaEvent(user_id=user_id, action=action, units=0))
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:
"""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,
not units, so it only works for actions charged exactly once per user action."""
"""How many slots of `action` this user has claimed so far in the current Pacific day — for
per-user, per-action daily caps (the live search, the related lists). Counts the zero-unit
claim events (`claim_action`) only: `record_usage`'s events for units the action spent are not
slots (it logs only when units were charged)."""
return (
db.scalar(
select(func.count())
@@ -176,6 +193,7 @@ def actions_today(db: Session, user_id: int, action: str) -> int:
.where(
owned_by(QuotaEvent.user_id, user_id),
QuotaEvent.action == action,
QuotaEvent.units == 0,
QuotaEvent.created_at >= pacific_day_start_utc(),
)
)
+30
View File
@@ -319,6 +319,36 @@ def held_by(db: Session, user: User, video_ids: list[str]) -> set[str]:
)
def seen_by(db: Session, user: User, video_ids: list[str]) -> set[str]:
"""Which of these videos the user has already seen or set aside: watched or hidden here, or in
their uploaded Takeout history unless they took it back to "new" here since. The ids need not
be in the catalog — a Takeout row names a video the catalog may never have had — so a list
of what YouTube suggests can drop them before anything is ingested."""
if not video_ids:
return set()
marked = select(VideoState.video_id).where(
owned_by(VideoState.user_id, user),
VideoState.video_id.in_(video_ids),
VideoState.status.in_(("watched", "hidden")),
)
unmarked_since = (
select(VideoUnmark.id)
.where(
owned_by(VideoUnmark.user_id, user),
VideoUnmark.video_id == TakeoutItem.video_id,
VideoUnmark.at > TakeoutItem.at,
)
.exists()
)
in_history = select(TakeoutItem.video_id).where(
owned_by(TakeoutItem.user_id, user),
TakeoutItem.kind == TakeoutKind.WATCHED,
TakeoutItem.video_id.in_(video_ids),
~unmarked_since,
)
return set(db.scalars(marked)) | set(db.scalars(in_history))
def feed_rows_by_ids(db: Session, user: User, video_ids: list[str]) -> list[dict]:
"""These videos serialized with the feed's projection (their channel, this user's watch state,
saved), in the order given."""
+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"
# spends no API quota, "api" spends 100 units a page.
"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_write": has_write_scope(user),
"pending_invites": pending_invites,
+283 -76
View File
@@ -13,10 +13,18 @@ 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
import threading
from collections.abc import Callable
from typing import Literal
from fastapi import APIRouter, Depends, Path
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -26,8 +34,10 @@ from app.catalog import queries
from app.catalog import search as catalog_search
from app.db import get_db
from app.models import User
from app.ratelimit import RateLimiter
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.innertube import ScrapeError
from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
from app.routes._common import youtube_refusal
@@ -37,6 +47,115 @@ router = APIRouter(prefix="/api/search", tags=["search"])
# search.list costs this many quota units per page.
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
# 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")}
# A request that fails outright gives its slot back, but its shape is the user's (a cursor is a
# free string): one built to fail would read YouTube for nothing, without end. So only this many
# refunds an hour per user and list; past them a failure keeps its slot, as a success does.
REFUNDS_PER_HOUR = 5
_refunds = RateLimiter(max_events=REFUNDS_PER_HOUR, window_seconds=3600)
# The API source's budget check and the search.list call it pays for run one at a time: checked
# apart, searches sent together at the last 100 units all passed, and all spent. One process
# serves the API (a single uvicorn worker, as `ratelimit` says), so a process lock holds them all.
# A search waits this long for it, then is refused as busy: each waiter holds a request worker, and
# a slow YouTube (30 s a call at worst) would otherwise park them all behind one another.
_api_search = threading.Lock()
API_SEARCH_WAIT_SECONDS = 10.0
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 _give_back(claimed: int, feature: str, user_id: int) -> None:
"""Refund the slot of a request that failed outright, while this user has refunds left."""
if _refunds.allow(f"{feature}:{user_id}"):
quota.refund_action(claimed)
return
log.info(
"a failed request kept its slot: this hour's refunds are spent",
extra=logs.event(f"{feature}.refund_refused", per_hour=REFUNDS_PER_HOUR),
)
def _budget_spent() -> None:
"""Say that a live search was refused because the shared YouTube budget cannot pay for it."""
log.info(
"a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
)
def _busy() -> None:
"""Say that a live search was refused because other API searches held the line too long."""
log.info(
"a live search was refused: other searches held the line",
extra=logs.event("search.refused", reason="busy", waited=API_SEARCH_WAIT_SECONDS),
)
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")
@@ -59,85 +178,89 @@ def search_youtube(
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
source = sysconfig.get_str(db, "search_source")
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
# since the search itself spends no quota.
# Every request takes a slot of the per-user daily cap, a cursor's too (anti-abuse); with
# 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")
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
if used >= daily_limit:
# The 429 is in the request line either way; which of the two caps produced it is not, and
# they are answered by different things — this one by tomorrow, the other by the admin.
log.info(
"a live search was refused: the per-user daily cap is spent",
extra=logs.event("search.refused", reason="daily_cap", limit=daily_limit),
)
raise refused(429, "search_daily_limit", limit=daily_limit)
# The 100-unit budget pre-check only applies to the API source.
if daily_limit <= 0:
raise refused(409, "search_disabled")
# The 100-unit budget pre-check only applies to the API source. It is asked again under the
# lock, right before the call (`fetch`); this one refuses before a slot is taken.
if source == "api" and not quota.can_spend(db, SEARCH_COST):
log.info(
"a live search was refused: the shared YouTube budget is spent",
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
)
_budget_spent()
raise refused(429, "quota_spent")
# The check and the take are one step (`quota.claim_action`), whichever the source: checked
# first and logged after, requests sent together at the last free slot all passed — and with
# the API source each of them spent 100 units. A search that fails outright gives it 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.
blocked = queries.blocked_ids(db, user)
collected: list[str] = []
next_cursor = cursor
MAX_PAGES = 12 # scrape safety bound
with YouTubeClient(db, user) as yt:
for _ in range(MAX_PAGES):
try:
if source == "api":
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
page = yt.search_videos(term, page_token=next_cursor)
else:
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
# Whether search.list was paid for: YouTube charges a call it refuses too, so once the client
# recorded its units — or the request went out and no answer came back — the search has spent
# the shared budget. One that never reached YouTube (no connection, no token) has not.
charged = False
def fetch(token: str | None) -> dict:
if source != "api":
return search_scrape.search(term, continuation=token)
if not _api_search.acquire(timeout=API_SEARCH_WAIT_SECONDS):
_busy()
raise refused(429, "search_busy")
try:
return paid_search(token)
finally:
_api_search.release()
def paid_search(token: str | None) -> dict:
nonlocal charged
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_SEARCH):
if not quota.can_spend(db, SEARCH_COST):
_budget_spent()
raise refused(429, "quota_spent")
spent = yt.spent
try:
page = yt.search_videos(term, page_token=token)
except YouTubeError as exc:
charged = yt.spent > spent or exc.sent
raise
charged = True
return page
def candidates_of(items: list[dict], _collected: list[str]) -> list[dict]:
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
candidates = [
return [
it
for it in page["items"]
for it in items
if it["live_broadcast"] == "none"
and it["channel_id"]
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
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
try:
collected.extend(catalog_search.ingest_candidates(db, yt, user, candidates))
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
# policy keeps out of the log unless the user turned Teljes/TRACE on for
# themselves, and `redact_text` does not cover it — it scrubs addresses and
# credential params, not what someone typed into the search box. What the
# ERROR line carries instead is the shape of the failure, which is what tells
# an ingest bug from an upstream one; the term follows at TRACE, and only
# under this user's own consent — not whenever the process runs at TRACE.
def scrape_failed(exc: Exception) -> None:
log.warning(
"the YouTube search scrape failed",
extra=logs.event("search.scrape_failed", error=str(exc)),
)
def ingest_failed(candidates: int, collected: int) -> None:
# The term is NOT in this line. A search string is exactly what the privacy policy keeps
# out of the log unless the user turned Teljes/TRACE on for themselves, and `redact_text`
# does not cover it — it scrubs addresses and credential params, not what someone typed
# into the search box. What the ERROR line carries instead is the shape of the failure,
# which is what tells an ingest bug from an upstream one; the term follows at TRACE, and
# only under this user's own consent — not whenever the process runs at TRACE.
log.exception(
"live search ingest failed",
extra=logs.event(
"search.live.ingest_failed",
source=source,
candidates=len(candidates),
collected=len(collected),
candidates=candidates,
collected=collected,
),
)
if logs.consented_trace():
@@ -147,21 +270,30 @@ def search_youtube(
# log-pii-ok: only under this user's own Teljes consent
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
# quota, so it never logs an event on its own; the API source already logged exactly one
# (via record_usage — it never auto-pages). Logging once here, after a search that produced
# at least one page (a total failure raises 502 above and never reaches this), keeps the cap
# counting user searches instead of internal continuation pages.
if source != "api":
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH)
try:
with YouTubeClient(db, user) as yt:
collected, next_cursor = _gather(
db,
yt,
user,
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, unless search.list
# was already paid for: then the slot is what stops a failing search (a bogus cursor gets
# a 400 every time) from spending the shared budget without end. A busy refusal paid
# nothing and gives it back.
if not charged:
_give_back(claimed, "search", user.id)
raise
ordered = collected[:limit]
@@ -201,3 +333,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`)."""
# 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 few an hour (`_give_back`). A limit of 0 turns related videos
# off on this instance.
daily_limit = sysconfig.get_int(db, "related_daily_limit_per_user")
if daily_limit <= 0:
raise refused(409, "related_disabled")
claimed = quota.claim_action(user.id, quota.QuotaAction.VIDEOS_RELATED, daily_limit)
if claimed is None:
_cap_spent("related", daily_limit)
raise refused(429, "related_daily_limit", limit=daily_limit)
hl, gl = RELATED_REGIONS[region]
blocked = queries.blocked_ids(db, user)
def candidates_of(items: list[dict], collected: list[str]) -> list[dict]:
ids = [it["id"] for it in items]
drop = reader_queries.seen_by(db, user, ids) | set(collected) | {video_id}
return [it for it in items if it["id"] not in drop and it["channel_id"] not in blocked]
def scrape_failed(exc: Exception) -> None:
log.warning(
"the related-videos scrape failed",
extra=logs.event("related.scrape_failed", error=str(exc)),
)
def ingest_failed(candidates: int, collected: int) -> None:
log.exception(
"a related list's ingest failed",
extra=logs.event("related.ingest_failed", candidates=candidates, collected=collected),
)
try:
with YouTubeClient(db, user) as yt:
collected, next_cursor = _gather(
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:
_give_back(claimed, "related", user.id)
raise
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.
followed = queries.followed_among(db, user, {r["channel_id"] for r in rows})
return {
"items": [{**r, "followed": r["channel_id"] in followed} for r in rows],
"next_cursor": next_cursor,
"has_more": bool(next_cursor),
}
+9
View File
@@ -140,6 +140,15 @@ SPECS: tuple[ConfigSpec, ...] = (
max=100_000,
applies=LIVE,
),
ConfigSpec(
"related_daily_limit_per_user",
"int",
"quota",
"related_daily_limit_per_user",
min=0,
max=100_000,
applies=LIVE,
),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
ConfigSpec("search_source", "choice", "quota", "search_source", applies=LIVE),
ConfigSpec(
+30 -6
View File
@@ -30,12 +30,26 @@ class YouTubeError(Exception):
message is prose, it changes under us, and it is deliberately the poorer of the two here.
`path` is the API endpoint that ANSWERED with the failure (`playlistItems`); empty when no
endpoint did — the request never got through (`transport`) or the token step refused first."""
endpoint did — the request never got through (`transport`) or the token step refused first.
def __init__(self, message: str, *, reason: str = "", path: str = ""):
`sent` is a `transport` failure that came after the request may have gone out (a read timeout,
a dropped answer): nothing answered, but YouTube may have counted the call."""
def __init__(self, message: str, *, reason: str = "", path: str = "", sent: bool = False):
super().__init__(message)
self.reason = reason
self.path = path
self.sent = sent
# The transport failures that stop a request before it reaches YouTube: nothing went out.
_UNSENT = (
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.PoolTimeout,
httpx.ProxyError,
httpx.UnsupportedProtocol,
)
def _error_reason(resp: httpx.Response) -> str:
@@ -162,6 +176,9 @@ def best_thumbnail(thumbnails: dict | None) -> str | None:
class YouTubeClient:
# The quota units this client has recorded: what its calls have spent of the shared budget.
spent: int = 0
def __init__(self, db, user: User):
self.db = db
self.user = user
@@ -189,9 +206,16 @@ class YouTubeClient:
return self._http.request(method, url, **kwargs)
except httpx.HTTPError as exc:
raise YouTubeError(
f"YouTube API unreachable ({exc.__class__.__name__}: {exc})", reason="transport"
f"YouTube API unreachable ({exc.__class__.__name__}: {exc})",
reason="transport",
sent=not isinstance(exc, _UNSENT),
) from exc
def _spend(self, units: int) -> None:
"""Record what a call YouTube answered cost — a refused one too: it is charged all the same."""
quota.record_usage(units)
self.spent += units
# --- auth ---
def _access_token(self) -> str:
tok = self.user.token
@@ -269,7 +293,7 @@ class YouTubeClient:
else:
headers["Authorization"] = f"Bearer {self._access_token()}"
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
quota.record_usage(cost)
self._spend(cost)
if resp.status_code != 200:
raise _upstream_failure("GET", path, resp, expected)
return resp.json()
@@ -401,7 +425,7 @@ class YouTubeClient:
params={"id": subscription_id},
headers={"Authorization": f"Bearer {self._access_token()}"},
)
quota.record_usage(50)
self._spend(50)
if resp.status_code not in (200, 204):
raise _upstream_failure("DELETE", "subscriptions", resp)
@@ -431,7 +455,7 @@ class YouTubeClient:
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
headers = {"Authorization": f"Bearer {self._access_token()}"}
resp = self._send(method, f"{API_BASE}/{path}", params=params, json=json, headers=headers)
quota.record_usage(50)
self._spend(50)
if resp.status_code not in (200, 204):
raise _upstream_failure(method, path, resp)
return resp.json() if resp.content else {}
+160
View File
@@ -0,0 +1,160 @@
"""The website's own InnerTube API (`youtubei/v1/*`), read the way the website reads it — no quota.
The zero-quota search (`search_scrape`) and the related videos (`related_scrape`) both POST here with
the public WEB key and a client version, read out of a live results page. This deliberately calls an
undocumented endpoint: if YouTube changes the JSON shape or blocks the host, each caller says so as a
`ScrapeError`, never as an empty answer.
The SOCS=CAI consent-skip cookie is sent like the Shorts probe's, so a server-side request isn't
bounced to consent.youtube.com."""
import re
import httpx
from app import logs
log = logs.get_logger(__name__)
_API_URL = "https://www.youtube.com/youtubei/v1/"
_RESULTS_URL = "https://www.youtube.com/results"
# Public WEB InnerTube key + a recent client version. These are also embedded in every results
# page; we fetch the live pair once and cache it, falling back to these constants.
_FALLBACK_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
_FALLBACK_CLIENT_VERSION = "2.20240101.00.00"
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
# SOCS skips YouTube's cookie-consent interstitial (otherwise every server-side request is
# redirected to consent.youtube.com).
"Cookie": "SOCS=CAI",
"Accept-Language": "en-US,en;q=0.9",
}
_KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"')
_VER_RE = re.compile(r'"INNERTUBE_CLIENT_VERSION":"([^"]+)"')
# Cached (api_key, client_version) extracted from a results page; refreshed on demand.
_config: tuple[str, str] | None = None
class ScrapeError(Exception):
"""An InnerTube read could not be completed (network, blocked, or unexpected shape)."""
def runs_text(node: dict | None) -> str | None:
"""The text of an InnerTube text node: a `simpleText`, or its `runs` joined."""
if not node:
return None
if node.get("simpleText"):
return node["simpleText"]
runs = node.get("runs") or []
if runs:
return "".join(r.get("text", "") for r in runs)
return None
def scraped_video(
vid: str, channel_id: object, channel_title: object, title: object, thumbnails: object
) -> dict:
"""One scraped video in the shape `YouTubeClient.search_videos` answers, so a page of either
source is ingested the same way. `published_at` is None (the website shows only a relative
"3 years ago"; the enrich fills the real date) and `live_broadcast` "none" (live/upcoming are
judged after the enrich, by `live_status`). The largest thumbnail is the last."""
thumbs = thumbnails if isinstance(thumbnails, list) else []
last = thumbs[-1] if thumbs else None
return {
"id": vid,
"channel_id": channel_id if isinstance(channel_id, str) and channel_id else None,
"channel_title": channel_title if isinstance(channel_title, str) else None,
"title": title if isinstance(title, str) else None,
"published_at": None,
"thumbnail_url": last.get("url") if isinstance(last, dict) else None,
"live_broadcast": "none",
}
def video_renderer(node: dict) -> dict | None:
"""A `videoRenderer` (search) or `compactVideoRenderer` (the older related column) flattened —
the two draw a video the same way. None without a video id; the channel is None when the byline
names none."""
vid = node.get("videoId")
if not isinstance(vid, str) or not vid:
return None
owner = node.get("ownerText") or node.get("longBylineText") or {}
runs = owner.get("runs") if isinstance(owner, dict) else None
first = runs[0] if isinstance(runs, list) and runs and isinstance(runs[0], dict) else {}
endpoint = (first.get("navigationEndpoint") or {}).get("browseEndpoint") or {}
return scraped_video(
vid,
endpoint.get("browseId"),
first.get("text"),
runs_text(node.get("title")),
(node.get("thumbnail") or {}).get("thumbnails"),
)
def continuation_token(node: object) -> str | None:
"""The "load more" cursor of a `continuationItemRenderer`, or None when `node` is not one."""
if not isinstance(node, dict):
return None
command = (node.get("continuationEndpoint") or {}).get("continuationCommand") or {}
token = command.get("token")
return token if isinstance(token, str) and token else None
def _get_config(client: httpx.Client) -> tuple[str, str]:
"""The current InnerTube (api_key, client_version), cached after the first fetch that FOUND them.
The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no
keys on it) at the wrong moment pinned every later search to a hard-coded key and version for
the life of the process — and when those went stale, search returned nothing, with no error
anywhere. Now the next call simply tries the live pair again."""
global _config
if _config is not None:
return _config
try:
html = client.get(_RESULTS_URL, params={"search_query": "music"}).text
except httpx.HTTPError as exc:
log.warning(
"the InnerTube config could not be fetched; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason=type(exc).__name__),
)
return (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION)
key = _KEY_RE.search(html)
ver = _VER_RE.search(html)
if key and ver:
_config = (key.group(1), ver.group(1))
return _config
log.warning(
"the results page carried no InnerTube config; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason="not_on_page"),
)
return (
key.group(1) if key else _FALLBACK_KEY,
ver.group(1) if ver else _FALLBACK_CLIENT_VERSION,
)
def post(endpoint: str, body: dict, *, hl: str = "en", gl: str = "US") -> dict:
"""POST `body` to `youtubei/v1/<endpoint>` as the WEB client in the `hl`/`gl` locale and return
the decoded JSON object. A network failure, an HTTP error or a body that is not a JSON object (a
block, consent or captcha page) raises `ScrapeError`; the caller judges the shape."""
with httpx.Client(timeout=20.0, headers=_HEADERS) as client:
api_key, client_version = _get_config(client)
context = {
"client": {"clientName": "WEB", "clientVersion": client_version, "hl": hl, "gl": gl}
}
try:
resp = client.post(
_API_URL + endpoint, params={"key": api_key}, json={"context": context, **body}
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
raise ScrapeError(f"InnerTube request failed: {exc}") from exc
except ValueError as exc: # non-JSON body (blocked / consent / captcha)
raise ScrapeError("InnerTube returned a non-JSON response") from exc
if not isinstance(data, dict):
raise ScrapeError("InnerTube returned an unexpected shape")
return data
+141
View File
@@ -0,0 +1,141 @@
"""A video's related videos, read from the watch page's InnerTube `next` endpoint — no quota.
The Data API has no related-videos call any more, so this is the only source: the same list the
website shows beside a playing video. A page holds about 20 videos and a continuation token for the
next 20. The returned page has the search scrape's shape (``{"items": [...], "next_page_token"}``)
so the catalog ingests it the same way (`catalog.search.ingest_candidates`).
YouTube draws each item as a `lockupViewModel` now; the older `compactVideoRenderer` is read too, so
a rollback on its side does not empty the list. An item that names no single channel — a video by
several channels draws an avatar stack, not one avatar — is left out: the catalog row needs its
channel up front, and the stack does not say which one uploaded it.
`hl`/`gl` pick the locale YouTube fills the list in: ``en``/``US`` keeps it on the video's topic,
``hu``/``HU`` mixes in what is trending in Hungary (measured)."""
from app.youtube.innertube import (
ScrapeError,
continuation_token,
post,
scraped_video,
video_renderer,
)
__all__ = ["ScrapeError", "related"]
# Where a first page and a continuation page put their item list.
_FIRST = (
"contents",
"twoColumnWatchNextResults",
"secondaryResults",
"secondaryResults",
"results",
)
_APPENDS = ("appendContinuationItemsAction", "reloadContinuationItemsCommand")
def _at(node: object, *path: str | int) -> object:
"""The value under `path` in nested dicts/lists, or None where the path stops."""
for key in path:
if isinstance(node, dict) and isinstance(key, str):
node = node.get(key)
elif isinstance(node, list) and isinstance(key, int) and -len(node) <= key < len(node):
node = node[key]
else:
return None
return node
def _lockup(node: dict) -> dict | None:
if node.get("contentType") != "LOCKUP_CONTENT_TYPE_VIDEO" or not node.get("contentId"):
return None
meta = _at(node, "metadata", "lockupMetadataViewModel")
channel_id = _at(
meta,
"image",
"decoratedAvatarViewModel",
"rendererContext",
"commandContext",
"onTap",
"innertubeCommand",
"browseEndpoint",
"browseId",
)
channel_title = _at(
meta, "metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts", 0
)
return scraped_video(
node["contentId"],
channel_id,
_at(channel_title, "text", "content"),
_at(meta, "title", "content"),
_at(node, "contentImage", "thumbnailViewModel", "image", "sources"),
)
def _unavailable(data: dict) -> bool:
"""Whether the watch page says the video is not available: its main column draws a
`backgroundPromoRenderer` ("This video isn't available anymore") and there is no related column.
YouTube's own word for "nothing related" — the one empty page that is not a shape we lost."""
sections = _at(data, "contents", "twoColumnWatchNextResults", "results", "results", "contents")
for section in sections if isinstance(sections, list) else []:
inner = _at(section, "itemSectionRenderer", "contents")
for node in inner if isinstance(inner, list) else []:
if isinstance(node, dict) and "backgroundPromoRenderer" in node:
return True
return False
def _items(data: dict) -> list | None:
"""The page's item list (first page or continuation), or None when it has neither frame."""
first = _at(data, *_FIRST)
if isinstance(first, list):
return first
if _unavailable(data):
return []
for endpoint in data.get("onResponseReceivedEndpoints") or []:
for key in _APPENDS:
items = _at(endpoint, key, "continuationItems")
if isinstance(items, list):
return items
return None
def related(
video_id: str, continuation: str | None = None, *, hl: str = "en", gl: str = "US"
) -> dict:
"""One page of `video_id`'s related videos (`continuation` = the token a previous page gave).
A video YouTube says it no longer has is an empty page. An answer of a shape this does not know
— no related column, or one whose entries are all renderers it cannot read — is a `ScrapeError`:
not an empty page, which would read as "nothing related"."""
body = {"continuation": continuation} if continuation else {"videoId": video_id}
data = post("next", body, hl=hl, gl=gl)
items = _items(data)
if items is None:
raise ScrapeError("InnerTube returned no related list")
out: list[dict] = []
seen: set[str] = set()
token = None
known = 0 # entries drawn by a renderer read here (a video's, or the cursor)
for entry in items:
if not isinstance(entry, dict):
continue
if "lockupViewModel" in entry:
item = _lockup(entry["lockupViewModel"])
elif "compactVideoRenderer" in entry:
item = video_renderer(entry["compactVideoRenderer"])
else:
found = continuation_token(entry.get("continuationItemRenderer"))
known += found is not None
token = token or found
continue
known += 1
# A video by several channels draws an avatar stack, not one: no channel, left out.
if item and item["channel_id"] and item["id"] not in seen:
seen.add(item["id"])
out.append(item)
# A list with entries but none of them one we read: YouTube draws its items some new way.
if items and not known:
raise ScrapeError("InnerTube's related list holds no renderer this reads")
return {"items": out, "next_page_token": token}
+12 -128
View File
@@ -6,9 +6,9 @@ calls — and read the result list straight out of the JSON. The returned video
through the normal enrich path (videos.list, 1 unit / 50), exactly as the official search does,
so the search itself is effectively free and only the (cheap, shared) enrichment is charged.
The SOCS=CAI consent-skip cookie is used like the Shorts probe so a server-side request isn't
bounced to consent.youtube.com. This deliberately scrapes an undocumented endpoint: if YouTube
changes the JSON shape or blocks the host, an admin can flip ``search_source`` back to ``api``.
The request itself (the live key, the consent cookie) is `innertube`'s. This deliberately scrapes an
undocumented endpoint: if YouTube changes the JSON shape or blocks the host, an admin can flip
``search_source`` back to ``api``.
The returned page mirrors :meth:`YouTubeClient.search_videos` exactly
(``{"items": [...], "next_page_token": str | None}``) so the route is source-agnostic — the
@@ -17,110 +17,16 @@ only difference is the cursor is an InnerTube *continuation token*, not a Data A
the real date is filled by the videos.list enrich. ``live_broadcast`` is reported as ``"none"``
and live/upcoming filtering happens post-enrich via ``live_status`` (same as the API path)."""
import re
from app.youtube.innertube import ScrapeError, continuation_token, post, video_renderer
import httpx
__all__ = ["ScrapeError", "search"]
from app import logs
log = logs.get_logger(__name__)
_SEARCH_URL = "https://www.youtube.com/youtubei/v1/search"
_RESULTS_URL = "https://www.youtube.com/results"
# Public WEB InnerTube key + a recent client version. These are also embedded in every results
# page; we fetch the live pair once and cache it, falling back to these constants.
_FALLBACK_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
_FALLBACK_CLIENT_VERSION = "2.20240101.00.00"
# sp / params value selecting the "type: video" search filter (base64 protobuf EgIQAQ==).
_TYPE_VIDEO = "EgIQAQ=="
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
# SOCS skips YouTube's cookie-consent interstitial (otherwise every server-side request is
# redirected to consent.youtube.com).
"Cookie": "SOCS=CAI",
"Accept-Language": "en-US,en;q=0.9",
}
# The top-level keys a first page (contents) and a continuation page (onResponseReceivedCommands)
# put their result list under.
_RESULT_FRAMES = {"contents", "onResponseReceivedCommands"}
_KEY_RE = re.compile(r'"INNERTUBE_API_KEY":"([^"]+)"')
_VER_RE = re.compile(r'"INNERTUBE_CLIENT_VERSION":"([^"]+)"')
# Cached (api_key, client_version) extracted from a results page; refreshed on demand.
_config: tuple[str, str] | None = None
class ScrapeError(Exception):
"""A scrape-based search could not be completed (network, blocked, or unexpected shape)."""
def _runs_text(node: dict | None) -> str | None:
if not node:
return None
if node.get("simpleText"):
return node["simpleText"]
runs = node.get("runs") or []
if runs:
return "".join(r.get("text", "") for r in runs)
return None
def _get_config(client: httpx.Client) -> tuple[str, str]:
"""The current InnerTube (api_key, client_version), cached after the first fetch that FOUND them.
The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no
keys on it) at the wrong moment pinned every later search to a hard-coded key and version for
the life of the process — and when those went stale, search returned nothing, with no error
anywhere. Now the next search simply tries the live pair again."""
global _config
if _config is not None:
return _config
try:
html = client.get(_RESULTS_URL, params={"search_query": "music"}).text
except httpx.HTTPError as exc:
log.warning(
"the InnerTube config could not be fetched; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason=type(exc).__name__),
)
return (_FALLBACK_KEY, _FALLBACK_CLIENT_VERSION)
key = _KEY_RE.search(html)
ver = _VER_RE.search(html)
if key and ver:
_config = (key.group(1), ver.group(1))
return _config
log.warning(
"the results page carried no InnerTube config; falling back to the pinned key",
extra=logs.event("youtube.scrape.config_fallback", reason="not_on_page"),
)
return (
key.group(1) if key else _FALLBACK_KEY,
ver.group(1) if ver else _FALLBACK_CLIENT_VERSION,
)
def _video_item(vr: dict) -> dict | None:
"""Flatten a YouTube `videoRenderer` into the same shape as YouTubeClient.search_videos."""
vid = vr.get("videoId")
if not vid:
return None
owner = vr.get("ownerText") or vr.get("longBylineText") or {}
runs = owner.get("runs") or []
channel_id = channel_title = None
if runs:
channel_title = runs[0].get("text")
channel_id = runs[0].get("navigationEndpoint", {}).get("browseEndpoint", {}).get("browseId")
thumbs = (vr.get("thumbnail") or {}).get("thumbnails") or []
return {
"id": vid,
"channel_id": channel_id,
"channel_title": channel_title,
"title": _runs_text(vr.get("title")),
"published_at": None, # scrape exposes only a relative time; enrich fills the real date
"thumbnail_url": thumbs[-1]["url"] if thumbs else None,
"live_broadcast": "none", # live/upcoming filtered post-enrich via live_status
}
def _collect_videos(obj, out: list[dict], seen: set[str]) -> None:
@@ -130,7 +36,7 @@ def _collect_videos(obj, out: list[dict], seen: set[str]) -> None:
if isinstance(obj, dict):
vr = obj.get("videoRenderer")
if isinstance(vr, dict):
item = _video_item(vr)
item = video_renderer(vr)
if item and item["id"] not in seen:
seen.add(item["id"])
out.append(item)
@@ -144,9 +50,7 @@ def _collect_videos(obj, out: list[dict], seen: set[str]) -> None:
def _find_continuation(obj) -> str | None:
"""First continuationItemRenderer token (the 'load more' cursor), or None at the end."""
if isinstance(obj, dict):
cir = obj.get("continuationItemRenderer")
if isinstance(cir, dict):
tok = cir.get("continuationEndpoint", {}).get("continuationCommand", {}).get("token")
tok = continuation_token(obj.get("continuationItemRenderer"))
if tok:
return tok
for v in obj.values():
@@ -167,34 +71,14 @@ def search(q: str, continuation: str | None = None) -> dict:
`continuation` is the InnerTube continuation token from a previous page (None for the first
page). Returns ``{"items": [...], "next_page_token": str | None}`` — the same shape as
YouTubeClient.search_videos so the route handles both sources identically."""
with httpx.Client(timeout=20.0, headers=_HEADERS) as client:
api_key, client_version = _get_config(client)
context = {
"client": {
"clientName": "WEB",
"clientVersion": client_version,
"hl": "en",
"gl": "US",
}
}
body: dict = {"context": context}
if continuation:
body["continuation"] = continuation
else:
body["query"] = q
body["params"] = _TYPE_VIDEO
try:
resp = client.post(_SEARCH_URL, params={"key": api_key}, json=body)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPError as exc:
raise ScrapeError(f"InnerTube request failed: {exc}") from exc
except ValueError as exc: # non-JSON body (blocked / consent / captcha)
raise ScrapeError("InnerTube returned a non-JSON response") from exc
body: dict = (
{"continuation": continuation} if continuation else {"query": q, "params": _TYPE_VIDEO}
)
data = post("search", body)
# Even a search with no results carries its result list's frame. Without one this is not "no
# results" but a shape we do not know (YouTube moved, or a block page in JSON) — say so.
if not isinstance(data, dict) or not _RESULT_FRAMES & data.keys():
if not _RESULT_FRAMES & data.keys():
raise ScrapeError("InnerTube returned an unexpected shape")
items: list[dict] = []
_collect_videos(data, items, set())
+527
View File
@@ -0,0 +1,527 @@
{
"onResponseReceivedEndpoints": [
{
"appendContinuationItemsAction": {
"continuationItems": [
{
"lockupViewModel": {
"contentImage": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/_HfdncCbMOE/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLARvB39pkdsiuUUQTsZjkhvbKrfcQ",
"width": 168,
"height": 94
},
{
"url": "https://i.ytimg.com/vi/_HfdncCbMOE/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB15aZU5-tzwYCEbNTnZK3UX6ZCzQ",
"width": 336,
"height": 188
}
]
},
"overlays": [
{
"thumbnailBottomOverlayViewModel": {
"badges": [
{
"thumbnailBadgeViewModel": {
"text": "1:08:18",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"animationActivationTargetId": "_HfdncCbMOE",
"animatedText": "Now playing",
"animationActivationEntitySelectorType": "THUMBNAIL_BADGE_ANIMATION_ENTITY_SELECTOR_TYPE_PLAYER_STATE"
}
}
]
}
}
]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "MIT 6.S191 (2025): Large Language Models (Liquid AI)"
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_k517G0Dt8QgsF86GKzYkG3Rs8jSGCiYGJw04lx4_1KXQ=s68-c-k-c0x00ffffff-no-rj",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel Alexander Amini",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCtslD4DGH6PKyG_1gFAX7sg"
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "Alexander Amini"
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "62K"
},
"accessibilityLabel": "62 thousand views",
"leadingIcon": {
"name": "PLAY_ARROW_OUTLINED",
"height": 12,
"width": 12,
"marginEndToken": {
"spacing": 2
}
}
},
{
"text": {
"content": "1y ago"
},
"accessibilityLabel": "1 year ago"
}
]
}
],
"delimiter": " "
}
}
}
},
"contentId": "_HfdncCbMOE",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"themedPalette": {
"lightThemePalette": {
"baseBackground": 4294964720,
"raisedBackground": 4294964720,
"additiveBackground": 566850118,
"textPrimary": 4281800969,
"textSecondary": 4285876787,
"outline": 860759827,
"keyLight": 4292983720,
"collectionStack": 4291337092,
"invertedBackground": 4287578649,
"textPrimaryInverse": 4294964720,
"textDisabled": 2135828243,
"dropShadow": 432632390,
"cardOutline": 180974150,
"overlayBackground": 3426162953,
"overlayTextPrimary": 4294964720,
"solidWash": 4283311891,
"solidRim": 4281800969,
"overlaySolidWash": 4294699488,
"overlaySolidRim": 4294964720,
"tonalWash": 2583688688,
"tonalRim": 202310912,
"overlayTonalWash": 436205040,
"overlayTonalRim": 436205040,
"outlineRim": 1280190227,
"overlayOutlineRim": 871618504,
"solidWashInverse": 4294170568,
"solidRimInverse": 2147215840,
"tonalWashInverse": 654308848,
"tonalRimInverse": 654043616,
"outlineRimInverse": 654308848,
"solidBackground": 4283311891,
"solidBackgroundInverse": 4291337092,
"tonalBackground": 868785028,
"tonalBackgroundInverse": 435410888,
"outlineInverse": 872147424,
"directionalLightStopPrimary": 4294699488,
"directionalLightStopSecondary": 4294964720,
"primary": 4279174400,
"filledStateLayer": 4281800969,
"tonalTouchLight": 4294964720,
"tonalHoverLight": 3439061472,
"tonalStateLayer": 420414720,
"overlayDisabledContainer": 871618504,
"overlayTonalTouchLight": 1291577824,
"overlayTonalHoverLight": 1291577824,
"overlayTonalStateLayer": 436205040,
"disabledContainer": 420414720,
"filledTouchLight": 4285876787,
"filledHoverLight": 4284627235,
"onOverlayDisabledContainer": 2583688688,
"overlayTonalContainer": 2583423456,
"overlayOutline": 872412656,
"onFilledContainer": 4294964720,
"filledContainer": 4280027906,
"tonalContainer": 641145097,
"overlayPrimary": 4294964720,
"onOverlayFilledContainer": 4279174400,
"overlayFilledTouchLight": 4294964720,
"overlayFilledHoverLight": 4294699488,
"overlayFilledStateLayer": 4292983720,
"overlayFilledContainer": 4294170568,
"onDisabledContainer": 1712260352,
"temporaryOutline": 860759827
},
"darkThemePalette": {
"baseBackground": 4280027906,
"raisedBackground": 4281800969,
"additiveBackground": 734622278,
"textPrimary": 4294699488,
"textSecondary": 4292983720,
"outline": 1072945096,
"keyLight": 4284627235,
"collectionStack": 4285876787,
"invertedBackground": 4293107837,
"textPrimaryInverse": 4280027906,
"textDisabled": 2146686920,
"dropShadow": 432632390,
"cardOutline": 180974150,
"overlayBackground": 3426162953,
"overlayTextPrimary": 4294964720,
"solidWash": 4294699488,
"solidRim": 4294964720,
"overlaySolidWash": 4294699488,
"overlaySolidRim": 4294964720,
"tonalWash": 217307080,
"tonalRim": 218101232,
"overlayTonalWash": 436205040,
"overlayTonalRim": 436205040,
"outlineRim": 871618504,
"overlayOutlineRim": 871618504,
"solidWashInverse": 4285876787,
"solidRimInverse": 871618504,
"tonalWashInverse": 872412656,
"tonalRimInverse": 870431656,
"outlineRimInverse": 1291843056,
"solidBackground": 4291337092,
"solidBackgroundInverse": 4283311891,
"tonalBackground": 435410888,
"tonalBackgroundInverse": 868785028,
"outlineInverse": 860759827,
"directionalLightStopPrimary": 4281800969,
"directionalLightStopSecondary": 4280027906,
"primary": 4294964720,
"filledStateLayer": 4292983720,
"tonalTouchLight": 1073474016,
"tonalHoverLight": 654043616,
"tonalStateLayer": 436205040,
"overlayDisabledContainer": 871618504,
"overlayTonalTouchLight": 1291577824,
"overlayTonalHoverLight": 1291577824,
"overlayTonalStateLayer": 436205040,
"disabledContainer": 436205040,
"filledTouchLight": 4294964720,
"filledHoverLight": 4294699488,
"onOverlayDisabledContainer": 2583688688,
"overlayTonalContainer": 2583423456,
"overlayOutline": 872412656,
"onFilledContainer": 4279174400,
"filledContainer": 4294170568,
"tonalContainer": 435410888,
"overlayPrimary": 4294964720,
"onOverlayFilledContainer": 4279174400,
"overlayFilledTouchLight": 4294964720,
"overlayFilledHoverLight": 4294699488,
"overlayFilledStateLayer": 4292983720,
"overlayFilledContainer": 4294170568,
"onDisabledContainer": 1728050672,
"temporaryOutline": 871618504
}
}
}
},
{
"lockupViewModel": {
"contentImage": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/P7Hkh2zOGQ0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAHInNtMierSAgtrGl5rLSKc-vdzQ",
"width": 168,
"height": 94
},
{
"url": "https://i.ytimg.com/vi/P7Hkh2zOGQ0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDXrm5g-rdjRIqqK309ImTdqpw4CA",
"width": 336,
"height": 188
}
]
},
"overlays": [
{
"thumbnailBottomOverlayViewModel": {
"badges": [
{
"thumbnailBadgeViewModel": {
"text": "1:08:19",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"animationActivationTargetId": "P7Hkh2zOGQ0",
"animatedText": "Now playing",
"animationActivationEntitySelectorType": "THUMBNAIL_BADGE_ANIMATION_ENTITY_SELECTOR_TYPE_PLAYER_STATE"
}
}
]
}
}
]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "MIT 6.S191 (2024): Google - Generative AI for Media"
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_k517G0Dt8QgsF86GKzYkG3Rs8jSGCiYGJw04lx4_1KXQ=s68-c-k-c0x00ffffff-no-rj",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel Alexander Amini",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCtslD4DGH6PKyG_1gFAX7sg"
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "Alexander Amini"
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "49K"
},
"accessibilityLabel": "49 thousand views",
"leadingIcon": {
"name": "PLAY_ARROW_OUTLINED",
"height": 12,
"width": 12,
"marginEndToken": {
"spacing": 2
}
}
},
{
"text": {
"content": "2y ago"
},
"accessibilityLabel": "2 years ago"
}
]
}
],
"delimiter": " "
}
}
}
},
"contentId": "P7Hkh2zOGQ0",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"themedPalette": {
"lightThemePalette": {
"baseBackground": 4293981951,
"raisedBackground": 4293981951,
"additiveBackground": 558201801,
"textPrimary": 4278655801,
"textSecondary": 4280831104,
"outline": 856631890,
"keyLight": 4288855783,
"collectionStack": 4286094803,
"invertedBackground": 4279847311,
"textPrimaryInverse": 4293981951,
"textDisabled": 2131700306,
"dropShadow": 423984073,
"cardOutline": 172325833,
"overlayBackground": 3423017785,
"overlayTextPrimary": 4293981951,
"solidWash": 4279183954,
"solidRim": 4278655801,
"overlaySolidWash": 4292864764,
"overlaySolidRim": 4293981951,
"tonalWash": 2582705919,
"tonalRim": 201328143,
"overlayTonalWash": 435222271,
"overlayTonalRim": 435222271,
"outlineRim": 1276062290,
"overlayOutlineRim": 868669942,
"solidWashInverse": 4291222006,
"solidRimInverse": 2145381116,
"tonalWashInverse": 653326079,
"tonalRimInverse": 652208892,
"outlineRimInverse": 653326079,
"solidBackground": 4279183954,
"solidBackgroundInverse": 4286094803,
"tonalBackground": 863542739,
"tonalBackgroundInverse": 432462326,
"outlineInverse": 870312700,
"directionalLightStopPrimary": 4292864764,
"directionalLightStopSecondary": 4293981951,
"primary": 4278191631,
"filledStateLayer": 4278655801,
"tonalTouchLight": 4293981951,
"tonalHoverLight": 3437226748,
"tonalStateLayer": 419431951,
"overlayDisabledContainer": 868669942,
"overlayTonalTouchLight": 1289743100,
"overlayTonalHoverLight": 1289743100,
"overlayTonalStateLayer": 435222271,
"disabledContainer": 419431951,
"filledTouchLight": 4280831104,
"filledHoverLight": 4280040297,
"onOverlayDisabledContainer": 2582705919,
"overlayTonalContainer": 2581588732,
"overlayOutline": 871429887,
"onFilledContainer": 4293981951,
"filledContainer": 4278324509,
"tonalContainer": 637999929,
"overlayPrimary": 4293981951,
"onOverlayFilledContainer": 4278191631,
"overlayFilledTouchLight": 4293981951,
"overlayFilledHoverLight": 4292864764,
"overlayFilledStateLayer": 4288855783,
"overlayFilledContainer": 4291222006,
"onDisabledContainer": 1711277583,
"temporaryOutline": 856631890
},
"darkThemePalette": {
"baseBackground": 4278324509,
"raisedBackground": 4278655801,
"additiveBackground": 725973961,
"textPrimary": 4292864764,
"textSecondary": 4288855783,
"outline": 1069996534,
"keyLight": 4280040297,
"collectionStack": 4280831104,
"invertedBackground": 4286359267,
"textPrimaryInverse": 4278324509,
"textDisabled": 2143738358,
"dropShadow": 423984073,
"cardOutline": 172325833,
"overlayBackground": 3423017785,
"overlayTextPrimary": 4293981951,
"solidWash": 4292864764,
"solidRim": 4293981951,
"overlaySolidWash": 4292864764,
"overlaySolidRim": 4293981951,
"tonalWash": 214358518,
"tonalRim": 217118463,
"overlayTonalWash": 435222271,
"overlayTonalRim": 435222271,
"outlineRim": 868669942,
"overlayOutlineRim": 868669942,
"solidWashInverse": 4280831104,
"solidRimInverse": 868669942,
"tonalWashInverse": 871429887,
"tonalRimInverse": 866303719,
"outlineRimInverse": 1290860287,
"solidBackground": 4286094803,
"solidBackgroundInverse": 4279183954,
"tonalBackground": 432462326,
"tonalBackgroundInverse": 863542739,
"outlineInverse": 856631890,
"directionalLightStopPrimary": 4278655801,
"directionalLightStopSecondary": 4278324509,
"primary": 4293981951,
"filledStateLayer": 4288855783,
"tonalTouchLight": 1071639292,
"tonalHoverLight": 652208892,
"tonalStateLayer": 435222271,
"overlayDisabledContainer": 868669942,
"overlayTonalTouchLight": 1289743100,
"overlayTonalHoverLight": 1289743100,
"overlayTonalStateLayer": 435222271,
"disabledContainer": 435222271,
"filledTouchLight": 4293981951,
"filledHoverLight": 4292864764,
"onOverlayDisabledContainer": 2582705919,
"overlayTonalContainer": 2581588732,
"overlayOutline": 871429887,
"onFilledContainer": 4278191631,
"filledContainer": 4291222006,
"tonalContainer": 432462326,
"overlayPrimary": 4293981951,
"onOverlayFilledContainer": 4278191631,
"overlayFilledTouchLight": 4293981951,
"overlayFilledHoverLight": 4292864764,
"overlayFilledStateLayer": 4288855783,
"overlayFilledContainer": 4291222006,
"onDisabledContainer": 1727067903,
"temporaryOutline": 868669942
}
}
}
},
{
"continuationItemRenderer": {
"trigger": "CONTINUATION_TRIGGER_ON_ITEM_SHOWN",
"continuationEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/next"
}
},
"continuationCommand": {
"token": "CCgSDRILU2RUWkFNREtyTlkYACqPDE1nb0lodVQ4eG9EdXVLSUYyczZMNnd6NENBcjFDQW9EOGo0QUNnN0NQZ3NJbk1XcnZzbWo2Y2UyQVFvRDhqNEFDZzNDUGdvSW90N0czWnVCMy1KSENnUHlQZ0FLRGNJLUNnaTVfOGVUbjV5cTJSd0tBX0ktQUFvT3dqNExDSkQydWJfNXp2ZXZfUUVLQV9JLUFBb053ajRLQ1BXWjVQakt2cjZWU2dvRDhqNEFDZzNDUGdvSW9aTDd1OUtGdF85NkNnUHlQZ0FLRGNJLUNnaUh3N2FLMzd6YzVIZ0tBX0ktQUFvT3dqNExDUE94aHRQTWtfVzB1UUVLQV9JLUFBb093ajRMQ092eTBzaXNsSWZScGdFS0FfSS1BQW9Pd2o0TENKS1FxdHpBeE5lZHJBRUtBX0ktQUFvTndqNEtDTFdteWE2cHNPMkJPZ29EOGo0QUNnM0NQZ29JanYtdy1wQ3k1YVozQ2dQeVBnQUtEc0ktQ3dqMmtmVF9tYlB1XzRvQkNnUHlQZ0FLRHNJLUN3aVpuYWZjc0tQVmpZUUJDZ1B5UGdBS0RjSS1DZ2lxM3BxM3FyX2s0U0VLQV9JLUFBb053ajRLQ0phNXU5eVJvTmJOS0FvRDhqNEFDZzNDUGdvSXFMeWR3T1NhNGJkV0NnUHlQZ0FLRGNJLUNnakh3X2ZucktlSDdXUUtBX0ktQUFvTndqNEtDUG4xak1mcXg4QzJNQW9EOGo0QUNnM0NQZ29JeWRpdXV0VDQ3UHNhQ2dQeVBnQUtEc0ktQ3dqaDRleUUzTFAzdV93QkNnUHlQZ0FLRGNJLUNnaU5zcmptOXBENTJEOEtBX0ktQUFvTndqNEtDTExudjR5ZXg3U1dTUW9EOGo0QUNnM0NQZ29JNnZHODhaR1JpTWNnQ2dQeVBnQUtEc0ktQ3dpRF85S3Z5WS1hbThJQkNnUHlQZ0FLRHNJLUN3alppNmJYbWR6YWxZb0JDZ1B5UGdBS0RzSS1Dd2lkemJfS2dPdWN0YUFCQ2dQeVBnQUtEY0ktQ2dqMXktMjYyb2JQeFdNS0FfSS1BQW9Pd2o0TENPLUE4SWVaMUlESXJRRUtBX0ktQUFvTndqNEtDUHlZNDVhUzhkeWpJZ29EOGo0QUNnM0NQZ29Jb191aWtfSE05ZHdTQ2dQeVBnQUtEc0ktQ3dqaTBiemlrdnpBbkxvQkNnUHlQZ0FLRHNJLUN3aW05cXI1eXNtTWlzOEJDZ1B5UGdBS0RjSS1DZ2k3M2V5NXlzQ1pzMG9LQV9JLUFBb093ajRMQ0xmT281WFU1X2JPbFFFS0FfSS1BQW9Od2o0S0NLTEVwYXlXOTliWkdnb0Q4ajRBQ2c3Q1Bnc0k2dWZSMnRPM3hLem9BUW9EOGo0QUNnN0NQZ3NJN2ZlZXZQRHV2WlRXQVFvRDhqNEFDZzNDUGdvSXFibS0zYXVBdDVWcUNnUHlQZ0FLRGNJLUNnaUc1UHpHZ082NG9nVVNLQUFDQkFZSUNnd09FQklVRmhnYUhCNGdJaVFtS0Nvc0xqQXlORFk0T2p3LVFFSkVSa2hLVEU0YUJBZ0FFQUVhQkFnQ0VBTWFCQWdFRUFVYUJBZ0dFQWNhQkFnSUVBa2FCQWdLRUFzYUJBZ01FQTBhQkFnT0VBOGFCQWdRRUJFYUJBZ1NFQk1hQkFnVUVCVWFCQWdXRUJjYUJBZ1lFQmthQkFnYUVCc2FCQWdjRUIwYUJBZ2VFQjhhQkFnZ0VDRWFCQWdpRUNNYUJBZ2tFQ1VhQkFnbUVDY2FCQWdvRUNrYUJBZ3FFQ3NhQkFnc0VDMGFCQWd1RUM4YUJBZ3dFREVhQkFneUVETWFCQWcwRURVYUJBZzJFRGNhQkFnNEVEa2FCQWc2RURzYUJBZzhFRDBhQkFnLUVEOGFCQWhBRUVFYUJBaENFRU1hQkFoRUVFVWFCQWhHRUVjYUJBaElFRWthQkFoS0VFc2FCQWhNRUUwYUJBaE9FRThxS0FBQ0JBWUlDZ3dPRUJJVUZoZ2FIQjRnSWlRbUtDb3NMakF5TkRZNE9qdy1RRUpFUmtoS1RFNGoPd2F0Y2gtbmV4dC1mZWVk",
"request": "CONTINUATION_REQUEST_TYPE_WATCH_NEXT"
}
}
}
}
]
}
}
]
}
+805
View File
@@ -0,0 +1,805 @@
{
"contents": {
"twoColumnWatchNextResults": {
"secondaryResults": {
"secondaryResults": {
"results": [
{
"lockupViewModel": {
"contentImage": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/to-lHJfK4pw/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBKP_oFChvoqvwVrKAy_PG9bn2_AA",
"width": 168,
"height": 94
},
{
"url": "https://i.ytimg.com/vi/to-lHJfK4pw/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDyH3Z8DJhXMmFJ5eKEWMufk7ql9g",
"width": 336,
"height": 188
}
]
},
"overlays": [
{
"thumbnailBottomOverlayViewModel": {
"badges": [
{
"thumbnailBadgeViewModel": {
"text": "1:02:00",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"animationActivationTargetId": "to-lHJfK4pw",
"animatedText": "Now playing",
"animationActivationEntitySelectorType": "THUMBNAIL_BADGE_ANIMATION_ENTITY_SELECTOR_TYPE_PLAYER_STATE"
}
}
]
}
}
]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "MIT 6.S191 (2025): Reinforcement Learning"
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_k517G0Dt8QgsF86GKzYkG3Rs8jSGCiYGJw04lx4_1KXQ=s68-c-k-c0x00ffffff-no-rj",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel Alexander Amini",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCtslD4DGH6PKyG_1gFAX7sg"
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "Alexander Amini"
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "128K"
},
"accessibilityLabel": "128 thousand views",
"leadingIcon": {
"name": "PLAY_ARROW_OUTLINED",
"height": 12,
"width": 12,
"marginEndToken": {
"spacing": 2
}
}
},
{
"text": {
"content": "1y ago"
},
"accessibilityLabel": "1 year ago"
}
]
}
],
"delimiter": " "
}
}
}
},
"contentId": "to-lHJfK4pw",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"themedPalette": {
"lightThemePalette": {
"baseBackground": 4293981439,
"raisedBackground": 4293981439,
"additiveBackground": 558196425,
"textPrimary": 4278850870,
"textSecondary": 4282009454,
"outline": 857088843,
"keyLight": 4289509597,
"collectionStack": 4287338944,
"invertedBackground": 4279842703,
"textPrimaryInverse": 4293981439,
"textDisabled": 2132157259,
"dropShadow": 423978697,
"cardOutline": 172320457,
"overlayBackground": 3423212854,
"overlayTextPrimary": 4293981439,
"solidWash": 4279640907,
"solidRim": 4278850870,
"overlaySolidWash": 4292929530,
"overlaySolidRim": 4293981439,
"tonalWash": 2582705407,
"tonalRim": 201327631,
"overlayTonalWash": 435221759,
"overlayTonalRim": 435221759,
"outlineRim": 1276519243,
"overlayOutlineRim": 868930802,
"solidWashInverse": 4291482866,
"solidRimInverse": 2145445882,
"tonalWashInverse": 653325567,
"tonalRimInverse": 652273658,
"outlineRimInverse": 653325567,
"solidBackground": 4279640907,
"solidBackgroundInverse": 4287338944,
"tonalBackground": 864786880,
"tonalBackgroundInverse": 432723186,
"outlineInverse": 870377466,
"directionalLightStopPrimary": 4292929530,
"directionalLightStopSecondary": 4293981439,
"primary": 4278191119,
"filledStateLayer": 4278850870,
"tonalTouchLight": 4293981439,
"tonalHoverLight": 3437291514,
"tonalStateLayer": 419431439,
"overlayDisabledContainer": 868930802,
"overlayTonalTouchLight": 1289807866,
"overlayTonalHoverLight": 1289807866,
"overlayTonalStateLayer": 435221759,
"disabledContainer": 419431439,
"filledTouchLight": 4282009454,
"filledHoverLight": 4280825181,
"onOverlayDisabledContainer": 2582705407,
"overlayTonalContainer": 2581653498,
"overlayOutline": 871429375,
"onFilledContainer": 4293981439,
"filledContainer": 4278323484,
"tonalContainer": 638194998,
"overlayPrimary": 4293981439,
"onOverlayFilledContainer": 4278191119,
"overlayFilledTouchLight": 4293981439,
"overlayFilledHoverLight": 4292929530,
"overlayFilledStateLayer": 4289509597,
"overlayFilledContainer": 4291482866,
"onDisabledContainer": 1711277071,
"temporaryOutline": 857088843
},
"darkThemePalette": {
"baseBackground": 4278323484,
"raisedBackground": 4278850870,
"additiveBackground": 725968585,
"textPrimary": 4292929530,
"textSecondary": 4289509597,
"outline": 1070257394,
"keyLight": 4280825181,
"collectionStack": 4282009454,
"invertedBackground": 4286355427,
"textPrimaryInverse": 4278323484,
"textDisabled": 2143999218,
"dropShadow": 423978697,
"cardOutline": 172320457,
"overlayBackground": 3423212854,
"overlayTextPrimary": 4293981439,
"solidWash": 4292929530,
"solidRim": 4293981439,
"overlaySolidWash": 4292929530,
"overlaySolidRim": 4293981439,
"tonalWash": 214619378,
"tonalRim": 217117951,
"overlayTonalWash": 435221759,
"overlayTonalRim": 435221759,
"outlineRim": 868930802,
"overlayOutlineRim": 868930802,
"solidWashInverse": 4282009454,
"solidRimInverse": 868930802,
"tonalWashInverse": 871429375,
"tonalRimInverse": 866957533,
"outlineRimInverse": 1290859775,
"solidBackground": 4287338944,
"solidBackgroundInverse": 4279640907,
"tonalBackground": 432723186,
"tonalBackgroundInverse": 864786880,
"outlineInverse": 857088843,
"directionalLightStopPrimary": 4278850870,
"directionalLightStopSecondary": 4278323484,
"primary": 4293981439,
"filledStateLayer": 4289509597,
"tonalTouchLight": 1071704058,
"tonalHoverLight": 652273658,
"tonalStateLayer": 435221759,
"overlayDisabledContainer": 868930802,
"overlayTonalTouchLight": 1289807866,
"overlayTonalHoverLight": 1289807866,
"overlayTonalStateLayer": 435221759,
"disabledContainer": 435221759,
"filledTouchLight": 4293981439,
"filledHoverLight": 4292929530,
"onOverlayDisabledContainer": 2582705407,
"overlayTonalContainer": 2581653498,
"overlayOutline": 871429375,
"onFilledContainer": 4278191119,
"filledContainer": 4291482866,
"tonalContainer": 432723186,
"overlayPrimary": 4293981439,
"onOverlayFilledContainer": 4278191119,
"overlayFilledTouchLight": 4293981439,
"overlayFilledHoverLight": 4292929530,
"overlayFilledStateLayer": 4289509597,
"overlayFilledContainer": 4291482866,
"onDisabledContainer": 1727067391,
"temporaryOutline": 868930802
}
}
}
},
{
"lockupViewModel": {
"contentImage": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/R8V8CbuxryI/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDqXMjrpgDyQKHL2lWrxczwxKoefw",
"width": 168,
"height": 94
},
{
"url": "https://i.ytimg.com/vi/R8V8CbuxryI/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB7JX9Z238s6DGqIS5-fhcEIkUxKw",
"width": 336,
"height": 188
}
]
},
"overlays": [
{
"thumbnailBottomOverlayViewModel": {
"badges": [
{
"thumbnailBadgeViewModel": {
"text": "49:36",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"animationActivationTargetId": "R8V8CbuxryI",
"animatedText": "Now playing",
"animationActivationEntitySelectorType": "THUMBNAIL_BADGE_ANIMATION_ENTITY_SELECTOR_TYPE_PLAYER_STATE"
}
}
]
}
}
]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "MIT 6.S191: Deep Generative Modeling"
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_k517G0Dt8QgsF86GKzYkG3Rs8jSGCiYGJw04lx4_1KXQ=s68-c-k-c0x00ffffff-no-rj",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel Alexander Amini",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCtslD4DGH6PKyG_1gFAX7sg"
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "Alexander Amini"
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "38K"
},
"accessibilityLabel": "38 thousand views",
"leadingIcon": {
"name": "PLAY_ARROW_OUTLINED",
"height": 12,
"width": 12,
"marginEndToken": {
"spacing": 2
}
}
},
{
"text": {
"content": "6mo ago"
},
"accessibilityLabel": "6 months ago"
}
]
}
],
"delimiter": " "
}
}
}
},
"contentId": "R8V8CbuxryI",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"themedPalette": {
"lightThemePalette": {
"baseBackground": 4294965488,
"raisedBackground": 4294965488,
"additiveBackground": 566856261,
"textPrimary": 4281737226,
"textSecondary": 4285420858,
"outline": 860565782,
"keyLight": 4292724140,
"collectionStack": 4290815883,
"invertedBackground": 4287583769,
"textPrimaryInverse": 4294965488,
"textDisabled": 2135634198,
"dropShadow": 432638533,
"cardOutline": 180980293,
"overlayBackground": 3426099210,
"overlayTextPrimary": 4294965488,
"solidWash": 4283117846,
"solidRim": 4281737226,
"overlaySolidWash": 4294635232,
"overlaySolidRim": 4294965488,
"tonalWash": 2583689456,
"tonalRim": 202311680,
"overlayTonalWash": 436205808,
"overlayTonalRim": 436205808,
"outlineRim": 1279996182,
"overlayOutlineRim": 871554762,
"solidWashInverse": 4294106826,
"solidRimInverse": 2147151584,
"tonalWashInverse": 654309616,
"tonalRimInverse": 653979360,
"outlineRimInverse": 654309616,
"solidBackground": 4283117846,
"solidBackgroundInverse": 4290815883,
"tonalBackground": 868263819,
"tonalBackgroundInverse": 435347146,
"outlineInverse": 872083168,
"directionalLightStopPrimary": 4294635232,
"directionalLightStopSecondary": 4294965488,
"primary": 4279175168,
"filledStateLayer": 4281737226,
"tonalTouchLight": 4294965488,
"tonalHoverLight": 3438997216,
"tonalStateLayer": 420415488,
"overlayDisabledContainer": 871554762,
"overlayTonalTouchLight": 1291513568,
"overlayTonalHoverLight": 1291513568,
"overlayTonalStateLayer": 436205808,
"disabledContainer": 420415488,
"filledTouchLight": 4285420858,
"filledHoverLight": 4284302120,
"onOverlayDisabledContainer": 2583689456,
"overlayTonalContainer": 2583359200,
"overlayOutline": 872413424,
"onFilledContainer": 4294965488,
"filledContainer": 4280029186,
"tonalContainer": 641081354,
"overlayPrimary": 4294965488,
"onOverlayFilledContainer": 4279175168,
"overlayFilledTouchLight": 4294965488,
"overlayFilledHoverLight": 4294635232,
"overlayFilledStateLayer": 4292724140,
"overlayFilledContainer": 4294106826,
"onDisabledContainer": 1712261120,
"temporaryOutline": 860565782
},
"darkThemePalette": {
"baseBackground": 4280029186,
"raisedBackground": 4281737226,
"additiveBackground": 734628421,
"textPrimary": 4294635232,
"textSecondary": 4292724140,
"outline": 1072881354,
"keyLight": 4284302120,
"collectionStack": 4285420858,
"invertedBackground": 4293112700,
"textPrimaryInverse": 4280029186,
"textDisabled": 2146623178,
"dropShadow": 432638533,
"cardOutline": 180980293,
"overlayBackground": 3426099210,
"overlayTextPrimary": 4294965488,
"solidWash": 4294635232,
"solidRim": 4294965488,
"overlaySolidWash": 4294635232,
"overlaySolidRim": 4294965488,
"tonalWash": 217243338,
"tonalRim": 218102000,
"overlayTonalWash": 436205808,
"overlayTonalRim": 436205808,
"outlineRim": 871554762,
"overlayOutlineRim": 871554762,
"solidWashInverse": 4285420858,
"solidRimInverse": 871554762,
"tonalWashInverse": 872413424,
"tonalRimInverse": 870172076,
"outlineRimInverse": 1291843824,
"solidBackground": 4290815883,
"solidBackgroundInverse": 4283117846,
"tonalBackground": 435347146,
"tonalBackgroundInverse": 868263819,
"outlineInverse": 860565782,
"directionalLightStopPrimary": 4281737226,
"directionalLightStopSecondary": 4280029186,
"primary": 4294965488,
"filledStateLayer": 4292724140,
"tonalTouchLight": 1073409760,
"tonalHoverLight": 653979360,
"tonalStateLayer": 436205808,
"overlayDisabledContainer": 871554762,
"overlayTonalTouchLight": 1291513568,
"overlayTonalHoverLight": 1291513568,
"overlayTonalStateLayer": 436205808,
"disabledContainer": 436205808,
"filledTouchLight": 4294965488,
"filledHoverLight": 4294635232,
"onOverlayDisabledContainer": 2583689456,
"overlayTonalContainer": 2583359200,
"overlayOutline": 872413424,
"onFilledContainer": 4279175168,
"filledContainer": 4294106826,
"tonalContainer": 435347146,
"overlayPrimary": 4294965488,
"onOverlayFilledContainer": 4279175168,
"overlayFilledTouchLight": 4294965488,
"overlayFilledHoverLight": 4294635232,
"overlayFilledStateLayer": 4292724140,
"overlayFilledContainer": 4294106826,
"onDisabledContainer": 1728051440,
"temporaryOutline": 871554762
}
}
}
},
{
"lockupViewModel": {
"contentImage": {
"thumbnailViewModel": {
"image": {
"sources": [
{
"url": "https://i.ytimg.com/vi/HLKo4fJx_7k/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAQcvMQEX1ny9Z8qu2mIMogpEMaVA",
"width": 168,
"height": 94
},
{
"url": "https://i.ytimg.com/vi/HLKo4fJx_7k/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCkoPSkHifs5ifpWXXcb96XG_Ly1w",
"width": 336,
"height": 188
}
]
},
"overlays": [
{
"thumbnailBottomOverlayViewModel": {
"badges": [
{
"thumbnailBadgeViewModel": {
"text": "47:36",
"badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT",
"animationActivationTargetId": "HLKo4fJx_7k",
"animatedText": "Now playing",
"animationActivationEntitySelectorType": "THUMBNAIL_BADGE_ANIMATION_ENTITY_SELECTOR_TYPE_PLAYER_STATE"
}
}
]
}
}
]
}
},
"metadata": {
"lockupMetadataViewModel": {
"title": {
"content": "MIT 6.S191 (2025): Language Models and New Frontiers"
},
"image": {
"decoratedAvatarViewModel": {
"avatar": {
"avatarViewModel": {
"image": {
"sources": [
{
"url": "https://yt3.ggpht.com/ytc/AIdro_k517G0Dt8QgsF86GKzYkG3Rs8jSGCiYGJw04lx4_1KXQ=s68-c-k-c0x00ffffff-no-rj",
"width": 68,
"height": 68
}
]
},
"avatarImageSize": "AVATAR_SIZE_M"
}
},
"a11yLabel": "Go to channel Alexander Amini",
"rendererContext": {
"commandContext": {
"onTap": {
"innertubeCommand": {
"browseEndpoint": {
"browseId": "UCtslD4DGH6PKyG_1gFAX7sg"
}
}
}
}
}
}
},
"metadata": {
"contentMetadataViewModel": {
"metadataRows": [
{
"metadataParts": [
{
"text": {
"content": "Alexander Amini"
}
}
]
},
{
"metadataParts": [
{
"text": {
"content": "63K"
},
"accessibilityLabel": "63 thousand views",
"leadingIcon": {
"name": "PLAY_ARROW_OUTLINED",
"height": 12,
"width": 12,
"marginEndToken": {
"spacing": 2
}
}
},
{
"text": {
"content": "1y ago"
},
"accessibilityLabel": "1 year ago"
}
]
}
],
"delimiter": " "
}
}
}
},
"contentId": "HLKo4fJx_7k",
"contentType": "LOCKUP_CONTENT_TYPE_VIDEO",
"themedPalette": {
"lightThemePalette": {
"baseBackground": 4294964208,
"raisedBackground": 4294964208,
"additiveBackground": 566847045,
"textPrimary": 4281734410,
"textSecondary": 4285417018,
"outline": 860561942,
"keyLight": 4292720812,
"collectionStack": 4290812043,
"invertedBackground": 4287575065,
"textPrimaryInverse": 4294964208,
"textDisabled": 2135630358,
"dropShadow": 432629317,
"cardOutline": 180971077,
"overlayBackground": 3426096394,
"overlayTextPrimary": 4294964208,
"solidWash": 4283114006,
"solidRim": 4281734410,
"overlaySolidWash": 4294633184,
"overlaySolidRim": 4294964208,
"tonalWash": 2583688176,
"tonalRim": 202310656,
"overlayTonalWash": 436204528,
"overlayTonalRim": 436204528,
"outlineRim": 1279992342,
"overlayOutlineRim": 871551946,
"solidWashInverse": 4294104010,
"solidRimInverse": 2147149536,
"tonalWashInverse": 654308336,
"tonalRimInverse": 653977312,
"outlineRimInverse": 654308336,
"solidBackground": 4283114006,
"solidBackgroundInverse": 4290812043,
"tonalBackground": 868259979,
"tonalBackgroundInverse": 435344330,
"outlineInverse": 872081120,
"directionalLightStopPrimary": 4294633184,
"directionalLightStopSecondary": 4294964208,
"primary": 4279174144,
"filledStateLayer": 4281734410,
"tonalTouchLight": 4294964208,
"tonalHoverLight": 3438995168,
"tonalStateLayer": 420414464,
"overlayDisabledContainer": 871551946,
"overlayTonalTouchLight": 1291511520,
"overlayTonalHoverLight": 1291511520,
"overlayTonalStateLayer": 436204528,
"disabledContainer": 420414464,
"filledTouchLight": 4285417018,
"filledHoverLight": 4284298280,
"onOverlayDisabledContainer": 2583688176,
"overlayTonalContainer": 2583357152,
"overlayOutline": 872412144,
"onFilledContainer": 4294964208,
"filledContainer": 4280027394,
"tonalContainer": 641078538,
"overlayPrimary": 4294964208,
"onOverlayFilledContainer": 4279174144,
"overlayFilledTouchLight": 4294964208,
"overlayFilledHoverLight": 4294633184,
"overlayFilledStateLayer": 4292720812,
"overlayFilledContainer": 4294104010,
"onDisabledContainer": 1712260096,
"temporaryOutline": 860561942
},
"darkThemePalette": {
"baseBackground": 4280027394,
"raisedBackground": 4281734410,
"additiveBackground": 734619205,
"textPrimary": 4294633184,
"textSecondary": 4292720812,
"outline": 1072878538,
"keyLight": 4284298280,
"collectionStack": 4285417018,
"invertedBackground": 4293105020,
"textPrimaryInverse": 4280027394,
"textDisabled": 2146620362,
"dropShadow": 432629317,
"cardOutline": 180971077,
"overlayBackground": 3426096394,
"overlayTextPrimary": 4294964208,
"solidWash": 4294633184,
"solidRim": 4294964208,
"overlaySolidWash": 4294633184,
"overlaySolidRim": 4294964208,
"tonalWash": 217240522,
"tonalRim": 218100720,
"overlayTonalWash": 436204528,
"overlayTonalRim": 436204528,
"outlineRim": 871551946,
"overlayOutlineRim": 871551946,
"solidWashInverse": 4285417018,
"solidRimInverse": 871551946,
"tonalWashInverse": 872412144,
"tonalRimInverse": 870168748,
"outlineRimInverse": 1291842544,
"solidBackground": 4290812043,
"solidBackgroundInverse": 4283114006,
"tonalBackground": 435344330,
"tonalBackgroundInverse": 868259979,
"outlineInverse": 860561942,
"directionalLightStopPrimary": 4281734410,
"directionalLightStopSecondary": 4280027394,
"primary": 4294964208,
"filledStateLayer": 4292720812,
"tonalTouchLight": 1073407712,
"tonalHoverLight": 653977312,
"tonalStateLayer": 436204528,
"overlayDisabledContainer": 871551946,
"overlayTonalTouchLight": 1291511520,
"overlayTonalHoverLight": 1291511520,
"overlayTonalStateLayer": 436204528,
"disabledContainer": 436204528,
"filledTouchLight": 4294964208,
"filledHoverLight": 4294633184,
"onOverlayDisabledContainer": 2583688176,
"overlayTonalContainer": 2583357152,
"overlayOutline": 872412144,
"onFilledContainer": 4279174144,
"filledContainer": 4294104010,
"tonalContainer": 435344330,
"overlayPrimary": 4294964208,
"onOverlayFilledContainer": 4279174144,
"overlayFilledTouchLight": 4294964208,
"overlayFilledHoverLight": 4294633184,
"overlayFilledStateLayer": 4292720812,
"overlayFilledContainer": 4294104010,
"onDisabledContainer": 1728050160,
"temporaryOutline": 871551946
}
}
}
},
{
"continuationItemRenderer": {
"trigger": "CONTINUATION_TRIGGER_ON_ITEM_SHOWN",
"continuationEndpoint": {
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/next"
}
},
"continuationCommand": {
"token": "CBQSDRILU2RUWkFNREtyTlkYACqXBk1nb0l5ZGl1dXRUNDdQc2EyczZMNnd5LUJBcTdCQW9EOGo0QUNnN0NQZ3NJbk1XcnZzbWo2Y2UyQVFvRDhqNEFDZzNDUGdvSW90N0czWnVCMy1KSENnUHlQZ0FLRGNJLUNnaTVfOGVUbjV5cTJSd0tBX0ktQUFvT3dqNExDSkQydWJfNXp2ZXZfUUVLQV9JLUFBb053ajRLQ1BXWjVQakt2cjZWU2dvRDhqNEFDZzNDUGdvSW9aTDd1OUtGdF85NkNnUHlQZ0FLRGNJLUNnaUh3N2FLMzd6YzVIZ0tBX0ktQUFvT3dqNExDUE94aHRQTWtfVzB1UUVLQV9JLUFBb093ajRMQ092eTBzaXNsSWZScGdFS0FfSS1BQW9Pd2o0TENKS1FxdHpBeE5lZHJBRUtBX0ktQUFvTndqNEtDTFdteWE2cHNPMkJPZ29EOGo0QUNnM0NQZ29JanYtdy1wQ3k1YVozQ2dQeVBnQUtEc0ktQ3dqMmtmVF9tYlB1XzRvQkNnUHlQZ0FLRHNJLUN3aVpuYWZjc0tQVmpZUUJDZ1B5UGdBS0RjSS1DZ2lxM3BxM3FyX2s0U0VLQV9JLUFBb053ajRLQ0phNXU5eVJvTmJOS0FvRDhqNEFDZzNDUGdvSXFMeWR3T1NhNGJkV0NnUHlQZ0FLRGNJLUNnakh3X2ZucktlSDdXUUtBX0ktQUFvTndqNEtDUG4xak1mcXg4QzJNQW9EOGo0QUNnM0NQZ29JeWRpdXV0VDQ3UHNhRWhRQUFnUUdDQW9NRGhBU0ZCWVlHaHdlSUNJa0pob0VDQUFRQVJvRUNBSVFBeG9FQ0FRUUJSb0VDQVlRQnhvRUNBZ1FDUm9FQ0FvUUN4b0VDQXdRRFJvRUNBNFFEeG9FQ0JBUUVSb0VDQklRRXhvRUNCUVFGUm9FQ0JZUUZ4b0VDQmdRR1JvRUNCb1FHeG9FQ0J3UUhSb0VDQjRRSHhvRUNDQVFJUm9FQ0NJUUl4b0VDQ1FRSlJvRUNDWVFKeW9VQUFJRUJnZ0tEQTRRRWhRV0dCb2NIaUFpSkNZag93YXRjaC1uZXh0LWZlZWQ%3D",
"request": "CONTINUATION_REQUEST_TYPE_WATCH_NEXT"
}
},
"button": {
"buttonRenderer": {
"style": "STYLE_SUGGESTIVE",
"size": "SIZE_DEFAULT",
"isDisabled": false,
"text": {
"runs": [
{
"text": "Show more"
}
]
},
"command": {
"commandMetadata": {
"webCommandMetadata": {
"sendPost": true,
"apiUrl": "/youtubei/v1/next"
}
},
"continuationCommand": {
"token": "CBQSDRILU2RUWkFNREtyTlkYACqXBk1nb0l5ZGl1dXRUNDdQc2EyczZMNnd5LUJBcTdCQW9EOGo0QUNnN0NQZ3NJbk1XcnZzbWo2Y2UyQVFvRDhqNEFDZzNDUGdvSW90N0czWnVCMy1KSENnUHlQZ0FLRGNJLUNnaTVfOGVUbjV5cTJSd0tBX0ktQUFvT3dqNExDSkQydWJfNXp2ZXZfUUVLQV9JLUFBb053ajRLQ1BXWjVQakt2cjZWU2dvRDhqNEFDZzNDUGdvSW9aTDd1OUtGdF85NkNnUHlQZ0FLRGNJLUNnaUh3N2FLMzd6YzVIZ0tBX0ktQUFvT3dqNExDUE94aHRQTWtfVzB1UUVLQV9JLUFBb093ajRMQ092eTBzaXNsSWZScGdFS0FfSS1BQW9Pd2o0TENKS1FxdHpBeE5lZHJBRUtBX0ktQUFvTndqNEtDTFdteWE2cHNPMkJPZ29EOGo0QUNnM0NQZ29JanYtdy1wQ3k1YVozQ2dQeVBnQUtEc0ktQ3dqMmtmVF9tYlB1XzRvQkNnUHlQZ0FLRHNJLUN3aVpuYWZjc0tQVmpZUUJDZ1B5UGdBS0RjSS1DZ2lxM3BxM3FyX2s0U0VLQV9JLUFBb053ajRLQ0phNXU5eVJvTmJOS0FvRDhqNEFDZzNDUGdvSXFMeWR3T1NhNGJkV0NnUHlQZ0FLRGNJLUNnakh3X2ZucktlSDdXUUtBX0ktQUFvTndqNEtDUG4xak1mcXg4QzJNQW9EOGo0QUNnM0NQZ29JeWRpdXV0VDQ3UHNhRWhRQUFnUUdDQW9NRGhBU0ZCWVlHaHdlSUNJa0pob0VDQUFRQVJvRUNBSVFBeG9FQ0FRUUJSb0VDQVlRQnhvRUNBZ1FDUm9FQ0FvUUN4b0VDQXdRRFJvRUNBNFFEeG9FQ0JBUUVSb0VDQklRRXhvRUNCUVFGUm9FQ0JZUUZ4b0VDQmdRR1JvRUNCb1FHeG9FQ0J3UUhSb0VDQjRRSHhvRUNDQVFJUm9FQ0NJUUl4b0VDQ1FRSlJvRUNDWVFKeW9VQUFJRUJnZ0tEQTRRRWhRV0dCb2NIaUFpSkNZag93YXRjaC1uZXh0LWZlZWQ%3D",
"request": "CONTINUATION_REQUEST_TYPE_WATCH_NEXT"
}
}
}
}
}
}
]
}
}
}
}
}
+8 -2
View File
@@ -1105,10 +1105,16 @@ def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
worker = threading.Thread(target=second_tab)
worker.start()
# Commit A only once B is blocked on a row lock, so B's read is the stale snapshot.
# Commit A only once B is blocked on a row lock, so B's read is the stale snapshot. pg_locks is
# the whole server's: only a wait in this database is B's (a parallel worker's is not).
waiting = 0
for _ in range(200):
waiting = probe.scalar(text("SELECT count(*) FROM pg_locks WHERE NOT granted"))
waiting = probe.scalar(
text(
"SELECT count(*) FROM pg_locks WHERE NOT granted AND pid IN"
" (SELECT pid FROM pg_stat_activity WHERE datname = current_database())"
)
)
probe.rollback()
if waiting:
break
+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
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
@@ -24,3 +25,24 @@ def test_me_names_the_running_search_source(client, db, make_user):
sysconfig.set_value(db, "search_source", "api")
db.commit()
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)
+3 -3
View File
@@ -75,9 +75,9 @@ def test_trimming_read_notifications_trims_only_mine(db, make_user):
def test_todays_actions_count_only_mine(db, make_user):
me, other = make_user(), make_user()
quota.log_action(me.id, quota.QuotaAction.VIDEOS_SEARCH)
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
quota.claim_action(me.id, quota.QuotaAction.VIDEOS_SEARCH, 5)
quota.claim_action(other.id, quota.QuotaAction.VIDEOS_SEARCH, 5)
quota.claim_action(other.id, quota.QuotaAction.VIDEOS_SEARCH, 5)
assert quota.actions_today(db, me.id, quota.QuotaAction.VIDEOS_SEARCH) == 1
+6 -2
View File
@@ -89,9 +89,13 @@ def test_a_claim_is_released_when_its_block_raises(db, make_user):
except RuntimeError:
pass
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
# again to that same connection (advisory locks are re-entrant per session).
# again to that same connection (advisory locks are re-entrant per session). pg_locks is the whole
# server's, so only this database's: a parallel worker's own claim on the same id counted here.
held = db.scalar(
text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"),
text(
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"
" AND database = (SELECT oid FROM pg_database WHERE datname = current_database())"
),
{"id": pl.id},
)
assert held == 0
+3 -3
View File
@@ -44,15 +44,15 @@ def test_reads_see_separate_session_spend_mid_transaction(db):
assert after - before == 7
def test_log_action_survives_caller_rollback(db):
def test_a_claimed_slot_survives_caller_rollback(db):
user = User(email="searcher@example.com")
db.add(user)
db.commit() # a committed user to attribute the (free) action to
db.add(User(email="pending@example.com")) # uncommitted caller work
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH)
assert quota.claim_action(user.id, quota.QuotaAction.VIDEOS_SEARCH, 5) is not None
db.rollback()
# The zero-cost action event survived (per-user daily caps must still count it) ...
# The zero-cost claim event survived (per-user daily caps must still count it) ...
events = db.execute(select(QuotaEvent)).scalars().all()
assert len(events) == 1
assert events[0].user_id == user.id
+428
View File
@@ -0,0 +1,428 @@
"""`GET /api/search/related/{video_id}` — a video's related videos, without what the user has seen.
The scrape is replaced by pages the test hands out, and YouTube's videos.list by an offline enrich
(30-minute videos, so no Shorts probe goes out). What is held: the list leaves out what this user
watched or hid — here or in their Takeout history, unless taken back to "new" since — blocked
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."""
import threading
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import func, select, text
from app import quota, sysconfig
from app.catalog.models import BlockedChannel, Channel, Subscription, Video
from app.models import QuotaEvent
from app.reader.models import TakeoutItem, TakeoutKind, VideoState, VideoUnmark
from app.routes import search as search_routes
from app.youtube.client import YouTubeError
from app.youtube.innertube import ScrapeError
T0 = datetime(2026, 10, 1, tzinfo=timezone.utc)
SOURCE = "srcvideo001"
def _vid(n: int) -> str:
return f"rel{n:08d}"
def _item(n: int, channel: str = "UCrelated01") -> dict:
return {
"id": _vid(n),
"channel_id": channel,
"channel_title": f"Chan {channel}",
"title": f"Related {n}",
"published_at": None,
"thumbnail_url": None,
"live_broadcast": "none",
}
class _FakeYT:
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):
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, recording each call."""
sysconfig.set_value(db, "shorts_probe_max_seconds", 60)
state: dict = {"pages": [], "calls": []}
def related(video_id, continuation=None, *, hl="en", gl="US"):
state["calls"].append((video_id, continuation, hl, gl))
page = state["pages"].pop(0)
if isinstance(page, Exception):
raise page
return page
monkeypatch.setattr(search_routes.related_scrape, "related", related)
monkeypatch.setattr(search_routes, "YouTubeClient", _FakeYT)
return state
def _page(items: list[dict], token: str | None = "NEXT") -> dict:
return {"items": items, "next_page_token": token}
def _ids(resp) -> list[str]:
assert resp.status_code == 200, resp.text
return [it["id"] for it in resp.json()["items"]]
def _known(db, *ns: int, channel: str = "UCrelated01") -> None:
"""These related videos already in the catalog (a state needs the row)."""
if db.get(Channel, channel) is None:
db.add(Channel(id=channel, title="Chan"))
for n in ns:
db.add(Video(id=_vid(n), channel_id=channel, title=f"Related {n}", published_at=T0))
db.commit()
def test_a_related_list_comes_into_the_catalog_as_search_finds_do(db, make_user, as_user, pages):
user = make_user()
pages["pages"] = [_page([_item(n) for n in range(1, 21)])]
resp = as_user(user).get(f"/api/search/related/{SOURCE}")
assert _ids(resp) == [_vid(n) for n in range(1, 21)]
assert resp.json()["next_cursor"] == "NEXT" and resp.json()["has_more"] is True
video = db.get(Video, _vid(1))
# Flagged like a search find: hidden from the shared Library, reclaimed when nobody keeps it.
assert video.via_search is True and video.enriched_at is not None
assert pages["calls"] == [(SOURCE, None, "en", "US")]
def test_what_the_user_watched_or_hid_here_is_left_out(db, make_user, as_user, pages):
user = make_user()
other = make_user()
_known(db, 1, 2, 3, 4)
db.add_all(
[
VideoState(user_id=user.id, video_id=_vid(1), status="watched", watched_at=T0),
VideoState(user_id=user.id, video_id=_vid(2), status="hidden"),
# In progress is not "seen": it stays, to be finished.
VideoState(user_id=user.id, video_id=_vid(3), status="new", position_seconds=120),
# Someone else's watch is theirs.
VideoState(user_id=other.id, video_id=_vid(4), status="watched", watched_at=T0),
]
)
db.commit()
pages["pages"] = [_page([_item(n) for n in range(1, 6)], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(3), _vid(4), _vid(5)]
def test_what_the_takeout_history_shows_watched_is_left_out_unless_taken_back(
db, make_user, as_user, pages
):
user = make_user()
other = make_user()
_known(db, 2)
db.add_all(
[
# Not in the catalog at all: the history alone names it.
TakeoutItem(user_id=user.id, kind=TakeoutKind.WATCHED, video_id=_vid(1), at=T0),
# Watched on YouTube, then taken back to "new" here: it may come again.
TakeoutItem(user_id=user.id, kind=TakeoutKind.WATCHED, video_id=_vid(2), at=T0),
VideoUnmark(user_id=user.id, video_id=_vid(2), at=T0 + timedelta(days=1)),
# Only in Watch later: not watched.
TakeoutItem(user_id=user.id, kind=TakeoutKind.WATCH_LATER, video_id=_vid(3), at=T0),
# Another user's history.
TakeoutItem(user_id=other.id, kind=TakeoutKind.WATCHED, video_id=_vid(4), at=T0),
]
)
db.commit()
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)]
# 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):
user = make_user()
_known(db, 1)
db.add_all(
[
VideoUnmark(user_id=user.id, video_id=_vid(1), at=T0 - timedelta(days=1)),
TakeoutItem(user_id=user.id, kind=TakeoutKind.WATCHED, video_id=_vid(1), at=T0),
]
)
db.commit()
pages["pages"] = [_page([_item(1), _item(2)], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(2)]
def test_another_users_unmark_does_not_bring_it_back(db, make_user, as_user, pages):
user = make_user()
other = make_user()
_known(db, 1)
db.add_all(
[
TakeoutItem(user_id=user.id, kind=TakeoutKind.WATCHED, video_id=_vid(1), at=T0),
VideoUnmark(user_id=other.id, video_id=_vid(1), at=T0 + timedelta(days=1)),
]
)
db.commit()
pages["pages"] = [_page([_item(1), _item(2)], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(2)]
def test_blocked_channels_and_the_video_itself_are_left_out_and_never_stored(
db, make_user, as_user, pages
):
user = make_user()
db.add(Channel(id="UCblocked01", title="Blocked"))
db.commit()
db.add(BlockedChannel(user_id=user.id, channel_id="UCblocked01"))
db.commit()
own = {**_item(9), "id": SOURCE}
pages["pages"] = [_page([_item(1), _item(2, "UCblocked01"), own], token=None)]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(1)]
assert db.get(Video, _vid(2)) is None
def test_it_pages_on_until_enough_unseen_videos_are_gathered(db, make_user, as_user, pages):
user = make_user()
_known(db, *range(1, 16))
for n in range(1, 16):
db.add(VideoState(user_id=user.id, video_id=_vid(n), status="watched", watched_at=T0))
db.commit()
pages["pages"] = [
_page([_item(n) for n in range(1, 21)], token="page2"), # 15 seen, 5 new
_page([_item(n) for n in range(21, 41)], token="page3"), # 20 new: 25 now
_page([_item(n) for n in range(41, 61)], token="page4"), # never asked
]
ids = _ids(resp := as_user(user).get(f"/api/search/related/{SOURCE}"))
assert ids == [_vid(n) for n in range(16, 41)]
assert [c[1] for c in pages["calls"]] == [None, "page2"]
assert resp.json()["next_cursor"] == "page3"
def test_a_repeat_on_a_later_page_counts_once(db, make_user, as_user, pages):
user = make_user()
pages["pages"] = [
_page([_item(n) for n in range(1, 11)], token="page2"),
_page([_item(n) for n in range(5, 25)], token=None),
]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [
_vid(n) for n in range(1, 25)
]
def test_it_reads_at_most_four_pages(db, make_user, as_user, pages):
user = make_user()
pages["pages"] = [_page([_item(n)], token=f"page{n + 1}") for n in range(1, 7)]
ids = _ids(resp := as_user(user).get(f"/api/search/related/{SOURCE}"))
assert ids == [_vid(n) for n in range(1, 5)]
assert len(pages["calls"]) == 4 and resp.json()["next_cursor"] == "page5"
def test_the_cursor_and_the_hungarian_locale_reach_the_scrape(db, make_user, as_user, pages):
user = make_user()
pages["pages"] = [_page([_item(1)], token=None)]
resp = as_user(user).get(f"/api/search/related/{SOURCE}?cursor=C1&region=hu")
assert _ids(resp) == [_vid(1)]
assert pages["calls"] == [(SOURCE, "C1", "hu", "HU")]
assert resp.json()["has_more"] is False
def test_an_unknown_region_is_refused(db, make_user, as_user, pages):
user = make_user()
assert as_user(user).get(f"/api/search/related/{SOURCE}?region=xx").status_code == 422
assert pages["calls"] == []
def test_a_failed_scrape_says_youtube_is_unavailable(db, make_user, as_user, pages):
user = make_user()
pages["pages"] = [ScrapeError("blocked")]
resp = as_user(user).get(f"/api/search/related/{SOURCE}")
assert resp.status_code == 422
assert resp.json()["detail"] == {"code": "youtube", "reason": "unavailable"}
def test_a_failure_on_a_later_page_keeps_what_came_before(db, make_user, as_user, pages):
user = make_user()
pages["pages"] = [_page([_item(1), _item(2)], token="page2"), ScrapeError("blocked")]
assert _ids(as_user(user).get(f"/api/search/related/{SOURCE}")) == [_vid(1), _vid(2)]
def test_one_list_is_one_event_against_the_days_cap(db, make_user, as_user, pages):
user = make_user()
sysconfig.set_value(db, "related_daily_limit_per_user", 1)
pages["pages"] = [
_page([_item(1)], token="page2"),
_page([_item(2)], token=None),
]
client = as_user(user)
assert client.get(f"/api/search/related/{SOURCE}").status_code == 200
events = db.scalars(
select(QuotaEvent).where(
QuotaEvent.user_id == user.id, QuotaEvent.action == quota.QuotaAction.VIDEOS_RELATED
)
).all()
assert len(events) == 1
refused = client.get(f"/api/search/related/{SOURCE}")
assert refused.status_code == 429
assert refused.json()["detail"] == {
"code": "refused",
"reason": "related_daily_limit",
"limit": 1,
}
assert len(pages["calls"]) == 2 # the refused one asked YouTube nothing
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()
sysconfig.set_value(db, "related_daily_limit_per_user", 1)
pages["pages"] = [_page([_item(1)], token=None), _page([_item(2)], token=None)]
client = as_user(user)
assert client.get(f"/api/search/related/{SOURCE}").status_code == 200
refused = client.get(f"/api/search/related/{SOURCE}?cursor=more")
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_failures_past_the_hours_refunds_keep_their_slot(db, make_user, as_user, pages):
# A cursor is a free string: a request built to fail would otherwise read YouTube without end.
user = make_user()
sysconfig.set_value(db, "related_daily_limit_per_user", 50)
n = search_routes.REFUNDS_PER_HOUR
pages["pages"] = [ScrapeError("bad cursor") for _ in range(n + 1)]
client = as_user(user)
for _ in range(n + 1):
assert client.get(f"/api/search/related/{SOURCE}?cursor=bogus").status_code == 422
assert quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_RELATED) == 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
# ...and counts only once it has the lock: the one holding it takes the last slot meanwhile.
db.add(QuotaEvent(user_id=user.id, action=action, units=0))
db.commit()
t.join(timeout=10)
assert got == [None]
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):
user = make_user()
stranger = make_user()
_known(db, 1, channel="UCfollowed1")
_known(db, 2, channel="UCstranger1")
db.add(Subscription(user_id=user.id, channel_id="UCfollowed1"))
db.add(Subscription(user_id=stranger.id, channel_id="UCstranger1"))
db.commit()
pages["pages"] = [
_page([_item(1, "UCfollowed1"), _item(2, "UCstranger1"), _item(3, "UCnobody01")], None)
]
rows = as_user(user).get(f"/api/search/related/{SOURCE}").json()["items"]
assert {r["id"]: r["followed"] for r in rows} == {
_vid(1): True,
_vid(2): False, # another user's follow is not this one's
_vid(3): False,
}
+232
View File
@@ -0,0 +1,232 @@
"""youtube/related_scrape — a video's related videos from the InnerTube `next` endpoint.
The two pages under `fixtures/` are real answers (2026-10-24), trimmed to three items and the
continuation: they hold the `lockupViewModel` shape YouTube draws today. Everything here breaks
silently when YouTube moves — a parser that stops finding videos, a cursor that stops paging — so a
shape it does not know must say so, never read as "nothing related". The network is an httpx
MockTransport."""
import copy
import json
from pathlib import Path
import httpx
import pytest
from app.youtube import innertube, related_scrape
FIXTURES = Path(__file__).parent / "fixtures"
FIRST = json.loads((FIXTURES / "innertube_next_first.json").read_text(encoding="utf-8"))
CONTINUATION = json.loads(
(FIXTURES / "innertube_next_continuation.json").read_text(encoding="utf-8")
)
RESULTS_HTML = (
'<script>{"INNERTUBE_API_KEY":"live-key","INNERTUBE_CLIENT_VERSION":"2.20261001"}</script>'
)
def _results(page: dict) -> list:
return page["contents"]["twoColumnWatchNextResults"]["secondaryResults"]["secondaryResults"][
"results"
]
@pytest.fixture
def net(monkeypatch):
"""Route every httpx.Client the InnerTube module opens through a handler the test sets."""
calls: list[httpx.Request] = []
state: dict = {"handler": None, "calls": calls}
def transport(request: httpx.Request) -> httpx.Response:
calls.append(request)
if request.url.path == "/results":
return httpx.Response(200, text=RESULTS_HTML)
return state["handler"](request)
real = httpx.Client
monkeypatch.setattr(
innertube.httpx,
"Client",
lambda **kw: real(transport=httpx.MockTransport(transport), **kw),
)
monkeypatch.setattr(innertube, "_config", None)
return state
def _answer(net, page, status=200):
net["handler"] = lambda request: (
httpx.Response(status, text=page)
if isinstance(page, str)
else httpx.Response(status, json=page)
)
def test_a_first_page_gives_its_videos_with_their_channel_and_the_next_cursor(net):
_answer(net, FIRST)
page = related_scrape.related("SdTZAMDKrNY")
assert [it["id"] for it in page["items"]] == ["to-lHJfK4pw", "R8V8CbuxryI", "HLKo4fJx_7k"]
first = page["items"][0]
assert first["channel_id"] == "UCtslD4DGH6PKyG_1gFAX7sg"
assert first["channel_title"] == "Alexander Amini"
assert first["title"] == "MIT 6.S191 (2025): Reinforcement Learning"
assert first["thumbnail_url"].startswith("https://i.ytimg.com/vi/to-lHJfK4pw/")
assert first["published_at"] is None and first["live_broadcast"] == "none"
assert page["next_page_token"]
body = json.loads(net["calls"][-1].content)
assert body["videoId"] == "SdTZAMDKrNY" and "continuation" not in body
assert net["calls"][-1].url.path == "/youtubei/v1/next"
def test_the_largest_thumbnail_is_taken(net):
_answer(net, FIRST)
item = related_scrape.related("SdTZAMDKrNY")["items"][0]
sources = _results(FIRST)[0]["lockupViewModel"]["contentImage"]["thumbnailViewModel"]["image"][
"sources"
]
assert item["thumbnail_url"] == sources[-1]["url"]
def test_a_continuation_page_reads_its_videos_and_sends_only_the_cursor(net):
_answer(net, CONTINUATION)
page = related_scrape.related("SdTZAMDKrNY", continuation="TOKEN", hl="hu", gl="HU")
assert len(page["items"]) == 2
assert all(it["channel_id"].startswith("UC") for it in page["items"])
assert page["next_page_token"]
body = json.loads(net["calls"][-1].content)
assert body["continuation"] == "TOKEN" and "videoId" not in body
assert body["context"]["client"]["hl"] == "hu" and body["context"]["client"]["gl"] == "HU"
def test_the_locale_defaults_to_the_topical_one(net):
_answer(net, FIRST)
related_scrape.related("SdTZAMDKrNY")
client = json.loads(net["calls"][-1].content)["context"]["client"]
assert (client["hl"], client["gl"]) == ("en", "US")
def test_a_video_by_several_channels_is_left_out(net):
page = copy.deepcopy(FIRST)
meta = _results(page)[1]["lockupViewModel"]["metadata"]["lockupMetadataViewModel"]
meta["image"] = {"avatarStackViewModel": {"avatars": []}}
_answer(net, page)
ids = [it["id"] for it in related_scrape.related("SdTZAMDKrNY")["items"]]
assert ids == ["to-lHJfK4pw", "HLKo4fJx_7k"]
def test_what_is_not_a_video_is_left_out_and_a_repeat_counts_once(net):
page = copy.deepcopy(FIRST)
results = _results(page)
playlist = copy.deepcopy(results[0])
playlist["lockupViewModel"]["contentType"] = "LOCKUP_CONTENT_TYPE_PLAYLIST"
playlist["lockupViewModel"]["contentId"] = "PLxyz"
results.insert(0, playlist)
results.insert(2, copy.deepcopy(results[1]))
results.insert(0, {"adSlotRenderer": {}})
_answer(net, page)
ids = [it["id"] for it in related_scrape.related("SdTZAMDKrNY")["items"]]
assert ids == ["to-lHJfK4pw", "R8V8CbuxryI", "HLKo4fJx_7k"]
def test_the_previous_compact_renderer_is_still_read(net):
page = copy.deepcopy(FIRST)
_results(page).insert(
0,
{
"compactVideoRenderer": {
"videoId": "oldshape001",
"title": {"simpleText": "Old shape"},
"longBylineText": {
"runs": [
{
"text": "Old Chan",
"navigationEndpoint": {"browseEndpoint": {"browseId": "UColdchan"}},
}
]
},
"thumbnail": {"thumbnails": [{"url": "s.jpg"}, {"url": "b.jpg"}]},
}
},
)
_answer(net, page)
first = related_scrape.related("SdTZAMDKrNY")["items"][0]
assert first == {
"id": "oldshape001",
"channel_id": "UColdchan",
"channel_title": "Old Chan",
"title": "Old shape",
"published_at": None,
"thumbnail_url": "b.jpg",
"live_broadcast": "none",
}
def test_the_last_page_has_no_cursor(net):
page = copy.deepcopy(FIRST)
_results(page).pop() # the continuationItemRenderer
_answer(net, page)
assert related_scrape.related("SdTZAMDKrNY")["next_page_token"] is None
def test_a_video_youtube_no_longer_has_is_an_empty_page(net):
_answer(
net,
{
"contents": {
"twoColumnWatchNextResults": {
"results": {
"results": {
"contents": [
{
"itemSectionRenderer": {
"contents": [{"backgroundPromoRenderer": {}}]
}
}
]
}
}
}
}
},
)
assert related_scrape.related("aaaaaaaaaaa") == {"items": [], "next_page_token": None}
def test_a_watch_frame_with_no_related_column_and_no_word_why_is_an_error(net):
# The frame alone, its related column moved somewhere this does not look: a lost shape, not
# "nothing related".
page = copy.deepcopy(FIRST)
watch = page["contents"]["twoColumnWatchNextResults"]
watch["sideColumn"] = watch.pop("secondaryResults")
_answer(net, page)
with pytest.raises(related_scrape.ScrapeError):
related_scrape.related("SdTZAMDKrNY")
def test_a_related_list_drawn_by_renderers_this_does_not_read_is_an_error(net):
page = copy.deepcopy(FIRST)
results = _results(page)
results[:] = [{"videoTileViewModel": entry} for entry in results]
_answer(net, page)
with pytest.raises(related_scrape.ScrapeError):
related_scrape.related("SdTZAMDKrNY")
@pytest.mark.parametrize(
"answer",
[
{"responseContext": {}}, # no watch page frame at all
{"onResponseReceivedEndpoints": [{"somethingElse": {}}]}, # a continuation we do not know
"<html>consent</html>", # not JSON
[1, 2], # JSON, not an object
],
)
def test_an_answer_of_an_unknown_shape_is_an_error_not_an_empty_list(net, answer):
_answer(net, answer)
with pytest.raises(related_scrape.ScrapeError):
related_scrape.related("SdTZAMDKrNY")
def test_a_refused_request_is_an_error_whatever_its_body_says(net):
_answer(net, FIRST, status=429)
with pytest.raises(related_scrape.ScrapeError):
related_scrape.related("SdTZAMDKrNY")
+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
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 ---------------------------------------------------------------
+361
View File
@@ -0,0 +1,361 @@
"""`GET /api/search/youtube` — the per-user daily cap of the live search, both sources.
The scrape and search.list are 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"; a lookup that fails says why instead of answering "no results"; only a few
failures an hour get their slot back; and the API source — 100 units a page — reads one page per
request, is refused before the call when the shared budget cannot pay for it (checked again under
one lock with the call, whose wait is bounded), counts one slot per search though search.list logs
its own event, and keeps the slot of a search search.list was already paid for — but not of one
that never reached YouTube."""
import threading
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):
self.spent = 0
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def get_channels(self, ids):
return []
# search.list: the pages the test hands out, each logged as the real client does (100 units,
# under the action the route attributes it to) — a refusal YouTube answered too, but not a
# failure no endpoint answered (no connection, no token).
api_pages: list = []
api_calls = 0
def search_videos(self, q, page_token=None, order="relevance"):
_FakeYT.api_calls += 1
page = _FakeYT.api_pages.pop(0)
if not (isinstance(page, YouTubeError) and not page.path):
quota.record_usage(100)
self.spent += 100
if isinstance(page, Exception):
raise page
return page
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)
monkeypatch.setattr(_FakeYT, "api_pages", [])
monkeypatch.setattr(_FakeYT, "api_calls", 0)
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
def _api(db) -> None:
sysconfig.set_value(db, "search_source", "api")
sysconfig.set_value(db, "quota_daily_budget", 10000)
def test_the_api_source_reads_one_page_per_request(db, make_user, as_user, pages):
# Each page is 100 units: a short page is answered as it is, with its cursor, never paged on.
_api(db)
_FakeYT.api_pages = [{"items": [_item(1)], "next_page_token": "nextpage"}, _page(2)]
resp = as_user(make_user()).get("/api/search/youtube?q=cats&limit=20")
assert resp.status_code == 200
assert _FakeYT.api_calls == 1
assert resp.json()["next_cursor"] == "nextpage"
def test_the_api_source_counts_one_slot_per_search(db, make_user, as_user, pages):
# search.list logs its own event under the action (record_usage): accounting, not a second slot.
user = make_user()
_api(db)
sysconfig.set_value(db, "search_daily_limit_per_user", 2)
_FakeYT.api_pages = [_page(1), _page(2), _page(3)]
client = as_user(user)
assert client.get("/api/search/youtube?q=cats").status_code == 200
assert quota.actions_today(db, user.id, ACTION) == 1
assert client.get("/api/search/youtube?q=dogs").status_code == 200
refused = client.get("/api/search/youtube?q=birds")
assert refused.status_code == 429
assert refused.json()["detail"]["reason"] == "search_daily_limit"
assert _FakeYT.api_calls == 2
def test_the_api_source_takes_its_slot_under_the_lock(db, make_user, as_user, pages, monkeypatch):
user = make_user()
_api(db)
claimed: list = []
real = quota.claim_action
def claim(uid, action, limit):
claimed.append(action)
return real(uid, action, limit)
monkeypatch.setattr(search_routes.quota, "claim_action", claim)
_FakeYT.api_pages = [_page(1)]
assert as_user(user).get("/api/search/youtube?q=cats").status_code == 200
assert claimed == [ACTION]
def test_the_api_source_is_refused_before_the_call_when_the_budget_cannot_pay(
db, make_user, as_user, pages
):
user = make_user()
_api(db)
sysconfig.set_value(db, "quota_daily_budget", 50)
_FakeYT.api_pages = [_page(1)]
resp = as_user(user).get("/api/search/youtube?q=cats")
assert resp.status_code == 429
assert resp.json()["detail"]["reason"] == "quota_spent"
assert _FakeYT.api_calls == 0
assert quota.actions_today(db, user.id, ACTION) == 0
def test_an_api_search_that_search_list_refused_keeps_its_slot(db, make_user, as_user, pages):
# YouTube charges a refused call too (the client records the units first), so a search that
# reached search.list has spent the shared budget: refunding it let a bogus cursor spend it all.
user = make_user()
_api(db)
sysconfig.set_value(db, "search_daily_limit_per_user", 1)
_FakeYT.api_pages = [
YouTubeError("search -> 400", reason="invalidPageToken", path="search"),
_page(1),
]
client = as_user(user)
assert client.get("/api/search/youtube?q=cats&cursor=bogus").status_code == 422
assert quota.actions_today(db, user.id, ACTION) == 1
refused = client.get("/api/search/youtube?q=cats")
assert refused.status_code == 429
assert refused.json()["detail"]["reason"] == "search_daily_limit"
assert _FakeYT.api_calls == 1
def test_an_api_search_the_budget_refused_at_the_call_gives_its_slot_back(
db, make_user, as_user, pages, monkeypatch
):
user = make_user()
_api(db)
answers = iter([True, False]) # the pre-check passes; the budget is gone by the call
monkeypatch.setattr(search_routes.quota, "can_spend", lambda _db, _u: next(answers))
_FakeYT.api_pages = [_page(1)]
resp = as_user(user).get("/api/search/youtube?q=cats")
assert resp.status_code == 429
assert resp.json()["detail"]["reason"] == "quota_spent"
assert _FakeYT.api_calls == 0
assert quota.actions_today(db, user.id, ACTION) == 0
def test_the_budget_check_and_the_call_it_pays_for_hold_one_lock(
db, make_user, as_user, pages, monkeypatch
):
# Checked apart, searches sent together at the last 100 units all passed, and all spent: the
# check and the call sit inside ONE hold of the lock, not two.
user = make_user()
_api(db)
events: list[str] = []
real_check = quota.can_spend
real_call = _FakeYT.search_videos
class _Recorded:
def __init__(self):
self._lock = threading.Lock()
def acquire(self, timeout=-1):
events.append("acquire")
return self._lock.acquire(timeout=timeout)
def release(self):
events.append("release")
self._lock.release()
def can_spend(db_, units):
events.append("check")
return real_check(db_, units)
def search_videos(self, q, page_token=None, order="relevance"):
events.append("call")
return real_call(self, q, page_token=page_token, order=order)
monkeypatch.setattr(search_routes, "_api_search", _Recorded())
monkeypatch.setattr(search_routes.quota, "can_spend", can_spend)
monkeypatch.setattr(_FakeYT, "search_videos", search_videos)
_FakeYT.api_pages = [_page(1)]
assert as_user(user).get("/api/search/youtube?q=cats").status_code == 200
assert events == ["check", "acquire", "check", "call", "release"]
def test_a_search_kept_waiting_for_the_lock_is_refused_as_busy_and_keeps_nothing(
db, make_user, as_user, pages, monkeypatch
):
# Each waiter holds a request worker: a slow YouTube must not park them all behind one another.
user = make_user()
_api(db)
monkeypatch.setattr(search_routes, "API_SEARCH_WAIT_SECONDS", 0.05)
_FakeYT.api_pages = [_page(1)]
# Held by another search for a while: a wait without its bound would get it, and be answered.
assert search_routes._api_search.acquire(timeout=1)
later = threading.Timer(2.0, search_routes._api_search.release)
later.start()
try:
resp = as_user(user).get("/api/search/youtube?q=cats")
finally:
later.join()
assert resp.status_code == 429
assert resp.json()["detail"]["reason"] == "search_busy"
assert _FakeYT.api_calls == 0
assert quota.actions_today(db, user.id, ACTION) == 0
@pytest.mark.parametrize(
"failure",
[
YouTubeError("YouTube API unreachable (ConnectError)", reason="transport"),
YouTubeError("User has no stored OAuth token"),
],
ids=["no-connection", "no-token"],
)
def test_an_api_search_that_never_reached_youtube_gives_its_slot_back(
db, make_user, as_user, pages, failure
):
user = make_user()
_api(db)
sysconfig.set_value(db, "search_daily_limit_per_user", 1)
_FakeYT.api_pages = [failure, _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
def test_an_api_search_whose_answer_never_came_keeps_its_slot(db, make_user, as_user, pages):
# The request went out (a read timeout): YouTube may have counted it.
user = make_user()
_api(db)
sysconfig.set_value(db, "search_daily_limit_per_user", 1)
_FakeYT.api_pages = [
YouTubeError("YouTube API unreachable (ReadTimeout)", reason="transport", sent=True),
_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) == 1
assert client.get("/api/search/youtube?q=cats").status_code == 429
def test_failures_past_the_hours_refunds_keep_their_slot(db, make_user, as_user, pages):
# A cursor is a free string: a request built to fail would otherwise read YouTube without end.
user = make_user()
sysconfig.set_value(db, "search_daily_limit_per_user", 50)
n = search_routes.REFUNDS_PER_HOUR
pages["pages"] = [ScrapeError("bad cursor") for _ in range(n + 1)]
client = as_user(user)
for _ in range(n + 1):
assert client.get("/api/search/youtube?q=cats&cursor=bogus").status_code == 422
assert quota.actions_today(db, user.id, ACTION) == 1
+4 -4
View File
@@ -9,7 +9,7 @@ import json
import httpx
import pytest
from app.youtube import search_scrape
from app.youtube import innertube, search_scrape
RESULTS_HTML = (
'<script>{"INNERTUBE_API_KEY":"live-key","INNERTUBE_CLIENT_VERSION":"2.20261001"}</script>'
@@ -79,11 +79,11 @@ def net(monkeypatch):
real = httpx.Client
monkeypatch.setattr(
search_scrape.httpx,
innertube.httpx,
"Client",
lambda **kw: real(transport=httpx.MockTransport(transport), **kw),
)
monkeypatch.setattr(search_scrape, "_config", None)
monkeypatch.setattr(innertube, "_config", None)
state["calls"] = calls
return state
@@ -171,7 +171,7 @@ def test_a_failed_key_fetch_falls_back_for_now_and_tries_again_next_time(net, re
with no error anywhere."""
net["handler"] = _serve(results_html=results_html)
search_scrape.search("a")
assert net["calls"][-1].url.params["key"] == search_scrape._FALLBACK_KEY
assert net["calls"][-1].url.params["key"] == innertube._FALLBACK_KEY
net["handler"] = _serve()
search_scrape.search("b")
@@ -13,10 +13,13 @@ a placeholder never becomes the video's permanent date.
from datetime import datetime, timezone
import pytest
from app import sysconfig
from app.models import User
from app.catalog.models import Video
from app.catalog.search import ingest_candidates
from app.youtube.client import YouTubeError
class _FakeYT:
@@ -97,6 +100,25 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
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):
# 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,
+42
View File
@@ -193,3 +193,45 @@ def test_any_other_token_refusal_keeps_the_grant(monkeypatch):
c._access_token()
assert tok.scopes == "s"
assert refusal_reason(caught.value) == "unavailable"
@pytest.mark.parametrize(
("failure", "sent"),
[
(httpx.ConnectError("refused"), False),
(httpx.ConnectTimeout("slow connect"), False),
(httpx.ProxyError("proxy down"), False),
(httpx.ReadTimeout("no answer"), True),
(httpx.RemoteProtocolError("dropped"), True),
],
)
def test_a_transport_failure_says_whether_the_request_went_out(failure, sent):
"""A search that never reached YouTube spent nothing and gets its slot back; one whose answer
never came may have been counted, and keeps it (`routes/search`)."""
def request(*_a, **_k):
raise failure
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
c._http = SimpleNamespace(request=request)
with pytest.raises(YouTubeError) as caught:
c._send("GET", "https://example.invalid")
assert caught.value.reason == "transport"
assert caught.value.sent is sent
def test_a_call_youtube_answered_is_spent_a_refused_one_too(monkeypatch):
resp = httpx.Response(
400,
json={"error": {"errors": [{"reason": "invalidPageToken"}]}},
request=httpx.Request("GET", f"{yt.API_BASE}/search"),
)
recorded: list[int] = []
c = yt.YouTubeClient.__new__(yt.YouTubeClient)
c.db, c.spent = None, 0
monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "a-key")
monkeypatch.setattr(yt.quota, "record_usage", recorded.append)
monkeypatch.setattr(c, "_send", lambda *a, **k: resp)
with pytest.raises(YouTubeError):
c._get("search", {}, cost=100)
assert (recorded, c.spent) == ([100], 100)
+34 -5
View File
@@ -225,7 +225,10 @@ Also in force:
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` (or a bare
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`, kept under the pointer
(no hover layer or hover colour over it). A mode cycle (the player's auto-advance and loop) is no
two-state switch: `lit` gives it the same look when it is not off, without `aria-pressed`, and its name
says the mode. A raw `<button>` (or a bare
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
@@ -251,8 +254,13 @@ Also in force:
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1]
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
panel's glyph-only row. [Nielsen #4]
## Surfaces and page chrome — _Decided_ (batch B)
@@ -498,7 +506,10 @@ of a full-width panel and nothing on the right.
## Interaction rules — _Decided_
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. An open dialog or player
silences the page's list keys, but a list inside the topmost one walks while the focus is in it
(the player's related videos); the walk stays inside that layer, and its arrows there move the list,
not the video. [WCAG 2.1.1, 2.4.3, 2.1.2]
- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
sticky or fixed layer never covers the focused element. A list item the keyboard lands on rings 2 px
outside itself where it has a gap round it (a card); a table row, a log line or a row flush in a
@@ -556,7 +567,8 @@ of a full-width panel and nothing on the right.
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
"Play from the beginning", and its picture carries the cards' progress bar.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
(its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
(its transport row: glyphs only — Previous, the count, Next, auto-advance, loop on one line, each named in
its tooltip, the modes with their current one — or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row
under the video (stacked). Wide, the panel shows the description outright, clamped to three
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
@@ -564,6 +576,18 @@ of a full-width panel and nothing on the right.
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured
live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls
only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13]
- **Related videos (owner's picks A, A, a, a):** on demand, never open by itself — the modal has no room to
spare. Wide, a "Related" tab beside the description in the side panel; stacked, a button beside the ⓘ
opens them in a drawer over the player. A row is the thumbnailed video row in its narrow form (a 6rem
picture, the relative time only, the "…" its one control — every toggle is a row of that menu), with
a check before the channel the user follows (named on hover). A click plays it in the same player
(Back returns to the opened video), and the list stays the opened video's. Played, it is the item it
now is (owner's pick D1 a): its title, its own watched / playlist / download controls, its progress
kept, marked watched at its end — and its end does not step the queue. What the user watched or hid
is never in it; a list read so far with nothing unseen says so above Show more. Over it, a two-way
switch: on topic or trending (the Hungarian chart, labelled just "Trending"), kept on the account. Turned off on the instance (a daily
limit of 0, owner's pick D2 a), there is no tab and no button; a refusal a retry cannot answer (the
day's cap, turned off, the shared budget) offers no Retry.
- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row,
before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the
player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the
@@ -795,3 +819,8 @@ of a full-width panel and nothing on the right.
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
@@ -34,6 +34,7 @@ import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys";
beforeAll(() => {
class Stub {
@@ -157,6 +158,52 @@ describe("the add-to-playlist popover", () => {
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
});
// The built-in Watch later list's items ARE the bookmark: ticked here, the video is saved wherever
// it is shown (a related list, a live search), and a user list's tick marks nothing.
it("marks the video saved when Watch later is ticked, and unsaved when it is unticked", async () => {
// The server's side: the lists the video is on, so the refetch after a write agrees with it.
const on = new Set<number>();
vi.spyOn(api, "playlists").mockImplementation(
async () =>
[
{ id: 9, name: "Watch later", kind: "watch_later", has_video: on.has(9) },
{ id: 3, name: "Mix", kind: "user", has_video: on.has(3) },
] as never,
);
vi.spyOn(api, "addToPlaylist").mockImplementation(async (id: number) => {
on.add(id);
return {} as never;
});
vi.spyOn(api, "removeFromPlaylist").mockImplementation(async (id: number) => {
on.delete(id);
return {} as never;
});
const qc = client();
const saved = () =>
qc.getQueryData<Record<string, { saved?: { value: boolean } }>>(qk.videoMarks())?.v1?.saved
?.value;
render(
<QueryClientProvider client={qc}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const row = async (name: string) =>
(await screen.findAllByRole("menuitemcheckbox", { hidden: true })).find((r) =>
r.textContent?.includes(name),
)!;
fireEvent.click(await row("Mix"));
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledTimes(1));
expect(saved()).toBeUndefined();
fireEvent.click(await row("playlists.watchLater"));
await waitFor(() => expect(saved()).toBe(true));
await waitFor(async () =>
expect((await row("playlists.watchLater")).getAttribute("aria-checked")).toBe("true"),
);
fireEvent.click(await row("playlists.watchLater"));
await waitFor(() => expect(saved()).toBe(false));
});
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
render(
@@ -52,6 +52,18 @@ describe("Button primitive", () => {
}
});
it("a lit cycle paints the pressed look without announcing a toggle", () => {
render(
<Button variant="overlay" lit>
Loop: All
</Button>,
);
const btn = screen.getByRole("button", { name: "Loop: All" });
expect(btn.hasAttribute("aria-pressed")).toBe(false);
expect(btn.hasAttribute("data-lit")).toBe(true);
expect(btn.className).toContain("data-lit:bg-accent");
});
it("a toggle announces aria-pressed and paints the pressed state", () => {
const { rerender } = render(
<Button variant="outline" pressed={false}>
+6 -1
View File
@@ -30,6 +30,9 @@ interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "
loading?: boolean;
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
pressed?: boolean;
/** A mode cycle that is not off: the pressed look without aria-pressed — its name says the mode (a
* cycle is no two-state switch). */
lit?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
@@ -55,6 +58,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
label,
loading = false,
pressed,
lit,
round = false,
fullWidth = false,
className,
@@ -84,9 +88,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
onBlur={onBlur}
aria-label={label}
aria-pressed={pressed}
data-lit={lit || undefined}
title={label}
className={clsx(
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed, lit }),
className,
)}
{...rest}
@@ -59,6 +59,25 @@ describe("IconButton", () => {
expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true");
});
// A mode cycle (auto-advance, loop) is no two-state switch: it looks on, its name says the mode.
it("a lit cycle tints its glyph without announcing a toggle", () => {
const { rerender } = render(
<IconButton label="Auto: Next" lit>
<svg />
</IconButton>,
);
const btn = screen.getByRole("button", { name: "Auto: Next" });
expect(btn.hasAttribute("aria-pressed")).toBe(false);
expect(btn.hasAttribute("data-lit")).toBe(true);
expect(btn.className).toContain("data-lit:text-chosen");
rerender(
<IconButton label="Auto: Off" lit={false}>
<svg />
</IconButton>,
);
expect(btn.hasAttribute("data-lit")).toBe(false);
});
it("loading swaps the glyph for a spinner and swallows the click", () => {
const onClick = vi.fn();
render(
+6 -1
View File
@@ -23,6 +23,9 @@ export interface IconButtonProps
/** A toggle: sets aria-pressed and tints the glyph with the chosen colour (the accent; the media
* amber on the player chrome). */
pressed?: boolean;
/** A mode cycle that is not off: the pressed tint without aria-pressed — its `label` says the mode
* (a cycle is no two-state switch). */
lit?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
@@ -40,6 +43,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
variant = "ghost",
size = "sm",
pressed,
lit,
round = false,
loading = false,
noTitle = false,
@@ -56,8 +60,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
busy={loading}
aria-label={label}
aria-pressed={pressed}
data-lit={lit || undefined}
title={noTitle ? undefined : label}
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
className={clsx(iconButtonClass({ variant, size, round, pressed, lit }), className)}
{...rest}
>
{loading ? <Loader2 className="animate-spin" /> : children}
@@ -9,6 +9,7 @@ import KeyboardHelp from "./KeyboardHelp";
import { useContextMenu } from "./useContextMenu";
import {
closeKeyboardHelp,
setScopeCoveredProbe,
useGlobalKeymap,
useHelpConditions,
useKeyScope,
@@ -16,7 +17,8 @@ import {
} from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { useMediaKeys } from "../../lib/mediaKeys";
import { useState } from "react";
import { useRef, useState } from "react";
import { navItemAttrs, useListNav } from "../../lib/listNav";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -144,6 +146,67 @@ describe("KeyboardHelp", () => {
expect(descriptions()).toContain("keys.help");
});
// The player's related videos are a list inside the layer: their keys work there (`inTopLayer`), so
// the sheet opened over the player lists them — and over a layer with no list, still does not.
it("lists the list and menu keys over a layer that holds a list of its own", () => {
function LayerList() {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
useContextMenu<string>();
return (
<div data-testid="layer">
<div ref={ref}>
<div {...navItemAttrs(0)}>row</div>
</div>
</div>
);
}
render(
<Host>
<LayerList />
</Host>,
);
const withList = pushLayer({ root: () => screen.getByTestId("layer") });
pressH();
expect(descriptions()).toContain("keys.listStep");
expect(descriptions()).toContain("keys.contextMenu");
act(() => closeKeyboardHelp());
removeLayer(withList);
const bare = pushLayer();
pressH();
removeLayer(bare);
expect(descriptions()).not.toContain("keys.listStep");
expect(descriptions()).not.toContain("keys.contextMenu");
});
// The YouTube player's keys wait while a layer sits above it (its related drawer, a dialog): a
// sheet opened there leaves them out, and one opened over the player itself lists them.
it("leaves out a surface's keys while a layer above it has them waiting", () => {
let covered = true;
setScopeCoveredProbe("ytPlayer", () => covered);
try {
render(
<Host>
<Surface scope="ytPlayer" />
</Host>,
);
const id = pushLayer();
pressH();
removeLayer(id);
expect(descriptions()).not.toContain("keys.playPause");
expect(descriptions()).toContain("keys.help");
act(() => closeKeyboardHelp());
covered = false;
pressH();
expect(descriptions()).toContain("keys.playPause");
} catch (e) {
setScopeCoveredProbe("ytPlayer", null);
throw e;
}
setScopeCoveredProbe("ytPlayer", null);
});
// Two live context menus (the Playlists page mounts one, its rail another) must not double the row.
it("lists the context-menu chords once however many surfaces register", () => {
function WithMenu() {
+12 -5
View File
@@ -10,6 +10,7 @@ import {
useHelpConditionValues,
useKeyboardHelpOpen,
useKeyboardHelpOverLayer,
useKeyboardHelpCovered,
useNavigablePages,
} from "../../lib/keymap";
@@ -33,6 +34,7 @@ export default function KeyboardHelp() {
const scopes = useActiveScopes();
const conditions = useHelpConditionValues();
const overLayer = useKeyboardHelpOverLayer();
const covered = useKeyboardHelpCovered();
const pages = useNavigablePages();
if (!open) return null;
@@ -41,11 +43,16 @@ export default function KeyboardHelp() {
// Opened over a player or a dialog, the page-level keys (`s`, `g …`, `j`/`k`) are quiet — listing
// them there would promise keys that do nothing until the overlay closes. And a `g` chord is listed
// only for a module this account can actually reach.
// The context-menu key goes too: every `useContextMenu` is a page list, and with an overlay up the
// focus Shift+F10 needs is inside the overlay (found in UAT).
const live = overLayer
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
: scopes;
// The context-menu key goes too, with the page's lists. Over a layer that holds a list of its own
// (the player's related videos) the list keys and the menu key work on THAT list, so they stay —
// but not Delete: no list inside a layer takes it, and a page's remover behind does not reach.
const inLayer = (s: (typeof scopes)[number]) =>
(!isPageLevelScope(s) && s !== "contextMenu") ||
(overLayer === "layerWithList" && (s === "list" || s === "contextMenu"));
// A surface under a layer of its own (the player under its related drawer) waits: not listed.
const live = (overLayer === "page" ? scopes : scopes.filter(inLayer)).filter(
(s) => !covered.has(s),
);
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
return (
+1 -1
View File
@@ -147,7 +147,7 @@ export default function Modal({
// An effect event, so a new handler each render never re-registers: the stack id is assigned once per mount.
const close = useEffectEvent(onClose);
useEffect(() => {
const id = pushLayer();
const id = pushLayer({ root: () => rootRef.current });
const onKey = (e: KeyboardEvent) => {
if (takeEscape(e, id)) {
e.stopPropagation();
+19 -4
View File
@@ -57,9 +57,16 @@ const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
};
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
// accent"); the layer utilities leave it alone, and its colours are marked important (`!`) because a
// variant's hover colour (`act:hover:text-fg`, the overlay's `act:hover:bg-white/15`) is the more
// specific selector — without it the accent turned grey, or white, under the pointer.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
"aria-pressed:bg-accent! aria-pressed:text-accent-fg! aria-pressed:border-accent! aria-pressed:no-underline!";
// A mode cycle that is not off (`lit`: auto-advance Next, loop One) looks pressed but is no toggle —
// its name says the mode, and aria-pressed would announce a two-state switch whose name changes on
// every press. The same look as PRESSED, under the pointer too: the layers stand down on `[data-lit]`.
const LIT =
"data-lit:bg-accent! data-lit:text-accent-fg! data-lit:border-accent! data-lit:no-underline!";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
@@ -120,8 +127,10 @@ const ICON_VARIANTS: Record<IconButtonVariant, string> = {
};
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const ICON_PRESSED = "aria-pressed:text-chosen";
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action. Important
// for the same reason as PRESSED: the variant's hover colour would take the tint off under the pointer.
const ICON_PRESSED = "aria-pressed:text-chosen!";
const ICON_LIT = "data-lit:text-chosen!";
const ICON_SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
@@ -138,17 +147,20 @@ export function buttonClass({
round = false,
fullWidth = false,
pressed,
lit,
}: {
variant: ButtonVariant;
size: ButtonSize;
round?: boolean;
fullWidth?: boolean;
pressed?: boolean;
lit?: boolean;
}) {
return clsx(
BASE,
BUTTON_VARIANTS[variant],
pressed !== undefined && PRESSED,
lit !== undefined && LIT,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
@@ -165,17 +177,20 @@ export function iconButtonClass({
size,
round = false,
pressed,
lit,
}: {
variant: IconButtonVariant;
size: IconButtonSize;
round?: boolean;
pressed?: boolean;
lit?: boolean;
}) {
return clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-chosen disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
ICON_VARIANTS[variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && ICON_PRESSED,
lit !== undefined && ICON_LIT,
ICON_SIZES[size],
);
}
@@ -1,4 +1,18 @@
[
{
"version": "0.112.0",
"date": "2026-10-25",
"new": [
"Player: a Related tab lists videos related to the one playing, leaving out what you have already watched (your uploaded YouTube history included). Channels you follow are marked, and you can switch between On topic and Trending.",
"Play a related video in place; Back returns to the one you opened. Admins set a daily per-user limit in Config › Quota (default 200, 0 turns it off).",
"Side panel: the list controls and the watched toggle are glyphs on one line, and Auto and Loop show when they are on, also under the pointer."
],
"fixed": [
"Live search: a search that never reached YouTube no longer uses up a daily slot, a busy moment says so after a few seconds instead of waiting on, and a first page that failed offers Retry.",
"Menus: Play from the beginning wears the same ↺ glyph everywhere, and Reset its own eraser glyph.",
"On the day of this update, API searches made earlier that day stop counting toward the daily search limit, until Pacific midnight."
]
},
{
"version": "0.111.0",
"date": "2026-10-24",
@@ -1,4 +1,18 @@
[
{
"version": "0.112.0",
"date": "2026-10-25",
"new": [
"Lejátszó: a Kapcsolódók fül a lejátszott videóhoz kapcsolódó videókat mutatja, a már megnézettek nélkül (a feltöltött YouTube-előzményeiddel együtt). A követett csatornák jelölve vannak, és válthatsz a Témába vágó és a Trend között.",
"A kapcsolódó videó helyben játszható, a Vissza a megnyitott videóhoz visz. Az adminok a Konfig › Kvóta alatt adhatnak napi felhasználónkénti limitet (alapból 200, a 0 kikapcsolja).",
"Oldalpanel: a listavezérlők és a megnézve-kapcsoló glifként egy sorban vannak, az Auto és a Loop bekapcsolt állapota az egérmutató alatt is látszik."
],
"fixed": [
"Élő keresés: a YouTube-ig el sem jutott keresés már nem fogyaszt napi keretet, túlterheléskor pár másodperc után üzenet jön a végtelen várakozás helyett, a hibás első oldal pedig Újra gombot ad.",
"Menük: a Lejátszás az elejétől mindenhol ugyanazt a ↺ glifet kapta, az Alaphelyzet saját radír glifet.",
"A frissítés napján az aznap korábban indított API-s keresések nem számítanak bele a napi keresési limitbe, a csendes-óceáni éjfélig."
]
},
{
"version": "0.111.0",
"date": "2026-10-24",
File diff suppressed because it is too large Load Diff
+259 -67
View File
@@ -23,6 +23,7 @@ import {
CheckCheck,
ExternalLink,
Info,
ListVideo,
Maximize,
Minimize,
Minimize2,
@@ -49,11 +50,13 @@ import {
anyLayerAbove,
escapeHandled,
} from "../../lib/layerStack";
import { focusInNavList } from "../../lib/listNav";
import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus";
import DownloadButton from "../downloads/DownloadButton";
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
import { api, type Video, type VideoStatus } from "../../lib/api";
import { markedValue, useVideoMarks } from "../../lib/progressCache";
import type { YTPlayer } from "../../lib/youtube";
import {
channelYouTubeUrl,
@@ -63,7 +66,7 @@ import {
relativeTime,
} from "../../lib/format";
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history";
import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster";
@@ -83,6 +86,11 @@ import { SURFACE } from "../../components/ui/surfaces";
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
import { SectionLabel } from "../../components/ui/Headings";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { useMe } from "../../lib/useMe";
import Modal from "../../components/ui/Modal";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
import { RelatedVideos } from "./RelatedVideos";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -155,6 +163,25 @@ const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
const META_MIN = "min-w-48 flex-1";
// The watched toggle's own word on an item: what it last asked for (`seq`, its write), and when — its
// click, then the server's last answer to any of its writes.
type OwnMark = { status: VideoStatus; seq: number; at: number };
// One of the toggle's writes was answered. Its answer has just come back as a session mark too (the
// write marks before it resolves), so the toggle's word is stamped after it and outranks it; a later
// mark from elsewhere still outranks the toggle. The latest write refused, the toggle's word is
// dropped and the item shows what the server holds.
function settled(marks: Record<string, OwnMark>, id: string, seq: number, ok: boolean) {
const own = marks[id];
if (!own) return marks;
if (!ok && own.seq === seq) {
const rest = { ...marks };
delete rest[id];
return rest;
}
return { ...marks, [id]: { ...own, at: Date.now() } };
}
export default function PlayerModal({
video,
startAt,
@@ -162,6 +189,7 @@ export default function PlayerModal({
startIndex,
onClose,
onState,
onWrite,
onRemoveFromList,
}: {
video: Video;
@@ -174,7 +202,13 @@ export default function PlayerModal({
queue?: Video[];
startIndex?: number;
onClose: () => void;
onState: (id: string, status: VideoStatus) => void;
/** The player's own marks (its watched toggle, the auto-mark at the end), for the item it is on —
* named by `video`, which may be none of the host's (a related video played in place). Returns
* the write: the toggle weighs its own word at the server's answer, and drops a refused one. */
onState: (id: string, status: VideoStatus, video?: Video) => Promise<unknown>;
/** A write the related list made itself, for a host that refetches once the player closes and its
* writes have landed (the feed's player session). */
onWrite?: (write: Promise<unknown>) => void;
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
@@ -230,6 +264,23 @@ export default function PlayerModal({
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
// A related video played in place (the owner's pick) is the item it now is: its progress is
// checkpointed, it is marked watched at its end, and its own watched / playlist / download
// controls show — while the queue stays where it was, and Back returns to the opened item. It is
// the SUBJECT of the player from here down; `active` stays the queue's item, which the related
// list, the counter and prev/next belong to. A queue step leaves it.
// `startAt` null resumes it where it was left (this session's stored position first); a number is
// where the user asked it to start — Restart's 0 — and beats that position.
const [related, setRelated] = useState<{ video: Video; startAt: number | null } | null>(null);
const [relatedOf, setRelatedOf] = useState(active.id);
if (relatedOf !== active.id) {
setRelatedOf(active.id);
setRelated(null);
}
const subject = related?.video ?? active;
const subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0)
: resumeAt;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
@@ -245,11 +296,16 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The player's place on the layer stack (registered below), for what must defer to a layer above.
const layerIdRef = useRef<number | null>(null);
// What the player does on its own (turning ready, the pointer leaving the video, the window
// regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have
// slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted
// their Tab from the first button.
// their Tab from the first button. Nor while a layer that owns the keyboard is open above the
// player (the related drawer, a dialog): its focus is outside the card by design, and the window
// regaining focus pulled it onto the card behind the scrim.
const reclaimFocus = useCallback(() => {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
const card = cardRef.current;
const active = document.activeElement;
if (
@@ -345,7 +401,7 @@ export default function PlayerModal({
// currently-playing id may differ from the active item.
const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== active.id;
const navigated = currentVideoId !== subject.id;
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
// the video, which is its preview; another video starts without one.
const [clip, setClip] = useState<ClipRange | null>(null);
@@ -365,7 +421,7 @@ export default function PlayerModal({
// The picture the crop frame is drawn on. The iframe never tells its video's size: a short is
// 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture
// (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars.
const picture = !navigated && active.is_short ? 9 / 16 : 16 / 9;
const picture = !navigated && subject.is_short ? 9 / 16 : 16 / 9;
// The docked clip panel's height, measured (it wraps with the window and the text size): the video
// above it ends there.
const clipDockRef = useRef<HTMLDivElement>(null);
@@ -482,7 +538,7 @@ export default function PlayerModal({
// Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
const heading = navigated ? (liveData?.title ?? t("player.loading")) : active.title;
const heading = navigated ? (liveData?.title ?? t("player.loading")) : subject.title;
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
@@ -493,9 +549,13 @@ export default function PlayerModal({
const loadVideo = useStableCallback((id: string, start: number | null) => {
const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the active item, start others at 0.
// Explicit start wins; otherwise resume the item the player is on, start others at 0.
const startSeconds =
start != null ? start : id === active.id ? (sessionPosRef.current.get(id) ?? resumeAt) : 0;
start != null
? start
: id === subject.id
? (sessionPosRef.current.get(id) ?? subjectResume)
: 0;
setPlayerError(null);
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id;
@@ -504,23 +564,41 @@ export default function PlayerModal({
});
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
// is ignored rather than displayed — that is the enforcement, and it has to be in code because
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
// item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
// onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
id: active.id,
value: active.status,
});
const watched = (status.id === active.id ? status.value : active.status) === "watched";
// (and server) via onState. Each mark is kept under the id it was MADE for, and that has to be in
// code because one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING
// item runs after the commit that already advanced `active`, and near the end of a clip it
// auto-marks that item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that late write is
// recorded for the item it belongs to (shown again if the user steps back to it) and never
// painted on the next one; unkeyed it landed last and painted the NEXT video as watched, and a
// corrective click then sent onState(next, "new") for an item that was already new.
// The newest word wins: this toggle's own mark, or one the session confirmed elsewhere since the
// player opened (the related row's own menu, a notice's Undo — `useVideoMarks`), else the item as
// it was opened. The toggle alone missed those, and its first click re-sent the same status.
// The toggle's own writes come back as session marks too, stamped at their answer, so a click
// weighed only at its own time lost to the late answer of the click before it: each answer stamps
// the toggle's word again (`settled`). A refused one is dropped, so the toggle does not keep showing
// what the server turned down.
const [marks, setMarks] = useState<Record<string, OwnMark>>({});
const ownSeq = useRef(0);
const [openedAt] = useState(() => Date.now());
const sessionMarks = useVideoMarks();
const own = marks[subject.id];
const confirmed = markedValue(sessionMarks, subject.id, "status");
const elsewhere = confirmed && confirmed.at >= openedAt ? confirmed : undefined;
const status =
own && (!elsewhere || own.at >= elsewhere.at)
? own.status
: (elsewhere?.value ?? subject.status);
const watched = status === "watched";
const setWatched = (on: boolean) => {
const id = subject.id;
const next = on ? "watched" : "new";
setStatus({ id: active.id, value: next });
onState(active.id, next);
const seq = ++ownSeq.current;
setMarks((m) => ({ ...m, [id]: { status: next, seq, at: Date.now() } }));
Promise.resolve(onState(id, next, subject)).then(
() => setMarks((m) => settled(m, id, seq, true)),
() => setMarks((m) => settled(m, id, seq, false)),
);
};
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
@@ -531,9 +609,9 @@ export default function PlayerModal({
// The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
// immune to the late writer this block cannot see (see that comment).
const [syncedTo, setSyncedTo] = useState(active.id);
if (syncedTo !== active.id) {
setSyncedTo(active.id);
setCurrentVideoId(active.id);
if (syncedTo !== subject.id) {
setSyncedTo(subject.id);
setCurrentVideoId(subject.id);
}
// `currentIdRef` must NOT move into the block above, and the reason is EFFECT ORDERING, which the
@@ -547,8 +625,8 @@ export default function PlayerModal({
// frozen launch queue's stale position — which is the exact bug the session-position map exists
// to prevent (748 instead of 830, in the test's numbers).
useEffect(() => {
currentIdRef.current = active.id;
}, [active.id]);
currentIdRef.current = subject.id;
}, [subject.id]);
const seekTo = useStableCallback((seconds: number) => {
const p = playerRef.current;
@@ -640,6 +718,8 @@ export default function PlayerModal({
if (clipOpenRef.current) return;
const { autoMode: a, loopMode: l } = modeRef.current;
if (l === "one") return replayCurrent();
// A related video's end is not the queue's: the list beside it is where the next one is picked.
if (related) return;
const { queue: q, index: i } = navRef.current;
if (!q || q.length === 0) return;
const wrap = l === "all";
@@ -669,6 +749,35 @@ export default function PlayerModal({
// a tablet could not read a description at all. Anchored ABOVE, so it grows upward over the player
// rather than off-screen; `flip` still rescues it when there is no room up there.
const [showDesc, setShowDesc] = useState(false);
// The related videos, on demand (the owner's pick: the modal has little room to spare). Wide, they
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [relatedOpen, setRelatedOpen] = useState(false);
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
// tab instead. Left open, it reopened unasked when the window narrowed again.
if (wide && relatedOpen) {
setRelatedOpen(false);
setPanelTab("related");
}
const panelTabs = useTabPanel();
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused.
const relatedOn = useMe().related_enabled;
const playRelated = (v: Video, startAt: number | null) => {
setRelatedOpen(false);
// The video already playing (its row stays in the list): a new player for the same id would
// never be built (one per video), so a Restart is a seek, and a plain pick just keeps playing.
// Unless a description link put another video in the player meanwhile (`navigated`): then the
// pick goes back to this one — the seek moved the linked video, and the pick did nothing.
if (v.id === subject.id) {
if (navigated) loadVideo(subject.id, startAt);
else if (startAt != null) seekTo(startAt);
return;
}
setRelated({ video: v, startAt });
};
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
@@ -693,10 +802,10 @@ export default function PlayerModal({
// over the player by doing so.
const overlayOrder = useOverlayOrder();
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or an anchored menu) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
// download dialog, or an anchored menu) takes Escape first and this player defers. Its root lets
// the keyboard walk a list inside it (the related videos).
useEffect(() => {
const id = pushLayer();
const id = pushLayer({ root: () => dialogRef.current });
layerIdRef.current = id;
return () => {
removeLayer(id);
@@ -705,6 +814,15 @@ export default function PlayerModal({
}, []);
useKeyScope("ytPlayer");
// Its keys wait under a layer above it (the drawer, a dialog — see onPlayerKey), so the help sheet
// opened there must not list them.
useEffect(() => {
setScopeCoveredProbe(
"ytPlayer",
() => layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current),
);
return () => setScopeCoveredProbe("ytPlayer", null);
}, []);
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
@@ -724,7 +842,9 @@ export default function PlayerModal({
// there: deferring would scroll something invisible and leave the viewer with no volume control at
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
// On a row of a list in the player (the related videos) the arrows walk the list instead.
const volume = (e: KeyboardEvent, up: boolean) => {
if (focusInNavList()) return;
const covered = isFullscreenRef.current || maximized;
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
return;
@@ -732,6 +852,7 @@ export default function PlayerModal({
nudgeVolume(up ? 5 : -5);
};
const seek = (e: KeyboardEvent, secs: number) => {
if (focusInNavList()) return;
e.preventDefault();
nudgeSeek(secs);
};
@@ -743,6 +864,15 @@ export default function PlayerModal({
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
// A layer that owns the keyboard above the player (the related drawer, a menu, a dialog) has the
// keys: on its ✕ the arrows changed the volume and seeked the video behind it. Escape is the
// exception, and `close` decides for itself which layer it closes.
if (
e.key !== "Escape" &&
layerIdRef.current != null &&
anyBlockingLayerAbove(layerIdRef.current)
)
return;
dispatch(e, "ytPlayer", {
clip: () => {
if (clip) closeClip();
@@ -817,8 +947,9 @@ export default function PlayerModal({
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
// the refs passed in. See lib/playerModalHooks.
useYtProgress({
activeId: active.id,
resumeAt,
activeId: subject.id,
resumeAt: subjectResume,
exactStart: related?.startAt != null,
mountRef,
playerRef,
currentIdRef,
@@ -1030,9 +1161,9 @@ export default function PlayerModal({
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
<DownloadButton videoId={subject.id} title={subject.title} variant="overlay" />
)}
{/* Clip mode here: the clip panel takes this bar's place. */}
{playerError == null && (
@@ -1212,7 +1343,7 @@ export default function PlayerModal({
</div>
)}
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
@@ -1245,11 +1376,36 @@ export default function PlayerModal({
<Info />
</IconButton>
)}
{navigated && (
{!wide && relatedOn && (
<IconButton
variant="ghost"
onClick={() => setRelatedOpen(true)}
aria-expanded={relatedOpen}
label={t("player.related.title")}
>
<ListVideo />
</IconButton>
)}
{/* Inside the card, whose click stops at its edge: a portal's events still bubble
along the React tree, and from the backdrop a click on a row closed the player. */}
{relatedOpen && !wide && (
<Modal
title={t("player.related.title")}
onClose={() => setRelatedOpen(false)}
placement="drawer"
maxWidth="max-w-sm"
initialFocus="close"
>
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
</Modal>
)}
{(navigated || related) && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
// One step back: from a linked video to the one it was linked from, from a
// related video to the opened item.
onClick={() => (navigated ? loadVideo(subject.id, null) : setRelated(null))}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
@@ -1282,8 +1438,8 @@ export default function PlayerModal({
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
src={subject.channel_thumbnail}
fallback={subject.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
@@ -1309,19 +1465,19 @@ export default function PlayerModal({
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
>
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
channelId={subject.channel_id}
title={subject.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
openChannel(subject.channel_id, subject.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
<IconLink
href={active.channel_url}
href={subject.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
@@ -1335,27 +1491,27 @@ export default function PlayerModal({
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
{subject.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
count: subject.view_count,
formattedCount: formatCompact(subject.view_count, lang),
})}
</span>
)}
<span>
{metaSep}
{relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
{relativeTime(subject.published_at, lang, t)}
{subject.published_at && ` · ${fullDate(subject.published_at)}`}
</span>
{active.duration_seconds != null && (
{subject.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(active.duration_seconds)}
{formatDuration(subject.duration_seconds)}
</span>
)}
{active.live_status === "was_live" && (
{subject.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
@@ -1408,10 +1564,10 @@ export default function PlayerModal({
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<AddToPlaylist videoId={subject.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
videoId={subject.id}
title={subject.title}
variant="outline"
size="md"
/>
@@ -1428,7 +1584,16 @@ export default function PlayerModal({
<Scissors />
</IconButton>
)}
{onRemoveFromList && (
<IconButton
variant="outline"
size="md"
pressed={watched}
onClick={() => setWatched(!watched)}
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
>
{watched ? <CheckCheck /> : <Check />}
</IconButton>
{onRemoveFromList && !related && (
<IconButton
variant="outline-danger"
size="md"
@@ -1441,20 +1606,10 @@ export default function PlayerModal({
<Trash2 />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
)}
</div>
{wide && (
{wide && !relatedOn && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
@@ -1466,6 +1621,43 @@ export default function PlayerModal({
/>
</section>
)}
{wide && relatedOn && (
<div className="mt-5">
{/* The class replaces Tabs' own, so it keeps the row's layout and gap with
its tighter margin. */}
<Tabs
tabs={[
{ id: "description", label: t("player.description") },
{ id: "related", label: t("player.related.title") },
]}
active={panelTab}
onChange={(id) => setPanelTab(id === "related" ? "related" : "description")}
idBase={panelTabs.idBase}
label={t("player.panelTabs")}
className="flex flex-wrap gap-1 mb-3"
/>
<div {...panelTabs.panelProps(panelTab)}>
{panelTab === "description" ? (
<section data-testid="player-description">
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
/>
</section>
) : (
// Out into the panel's padding: in 20rem, every rem is the title's.
<div className="-mx-3">
<RelatedVideos
videoId={active.id}
onPlay={playRelated}
onWrite={onWrite}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
@@ -0,0 +1,410 @@
// @vitest-environment jsdom
//
// A video's related videos, as the player shows them. What is held: a row says when the user follows
// its channel; the region the user picks is asked for and kept on the account; a video marked seen
// here leaves the list (and comes back when the server refuses the mark); reading further sends the
// cursor; and a failure says so rather than drawing an empty list.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../navigation/navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
import { RelatedVideos } from "./RelatedVideos";
import { api, HttpError, type RelatedVideo, type Video } from "../../lib/api";
import { clearAll, getNotifications } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { setHintsEnabled } from "../../lib/hints";
import { writeVideoState } from "../video/videoWrites";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
clearAll();
});
const row = (id: string, extra: Partial<RelatedVideo> = {}): RelatedVideo =>
({
id,
title: `Video ${id}`,
channel_id: `UC${id}`,
channel_title: `Channel ${id}`,
watch_url: `https://www.youtube.com/watch?v=${id}`,
status: "new",
saved: false,
position_seconds: 0,
followed: false,
...extra,
}) as RelatedVideo;
const page = (items: RelatedVideo[], next: string | null = null) => ({
items,
has_more: next != null,
next_cursor: next,
});
function setup(
onPlay: (v: Video, startAt: number | null) => void = () => {},
onWrite?: (write: Promise<unknown>) => void,
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
) {
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
const saved = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<RelatedVideos videoId="SRC" onPlay={onPlay} onWrite={onWrite} />
</FloatingTree>
</QueryClientProvider>,
);
return { saved, qc };
}
// A row's title (its picture is a link of the same name).
const title = async (name: string) =>
(await screen.findAllByRole("link", { name })).find((a) => a.hasAttribute("data-nav-open"))!;
const titles = () =>
screen
.queryAllByRole("link")
.filter((a) => a.hasAttribute("data-nav-open"))
.map((a) => a.textContent);
// The menu's surface is visibility:hidden in jsdom: an item is matched on its text.
const menuItem = (label: string) =>
screen.queryAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === label)!;
describe("RelatedVideos", () => {
it("marks the rows whose channel the user follows, and only those", async () => {
vi.spyOn(api, "related").mockResolvedValue(
page([row("A", { followed: true }), row("B"), row("C", { followed: true })]),
);
setup();
await title("Video A");
const marks = screen.getAllByRole("img", { name: "player.related.followed" });
const owners = marks.map((m) => m.closest("[data-video-id]")?.getAttribute("data-video-id"));
expect(owners).toEqual(["A", "C"]);
});
it("asks for the region picked, and keeps the pick on the account", async () => {
const asked = vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
const { saved } = setup();
await title("Video A");
expect(asked.mock.calls).toEqual([["SRC", "topical", null]]);
fireEvent.click(screen.getByRole("button", { name: "player.related.hu" }));
await waitFor(() => expect(asked.mock.calls).toContainEqual(["SRC", "hu", null]));
expect(saved).toHaveBeenCalledWith({ relatedRegion: "hu" });
});
it("sets the region switch at the toolbar rung, not the 10px caption one", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
setup();
await title("Video A");
const track = screen.getByRole("group", { name: "player.related.region" });
expect(track.className).toMatch(/\btext-xs\b/);
});
// A plain pick resumes (null: the player knows the freshest position); the row's own position
// travels on the video it hands over.
it("plays a row from where the user left it", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
const onPlay = vi.fn();
setup(onPlay);
fireEvent.click(await title("Video A"));
expect(onPlay).toHaveBeenCalledWith(
expect.objectContaining({ id: "A", position_seconds: 95 }),
null,
);
});
it("drops a video hidden here at once, and brings it back when the server refuses", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
let refuse: (e: Error) => void = () => {};
const write = vi
.spyOn(api, "setState")
.mockReturnValue(new Promise((_, reject) => (refuse = reject)) as never);
setup();
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden"));
expect(titles()).toEqual(["Video B"]);
await act(async () => refuse(new Error("offline")));
expect(titles()).toEqual(["Video A", "Video B"]);
});
it("keeps a video marked watched out of the list once the server has it", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
setup();
fireEvent.contextMenu((await title("Video B")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.markWatched"));
await waitFor(() => expect(titles()).toEqual(["Video A"]));
});
it("reads further with the cursor the last page gave", async () => {
const asked = vi
.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A")], "CUR1"))
.mockResolvedValueOnce(page([row("B")]));
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
await title("Video B");
expect(asked.mock.calls[1]).toEqual(["SRC", "topical", "CUR1"]);
expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull();
});
it("says so when YouTube refuses, rather than drawing an empty list", async () => {
vi.spyOn(api, "related").mockRejectedValue(
new HttpError(422, "YouTube isn't reachable", undefined),
);
setup();
expect((await screen.findByRole("alert")).textContent).toContain("YouTube isn't reachable");
expect(screen.queryByText("player.related.empty")).toBeNull();
});
// A deferred answer, settled when the test says.
const deferred = () => {
let resolve: () => void = () => {};
let reject: (e: Error) => void = () => {};
const promise = new Promise<never>((res, rej) => {
resolve = res as () => void;
reject = rej;
});
return { promise, resolve, reject };
};
it("keeps a row hidden while its hide is on its way, though an earlier save landed", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
const save = deferred();
const saving = vi.spyOn(api, "watchLaterAdd").mockReturnValue(save.promise as never);
const hide = deferred();
const hiding = vi.spyOn(api, "setState").mockReturnValue(hide.promise as never);
setup();
const card = (await title("Video A")).closest("[data-video-id]")!;
fireEvent.contextMenu(card);
fireEvent.click(menuItem("card.saveForLater"));
await waitFor(() => expect(saving).toHaveBeenCalledWith("A"));
fireEvent.contextMenu(card);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(hiding).toHaveBeenCalledWith("A", "hidden"));
expect(titles()).toEqual(["Video B"]);
await act(async () => save.resolve());
expect(titles()).toEqual(["Video B"]);
});
it("announces a hide with the video it names, for the notification center to find", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
const writes: Promise<unknown>[] = [];
setup(undefined, (w) => void writes.push(w));
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(getNotifications().length).toBeGreaterThan(0));
expect(getNotifications().map((n) => n.meta)).toContainEqual({
kind: "video-hidden",
videoId: "A",
title: "Video A",
channelId: "UCA",
channelName: "Channel A",
});
// And the write reached the host's session.
expect(writes).toHaveLength(1);
});
it("flips a row's bookmark at once, and back when the server refuses", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
const save = deferred();
vi.spyOn(api, "watchLaterAdd").mockReturnValue(save.promise as never);
setup();
const card = (await title("Video A")).closest("[data-video-id]")!;
// What the menu offers is the row's state: un-save once saved.
const offers = (label: string) => {
fireEvent.contextMenu(card);
return !!menuItem(label);
};
expect(offers("card.saveForLater")).toBe(true);
fireEvent.click(menuItem("card.saveForLater"));
await waitFor(() => expect(offers("card.menu.unsave")).toBe(true));
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
await act(async () => save.reject(new Error("offline")));
await waitFor(() => expect(offers("card.saveForLater")).toBe(true));
});
it("is kept for the session, not collected when the list closes", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A")]));
const { qc } = setup();
await title("Video A");
const [cached] = qc.getQueryCache().findAll({ queryKey: qk.related() });
expect(cached!.options.gcTime).toBe(Infinity);
});
it("says the next page failed under the rows it has, and retries that page", async () => {
const asked = vi
.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A")], "CUR1"))
.mockRejectedValueOnce(new HttpError(422, "YouTube isn't reachable", undefined))
.mockResolvedValueOnce(page([row("B")]));
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
expect((await screen.findByRole("alert")).textContent).toContain("YouTube isn't reachable");
expect(titles()).toEqual(["Video A"]);
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
await title("Video B");
expect(asked.mock.calls.map((c) => c[2])).toEqual([null, "CUR1", "CUR1"]);
});
it("with nothing unseen read so far, says so above Show more, and retries the page it failed", async () => {
const asked = vi
.spyOn(api, "related")
.mockResolvedValueOnce(page([], "CUR1"))
.mockRejectedValueOnce(new HttpError(422, "YouTube isn't reachable", undefined))
.mockResolvedValueOnce(page([row("B")]));
setup();
expect(await screen.findByText("player.related.emptySoFar")).toBeTruthy();
expect(screen.queryByText("player.related.empty")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
await screen.findByRole("alert");
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
await title("Video B");
expect(asked.mock.calls.map((c) => c[2])).toEqual([null, "CUR1", "CUR1"]);
});
it.each([
["the day's cap", { code: "refused", reason: "related_daily_limit", limit: 5 }],
["turned off", { code: "refused", reason: "related_disabled" }],
["the shared budget", { code: "youtube", reason: "quota" }],
])("offers no retry a retry cannot answer: %s", async (_name, detail) => {
vi.spyOn(api, "related").mockRejectedValue(new HttpError(429, "Refused", detail));
setup();
expect((await screen.findByRole("alert")).textContent).toContain("Refused");
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
});
it("offers a retry where trying again can help", async () => {
vi.spyOn(api, "related").mockRejectedValue(
new HttpError(422, "YouTube isn't reachable", { code: "youtube", reason: "unavailable" }),
);
setup();
await screen.findByRole("alert");
expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy();
});
it("names the followed mark on hover", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })]));
setup();
await title("Video A");
const mark = screen.getByRole("img", { name: "player.related.followed" });
fireEvent.mouseEnter(mark.parentElement!);
fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" });
expect(await screen.findByText("player.related.followed")).toBeTruthy();
});
// An infinite query builds its next page from the pages it read when that fetch started: a mark
// that landed in between was dropped with them, for the session.
it("keeps a hide that landed while Show more was on its way", async () => {
let land: (p: ReturnType<typeof page>) => void = () => {};
vi.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A"), row("C")], "CUR1"))
.mockReturnValueOnce(new Promise((r) => (land = r)) as never);
const write = vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
fireEvent.click(menuItem("card.hide"));
await waitFor(() => expect(write).toHaveBeenCalledWith("A", "hidden"));
await act(async () => {});
await act(async () => land(page([row("B")])));
await title("Video B");
expect(titles()).toEqual(["Video C", "Video B"]);
});
// Hidden in this session, then un-hidden on another device: a list read after the mark carries
// the server's word, and the mark does not paint over it.
it("shows a list read after a mark as the server has it", async () => {
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await writeVideoState(qc, "A", "hidden");
await new Promise((r) => setTimeout(r, 5)); // the list is read a moment after the mark
setup(undefined, undefined, qc);
await title("Video B");
expect(titles()).toEqual(["Video A", "Video B"]);
});
// The Notification Center's undo, the feed's reset, a card elsewhere: every write reaches it.
it("follows a video's state written anywhere else", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const { qc } = setup();
await title("Video A");
await act(async () => void (await writeVideoState(qc, "A", "hidden")));
await waitFor(() => expect(titles()).toEqual(["Video B"]));
await act(async () => void (await writeVideoState(qc, "A", "new")));
await waitFor(() => expect(titles()).toEqual(["Video A", "Video B"]));
});
// Show more is the request Retry would make: refused for good, it is not offered either.
it("offers no Show more once the next page was refused for good", async () => {
vi.spyOn(api, "related")
.mockResolvedValueOnce(page([row("A")], "CUR1"))
.mockRejectedValueOnce(
new HttpError(429, "Limit", { code: "refused", reason: "related_daily_limit", limit: 3 }),
);
setup();
await title("Video A");
fireEvent.click(screen.getByRole("button", { name: "player.related.more" }));
expect((await screen.findByRole("alert")).textContent).toContain("Limit");
expect(screen.queryByRole("button", { name: "player.related.more" })).toBeNull();
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
expect(titles()).toEqual(["Video A"]);
});
it("names the followed mark on hover with hints turned off too", async () => {
setHintsEnabled(false);
try {
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { followed: true })]));
setup();
await title("Video A");
const mark = screen.getByRole("img", { name: "player.related.followed" });
fireEvent.mouseEnter(mark.parentElement!);
fireEvent.pointerEnter(mark.parentElement!, { pointerType: "mouse" });
expect(await screen.findByText("player.related.followed")).toBeTruthy();
} catch (e) {
setHintsEnabled(true);
throw e;
}
setHintsEnabled(true);
});
it("says when nothing is left that the user has not seen", async () => {
vi.spyOn(api, "related").mockResolvedValue(page([]));
setup();
expect(await screen.findByText("player.related.empty")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
});
@@ -0,0 +1,236 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react";
import {
api,
HttpError,
refusalReason,
type RelatedRegion,
type Video,
type VideoStatus,
} from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage";
import { usePref, type UsePrefOptions } from "../../lib/usePref";
import { markedRows, stampFetch, useVideoMarks } from "../../lib/progressCache";
import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useContextMenu } from "../../components/ui/useContextMenu";
import NavGrid from "../../components/ui/NavGrid";
import Tooltip from "../../components/ui/Tooltip";
import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */
const RELATED_REGION_PREF = "relatedRegion";
const RELATED_REGION_OPTIONS: UsePrefOptions<RelatedRegion> = {
parse: (raw) => (raw === "hu" ? "hu" : "topical"),
};
// The list leaves out what the user has seen (the server does, as it fetches); a video marked here
// leaves it the same way, at once.
const isSeen = (v: Video) => v.status === "watched" || v.status === "hidden";
/** The refusals a retry cannot answer: the day's cap (tomorrow does), the feature turned off on
* this instance and the shared budget spent (the admin, or tomorrow). */
const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]);
const isFinal = (e: unknown) => FINAL_REASONS.has(refusalReason(e) ?? "");
// A write not yet confirmed: its change shows at once, and leaves when the server answers.
type Pending = { seq: number; id: string; change: Partial<Video> };
/** The videos YouTube shows beside `videoId`, without the ones the user has watched or hidden. A
* click plays one in this player (`onPlay`); the list stays the opened video's while it does. Its
* rows carry the feed's whole menu. */
export function RelatedVideos({
videoId,
onPlay,
onWrite,
}: {
videoId: string;
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
onPlay: (v: Video, startAt: number | null) => void;
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
onWrite?: (write: Promise<unknown>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [region, setRegion] = usePref(RELATED_REGION_PREF, "topical", RELATED_REGION_OPTIONS);
// Kept for the session — never refetched, never collected while the app is open: the list
// unmounts whenever the drawer closes or the tab changes, and a refetch is another scrape and
// another slot of the day's cap.
const query = useInfiniteQuery({
queryKey: qk.related(videoId, region),
queryFn: ({ pageParam }) => stampFetch(() => api.related(videoId, region, pageParam)),
initialPageParam: null as string | null,
getNextPageParam: (last) => (last.has_more ? last.next_cursor : undefined),
staleTime: Infinity,
gcTime: Infinity,
retry: false,
});
// The marks on their way, one entry per WRITE: a row leaves (or its bookmark flips) at once, and
// comes back if the server refuses that write. Kept per video, the first answer to land cleared
// the video's whole entry, and a save then a hide put the hidden row back until the hide landed.
const [pending, setPending] = useState<Pending[]>([]);
const seqRef = useRef(0);
const begin = (id: string, change: Partial<Video>) => {
const seq = ++seqRef.current;
setPending((p) => [...p, { seq, id, change }]);
return seq;
};
const settle = (seq: number) => setPending((p) => p.filter((w) => w.seq !== seq));
const onState = (id: string, status: VideoStatus) => {
const v = rows.find((x) => x.id === id);
const seq = begin(id, { status });
const write = writeVideoState(qc, id, status);
onWrite?.(write);
write
.then(() => {
settle(seq);
// Stale-mark the feed, as its own marks do: the video may be in it too.
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
announceVideoState(id, status, v, () => onState(id, "new"));
})
.catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState"));
settle(seq);
});
};
const onToggleSave = (id: string, saved: boolean) => {
const seq = begin(id, { saved });
const write = writeWatchLater(qc, id, saved);
onWrite?.(write);
write
.then(() => {
settle(seq);
void qc.invalidateQueries({ queryKey: qk.playlists() });
void qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch((e: unknown) => {
notifyActionError(
e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
);
settle(seq);
});
};
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? null);
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
// What this session confirmed about a row since its page was read (here or anywhere else), then
// what is on its way.
const marks = useVideoMarks();
const rows = markedRows(query.data?.pages, marks)
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
.filter((v) => !isSeen(v));
const followed = new Set(
(query.data?.pages ?? []).flatMap((p) => p.items.filter((v) => v.followed).map((v) => v.id)),
);
const error =
query.error instanceof HttpError
? query.error.detail || t("player.related.error")
: query.isError
? t("player.related.error")
: null;
// A refused page is the one to try again: with pages loaded, a refetch started over from the first
// (another scrape and another slot) instead of asking for the page that failed.
const final = isFinal(query.error);
const retry = final
? undefined
: query.data?.pages.length
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
: () => void query.refetch();
return (
<section data-testid="player-related" aria-label={t("player.related.title")}>
<SegmentedToggle
options={[
{ value: "topical", label: t("player.related.topical") },
{ value: "hu", label: t("player.related.hu") },
]}
value={region}
onChange={setRegion}
ariaLabel={t("player.related.region")}
fullWidth
className="mb-3"
/>
{query.isPending ? (
<LoadingState shape="list" />
) : error && !rows.length ? (
<StateMessage tone="error" compact onRetry={retry}>
{error}
</StateMessage>
) : !rows.length && !query.hasNextPage ? (
<StateMessage compact>{t("player.related.empty")}</StateMessage>
) : (
<>
{/* What was read so far was all seen (or marked here since): the list goes on, it is not
empty — said, so Show more is not a lone button under nothing. */}
{!rows.length && <StateMessage compact>{t("player.related.emptySoFar")}</StateMessage>}
<NavGrid className="flex flex-col gap-1.5">
{rows.map((v, i) => (
<VideoCard
key={v.id}
navIndex={i}
video={v}
view="rows"
narrow
width={0}
onState={onState}
onToggleSave={onToggleSave}
onOpen={open}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
menuOpen={openId === v.id}
channelMark={
followed.has(v.id) && (
// A bare mark means nothing to a mouse: it names itself on hover, whatever
// the hints setting, since the caption is all it has.
<Tooltip hint={t("player.related.followed")} always>
<CircleCheck
className="w-3.5 h-3.5 shrink-0 text-success"
aria-label={t("player.related.followed")}
role="img"
/>
</Tooltip>
)
}
/>
))}
</NavGrid>
{/* Show more is the same request a Retry makes: refused for good, neither is offered. */}
{query.hasNextPage && !final && (
<div className="mt-3 flex justify-center">
<Button
variant="outline"
size="sm"
disabled={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))}
>
{query.isFetchingNextPage ? t("player.related.loading") : t("player.related.more")}
</Button>
</div>
)}
{error && rows.length > 0 && (
<StateMessage tone="error" compact onRetry={retry}>
{error}
</StateMessage>
)}
</>
)}
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onOpen: open }} />
</section>
);
}
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
//
// The queue transport cluster, in both of its places. The auto-advance mode wears glyphs of its own:
// on the side panel's glyph-only row the skip arrows are Previous / Next, and a mode that wore them
// too put two identical ⏭ side by side, the second one silently changing a saved setting. The modes
// are cycles, not switches (lit, no aria-pressed), and every control's tooltip names it and its mode
// in both places. i18n is key-passthrough.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
import { PlayerTransportControls } from "./playerModalControls";
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
afterEach(cleanup);
function mount(variant: "modal" | "docked", autoMode: AutoMode, loopMode: LoopMode = "off") {
render(
<PlayerTransportControls
variant={variant}
index={1}
queueLength={3}
autoMode={autoMode}
loopMode={loopMode}
onPrev={() => {}}
onNext={() => {}}
onCycleAuto={() => {}}
onCycleLoop={() => {}}
/>,
);
}
const glyph = (el: HTMLElement) => el.querySelector("svg")!.getAttribute("class");
const button = (name: string | RegExp) => screen.getByRole("button", { name });
describe("PlayerTransportControls", () => {
for (const variant of ["modal", "docked"] as const)
for (const mode of AUTO_MODES)
it(`${variant}: auto-advance ${mode} wears neither Previous's glyph nor Next's`, () => {
mount(variant, mode);
const auto = glyph(button(/^player\.autoAdvance\.label: /));
expect(auto).not.toBe(glyph(button("player.previous")));
expect(auto).not.toBe(glyph(button("player.next")));
});
for (const variant of ["modal", "docked"] as const)
it(`${variant}: the modes are lit when on, and announce no toggle`, () => {
mount(variant, "next", "off");
const auto = button(/^player\.autoAdvance\.label: /);
const loop = button(/^player\.loop\.label: /);
expect(auto.hasAttribute("aria-pressed")).toBe(false);
expect(loop.hasAttribute("aria-pressed")).toBe(false);
expect(auto.hasAttribute("data-lit")).toBe(true);
expect(loop.hasAttribute("data-lit")).toBe(false);
});
for (const variant of ["modal", "docked"] as const)
it(`${variant}: each mode's tooltip names it and the mode it is in`, () => {
for (const loopMode of LOOP_MODES) {
mount(variant, "random", loopMode);
expect(button(/^player\.autoAdvance\.label: /).title).toBe(
"player.autoAdvance.label: player.autoAdvance.random · player.autoAdvance.hint",
);
expect(button(/^player\.loop\.label: /).title).toBe(
`player.loop.label: player.loop.${loopMode} · player.loop.hint`,
);
cleanup();
}
});
});
@@ -1,9 +1,10 @@
import type { CSSProperties, Ref } from "react";
import type { CSSProperties, ReactNode, Ref } from "react";
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { VideoDetail } from "../../lib/api";
import type { AutoMode, LoopMode } from "../../lib/playerModes";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { StateMessage } from "../../components/ui/QueryState";
import { ClampedText } from "../../components/ui/ClampedText";
import { renderDescription } from "./descriptionLinks";
@@ -12,11 +13,16 @@ import { renderDescription } from "./descriptionLinks";
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes.
* Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal
* glass theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view
* docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a
* control does, its glyph and its tooltip are said once; only the primitive and the label's
* visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has
* one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles,
* not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its
* own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). The
* wrapper (fragment vs. bordered row) stays at the call site. */
export function PlayerTransportControls({
variant,
index,
@@ -40,69 +46,105 @@ export function PlayerTransportControls({
}) {
const { t } = useTranslation();
const docked = variant === "docked";
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
const chrome = docked ? "overlay" : "ghost";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggleLabel = docked ? "hidden md:inline" : "";
const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
type Control = {
key: string;
glyph: ReactNode;
name: string;
title: string;
onClick: () => void;
disabled?: boolean;
lit?: boolean;
/** From which width the docked bar shows the name beside the glyph. */
labelFrom: "sm" | "md";
/** The glyph after the name (Next points the way it goes). */
trailing?: boolean;
};
const step: Control[] = [
{
key: "prev",
glyph: <SkipBack />,
name: t("player.previous"),
title: `${t("player.previous")} · Shift+←`,
onClick: onPrev,
disabled: index === 0,
labelFrom: "sm",
},
{
key: "next",
glyph: <SkipForward />,
name: t("player.next"),
title: `${t("player.next")} · Shift+→`,
onClick: onNext,
disabled: index === queueLength - 1,
labelFrom: "sm",
trailing: true,
},
];
// Persistent playback settings — cycle on click, saved to your account.
const modes: Control[] = [
{
key: "auto",
glyph:
autoMode === "prev" ? <ListStart /> : autoMode === "random" ? <Shuffle /> : <ListEnd />,
name: autoName,
title: `${autoName} · ${t("player.autoAdvance.hint")}`,
onClick: onCycleAuto,
lit: autoMode !== "off",
labelFrom: "md",
},
{
key: "loop",
glyph: loopMode === "one" ? <Repeat1 /> : <Repeat />,
name: loopName,
title: `${loopName} · ${t("player.loop.hint")}`,
onClick: onCycleLoop,
lit: loopMode !== "off",
labelFrom: "md",
},
];
const render = (c: Control) =>
docked ? (
<Button
key={c.key}
variant="overlay"
size="sm"
icon={c.trailing ? undefined : c.glyph}
onClick={c.onClick}
disabled={c.disabled}
lit={c.lit}
label={c.name}
title={c.title}
>
<span className={c.labelFrom === "sm" ? "hidden sm:inline" : "hidden md:inline"}>
{c.name}
</span>
{c.trailing && c.glyph}
</Button>
) : (
<IconButton
key={c.key}
onClick={c.onClick}
disabled={c.disabled}
lit={c.lit}
label={c.name}
title={c.title}
>
{c.glyph}
</IconButton>
);
return (
<>
<Button
variant={chrome}
size="sm"
icon={<SkipBack />}
onClick={onPrev}
disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
>
<span className={navLabel}>{t("player.previous")}</span>
</Button>
{render(step[0]!)}
<span className={counter}>
{index + 1} / {queueLength}
</span>
<Button
variant={chrome}
size="sm"
onClick={onNext}
disabled={index === queueLength - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward />
</Button>
{render(step[1]!)}
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<Button
variant={chrome}
size="sm"
pressed={autoMode !== "off"}
icon={
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
}
onClick={onCycleAuto}
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
title={t("player.autoAdvance.hint")}
>
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</Button>
<Button
variant={chrome}
size="sm"
pressed={loopMode !== "off"}
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
onClick={onCycleLoop}
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
title={t("player.loop.hint")}
>
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</Button>
{modes.map(render)}
</>
);
}
@@ -2,7 +2,7 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { api, HttpError, type PlexCollection } from "../../lib/api";
import { api, refusalReason, type PlexCollection } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
@@ -123,8 +123,7 @@ export default function PlexCollectionAddAt({
/** A create refused only because the read after it failed: the collection is on Plex, with this
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
function madeOnPlex(e: unknown): boolean {
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
return d?.reason === "collection_pending_sync";
return refusalReason(e) === "collection_pending_sync";
}
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { Check, Info, Layers, ListPlus, Play, RotateCcw, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
@@ -98,7 +98,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
icon: <RotateCcw className={icon} />,
onSelect: () => onRestart(c),
},
]
@@ -5,6 +5,7 @@ import { qk } from "../../lib/queryKeys";
import { ListPlus } from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api";
import { addPlaylistItem, removePlaylistItem } from "./videoWrites";
import { notifyActionError } from "../../lib/actionErrors";
import { playlistName } from "../../lib/playlistName";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
@@ -115,8 +116,8 @@ function MembershipPanel({
if (busy) return;
setBusy(true);
try {
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
else await api.addToPlaylist(pl.id, videoId);
if (pl.has_video) await removePlaylistItem(qc, pl, videoId);
else await addPlaylistItem(qc, pl, videoId);
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
);
@@ -133,7 +134,7 @@ function MembershipPanel({
let made = false;
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
await addPlaylistItem(qc, pl, videoId);
made = true;
} catch (e) {
notifyActionError(e, t("playlists.addFailed"));
+41 -2
View File
@@ -374,6 +374,8 @@ function VideoCard({
menuOpen = false,
navIndex,
shelf = false,
narrow = false,
channelMark,
}: {
video: Video;
view: FeedView;
@@ -397,6 +399,12 @@ function VideoCard({
navIndex?: number;
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
shelf?: boolean;
/** The thumbnailed row in a side panel's ~20rem column: a smaller picture, and the "…" alone at the
* row's end — the hover toggles leave no room for the title there, and every one of them is a row
* of the menu. */
narrow?: boolean;
/** Something the list says about the channel (a "followed" mark), set before its name. */
channelMark?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -610,15 +618,46 @@ function VideoCard({
const textBlock = (
<>
{title}
<div className="mt-0.5">{channelLink}</div>
<div className="mt-0.5">
{channelMark ? (
<div className="flex items-center gap-1 min-w-0">
{channelMark}
<div className="min-w-0">{channelLink}</div>
</div>
) : (
channelLink
)}
</div>
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{published}
{/* The narrow row keeps the relative time and moves the date into its tooltip, as the small
card does: in a 20rem panel the full pair wrapped to a line of its own. */}
{narrow ? <RelativeTime at={video.published_at} /> : published}
</div>
</>
);
if (spec.family === "row" && narrow) {
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-2 p-1.5", watched && "opacity-55")}
>
<Thumb video={video} className="aspect-video shrink-0 w-24" onOpen={onOpen} small />
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
{more && (
<div className="shrink-0 self-start">
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
</div>
)}
</div>
);
}
if (spec.family === "row") {
return (
<div
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { type Video } from "../../lib/api";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { type ContextMenuController } from "../../components/ui/useContextMenu";
import { useVideoActions, type VideoActionHandlers } from "../../features/video/useVideoActions";
import { useVideoActions, type VideoActionHandlers } from "./useVideoActions";
/** A video list's one menu (the feed grid and a channel page): every trigger — right-click,
* long-press, Shift+F10 and each card's "…" — opens it with the video's whole action list. Render
@@ -0,0 +1,38 @@
import i18n from "../../i18n";
import { notify, resolveVideo } from "../../lib/notifications";
import type { Video, VideoStatus } from "../../lib/api";
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */
export type NoticeVideo = Pick<Video, "title" | "channel_id" | "channel_title">;
/** Say that a video's mark landed, once the server has it: "Hidden" / "Marked watched" with the
* Undo (`undo` puts it back to new) and the meta the Notification Center keeps it under, so the
* notice still finds the video after a reload. A mark back to new quietly resolves a hide or watch
* notice still standing for it, so it does not linger with a dead action. A claim of success:
* never called before the write is confirmed. One definition, because every list that marks a
* video must announce it the same way (the feed's cards, a related list). */
export function announceVideoState(
id: string,
status: VideoStatus,
video: NoticeVideo | undefined,
undo: () => void,
): void {
if (status === "new") {
resolveVideo(id);
return;
}
const hidden = status === "hidden";
notify({
message: video?.title
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: {
kind: hidden ? "video-hidden" : "video-watched",
videoId: id,
title: video?.title ?? i18n.t("feed.thisVideo"),
channelId: video?.channel_id ?? "",
channelName: video?.channel_title ?? "",
},
});
}
@@ -7,6 +7,7 @@ import {
Check,
Download,
Eye,
Eraser,
EyeOff,
Filter,
FilterX,
@@ -14,7 +15,6 @@ import {
ListPlus,
Play,
RotateCcw,
SkipBack,
Tv,
Undo2,
} from "lucide-react";
@@ -104,7 +104,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
icon: <RotateCcw className={icon} />,
onSelect: () => onOpen(v, 0),
},
]
@@ -193,7 +193,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
icon: <Eraser className={icon} />,
onSelect: () => onResetState(v.id),
},
]
@@ -0,0 +1,44 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The whole app source: a state write can be made from any page.
const root = new URL("../../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.replace(/\\/g, "/"))
.filter((f) => /\.tsx?$/.test(f) && !f.includes(".test."))
.map((f) => ({ f, src: readFileSync(new URL(f, root), "utf8") }));
// The api's own definitions of these writes, and the one module that may call them.
const ALLOWED = new Set([
"lib/api/feed.ts",
"lib/api/playlists.ts",
"features/video/videoWrites.ts",
]);
// A related list and a live search are never refetched, so a state, Watch-later or playlist-item
// write that does not reach them (`markVideo`) leaves them showing the old mark for the session —
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
// together; a direct api call skips the mark.
const WRITES =
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
describe("a video's state, Watch-later and playlist-item writes", () => {
it("go through features/video/videoWrites, never the api directly", () => {
const offenders = sources()
.filter(({ f }) => !ALLOWED.has(f))
// Only the api object's members count (React's own `setState` is not ours) — named, not only
// called: `(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)` makes the write too. The
// dot may sit on the next line of a chain.
.filter(({ src }) => WRITES.test(src))
.map(({ f }) => f);
expect(offenders).toEqual([]);
});
it("catches a write named without being called on the spot, and a playlist item's", () => {
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
expect(WRITES.test("api.moveToPlaylist(a, id, b)")).toBe(true);
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
});
});
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
//
// Each write reaches the lists that are never refetched once the server has it — and not before:
// a refused write leaves them as they were.
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import {
addPlaylistItem,
movePlaylistItem,
removePlaylistItem,
resetVideoState,
writeVideoState,
writeWatchLater,
} from "./videoWrites";
afterEach(() => vi.restoreAllMocks());
// The marks' values, without when each was confirmed.
const marks = (qc: QueryClient) => {
const m = qc.getQueryData<Record<string, Record<string, { value: unknown }>>>(qk.videoMarks());
return m
? Object.fromEntries(
Object.entries(m).map(([id, f]) => [
id,
Object.fromEntries(Object.entries(f).map(([k, x]) => [k, x.value])),
]),
)
: undefined;
};
const WATCH_LATER = { id: 1, kind: "watch_later" as const };
const MINE = { id: 2, kind: "user" as const };
describe("videoWrites", () => {
it("marks a state written", async () => {
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await writeVideoState(qc, "X", "hidden");
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
});
it("marks a reset as new, from the start", async () => {
vi.spyOn(api, "clearState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await resetVideoState(qc, "X");
expect(marks(qc)).toEqual({ X: { status: "new", position_seconds: 0 } });
});
it("marks a Watch-later save and its removal", async () => {
vi.spyOn(api, "watchLaterAdd").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "watchLaterRemove").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await writeWatchLater(qc, "X", true);
expect(marks(qc)).toEqual({ X: { saved: true } });
await writeWatchLater(qc, "X", false);
expect(marks(qc)).toEqual({ X: { saved: false } });
});
it("marks an item of the Watch later list as the bookmark it is", async () => {
vi.spyOn(api, "addToPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await addPlaylistItem(qc, WATCH_LATER, "X");
expect(marks(qc)).toEqual({ X: { saved: true } });
await removePlaylistItem(qc, WATCH_LATER, "X");
expect(marks(qc)).toEqual({ X: { saved: false } });
});
it("marks nothing for an item of any other list", async () => {
vi.spyOn(api, "addToPlaylist").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await addPlaylistItem(qc, MINE, "X");
await removePlaylistItem(qc, { id: 3, kind: undefined }, "X");
expect(marks(qc)).toBeUndefined();
});
it("marks a move into and out of the Watch later list", async () => {
vi.spyOn(api, "moveToPlaylist").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient();
await movePlaylistItem(qc, MINE, "X", WATCH_LATER);
expect(marks(qc)).toEqual({ X: { saved: true } });
await movePlaylistItem(qc, WATCH_LATER, "X", MINE);
expect(marks(qc)).toEqual({ X: { saved: false } });
});
it("marks nothing the server refused", async () => {
vi.spyOn(api, "setState").mockRejectedValue(new Error("offline"));
const qc = new QueryClient();
await expect(writeVideoState(qc, "X", "hidden")).rejects.toThrow("offline");
expect(marks(qc)).toBeUndefined();
});
});
@@ -0,0 +1,69 @@
import type { QueryClient } from "@tanstack/react-query";
import { api, type Playlist, type VideoStatus } from "../../lib/api";
import { markVideo } from "../../lib/progressCache";
// A video's user state, its Watch-later mark and its playlist items, written to the server and —
// once it has the change — into the lists that are never refetched (`markVideo`: every related
// list, every live search). These are the ONE way to make those writes, and
// `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the
// Notification Center's undo, the feed's reset, the feed's and Home's Watch-later toggles) left a
// related list hiding a video the user had un-hidden, or showing it unsaved after a save. A
// playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked
// in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each
// returns the server's answer, so a caller keeps its own optimistic layer, notices and rollback.
export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus) {
return api.setState(id, status).then((r) => {
markVideo(qc, id, { status });
return r;
});
}
/** Back to pristine: the state row and its resume position both go. */
export function resetVideoState(qc: QueryClient, id: string) {
return api.clearState(id).then((r) => {
markVideo(qc, id, { status: "new", position_seconds: 0 });
return r;
});
}
export function writeWatchLater(qc: QueryClient, id: string, saved: boolean) {
return (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)).then((r) => {
markVideo(qc, id, { saved });
return r;
});
}
/** The list a playlist item write goes to: on the built-in Watch later list (`kind`) the item IS
* the video's Watch-later mark. A kind the caller cannot tell (undefined) marks nothing. */
type ListRef = { id: number; kind: Playlist["kind"] | undefined };
const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boolean) => {
if (list.kind === "watch_later") markVideo(qc, id, { saved });
};
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
* removal took it from. */
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
return api.addToPlaylist(list.id, id, position).then((r) => {
markIfWatchLater(qc, list, id, true);
return r;
});
}
export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
return api.removeFromPlaylist(list.id, id).then((r) => {
markIfWatchLater(qc, list, id, false);
return r;
});
}
/** Move a video from one playlist to another, in one request (see `api.moveToPlaylist`). */
export function movePlaylistItem(qc: QueryClient, from: ListRef, id: string, to: ListRef) {
return api.moveToPlaylist(from.id, id, to.id).then((r) => {
markIfWatchLater(qc, from, id, false);
markIfWatchLater(qc, to, id, true);
return r;
});
}
+4
View File
@@ -54,6 +54,10 @@
"label": "Backfill quota reserve",
"hint": "Units kept in reserve so scheduled backfill never starves interactive syncs / enrichment."
},
"related_daily_limit_per_user": {
"label": "Related videos — daily limit per user",
"hint": "Max related-video requests a user can make per day — opening a list counts one, and so does every “Show more”. 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 (up to 5 an hour). Set 0 to turn related videos off."
},
"search_daily_limit_per_user": {
"label": "Live search — daily limit per user",
"hint": "Max live YouTube searches per user per day. With the API source each search costs 100 units (so the shared budget affords only ~80-90/day total); with the scrape source it's a pure anti-abuse limit. Set 0 to disable live search."
+4
View File
@@ -188,6 +188,10 @@
"quota_spent": "Today's shared YouTube budget is spent. Try again tomorrow.",
"recipient_required": "A recipient email is required",
"registration_closed": "Registration is currently closed",
"related_disabled": "Related videos are turned off on this instance.",
"related_daily_limit": "You've reached today's related videos limit ({{limit}}). Opening a list counts as one request, and so does every “Show more”. Try again tomorrow.",
"search_busy": "Too many searches are running right now. Try again in a few seconds.",
"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_term_required": "Enter something to search for",
"session_gone": "The playback session ended",
+13
View File
@@ -26,6 +26,19 @@
"descriptionFailed": "Couldn't load the description",
"descriptionMore": "More",
"descriptionLess": "Less",
"panelTabs": "Video details",
"related": {
"title": "Related",
"region": "Which related videos",
"topical": "On topic",
"hu": "Trending",
"followed": "You follow this channel",
"empty": "No related videos you haven't seen yet.",
"emptySoFar": "Nothing you haven't seen among these yet — there are more.",
"error": "Couldn't load the related videos",
"more": "Show more",
"loading": "Loading…"
},
"collapsePanel": "Hide the details",
"expandPanel": "Show the details",
"channel": "Channel",
@@ -10,6 +10,7 @@
"videos_enrich": "Video enrichment",
"videos_lookup": "Video lookup",
"videos_search": "Live search",
"videos_related": "Related videos",
"channels_discover": "Channel discovery",
"channels_subscribe": "Subscribe",
"channels_unsubscribe": "Unsubscribe",
+4
View File
@@ -54,6 +54,10 @@
"label": "Behúzási kvótatartalék",
"hint": "Tartalékban tartott egységek, hogy az ütemezett behúzás ne éheztesse ki az interaktív szinkront / dúsítást."
},
"related_daily_limit_per_user": {
"label": "Kapcsolódó videók — napi limit / felhasználó",
"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 (óránként legfeljebb 5). 0 = a kapcsolódó videók kikapcsolva."
},
"search_daily_limit_per_user": {
"label": "Élő keresés — napi limit / felhasználó",
"hint": "Maximális élő YouTube-keresés felhasználónként naponta. Az API-forrásnál egy keresés 100 egységbe kerül (így a közös büdzséből összesen csak ~80-90 fér bele naponta); a scrape-forrásnál ez tiszta visszaélés-elleni limit. 0 = élő keresés kikapcsolva."
+4
View File
@@ -188,6 +188,10 @@
"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",
"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 kapcsolódó videók mai korlátját ({{limit}}). Egy lista megnyitása és minden „Mutass többet” egy-egy kérésnek számít. Próbáld holnap.",
"search_busy": "Most túl sok keresés fut egyszerre. Próbáld újra pár másodperc múlva.",
"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_term_required": "Írj be egy keresőkifejezést",
"session_gone": "A lejátszási munkamenet véget ért",
+13
View File
@@ -26,6 +26,19 @@
"descriptionFailed": "Nem sikerült betölteni a leírást",
"descriptionMore": "Több",
"descriptionLess": "Kevesebb",
"panelTabs": "A videó részletei",
"related": {
"title": "Kapcsolódók",
"region": "Milyen kapcsolódó videók",
"topical": "Témába vágó",
"hu": "Trend",
"followed": "Követed ezt a csatornát",
"empty": "Nincs olyan kapcsolódó videó, amit még nem láttál.",
"emptySoFar": "Ezek közt még nincs olyan, amit nem láttál — van több is.",
"error": "Nem sikerült betölteni a kapcsolódó videókat",
"more": "Mutass többet",
"loading": "Betöltés…"
},
"collapsePanel": "Részletek elrejtése",
"expandPanel": "Részletek megjelenítése",
"channel": "Csatorna",
@@ -10,6 +10,7 @@
"videos_enrich": "Videóadatok dúsítása",
"videos_lookup": "Videó-lekérdezés",
"videos_search": "Élő keresés",
"videos_related": "Kapcsolódó videók",
"channels_discover": "Csatorna-felfedezés a listákból",
"channels_subscribe": "Feliratkozás",
"channels_unsubscribe": "Leiratkozás",
+16 -4
View File
@@ -99,13 +99,25 @@
@custom-variant act (&:not(:disabled, [aria-disabled="true"]));
/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act:
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose
solid accent is its state and must not turn grey under the pointer. */
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle or a
lit mode cycle (`data-lit`), whose look is its state and must not turn grey under the pointer. */
@utility state-layer {
&:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) {
&:hover:not(
:disabled,
[aria-disabled="true"],
[aria-pressed="true"],
[aria-selected="true"],
[data-lit]
) {
background-color: var(--hover);
}
&:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"], [aria-selected="true"]) {
&:active:not(
:disabled,
[aria-disabled="true"],
[aria-pressed="true"],
[aria-selected="true"],
[data-lit]
) {
background-color: var(--press);
}
}
@@ -12,6 +12,7 @@ import {
clearEntryUrl,
reloadAfterRefusal,
} from "./entryUrl";
import { HttpError } from "./api/core";
const notice = (search: string, hash = "") =>
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
@@ -112,14 +113,14 @@ describe("a tab whose session the server refused", () => {
it("reloads as the account signed in now, saying why, when it showed another", () => {
const loc = stubLocation();
reloadAfterRefusal({ detailData: { code: "refused", reason: "account_changed" } });
reloadAfterRefusal(new HttpError(401, "x", { code: "refused", reason: "account_changed" }));
expect(loc.replace).toHaveBeenCalledWith(`/?page=stats&${ACCOUNT_CHANGED_FLAG}`);
expect(loc.reload).not.toHaveBeenCalled();
});
it("reloads plainly when the session ended", () => {
const loc = stubLocation();
reloadAfterRefusal({ detailData: { code: "refused", reason: "not_authenticated" } });
reloadAfterRefusal(new HttpError(401, "x", { code: "refused", reason: "not_authenticated" }));
expect(loc.reload).toHaveBeenCalled();
expect(loc.replace).not.toHaveBeenCalled();
});
+2 -2
View File
@@ -6,10 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
import { HttpError, setUnauthorizedHandler } from "./api/core";
import { HttpError, refusalReason, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { HttpError, setUnauthorizedHandler };
export { HttpError, refusalReason, setUnauthorizedHandler };
import { accountApi } from "./api/account";
export type * from "./api/account";
+4
View File
@@ -20,6 +20,10 @@ export interface Me {
google_enabled: boolean;
plex_enabled: boolean;
search_source: "scrape" | "api";
/** Whether live YouTube search / related videos are on: an admin turns either off with a daily
* limit of 0, and what would only be refused is then left out. */
search_enabled: boolean;
related_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
+6
View File
@@ -65,6 +65,12 @@ export class HttpError extends Error {
}
}
/** The structured refusal's reason (`{ code, reason }`), when the server gave one. */
export const refusalReason = (err: unknown): string | undefined =>
err instanceof HttpError
? (err.detailData as { reason?: string } | undefined)?.reason
: undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
+13
View File
@@ -167,4 +167,17 @@ describe("watch state and progress", () => {
});
expect(opts).toEqual({ quiet: true });
});
it("a related list is quiet too, and sends its region and cursor", () => {
void feedApi.related("a/b", "hu", "CUR1");
const [url, , opts] = vi.mocked(req).mock.lastCall!;
const u = new URL(url as string, "http://x");
expect(u.pathname).toBe("/api/search/related/a%2Fb");
expect(Object.fromEntries(u.searchParams)).toEqual({ region: "hu", cursor: "CUR1" });
expect(opts).toEqual({ quiet: true });
void feedApi.related("abc", "topical", null);
const first = new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x");
expect(Object.fromEntries(first.searchParams)).toEqual({ region: "topical" });
});
});
+30
View File
@@ -35,6 +35,21 @@ export function isResettable(v: Pick<Video, "status" | "position_seconds">): boo
return v.status !== "new" || v.position_seconds > 0;
}
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
* what trends in Hungary (the server's `RELATED_REGIONS`). */
export type RelatedRegion = "topical" | "hu";
/** A row of a related list: a feed row that also says whether the user follows its channel. */
export interface RelatedVideo extends Video {
followed: boolean;
}
export interface RelatedResponse {
items: RelatedVideo[];
has_more: boolean;
next_cursor: string | null;
}
export interface VideoDetail {
id: string;
description: string | null;
@@ -235,6 +250,21 @@ export const feedApi = {
if (limit) p.set("limit", String(limit));
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
},
// The videos YouTube shows beside this one, without what the user has watched or hidden. `quiet`
// for the same reason as the search: the list renders its refusal (the day's cap, YouTube) inline.
related: (
videoId: string,
region: RelatedRegion,
cursor: string | null,
): Promise<RelatedResponse> => {
const p = new URLSearchParams({ region });
if (cursor) p.set("cursor", cursor);
return req(
`/api/search/related/${encodeURIComponent(videoId)}?${p.toString()}`,
{},
{ quiet: true },
);
},
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
// the now-orphaned, un-kept videos). Returns how many videos were removed.
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
+3 -3
View File
@@ -15,6 +15,7 @@
// with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications";
import { refusalReason } from "./api/core";
import { hrefHere } from "./urlState";
export const entrySearch = new URLSearchParams(window.location.search);
@@ -45,9 +46,8 @@ export function accountChangedUrl(): string {
* signed in now (`account_changed`: a sign-in as someone else in another tab), it boots as that one
* and says why — a Sign out that silently left someone else signed in would read as broken. Any
* other refusal means the session ended: a plain reload lands on the sign-in page. */
export function reloadAfterRefusal(err: { detailData?: unknown }): void {
if ((err.detailData as { reason?: string } | undefined)?.reason === "account_changed")
window.location.replace(accountChangedUrl());
export function reloadAfterRefusal(err: unknown): void {
if (refusalReason(err) === "account_changed") window.location.replace(accountChangedUrl());
else window.location.reload();
}
+40 -11
View File
@@ -25,7 +25,8 @@ import type { Page } from "./urlState";
* `page`, `search` and `list` are the PAGE-LEVEL scopes: they act on the page underneath
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
* run with the player's own layer up. */
* run with the player's own layer up. The one exception is a list INSIDE the topmost layer, with the
* focus in it (`dispatch`'s `inTopLayer`, decided by lib/listNav). */
export type KeyScope =
| "global"
| "page"
@@ -358,11 +359,13 @@ const stepsOf = (chord: string): string[] => chord.split(" ");
* implementation cannot drift without tsc saying so. Skipped while typing unless the row opts in
* with `allowInInput` (Escape, and only Escape), on a key a focused widget already handled, and — for a page-level scope — while a layer that
* owns the keyboard is open. The rule lives here rather than in each listener so a new page-level
* surface cannot forget it. */
* surface cannot forget it. `inTopLayer` is the one way past it: the caller has checked that the
* key acts inside that topmost layer, not on the page behind (a list in the player). */
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
{ inTopLayer = false }: { inTopLayer?: boolean } = {},
): boolean {
if (sequenceEvents.has(e)) return false;
// A key a focused widget already handled is not a shortcut: every listener here is on `window`,
@@ -374,7 +377,7 @@ export function dispatch<S extends KeyScope>(
if (e.key === "Escape" && escapeHandled(e)) return false;
const live = liveSequence(e);
if (live && live.scope !== scope) return false;
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
const quiet = isPageLevelScope(scope) && anyBlockingLayer() && !inTopLayer;
const typing = isTypingTarget(e);
const run = (id: string) => (handlers as Record<string, (ev: KeyboardEvent) => void>)[id]?.(e);
@@ -469,22 +472,48 @@ export function useActiveScopes(): KeyScope[] {
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
// there is exactly one answer to "what can I press here" instead of one per player.
const helpOpen = createStore(false);
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
const helpOverLayer = createStore(false);
// What the sheet was opened OVER: the page, a layer that owns the keyboard (a player, a dialog), or
// such a layer with a list inside it (the player's related videos). The page-level keys are quiet over
// a layer, so the sheet must not list them — except the list keys, which walk a list inside the
// topmost layer (see `dispatch`'s `inTopLayer`). The sheet cannot ask the layer stack itself once it
// is showing, because its own Modal is a layer too, so the answer is taken at the moment of opening,
// before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
export type HelpOver = "page" | "layer" | "layerWithList";
const helpOverLayer = createStore<HelpOver>("page");
// Whether the topmost layer holds a list the keyboard walks. The lists are listNav's, which imports
// this module and so cannot be imported back: it hands the question in when it loads.
let listInTopLayer: () => boolean = () => false;
export const setListInTopLayerProbe = (probe: () => boolean): void => {
listInTopLayer = probe;
};
// A surface whose keys wait while a layer sits above it (the YouTube player under its related
// drawer or a dialog: every key but Escape is the layer's then; the file viewer under its menu or
// Share dialog). Its rows would promise keys that do
// nothing, so the sheet leaves the scope out — asked at the same moment as `helpOverLayer`.
const coveredProbes = new Map<KeyScope, () => boolean>();
export const setScopeCoveredProbe = (scope: KeyScope, probe: (() => boolean) | null): void => {
if (probe) coveredProbes.set(scope, probe);
else coveredProbes.delete(scope);
};
const helpCovered = createStore<ReadonlySet<KeyScope>>(new Set());
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get();
if (next) helpOverLayer.set(anyBlockingLayer());
if (next) {
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
helpCovered.set(new Set([...coveredProbes].filter(([, covered]) => covered()).map(([s]) => s)));
}
helpOpen.set(next);
};
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.useValue();
/** What the sheet was opened over (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.useValue();
/** The surfaces a layer covered when the sheet opened (see `setScopeCoveredProbe`). */
export const useKeyboardHelpCovered = (): ReadonlySet<KeyScope> => helpCovered.useValue();
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
+21 -2
View File
@@ -26,12 +26,21 @@ const passive = new Set<number>();
// none, and Tab from anything outside the prompt walked into the page behind the scrim. The two
// questions are separate; a layer answers each for itself.
const defers = new Set<number>();
// The element a layer draws into, for the layers that hold content of their own to navigate (a
// dialog, the player): a list inside the TOPMOST such layer is walked by the keyboard although a
// layer is open — it is the one the user is looking at, not the page behind.
const roots = new Map<number, () => HTMLElement | null>();
export function pushLayer(opts?: { passive?: boolean; trapsTab?: boolean }): number {
export function pushLayer(opts?: {
passive?: boolean;
trapsTab?: boolean;
root?: () => HTMLElement | null;
}): number {
const id = nextId++;
stack.push(id);
if (opts?.passive) passive.add(id);
if (opts?.trapsTab === false) defers.add(id);
if (opts?.root) roots.set(id, opts.root);
return id;
}
@@ -39,11 +48,21 @@ export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
passive.delete(id);
defers.delete(id);
roots.delete(id);
}
/** The element of the topmost layer that owns the keyboard — null while none is open, or while that
* layer gave none (a menu: its own keys move inside it). */
export function topBlockingLayerRoot(): HTMLElement | null {
const blocking = stack.filter((id) => !passive.has(id));
const top = blocking[blocking.length - 1];
return top === undefined ? null : (roots.get(top)?.() ?? null);
}
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
* underneath, which the user cannot see or reach while such a layer is up. */
* underneath, which the user cannot see or reach while such a layer is up. A list INSIDE the
* topmost layer is the exception: it is walked (`topBlockingLayerRoot`, listNav). */
export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id));
}
+74
View File
@@ -202,6 +202,35 @@ describe("useListNav", () => {
removeLayer(tooltip);
});
// A list INSIDE the topmost layer (the player's related videos) is the one the user is in.
it("walks a list inside the topmost layer, and only inside it", () => {
render(
<>
<List id="a" count={2} />
<div data-testid="layer">
<List id="b" count={2} />
</div>
</>,
);
const dialog = pushLayer({ root: () => screen.getByTestId("layer") });
act(() => screen.getByTestId("b0-action").focus());
press("j");
expect(focused()).toBe("b1");
press("k");
press("k"); // its first row: the walk stops there, not on the page's list behind
expect(focused()).toBe("b0");
// A menu opened over it moves with its own keys: the list stays still.
const menu = pushLayer();
press("j");
expect(focused()).toBe("b0");
removeLayer(menu);
// Focus left behind on the page's list does not walk it while the layer is up.
act(() => screen.getByTestId("a0-action").focus());
press("j");
expect(focused()).toBe("a0-action");
removeLayer(dialog);
});
// A virtualized grid only mounts a screen of rows: the next item may not exist yet.
it("asks the list to reveal an unmounted item, then focuses it once it renders", async () => {
const reveal = vi.fn();
@@ -428,6 +457,51 @@ describe("useListNav · the roving tabindex and a removed item", () => {
},
);
});
// The same inside a layer (the player's related videos): a row marked from its menu leaves and the
// focus falls to <body>, where the layer's quiet rule left `j` doing nothing.
it("goes on from the slot of a removed item inside the topmost layer too", () => {
function Removable() {
const [ids, setIds] = useState([0, 1, 2, 3]);
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div data-testid="layer">
<div ref={ref}>
{ids.map((id, i) => (
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
<button
data-testid={`r${id}-hide`}
onClick={() => setIds((xs) => xs.filter((x) => x !== id))}
>
hide
</button>
</div>
))}
</div>
</div>
);
}
withLayout(
(_, n) => ({ left: 0, top: n * 100 }),
() => {
render(<Removable />);
const layer = pushLayer({ root: () => screen.getByTestId("layer") });
try {
act(() => screen.getByTestId("r1-hide").focus());
press("j");
expect(focused()).toBe("r2");
act(() => screen.getByTestId("r2-hide").focus());
fireEvent.click(screen.getByTestId("r2-hide"));
expect(document.activeElement).toBe(document.body);
press("j");
expect(focused()).toBe("r3");
} finally {
removeLayer(layer);
}
},
);
});
});
describe("useListNav · leaving the list, and naming the item", () => {
+41 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
import { dispatch, useKeyScope } from "./keymap";
import { dispatch, setListInTopLayerProbe, useKeyScope } from "./keymap";
import { anyBlockingLayer, topBlockingLayerRoot } from "./layerStack";
import { scrollParent } from "./listPaging";
import { focusIsUnclaimed } from "./useFocusKeeper";
@@ -73,11 +74,15 @@ function itemAt(list: NavList, index: number): HTMLElement | null {
}
function ordered(): NavList[] {
// Inside an open layer (the player) the walk stays in it: `k` on its first row must not land on
// the feed behind the scrim.
const layer = anyBlockingLayer() ? topBlockingLayerRoot() : null;
return (
[...lists.values()]
// Only lists on screen: a list can be mounted yet hidden (display:none in a collapsed panel or
// an inactive tab), and `j` must not walk into rows nobody can see.
.filter((l) => l.el.isConnected && l.el.checkVisibility?.() !== false)
.filter((l) => !layer || layer.contains(l.el))
.sort((a, b) =>
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
)
@@ -92,6 +97,16 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
return item && list ? { list, index: indexOf(item), item } : null;
}
/** True while the focus is on an item of a keyboard-walked list (or a control inside one): its arrows
* move between the items, so a surface that also binds them (the player's seek) leaves them alone. */
export const focusInNavList = (): boolean => current() !== null;
// The help sheet lists the list keys over a layer only when this says there is a list to walk in it.
setListInTopLayerProbe(() => {
const layer = topBlockingLayerRoot();
return !!layer && [...lists.keys()].some((el) => layer.contains(el));
});
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
function ownItems(el: HTMLElement): HTMLElement[] {
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
@@ -325,7 +340,21 @@ function onKey(e: KeyboardEvent): void {
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
if (move(dx, dy)) ev.preventDefault();
};
dispatch(e, "list", {
// The list keys act on the page, so an open layer silences them — unless the focus is in a list
// INSIDE the topmost layer: that list is what the user is in (the player's related videos). Or was,
// a moment ago: a row marked from its menu leaves the list and the focus falls to <body>, and `j`
// goes on from its slot (`slotOfRemoved`) as it does on the page.
const layer = topBlockingLayerRoot();
const cur = current();
const inTopLayer =
!!layer &&
(cur
? layer.contains(cur.item)
: focusIsUnclaimed(document.activeElement) && !!last && layer.contains(last.list.el));
dispatch(
e,
"list",
{
next: (ev) => {
ev.preventDefault();
step(1);
@@ -349,8 +378,13 @@ function onKey(e: KeyboardEvent): void {
// second copy of what opening means that could drift from it.
target.click();
},
});
dispatch(e, "listDelete", {
},
{ inTopLayer },
);
dispatch(
e,
"listDelete",
{
// On the item root, like Enter — and only in a list that registered a remover.
remove: (ev) => {
const cur = current();
@@ -360,7 +394,9 @@ function onKey(e: KeyboardEvent): void {
remove(cur.index);
refocusSlot(cur.list, cur.index, cur.item);
},
});
},
{ inTopLayer },
);
}
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
@@ -0,0 +1,34 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// DESIGN "A glyph means one thing": a menu row wears the glyph its action wears everywhere else. The
// "…" menus' Play from the beginning wore the skip arrow ⏮, which beside the player is Previous, while
// the card's own Restart wore RotateCcw — and RotateCcw was the same menu's Reset, the Files menu's
// Redownload. Each row is a `label: t("…")` followed by its `icon`.
const ROW_GLYPHS: Record<string, string> = {
"card.restartTitle": "RotateCcw",
"card.resetState": "Eraser",
"files.actions.redownload": "RefreshCw",
};
const root = new URL("../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.replace(/\\/g, "/"))
.filter((f) => /\.tsx?$/.test(f) && !f.includes(".test.") && !f.startsWith("design-lab/"))
.map((f) => ({ f, src: readFileSync(new URL(f, root), "utf8") }));
describe("a menu row's glyph", () => {
for (const [key, glyph] of Object.entries(ROW_GLYPHS))
it(`${key} wears ${glyph}`, () => {
const row = new RegExp(
`label: t\\("${key.replace(/\./g, "\\.")}"\\),\\s*icon: (?:<|icon\\()(\\w+)`,
"g",
);
const worn = sources().flatMap(({ f, src }) =>
[...src.matchAll(row)].map((m) => `${f}: ${m[1]}`),
);
expect(worn.length, key).toBeGreaterThan(0);
expect(worn.filter((w) => !w.endsWith(`: ${glyph}`))).toEqual([]);
});
});
@@ -30,6 +30,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
useYtProgress({
activeId: VID,
resumeAt: 0,
exactStart: false,
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
@@ -72,6 +72,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
useYtProgress({
activeId: VID,
resumeAt: 748,
exactStart: false,
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
@@ -186,6 +187,17 @@ describe("patchCachedProgress", () => {
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
});
it("patches a related list too — its rows open the same player", () => {
const qc = new QueryClient();
const key = qk.related("SRC", "topical");
qc.setQueryData(key, { pages: [{ items: [video()], next_cursor: null }], pageParams: [null] });
patchCachedProgress(qc, new Map([[VID, 830]]));
const after = qc.getQueryData(key) as { pages: { items: Video[] }[] };
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
});
it("is a no-op when nothing was stored", () => {
const qc = new QueryClient();
seedFeed(qc, video());
+13 -6
View File
@@ -339,6 +339,7 @@ function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
export function useYtProgress({
activeId,
resumeAt,
exactStart,
mountRef,
playerRef,
currentIdRef,
@@ -355,6 +356,8 @@ export function useYtProgress({
}: {
activeId: string;
resumeAt: number;
// `resumeAt` is where the user asked to start, so it beats a position this session stored.
exactStart: boolean;
mountRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>;
currentIdRef: RefObject<string>;
@@ -378,6 +381,7 @@ export function useYtProgress({
// the end of the effect).
const itemSnapshot = useEffectEvent(() => ({
resumeAt,
exactStart,
setWatched,
setPlayerError,
setLiveData,
@@ -392,6 +396,7 @@ export function useYtProgress({
const id = activeId;
const {
resumeAt,
exactStart,
setWatched,
setPlayerError,
setLiveData,
@@ -401,7 +406,8 @@ export function useYtProgress({
qc,
} = itemSnapshot();
// A position this session already stored for the item beats the queue's snapshot of it.
const startAt = sessionPosRef.current.get(id) ?? resumeAt;
// …unless the user asked for a start of their own (Restart on a related row).
const startAt = exactStart ? resumeAt : (sessionPosRef.current.get(id) ?? resumeAt);
autoMarkedRef.current = false;
setPlayerError(null);
@@ -557,10 +563,11 @@ export function useYtProgress({
// `reclaimFocus`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
// player is built: they describe the video it was built for, and re-reading them mid-session
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What
// genuinely has to stay live mid-session is behind refs (currentIdRef, modeRef, navRef,
// sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and THAT split is the enforcement:
// a value that is not a snapshot does not belong in this closure. (This replaced an
// exhaustive-deps suppression, which made the React Compiler skip the whole hook.)
// this cleanup's late auto-mark land on the item being LEFT, which the modal records for that
// item and never paints on the next one. What genuinely has to stay live mid-session is behind
// refs (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef)
// — and THAT split is the enforcement: a value that is not a snapshot does not belong in this
// closure. (This replaced an exhaustive-deps suppression, which made the React Compiler skip
// the whole hook.)
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
}
+78
View File
@@ -0,0 +1,78 @@
// What a confirmed mark reaches. A related list and a live YouTube search are never refetched (a
// scrape and a slot of the day's cap, or 100 quota units), so the mark is written into them; and it is
// kept apart from them too, laid over each page read before it (a page landing later, from a fetch
// that started earlier, cannot take it back) and over none read after (those carry the server's word).
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { markedRows, markVideo, patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys";
import type { Video } from "./api";
const v = (id: string): Video =>
({ id, status: "new", saved: false, position_seconds: 0 }) as Video;
const pages = (...ids: string[]) => ({ pages: [{ items: ids.map(v) }], pageParams: [null] });
const items = (qc: QueryClient, key: readonly unknown[]) =>
(qc.getQueryData(key) as { pages: { items: Video[] }[] }).pages[0]!.items;
describe("markVideo", () => {
it("writes the mark into every live search and every related list", () => {
const qc = new QueryClient();
qc.setQueryData(qk.ytSearch("cats", 20), pages("X", "Y"));
qc.setQueryData(qk.related("A", "topical"), pages("X"));
markVideo(qc, "X", { status: "watched" });
expect(items(qc, qk.ytSearch("cats", 20)).map((r) => r.status)).toEqual(["watched", "new"]);
expect(items(qc, qk.related("A", "topical"))[0]!.status).toBe("watched");
});
it("keeps the mark apart from the lists, for the session", () => {
const qc = new QueryClient();
markVideo(qc, "X", { status: "hidden" });
markVideo(qc, "X", { saved: true });
patchCachedProgress(qc, new Map([["X", 90]]));
expect(qc.getQueryData(qk.videoMarks())).toEqual({
X: {
status: { value: "hidden", at: expect.any(Number) },
saved: { value: true, at: expect.any(Number) },
position_seconds: { value: 90, at: expect.any(Number) },
},
});
// Nothing observes it before a related list opens: it must not be collected meanwhile.
expect(qc.getQueryCache().find({ queryKey: qk.videoMarks() })?.options.gcTime).toBe(Infinity);
});
});
describe("markedRows", () => {
afterEach(() => vi.useRealTimers());
const marked = (qc: QueryClient) =>
qc.getQueryData(qk.videoMarks()) as Parameters<typeof markedRows>[1];
it("lays a mark over a page whose fetch started before it", () => {
vi.useFakeTimers({ now: 2000 });
const qc = new QueryClient();
markVideo(qc, "X", { saved: true });
// A Show more that started at 1000 and landed after the mark: built from the pages it started from.
const rows = markedRows([{ items: [v("X")], fetchedAt: 1000 }], marked(qc));
expect(rows[0]!.saved).toBe(true);
});
it("leaves a page read after the mark with the server's word", () => {
vi.useFakeTimers({ now: 2000 });
const qc = new QueryClient();
markVideo(qc, "X", { saved: true, position_seconds: 40 });
// Taken off Watch later on the Playlists page, played on another device: the server says so.
const fresh = { ...v("X"), saved: false, position_seconds: 300 };
const rows = markedRows([{ items: [fresh], fetchedAt: 3000 }], marked(qc));
expect(rows[0]).toEqual(fresh);
});
it("weighs each field by when it was marked", () => {
const qc = new QueryClient();
vi.useFakeTimers({ now: 1000 });
markVideo(qc, "X", { saved: true });
vi.setSystemTime(3000);
markVideo(qc, "X", { status: "hidden" });
const rows = markedRows([{ items: [v("X")], fetchedAt: 2000 }], marked(qc));
expect(rows[0]).toMatchObject({ saved: false, status: "hidden" });
});
});
+104 -14
View File
@@ -1,4 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import type { Video } from "./api";
@@ -40,17 +40,113 @@ export const progressFlushes = (): Promise<unknown>[] => [...flushes];
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
if (positions.size === 0) return;
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
patchLists(qc, [qk.feed(), qk.playlist(), qk.ytSearch(), qk.related()], (v) => {
const pos = positions.get(v.id);
return pos === undefined || v.position_seconds === pos ? v : { ...v, position_seconds: pos };
});
for (const [id, pos] of positions) remember(qc, id, { position_seconds: pos });
}
/** Write one video's confirmed change (its status, its Watch-later mark) into the lists that are
* never refetched: every related list (both regions', every opened video's) and every live
* YouTube search — another scrape and a slot of the day's cap, or 100 quota units, each. A video
* marked anywhere else (a feed card, a playlist, the player, the Notification Center's undo) still
* showed there unmarked. Called by `features/video/videoWrites`, which every such write goes
* through (its contract test holds that). */
export function markVideo(qc: QueryClient, id: string, change: Partial<Video>): void {
patchLists(qc, [qk.ytSearch(), qk.related()], (v) => (v.id === id ? { ...v, ...change } : v));
remember(qc, id, change);
}
// What this session confirmed about each video, kept APART from the lists that show it, field by
// field with WHEN. A list's own cache cannot hold it alone: an infinite query builds its next page's
// data from the pages it read when that fetch STARTED (react-query's infiniteQueryBehavior), so a
// mark confirmed while a Show more / Load more was on its way vanished when the page landed — for
// the session, since those lists are never refetched. Laid over a page only where the mark is
// newer than the page's fetch (`markedRows`): a page read later carries the server's own newer
// word (a Watch-later change made on the Playlists page, a position saved on another device),
// and a session-long mark painted over it would show the old one until a reload.
type Mark = { [K in keyof Video]?: { value: Video[K]; at: number } };
type Marks = Record<string, Mark>;
function remember(qc: QueryClient, id: string, change: Partial<Video>): void {
const at = Date.now();
const stamped = Object.fromEntries(
Object.entries(change).map(([k, value]) => [k, { value, at }]),
) as Mark;
// Kept while the app is open, observed or not: a mark made before any related list was opened
// must still be there when one is.
qc.setQueryDefaults(qk.videoMarks(), { staleTime: Infinity, gcTime: Infinity });
qc.setQueryData<Marks>(qk.videoMarks(), (m) => ({ ...m, [id]: { ...m?.[id], ...stamped } }));
}
/** This session's confirmed marks, per video id (see `markVideo`). */
export function useVideoMarks(): Marks {
const qc = useQueryClient();
return useQuery({
queryKey: qk.videoMarks(),
// Nothing to fetch: the marks are what this session wrote, and the cache is where they live.
queryFn: () => qc.getQueryData<Marks>(qk.videoMarks()) ?? {},
initialData: {},
staleTime: Infinity,
gcTime: Infinity,
}).data;
}
/** The newest confirmed value of one field of a video, or undefined when this session set none. */
export const markedValue = <K extends keyof Video>(
marks: Marks,
id: string,
field: K,
): { value: Video[K]; at: number } | undefined => marks[id]?.[field];
/** A page of a never-refetched list, with when its fetch STARTED (`stampFetch`). */
export type Stamped = { fetchedAt?: number };
/** Run a never-refetched list's page fetch, stamping the page with when it started — what
* `markedRows` weighs a mark against. */
export async function stampFetch<T extends object>(
fetch: () => Promise<T>,
): Promise<T & { fetchedAt: number }> {
const fetchedAt = Date.now();
return { ...(await fetch()), fetchedAt };
}
/** A never-refetched list's rows, each with this session's marks laid over it where the mark is
* newer than the page it came in (a page with no stamp takes every mark). */
export function markedRows(
pages: readonly ({ items: Video[] } & Stamped)[] | undefined,
marks: Marks,
): Video[] {
return (pages ?? []).flatMap((page) =>
page.items.map((v) => {
const mark = marks[v.id];
if (!mark) return v;
const since = page.fetchedAt ?? -Infinity;
const newer = Object.entries(mark).filter(([, m]) => m.at >= since);
return newer.length
? { ...v, ...Object.fromEntries(newer.map(([k, m]) => [k, m.value])) }
: v;
}),
);
}
/** Apply `edit` to every row of every cached list under `keys` (prefix keys), keeping each untouched
* page's and list's identity — so an unchanged list does not re-render every card behind the
* open player. */
function patchLists(qc: QueryClient, keys: readonly QueryKey[], edit: (v: Video) => Video): void {
const patchList = (items: Video[]): Video[] => {
let changed = false;
const next = items.map((v) => {
const pos = positions.get(v.id);
if (pos === undefined || v.position_seconds === pos) return v;
changed = true;
return { ...v, position_seconds: pos };
const out = edit(v);
if (out !== v) changed = true;
return out;
});
// Keep the previous array identity when nothing moved, so an untouched list does not
// re-render every card behind the open player.
return changed ? next : items;
};
@@ -74,11 +170,5 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
return old;
};
// Prefix keys: every filter combination of the feed, every playlist detail, and every live YouTube
// search — its rows come from the same projection, carry the same per-user position, and open the
// same player, so leaving it out left the rewind in place on that surface. (Patched, never
// invalidated: a refetch of an API-sourced search page spends 100 quota units.)
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
for (const queryKey of keys) qc.setQueriesData<Cached>({ queryKey }, patch);
}
+4
View File
@@ -32,6 +32,10 @@ export const qk = {
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
videoMarks: () => ["video-marks"] as const,
// --- channels ---
channels: () => ["channels"] as const,
+22 -3
View File
@@ -147,7 +147,7 @@ it("styles exactly the schemes the app offers", () => {
// advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its
// own state; the primitives (Button, IconButton) rely on the utilities to hold this for them.
it("the state layers stand down on a disabled, busy or pressed control", () => {
const guard = /:not\(:disabled, \[aria-disabled="true"\]/;
const guard = /:not\(\s*:disabled,\s*\[aria-disabled="true"\]/;
for (const name of ["state-layer", "state-filled", "state-danger"]) {
const at = css.indexOf(`@utility ${name} {`);
const body = css.slice(at, css.indexOf("\n}", at));
@@ -155,9 +155,28 @@ it("the state layers stand down on a disabled, busy or pressed control", () => {
expect(states.length, name).toBe(2);
for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard);
}
expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch(
/&:hover:not\([^)]*\[aria-pressed="true"\][^)]*\)/,
// A pressed toggle and a lit mode cycle keep their look under the pointer: no layer over them.
const layer = css.slice(
css.indexOf("@utility state-layer {"),
css.indexOf("@utility state-filled {"),
);
const states = [...layer.matchAll(/&:(hover|active):not\(([^)]*)\)/g)];
expect(states.map(([, state]) => state)).toEqual(["hover", "active"]);
for (const [, state, exempt] of states) {
expect(exempt, state).toContain('[aria-pressed="true"]');
expect(exempt, state).toContain("[data-lit]");
}
});
// …and their colours outrank a variant's hover colour (`act:hover:text-fg`, the overlay's
// `act:hover:bg-white/15`), the more specific selector: only an important one does.
it("a pressed or lit control's colours are marked important", () => {
const styles = readFileSync(new URL("../components/ui/buttonStyles.ts", import.meta.url), "utf8");
for (const name of ["PRESSED", "LIT", "ICON_PRESSED", "ICON_LIT"]) {
const value = new RegExp(`const ${name} =\\s*"([^"]+)"`).exec(styles)?.[1];
expect(value, name).toBeTruthy();
for (const cls of value!.split(/\s+/)) expect(cls, name).toMatch(/!$/);
}
});
// The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent
@@ -35,8 +35,18 @@ vi.mock("../../features/navigation/navigationContext", () => ({
useNavigationActions: () => ({ setPage: h.setPage }),
}));
vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab }));
// The open player's mark, for a test to make as the player would.
const playerMark = vi.hoisted(() => ({
onState: null as null | ((id: string, s: string) => Promise<unknown>),
}));
vi.mock("../../features/player/PlayerModal", () => ({
default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => {
default: (p: {
video: { id: string };
queue: { id: string }[];
startIndex: number;
onState: (id: string, s: string) => Promise<unknown>;
}) => {
playerMark.onState = p.onState;
h.player(
p.video.id,
p.queue.map((v) => v.id),
@@ -161,6 +171,31 @@ describe("WatchedChannels", () => {
await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0));
});
it("puts the player's mark into its related lists, which are never refetched", async () => {
vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({
ok: true,
items: [video("b")],
gone: 0,
});
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
const qc = mount({ imported: true, pending: 0, channels: [channel(1)] });
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [{ items: [{ id: "b", status: "new" }], has_more: false, next_cursor: null }],
pageParams: [null],
});
fireEvent.click(await screen.findByRole("button", { name: "29" }));
const dialog = await screen.findByRole("dialog");
fireEvent.click(await within(dialog).findByRole("button", { name: "card.play — Video b" }));
await waitFor(() => expect(playerMark.onState).not.toBeNull());
act(() => void playerMark.onState!("b", "watched"));
await waitFor(() => {
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { status: string }[] }[];
};
expect(cached.pages[0]!.items[0]!.status).toBe("watched");
});
});
it("asks for a channel's videos once per opening, whatever the list does meanwhile", async () => {
// The ask explores the channel and may spend quota: the list's own refetch (an Undo, a follow)
// must not repeat it under an open dialog, nor swap the queue of a video playing.
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EyeOff, Play, UserPlus } from "lucide-react";
import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../../features/video/videoWrites";
import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api";
import { formatDate, formatDuration } from "../../lib/format";
import { notifyActionError } from "../../lib/actionErrors";
@@ -53,8 +54,8 @@ export default function WatchedVideosDialog({
// The player's watched / in-progress marks: saved, then shown on this list and the feed's.
const onState = useStableCallback((id: string, status: VideoStatus) => {
api
.setState(id, status)
const write = writeVideoState(qc, id, status);
write
.then(() => {
qc.setQueryData(
key,
@@ -67,6 +68,7 @@ export default function WatchedVideosDialog({
void qc.invalidateQueries({ queryKey: qk.feed() });
})
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
return write;
});
const closePlayer = useStableCallback(() => setPlaying(null));
@@ -54,10 +54,12 @@ vi.mock("../../features/player/PlayerModal", async () => {
default: function Player({
video,
onState,
onWrite,
onClose,
}: {
video: { id: string };
onState: (id: string, status: string) => void;
onState: (id: string, status: string, v?: unknown) => void;
onWrite?: (write: Promise<unknown>) => void;
onClose: () => void;
}) {
useEffect(
@@ -70,6 +72,19 @@ vi.mock("../../features/player/PlayerModal", async () => {
return (
<div>
<button onClick={() => onState(video.id, "watched")}>mark</button>
<button
onClick={() =>
onState("rel1", "watched", {
id: "rel1",
title: "Related one",
channel_id: "UCr",
channel_title: "Rel channel",
})
}
>
mark related
</button>
<button onClick={() => onWrite?.(Promise.resolve())}>related list wrote</button>
<button onClick={onClose}>close</button>
</div>
);
@@ -81,6 +96,7 @@ import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { patchCachedProgress } from "../../lib/progressCache";
import { clearAll, getNotifications } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -99,8 +115,24 @@ afterEach(() => {
vi.restoreAllMocks();
player.watchOnUnmount = false;
player.flush = null;
clearAll();
});
// A cached related list (the player's, kept for the session) holding these videos.
const seedRelated = (qc: QueryClient, ...ids: string[]) =>
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [
{ items: ids.map((id) => ({ id, status: "new" })), has_more: false, next_cursor: null },
],
pageParams: [null],
});
const relatedStatus = (qc: QueryClient, id: string) =>
(
qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { id: string; status: string }[] }[];
}
).pages[0]!.items.find((v) => v.id === id)?.status;
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
@@ -227,6 +259,39 @@ describe("Feed — a video marked watched in the player", () => {
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("refetches when the player's related list wrote", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("related list wrote"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("names a video that is none of its own in the notice", async () => {
mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark related"));
await waitFor(() =>
expect(getNotifications().map((n) => n.meta)).toContainEqual({
kind: "video-watched",
videoId: "rel1",
title: "Related one",
channelId: "UCr",
channelName: "Rel channel",
}),
);
});
it("puts the mark into the player's related lists, which are never refetched", async () => {
const { qc } = mount();
seedRelated(qc, "v1", "other");
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
await waitFor(() => expect(relatedStatus(qc, "v1")).toBe("watched"));
expect(relatedStatus(qc, "other")).toBe("new");
});
it("does not refetch on a close with nothing changed", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
//
// An empty feed with a term typed offers to search YouTube for it — unless the instance turned live
// search off (a daily limit of 0), where that search could only be refused.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "cats",
sort: "relevance",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
function mount(searchEnabled: boolean) {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 0, facets: {} } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, search_enabled: searchEnabled });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
}
describe("Feed — an empty result with a term typed", () => {
it("offers a live YouTube search for it", async () => {
mount(true);
await screen.findByText("feed.noMatches");
expect(screen.getByRole("button", { name: "feed.yt.searchFor" })).toBeTruthy();
});
it("offers none when the instance turned live search off", async () => {
mount(false);
// The empty state is up: what it offers is final.
await screen.findByText("feed.noMatches");
expect(screen.queryByRole("button", { name: "feed.yt.searchFor" })).toBeNull();
});
});
+102 -63
View File
@@ -22,12 +22,32 @@ import {
type LucideIcon,
} from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
import {
api,
HttpError,
refusalReason,
type FeedFilters,
type Video,
type VideoStatus,
} from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { notify, resolveVideo } from "../../lib/notifications";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import {
resetVideoState,
writeVideoState,
writeWatchLater,
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import {
markedRows,
patchCachedProgress,
progressFlushes,
stampFetch,
useVideoMarks,
} from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -107,10 +127,17 @@ function matchesView(status: VideoStatus, show: string): boolean {
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
// than its answer, and so is one sent just before it whose answer had not come back (the server may
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
type Stamped<T> = { value: T; seq: number };
// `answered`: the server has taken the write — the live search then reads it from the marks
// (`markedRows`), where a newer word from elsewhere can follow it; the feed holds it until a refetch.
type Stamped<T> = { value: T; seq: number; answered?: boolean };
type Overrides<T> = Record<string, Stamped<T>>;
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
// The server took a write: say so on its own value only — a later write for the same video stands.
const withAnswer = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
const own = o[id];
return own?.seq === seq ? { ...o, [id]: { ...own, answered: true } } : o;
};
// A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o;
@@ -122,6 +149,15 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
"search_daily_limit",
"search_disabled",
"quota_spent",
"quota",
]);
export default function Feed({
filters,
setFilters,
@@ -178,6 +214,9 @@ export default function Feed({
null,
);
const qc = useQueryClient();
const videoMarks = useVideoMarks();
// Live search turned off on this instance: the empty feed offers no search that would be refused.
const searchOn = useMe().search_enabled;
// What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
@@ -251,7 +290,7 @@ export default function Feed({
const ytQuery = useInfiniteQuery({
queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
enabled: ytActive,
@@ -339,18 +378,23 @@ export default function Feed({
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
const onState = useCallback(
(id: string, status: VideoStatus) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
// A write made while the player is open joins its session: the list refetches once the player
// has closed and every such write has landed (the effect above). The player's own related list
// writes through this too — its marks reach videos this list may hold.
const trackSessionWrite = useCallback((write: Promise<unknown>) => {
const session = playerSession.current;
if (session.open) {
if (!session.open) return;
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}
}, []);
const onState = useCallback(
(id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = writeVideoState(qc, id, status);
trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
@@ -363,45 +407,11 @@ export default function Feed({
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "watched") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: {
label: i18n.t("feed.unwatch"),
onClick: () => onStateRef.current(id, "new"),
},
meta: {
kind: "video-watched",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
resolveVideo(id);
}
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
})
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
@@ -410,8 +420,9 @@ export default function Feed({
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => withoutWrite(o, id, seq));
});
return write;
},
[qc],
[qc, trackSessionWrite],
);
const onResetState = useCallback(
@@ -426,11 +437,11 @@ export default function Feed({
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq);
api
.clearState(id)
resetVideoState(qc, id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => {
answered.current.add(seq);
setOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => {
@@ -448,9 +459,10 @@ export default function Feed({
(id: string, saved: boolean) => {
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
writeWatchLater(qc, id, saved)
.then(() => {
answered.current.add(seq);
setSavedOverrides((o) => withAnswer(o, id, seq));
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
@@ -468,21 +480,27 @@ export default function Feed({
);
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
// A mark made anywhere else (the player's related list, the Notification Center's undo) since a
// page was read is laid over it: a Load more on its way when the mark landed rebuilt the list
// from the pages it started from, without the mark. The feed's own writes reach the marks too,
// once answered: over these rows only an unanswered one of them is laid (`withOverrides`), or an
// earlier click of the feed's hid a newer mark from elsewhere for the rest of the search.
const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks);
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
useLayoutEffect(() => {
onStateRef.current = onState;
loadedRef.current = ytActive ? ytLoaded : loaded;
});
const withOverrides = (list: Video[]): Video[] =>
// `live`: the live search's rows, which carry the marks — there an answered write stands down.
const withOverrides = (list: Video[], live = false): Video[] =>
list
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status: status.value } : v;
return status && !(live && status.answered) ? { ...v, status: status.value } : v;
})
.map((v) => {
const saved = savedOverrides[v.id];
return saved ? { ...v, saved: saved.value } : v;
return saved && !(live && saved.answered) ? { ...v, saved: saved.value } : v;
});
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
@@ -504,7 +522,7 @@ export default function Feed({
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
if (ytActive) {
const ytItems: Video[] = sortYtResults(
withOverrides(ytLoaded),
withOverrides(ytLoaded, true),
ytSort.key as SortKey,
ytSort.dir,
lang,
@@ -516,6 +534,9 @@ export default function Feed({
: ytQuery.isError
? t("feed.yt.error")
: null;
// A refusal a retry cannot answer (the day's cap, turned off, the shared budget): Load more —
// the same request — is not offered after it.
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
@@ -602,8 +623,17 @@ export default function Feed({
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytError && !ytItems.length ? (
// A first page that failed is said as the error it is, with Retry where trying again can
// help (YouTube busy or unreachable): Search YouTube again with the same term reads the
// failure from the cache and sends nothing.
<StateMessage
tone="error"
className="max-w-md mx-auto"
onRetry={ytFinal ? undefined : () => void ytQuery.refetch()}
>
{ytError}
</StateMessage>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
) : (
@@ -619,7 +649,7 @@ export default function Feed({
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
{ytQuery.hasNextPage && !ytFinal && (
<div className="text-center pt-4">
<Button
variant="outline"
@@ -636,6 +666,13 @@ export default function Feed({
</Button>
</div>
)}
{/* A page that failed after others loaded: they stay, and the failure is said under
them (Load more above is the retry, when there is one). */}
{ytError && (
<StateMessage tone="error" compact className="mt-3">
{ytError}
</StateMessage>
)}
</>
)}
@@ -647,6 +684,7 @@ export default function Feed({
queue={ytPlayerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
@@ -928,7 +966,7 @@ export default function Feed({
</Button>
</div>
)}
{filters.q.trim() && (
{filters.q.trim() && searchOn && (
<Button
className="mt-4"
icon={<YouTubeIcon />}
@@ -971,6 +1009,7 @@ export default function Feed({
queue={playerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
//
// A live YouTube search's Load more that fails. The results already read stay on screen and the
// failure is said under them; before, the whole pane turned into the error text. Load more is the
// retry where trying again can help, and is not offered after a refusal it cannot answer (the day's
// cap, the feature turned off, the shared budget). And a mark made elsewhere while a Load more is on
// its way is not undone by the page landing, nor by an earlier write of the feed's own.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { act, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("../../features/wizard/wizardContext", () => ({
useWizard: () => ({ openWizard: vi.fn() }),
}));
vi.mock("./VirtualFeed", () => ({
default: ({
items,
onState,
onToggleSave,
}: {
items: { id: string; title: string; status: string; saved?: boolean }[];
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
}) => (
<>
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
{v.title} ({v.status}
{v.saved ? ", saved" : ""})
</li>
))}
</ol>
{items.map((v) => (
<span key={v.id}>
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
<button onClick={() => onToggleSave(v.id, true)}>save {v.id}</button>
</span>
))}
</>
),
}));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const hit = (id: string): Video =>
({
id,
title: `hit ${id}`,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
function setup(failure: HttpError) {
vi.spyOn(api, "searchYoutube")
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
.mockRejectedValueOnce(failure);
return mount();
}
function mount(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch="lemmino"
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
return qc;
}
const loadMore = () => screen.queryByRole("button", { name: "feed.yt.loadMoreFree" });
describe("Feed — a live search's Load more that fails", () => {
it("keeps the results read, says why under them, and offers Load more again", async () => {
setup(
new HttpError(422, "YouTube isn't reachable", { code: "youtube", reason: "unavailable" }),
);
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
expect(await screen.findByText("YouTube isn't reachable")).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a");
expect(loadMore()).toBeTruthy();
});
it("offers no Load more after a refusal a retry cannot answer", async () => {
setup(
new HttpError(429, "Today's limit", {
code: "refused",
reason: "search_daily_limit",
limit: 2,
}),
);
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
expect(await screen.findByText("Today's limit")).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a");
await waitFor(() => expect(loadMore()).toBeNull());
});
});
describe("Feed — a live search whose first page fails", () => {
// Search YouTube again with the same term reads the cached failure: Retry is the way to ask again.
it("offers Retry where trying again can help, and reads the page on it", async () => {
const search = vi
.spyOn(api, "searchYoutube")
.mockRejectedValueOnce(
new HttpError(429, "Too many searches", { code: "refused", reason: "search_busy" }),
)
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: null, source: "scrape" } as never);
mount();
expect(await screen.findByText("Too many searches")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
await waitFor(() =>
expect(screen.getByRole("list", { name: "results" }).textContent).toBe("hit a (new)"),
);
expect(search).toHaveBeenCalledTimes(2);
});
it("offers no Retry after a refusal a retry cannot answer", async () => {
vi.spyOn(api, "searchYoutube").mockRejectedValue(
new HttpError(429, "Today's limit", {
code: "refused",
reason: "search_daily_limit",
limit: 2,
}),
);
mount();
expect(await screen.findByText("Today's limit")).toBeTruthy();
expect(screen.queryByRole("button", { name: "common.retry" })).toBeNull();
});
});
describe("Feed — a live search's Load more on its way", () => {
// Marked in this session, then changed back elsewhere: a search read after the mark carries the
// server's word.
it("shows a search read after a mark as the server has it", async () => {
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a")],
next_cursor: null,
source: "scrape",
} as never);
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await writeVideoState(qc, "a", "watched");
await new Promise((r) => setTimeout(r, 5)); // the search is read a moment after the mark
mount(qc);
expect(await screen.findByText(/hit a/)).toBeTruthy();
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a (new)");
});
it("keeps a mark made elsewhere meanwhile when the page lands", async () => {
let land: (p: unknown) => void = () => {};
vi.spyOn(api, "searchYoutube")
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
.mockReturnValueOnce(new Promise((r) => (land = r)) as never);
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = mount();
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
// Hidden from the player's related list, say: not one of the feed's own writes.
await act(async () => void (await writeVideoState(qc, "a", "hidden")));
await act(async () => land({ items: [hit("b")], next_cursor: null, source: "scrape" }));
await screen.findByText(/hit b/);
expect(screen.getByRole("list", { name: "results" }).textContent).toBe(
"hit a (hidden)hit b (new)",
);
});
});
describe("Feed — a live search's own writes and a newer mark", () => {
const results = () => screen.getByRole("list", { name: "results" }).textContent;
const searched = () => {
vi.spyOn(api, "searchYoutube").mockResolvedValue({
items: [hit("a")],
next_cursor: null,
source: "scrape",
} as never);
return mount();
};
const tick = () => act(async () => void (await new Promise((r) => setTimeout(r, 5))));
// Hidden from its card, then put back from the player's related row (which writes past the feed).
it("shows a status marked elsewhere after the feed's own answered write", async () => {
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = searched();
fireEvent.click(await screen.findByRole("button", { name: "hide a" }));
await waitFor(() => expect(results()).toBe("hit a (hidden)"));
await tick();
await act(async () => void (await writeVideoState(qc, "a", "new")));
await waitFor(() => expect(results()).toBe("hit a (new)"));
});
it("shows a bookmark taken off elsewhere after the feed's own answered save", async () => {
vi.spyOn(api, "watchLaterAdd").mockResolvedValue({ ok: true } as never);
vi.spyOn(api, "watchLaterRemove").mockResolvedValue({ ok: true } as never);
const qc = searched();
fireEvent.click(await screen.findByRole("button", { name: "save a" }));
await waitFor(() => expect(results()).toBe("hit a (new, saved)"));
await tick();
await act(async () => void (await writeWatchLater(qc, "a", false)));
await waitFor(() => expect(results()).toBe("hit a (new)"));
});
it("keeps its own write over the rows until the server has answered it", async () => {
vi.spyOn(api, "setState").mockReturnValue(new Promise(() => {}) as never);
searched();
fireEvent.click(await screen.findByRole("button", { name: "hide a" }));
await waitFor(() => expect(results()).toBe("hit a (hidden)"));
});
});
@@ -45,7 +45,7 @@ function tipFor(source: Me["search_source"]): string | null {
disconnect() {}
},
);
me = { search_source: source, role: "user" } as Me;
me = { search_source: source, search_enabled: true, role: "user" } as Me;
render(
<PageShell header={<div />}>
<FeedHeader />
@@ -63,3 +63,22 @@ describe("the live-search tooltip", () => {
expect(tipFor("api")).toBe("header.searchYoutubeTipApi");
});
});
describe("the live-search button", () => {
it("is left out when the instance turned live search off", () => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
me = { search_source: "scrape", search_enabled: false, role: "user" } as Me;
render(
<PageShell header={<div />}>
<FeedHeader />
</PageShell>,
);
expect(screen.queryByRole("button", { name: /header\.searchYoutube$/ })).toBeNull();
});
});
+2 -1
View File
@@ -30,7 +30,8 @@ export default function FeedHeader() {
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onGo={() => enterYtSearch(trimmed)}
// Turned off on this instance, the box only filters: no Go that could only be refused.
onGo={me.search_enabled ? () => enterYtSearch(trimmed) : undefined}
goLabel={t("header.searchYoutube")}
goTitle={t(
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Video, VideoStatus } from "../../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
import type { NEXT_PAGE } from "../../lib/nextPage";
import VideoCard from "../../features/video/VideoCard";
import { VideoMenu } from "./VideoMenu";
import { VideoMenu } from "../../features/video/VideoMenu";
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
import { useContextMenu } from "../../components/ui/useContextMenu";
import VirtualGrid from "../../components/ui/VirtualGrid";
@@ -22,6 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import FilePreview from "./FilePreview";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { closeKeyboardHelp, toggleKeyboardHelp, useKeyboardHelpCovered } from "../../lib/keymap";
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
({
@@ -170,6 +171,27 @@ describe("FilePreview · stepping", () => {
expect(onFileChange).toHaveBeenLastCalledWith(3);
});
// …so the help sheet opened over that layer leaves them out, and one over the viewer lists them.
it("tells the help sheet its keys wait while a layer sits above it", () => {
function Covered() {
return <span data-testid="covered">{[...useKeyboardHelpCovered()].join(",")}</span>;
}
mount(list, 2);
render(<Covered />);
const help = () => act(() => toggleKeyboardHelp());
const confirm = pushLayer();
try {
help();
expect(screen.getByTestId("covered").textContent).toBe("fileViewer");
act(() => closeKeyboardHelp());
} finally {
removeLayer(confirm);
}
help();
expect(screen.getByTestId("covered").textContent).toBe("");
act(() => closeKeyboardHelp());
});
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
+7 -1
View File
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../../lib/api";
import { formatBytes } from "../../lib/format";
import { dispatch, useKeyScope } from "../../lib/keymap";
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
import { jobTitle } from "../../features/downloads/downloadTitle";
@@ -85,6 +85,12 @@ export default function FilePreview({
// child first, so it sits just ABOVE the dialog's layer: the dialog is never topmost, and Escape is
// this layer's to take — passive, so the dialog keeps its Tab trap.
const layer = useLayer(!!file, onClose, { passive: true });
// …and the help sheet opened over that layer must not list them (keymap `setScopeCoveredProbe`).
const coveredNow = useEffectEvent(() => layer.blockingAbove());
useEffect(() => {
setScopeCoveredProbe("fileViewer", () => coveredNow());
return () => setScopeCoveredProbe("fileViewer", null);
}, []);
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (layer.blockingAbove()) return;
// The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
@@ -11,10 +11,10 @@ import {
Pin,
PinOff,
PlayCircle,
RefreshCw,
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../../lib/api";
@@ -126,7 +126,7 @@ export function useFileActions({
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
icon: icon(RotateCcw),
onSelect: () => onPlay(file, true),
},
]
@@ -269,7 +269,7 @@ export function useFileActions({
{
id: "redownload",
label: t("files.actions.redownload"),
icon: icon(RotateCcw),
icon: icon(RefreshCw),
onSelect: () =>
void confirmThen(
t("downloads.confirm.redownloadTitle", { name: jobTitle(file) }),
@@ -495,6 +495,31 @@ describe("Home", () => {
expect(screen.queryByText("Hero v1")).toBeNull();
});
it("puts a mark into the player's related lists, which are never refetched", async () => {
const { qc } = mountWithClient();
await screen.findByText("Second thing");
qc.setQueryData(qk.related("SRC", "topical"), {
pages: [
{
items: [
{ id: "v1", status: "new" },
{ id: "v2", status: "new" },
],
has_more: false,
next_cursor: null,
},
],
pageParams: [null],
});
fireEvent.click(screen.getAllByTitle("card.markWatched")[0]!);
await waitFor(() => {
const cached = qc.getQueryData(qk.related("SRC", "topical")) as {
pages: { items: { status: string }[] }[];
};
expect(cached.pages[0]!.items.some((v) => v.status === "watched")).toBe(true);
});
});
it("refreshes the payload the bookmark is read from", async () => {
mount();
await screen.findByText("Second thing");
+9 -6
View File
@@ -10,7 +10,6 @@ import {
PinOff,
Play,
RotateCcw,
SkipBack,
Star,
} from "lucide-react";
import { qk } from "../../lib/queryKeys";
@@ -53,6 +52,7 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
import { useVideoActions } from "../../features/video/useVideoActions";
import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites";
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
import { usePlaylistsActions } from "../playlists";
@@ -194,7 +194,7 @@ function useHomeCardMenu({
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className="w-4 h-4" />,
icon: <RotateCcw className="w-4 h-4" />,
onSelect: () => onPlay(target, 0),
},
]
@@ -836,9 +836,11 @@ export default function Home() {
// A full refetch, unlike the feed's stale-mark: these rails are two dozen rows, and marking
// something watched genuinely changes what the page should answer (it can retire the hero).
// The feed avoids this because it would refetch ten infinite-query pages per click.
void api
.setState(id, status)
.then(() => qc.invalidateQueries({ queryKey: qk.discover() }))
const write = writeVideoState(qc, id, status);
void write
.then(() => {
return qc.invalidateQueries({ queryKey: qk.discover() });
})
// Without this the rejection escapes unhandled and the click just does nothing — the
// feed's equivalents both catch, and the reorder on this same page already does.
// A WRITE key, not `home.error`: that string is the page's LOAD failure ("could not load
@@ -847,13 +849,14 @@ export default function Home() {
// would describe a state the user can see is untrue, over a fully populated page whose
// invalidations never even ran (they sit inside `.then`).
.catch((e: unknown) => notifyActionError(e, t("home.actionFailed")));
return write;
},
[qc, t],
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
void (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
void writeWatchLater(qc, id, saved)
.then(() => {
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
@@ -48,6 +48,7 @@ import { PageToolbar } from "../../components/ui/PageShell";
import { PageMenu } from "../../components/ui/PageMenu";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import { useNavigationActions } from "../../features/navigation/navigationContext";
import { writeVideoState } from "../../features/video/videoWrites";
import { useFilesActions } from "../files";
import { useChannelsActions } from "../channels";
import { useFeedFilters, useFeedFiltersActions } from "../feed";
@@ -170,8 +171,7 @@ export default function NotificationsPanel() {
setPage("feed");
}
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta) {
api
.setState(meta.videoId, "new")
writeVideoState(qc, meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });

Some files were not shown because too many files have changed in this diff Show More