Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c430558ab3 | ||
|
|
be38d652a5 | ||
|
|
31a75f4985 | ||
|
|
f8a8e905da | ||
|
|
5e8589bbf8 | ||
|
|
ceadf69e43 | ||
|
|
4379f76747 | ||
|
|
b3be568e36 | ||
|
|
a8cbc7b338 | ||
|
|
cf8dc80428 | ||
|
|
da74b8b9bf | ||
|
|
6944622085 | ||
|
|
2bdcebdb34 | ||
|
|
046dde44a4 | ||
|
|
74f58ea001 | ||
|
|
b515c16117 | ||
|
|
4a0d4b0964 | ||
|
|
cb8b99e0e7 | ||
|
|
e5c75702e8 | ||
|
|
4ef772d37b | ||
|
|
6986eb1937 | ||
|
|
41ad538edc | ||
|
|
7489cab3bc | ||
|
|
91256b10b0 | ||
|
|
b22e903174 | ||
|
|
d6e72d7576 | ||
|
|
e3a4593acf | ||
|
|
fa271a31b9 | ||
|
|
947efaba6e | ||
|
|
22d873ed49 | ||
|
|
d825aff30d | ||
|
|
9dd56e94d4 | ||
|
|
25ad2ee1ba | ||
|
|
c86ec7f7a5 | ||
|
|
0469812499 | ||
|
|
ee658b8bfc | ||
|
|
52b93d0014 | ||
|
|
c3ad7684ea | ||
|
|
a5666bfde9 | ||
|
|
f49dc6532f | ||
|
|
0d410592e2 | ||
|
|
7e5d4a7d93 | ||
|
|
24190e4c16 | ||
|
|
e96a562f14 | ||
|
|
4084dd2f92 | ||
|
|
6aa6a6efdf | ||
|
|
5686759bca | ||
|
|
99a33e7fd1 | ||
|
|
8804bdde63 | ||
|
|
35266fd7e8 | ||
|
|
6211e15611 | ||
|
|
02906d0acb | ||
|
|
66e427dfa4 | ||
|
|
011dea8eb7 | ||
|
|
e2b725dba7 | ||
|
|
6d57d62ac8 | ||
|
|
0d5c6273f1 | ||
|
|
7b4e007680 | ||
|
|
3d26ca0efa | ||
|
|
fa893c73a3 | ||
|
|
d4f24de4ba | ||
|
|
91d36b3357 | ||
|
|
9bd7304bb9 | ||
|
|
b30c60d1a5 | ||
|
|
4aff19a9a4 | ||
|
|
dc50b856fd | ||
|
|
ee6e37f032 | ||
|
|
4c2fbf9b8d | ||
|
|
9d6d7525e5 | ||
|
|
ee4a5b46f8 | ||
|
|
0bc1024287 | ||
|
|
13f65b4844 | ||
|
|
2e1934689f | ||
|
|
84db570951 | ||
|
|
b25179c06a | ||
|
|
620dd8178f | ||
|
|
5d009964d8 | ||
|
|
4b5b66343d | ||
|
|
ed51f5be7e | ||
|
|
005784e291 | ||
|
|
b5045165c0 | ||
|
|
d53729a63d | ||
|
|
b04b51c909 | ||
|
|
7d45c4f59f | ||
|
|
5205091116 | ||
|
|
686c673fb2 | ||
|
|
8b37c470ba |
@@ -0,0 +1,8 @@
|
|||||||
|
# gitleaks false positives, one fingerprint per finding (commit:file:rule:line). Each says why it is no secret.
|
||||||
|
#
|
||||||
|
# YouTube's public WEB InnerTube key: embedded in every youtube.com results page, read live and cached;
|
||||||
|
# this constant is only the fallback (backend/app/youtube/innertube.py).
|
||||||
|
8b37c470ba5d11c45757f0b2e62d660b782e816d:backend/app/youtube/innertube.py:gcp-api-key:23
|
||||||
|
# A recorded watch-next page's continuation cursors (continuationCommand.token): page pointers, not credentials.
|
||||||
|
8b37c470ba5d11c45757f0b2e62d660b782e816d:backend/tests/fixtures/innertube_next_first.json:generic-api-key:768
|
||||||
|
8b37c470ba5d11c45757f0b2e62d660b782e816d:backend/tests/fixtures/innertube_next_first.json:generic-api-key:792
|
||||||
@@ -7,6 +7,69 @@ 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).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.114.3 — 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Editing a file that is neither video nor audio now says why it can't be done, instead of a generic error.
|
||||||
|
|
||||||
|
## 0.114.2 — 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Security: a library the sign-in relies on is updated, closing two published vulnerabilities.
|
||||||
|
|
||||||
|
## 0.114.1 — 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too.
|
||||||
|
|
||||||
|
## 0.114.0 — 2026-10-29
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.
|
||||||
|
- After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open.
|
||||||
|
|
||||||
|
## 0.113.0 — 2026-10-28
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.
|
||||||
|
- A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.
|
||||||
|
- Signed out on a Plex page, the sign-in card could show a button with no text.
|
||||||
|
- The channel page's address names the channel and its own view, not the page under it.
|
||||||
|
- A reload during a Plex binge reopens the episode playing now, not the one it started from.
|
||||||
|
|
||||||
|
## 0.112.1 — 2026-10-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Security: the sign-in library is updated, closing two published vulnerabilities.
|
||||||
|
|
||||||
|
## 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
|
## 0.111.0 — 2026-10-24
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
|
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
|
||||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||||
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
| User-facing error | structured `detail` (`{code, reason}`), its sentence named in `lib/api/refusalCodes.ts` (contract-held) | a bare English string under a bilingual UI |
|
||||||
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
|
||||||
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
|
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
|
||||||
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
|
||||||
@@ -115,6 +115,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
|
| FE view state | the module's URL codec: its keys in `urlKeys` (`module.ts`), read and written by `lib/<id>UrlState.ts` through `useUrlMirror` (contract-held, `e2e/bookmark.spec.ts`) | `useState` / sessionStorage alone: a reload, a bookmark or a shared link loses the view |
|
||||||
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
|
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
|
||||||
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
|
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
|
||||||
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
|
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
|
||||||
|
|||||||
@@ -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]:
|
def blocked_channels(db: Session, user: User, lang: str) -> list[Channel]:
|
||||||
return list(
|
return list(
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ def ingest_candidates(
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Materialise one page of search candidates into the catalog (channel stubs + enrich,
|
"""Materialise one page of search candidates into the catalog (channel stubs + enrich,
|
||||||
video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
|
video stubs flagged via_search, enrich, Shorts classification) and return the KEEPABLE
|
||||||
video ids — non-Short, non-live — in the input relevance order."""
|
video ids — non-Short, non-live — in the input relevance order. A videos.list that fails
|
||||||
|
raises its YouTubeError: with nothing enriched, nothing could be kept."""
|
||||||
ids = [it["id"] for it in candidates]
|
ids = [it["id"] for it in candidates]
|
||||||
if not ids:
|
if not ids:
|
||||||
return []
|
return []
|
||||||
@@ -132,13 +133,16 @@ def ingest_candidates(
|
|||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
||||||
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
|
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
|
||||||
except YouTubeError:
|
except YouTubeError:
|
||||||
# Every candidate then stays unenriched, and an unenriched video cannot be judged live or
|
# An unenriched video cannot be judged live or Short, so none of this page could be kept:
|
||||||
# Short — so it is dropped. A working search returns nothing and reads as "no results".
|
# swallowed, the failure answered as a page with nothing on it — "no results" for a search
|
||||||
|
# that never ran (the shared budget spent, no API key). The stubs stay (`insert_stubs`
|
||||||
|
# committed them), as the channels' do above; the caller answers the failure (its refusal
|
||||||
|
# says which).
|
||||||
log.warning(
|
log.warning(
|
||||||
"the videos found by a search could not be enriched",
|
"the videos found by a search could not be enriched",
|
||||||
extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
|
extra=logs.event("search.video_enrich_failed", candidates=len(ids)),
|
||||||
)
|
)
|
||||||
detail_by_id = {}
|
raise
|
||||||
enriched_at = datetime.now(
|
enriched_at = datetime.now(
|
||||||
timezone.utc
|
timezone.utc
|
||||||
) # when THIS enrich ran (distinct from the stub placeholder)
|
) # when THIS enrich ran (distinct from the stub placeholder)
|
||||||
@@ -212,17 +216,25 @@ def clear(db: Session, user: User, video_ids: list[str]) -> int:
|
|||||||
"""Discard these results "as if never added": drop this user's finds of them, then delete the
|
"""Discard these results "as if never added": drop this user's finds of them, then delete the
|
||||||
ones nobody kept — a `via_search` video no reader holds (`reader.queries.kept_by_a_reader`),
|
ones nobody kept — a `via_search` video no reader holds (`reader.queries.kept_by_a_reader`),
|
||||||
whose channel nobody subscribes to, and that no other user still has as a find. Returns how
|
whose channel nobody subscribes to, and that no other user still has as a find. Returns how
|
||||||
many videos went."""
|
many videos went.
|
||||||
db.execute(
|
|
||||||
delete(SearchFind).where(
|
Only the ones this user found: a video in the catalog with no find at all (a related list's,
|
||||||
owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids)
|
which records none) is in someone's list right now, and an id any user could name would let
|
||||||
|
them delete it from under that list."""
|
||||||
|
mine = list(
|
||||||
|
db.scalars(
|
||||||
|
delete(SearchFind)
|
||||||
|
.where(owned_by(SearchFind.user_id, user), SearchFind.video_id.in_(video_ids))
|
||||||
|
.returning(SearchFind.video_id)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if not mine:
|
||||||
|
return 0
|
||||||
subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
|
subscribed = select(Subscription.id).where(Subscription.channel_id == Video.channel_id).exists()
|
||||||
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
|
other_find = select(SearchFind.id).where(SearchFind.video_id == Video.id).exists()
|
||||||
result = db.execute(
|
result = db.execute(
|
||||||
delete(Video).where(
|
delete(Video).where(
|
||||||
Video.id.in_(video_ids),
|
Video.id.in_(mine),
|
||||||
Video.via_search.is_(True),
|
Video.via_search.is_(True),
|
||||||
~reader_queries.kept_by_a_reader(Video.id),
|
~reader_queries.kept_by_a_reader(Video.id),
|
||||||
~subscribed,
|
~subscribed,
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ class Settings(BaseSettings):
|
|||||||
# each search.list costs 100 units, so the shared budget only affords ~80-90/day total; with
|
# each search.list costs 100 units, so the shared budget only affords ~80-90/day total; with
|
||||||
# the zero-quota scrape source it's a pure anti-abuse rate limit. Admin-tunable.
|
# the zero-quota scrape source it's a pure anti-abuse rate limit. Admin-tunable.
|
||||||
search_daily_limit_per_user: int = 70
|
search_daily_limit_per_user: int = 70
|
||||||
|
# Per-user cap on related-video 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,
|
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list,
|
||||||
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
|
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
|
||||||
search_source: SearchSource = "scrape"
|
search_source: SearchSource = "scrape"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import json
|
|||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||||
from app.plex.sync import HDR_TRANSFERS
|
from app.plex.sync import HDR_TRANSFERS
|
||||||
@@ -57,12 +58,27 @@ _CROP_ASPECTS = (0.25, 4.0)
|
|||||||
_EDGE_SLACK = 1e-3
|
_EDGE_SLACK = 1e-3
|
||||||
|
|
||||||
|
|
||||||
|
# Every reason a clip is refused; the UI names each one (`clip.errors.<reason>`, held by the frontend's
|
||||||
|
# backendIds contract, which reads this Literal).
|
||||||
|
ClipReason = Literal[
|
||||||
|
"bad_source",
|
||||||
|
"bad_range",
|
||||||
|
"too_short",
|
||||||
|
"too_long",
|
||||||
|
"bad_audio",
|
||||||
|
"bad_crop",
|
||||||
|
"not_found",
|
||||||
|
"media_missing",
|
||||||
|
"past_end",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ClipError(Exception):
|
class ClipError(Exception):
|
||||||
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
|
||||||
message names (the cap itself, so the client never says a stale one)."""
|
message names (the cap itself, so the client never says a stale one)."""
|
||||||
|
|
||||||
def __init__(self, reason: str, **params: float):
|
def __init__(self, reason: ClipReason, **params: float):
|
||||||
self.reason = reason
|
self.reason: ClipReason = reason
|
||||||
self.params = params
|
self.params = params
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Security controls enforced here:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -163,9 +164,18 @@ def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredenti
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# Every reason a pasted cookies.txt is refused; the UI names each one (`downloads.errors.cookies.<reason>`,
|
||||||
|
# held by the frontend's backendIds contract, which reads this Literal).
|
||||||
|
CookieReason = Literal["empty", "format"]
|
||||||
|
|
||||||
|
|
||||||
class InvalidCookies(ValueError):
|
class InvalidCookies(ValueError):
|
||||||
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
|
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
|
||||||
|
|
||||||
|
def __init__(self, reason: CookieReason):
|
||||||
|
super().__init__(reason)
|
||||||
|
self.reason: CookieReason = reason
|
||||||
|
|
||||||
|
|
||||||
def validate_cookies(raw: str) -> str:
|
def validate_cookies(raw: str) -> str:
|
||||||
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
|
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ kept it — and its `ready_at`, the moment it finished (`stamp_done`).
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -181,12 +182,22 @@ def stamp_done(db: Session, job: DownloadJob) -> None:
|
|||||||
job.expiry_warned = False
|
job.expiry_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
# Every folder change the rules refuse, answered as a generic refusal; the UI names each one
|
||||||
|
# (`errors.refused.<reason>`, held by the frontend's backendIds contract, which reads this Literal).
|
||||||
|
FolderReason = Literal[
|
||||||
|
"file_folder_gone",
|
||||||
|
"file_folder_name_taken",
|
||||||
|
"file_folder_not_its_source",
|
||||||
|
"file_folder_not_own",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class FolderError(Exception):
|
class FolderError(Exception):
|
||||||
"""A folder change the rules refuse; `reason` is the refusal the route answers with."""
|
"""A folder change the rules refuse; `reason` is the refusal the route answers with."""
|
||||||
|
|
||||||
def __init__(self, reason: str) -> None:
|
def __init__(self, reason: FolderReason) -> None:
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
self.reason = reason
|
self.reason: FolderReason = reason
|
||||||
|
|
||||||
|
|
||||||
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
|
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ their output's real size before it is placed. Edit jobs do not consult it at all
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -50,11 +51,16 @@ class QuotaLimits:
|
|||||||
unlimited: bool
|
unlimited: bool
|
||||||
|
|
||||||
|
|
||||||
class QuotaExceeded(Exception):
|
# Every cap a download can hit; the UI names each one (`downloads.errors.quota.<reason>`, held by the
|
||||||
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client)."""
|
# frontend's backendIds contract, which reads this Literal).
|
||||||
|
QuotaReason = Literal["max_jobs", "max_bytes"]
|
||||||
|
|
||||||
def __init__(self, reason: str, limit: int, current: int):
|
|
||||||
self.reason = reason
|
class QuotaExceeded(Exception):
|
||||||
|
"""Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client)."""
|
||||||
|
|
||||||
|
def __init__(self, reason: QuotaReason, limit: int, current: int):
|
||||||
|
self.reason: QuotaReason = reason
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.current = current
|
self.current = current
|
||||||
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
|
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ handler); the folder, link and quota writes are `folders`, `links` and `quota`.
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from sqlalchemy import delete as delete_rows
|
from sqlalchemy import delete as delete_rows
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
@@ -39,11 +40,16 @@ from app.plex import paths as plex_paths
|
|||||||
from app.userscope import owned_by, owned_row
|
from app.userscope import owned_by, owned_row
|
||||||
|
|
||||||
|
|
||||||
class EditError(Exception):
|
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
|
||||||
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key."""
|
# the frontend's backendIds contract, which reads this Literal).
|
||||||
|
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
|
||||||
|
|
||||||
def __init__(self, reason: str):
|
|
||||||
self.reason = reason
|
class EditError(Exception):
|
||||||
|
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
|
||||||
|
|
||||||
|
def __init__(self, reason: EditReason):
|
||||||
|
self.reason: EditReason = reason
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,24 +33,33 @@ user-controlled destination to guard.
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
# vocabulary — keep them stable. The UI names each one (`downloads.errors.urlBlocked.<reason>`, held by
|
||||||
REASON_SCHEME = "scheme" # not http(s)
|
# the frontend's backendIds contract, which reads this Literal).
|
||||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
UrlBlockedReason = Literal["scheme", "no_host", "malformed", "unresolvable", "private"]
|
||||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
REASON_SCHEME: UrlBlockedReason = "scheme" # not http(s)
|
||||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
REASON_NO_HOST: UrlBlockedReason = "no_host" # URL carries no hostname
|
||||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
REASON_MALFORMED: UrlBlockedReason = (
|
||||||
|
"malformed" # unparseable authority or port; urlparse raises late
|
||||||
|
)
|
||||||
|
REASON_UNRESOLVABLE: UrlBlockedReason = (
|
||||||
|
"unresolvable" # DNS said nothing (not the same as "blocked")
|
||||||
|
)
|
||||||
|
REASON_PRIVATE: UrlBlockedReason = (
|
||||||
|
"private" # resolves into a network the server must not fetch from
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BlockedUrl(Exception):
|
class BlockedUrl(Exception):
|
||||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||||
REASON_* codes above."""
|
REASON_* codes above."""
|
||||||
|
|
||||||
def __init__(self, reason: str):
|
def __init__(self, reason: UrlBlockedReason):
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
self.reason = reason
|
self.reason: UrlBlockedReason = reason
|
||||||
|
|
||||||
|
|
||||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's
|
|||||||
after a write-back, its local cleanup after a delete.
|
after a write-back, its local cleanup after a delete.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import CursorResult, delete, func, select, update
|
from sqlalchemy import CursorResult, delete, func, or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -271,6 +271,10 @@ def reorder_playlist(db: Session, pl: PlexPlaylist, rating_keys: list[str]) -> N
|
|||||||
|
|
||||||
# --- a user's watch state --------------------------------------------------------------------
|
# --- a user's watch state --------------------------------------------------------------------
|
||||||
|
|
||||||
|
# How long after a watched mark a mid-item position is read as the finishing player's late tick
|
||||||
|
# (`checkpoint`): the tick is every 10 s, plus the request's own latency and a retry.
|
||||||
|
_LATE_CHECKPOINT_S = 30
|
||||||
|
|
||||||
|
|
||||||
def mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
|
def mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
|
||||||
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
|
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
|
||||||
@@ -352,16 +356,33 @@ def checkpoint(
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
*,
|
*,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
) -> PlexState:
|
) -> bool:
|
||||||
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so
|
"""Record a resume position, and say whether it was recorded. `dirty` marks it not yet mirrored
|
||||||
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms."""
|
to Plex — set before a push, so the reconcile re-pushes it if the push fails and the push can
|
||||||
|
settle it once Plex confirms.
|
||||||
|
|
||||||
|
Not onto a row marked watched in the last `_LATE_CHECKPOINT_S`: that position is the player's
|
||||||
|
tick sent just before its own finish (or a mark from another tab), and writing it made a watched
|
||||||
|
item in progress again — on Plex too, through the resume push it carried. Decided by the write
|
||||||
|
itself, since the mark can commit between any read here and the write. A rewatch is checkpointed
|
||||||
|
as before once the mark is that far behind it."""
|
||||||
st = state_row(db, user, item_id, st)
|
st = state_row(db, user, item_id, st)
|
||||||
st.position_seconds = position
|
values: dict[str, object] = {"position_seconds": position, "progress_updated_at": now}
|
||||||
st.progress_updated_at = now
|
|
||||||
if dirty:
|
if dirty:
|
||||||
st.synced_to_plex = False
|
values["synced_to_plex"] = False
|
||||||
|
not_just_finished = or_(
|
||||||
|
PlexState.status != "watched",
|
||||||
|
PlexState.watched_at.is_(None),
|
||||||
|
PlexState.watched_at <= now - timedelta(seconds=_LATE_CHECKPOINT_S),
|
||||||
|
)
|
||||||
|
result = db.execute(
|
||||||
|
update(PlexState)
|
||||||
|
.where(PlexState.id == st.id, not_just_finished)
|
||||||
|
.values(values)
|
||||||
|
.execution_options(synchronize_session="fetch")
|
||||||
|
)
|
||||||
db.flush()
|
db.flush()
|
||||||
return st
|
return isinstance(result, CursorResult) and result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def forget_state(db: Session, st: PlexState) -> None:
|
def forget_state(db: Session, st: PlexState) -> None:
|
||||||
|
|||||||
+95
-18
@@ -66,7 +66,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
|
|||||||
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
|
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
|
||||||
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
|
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
|
||||||
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
|
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
|
||||||
# under the root" would take the neighbours with it.
|
# under the root" would take the neighbours with it. A torn-down session's directory is renamed to
|
||||||
|
# its own name + `_TOMB` + 8 hex digits before it is deleted (see `_detach`), so a crash between the
|
||||||
|
# two leaves a name this sweep still recognises.
|
||||||
#
|
#
|
||||||
# The KEY is anchored to digits (a Plex rating_key is a number) — that is what keeps foreign names
|
# The KEY is anchored to digits (a Plex rating_key is a number) — that is what keeps foreign names
|
||||||
# out. A `.+` there let `snapshot_2024_backup_+1.00` through, and pairing `.+` with an optional tail
|
# out. A `.+` there let `snapshot_2024_backup_+1.00` through, and pairing `.+` with an optional tail
|
||||||
@@ -74,8 +76,10 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
|
|||||||
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
|
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
|
||||||
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
|
||||||
# and someone else's data.
|
# and someone else's data.
|
||||||
|
_TOMB = ".dead-"
|
||||||
_SESSION_DIR_RE = re.compile(
|
_SESSION_DIR_RE = re.compile(
|
||||||
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
|
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$"
|
||||||
|
rf"|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{{2}}({re.escape(_TOMB)}[0-9a-f]{{8}})?$"
|
||||||
)
|
)
|
||||||
# When this process started. `sweep_orphans` only deletes directories older than this: everything
|
# When this process started. `sweep_orphans` only deletes directories older than this: everything
|
||||||
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
|
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path
|
||||||
@@ -93,7 +97,7 @@ _MTIME_GRACE_S = 2.0
|
|||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
|
# Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
|
||||||
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
|
# the same film tore down the first one's session: the displaced viewer got a 404, reclaimed at its
|
||||||
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
|
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
|
||||||
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
|
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
|
||||||
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
_sessions: dict[tuple[int, str], "HlsSession"] = {}
|
||||||
@@ -641,29 +645,82 @@ def _probe_first_pts(seg: Path) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _kill(s: HlsSession) -> None:
|
# Teardown is split in two, because every segment request and checkpoint takes `_lock`: waiting up to
|
||||||
|
# 3 s for an ffmpeg to exit and deleting a whole segmented film under it stalled every other
|
||||||
|
# viewer's playback for one viewer's seek or one reap pass. `_detach` (under the lock) signals the
|
||||||
|
# process and renames its directory aside — so the deletion can never hit a NEW session that reuses
|
||||||
|
# the path — and `_finish` (after the lock is released) waits and deletes.
|
||||||
|
#
|
||||||
|
# What the rename cannot cover is the dying ffmpeg itself: it writes by path, so until it has exited
|
||||||
|
# it can still put a segment, or its final ENDLIST playlist, into a new session's directory on the
|
||||||
|
# same path. `_dying` remembers it by that path, and `_spawn` waits it out — under the lock, on
|
||||||
|
# purpose — before it creates the directory again.
|
||||||
|
_dying: dict[Path, subprocess.Popen] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _await_exit(key: str, proc: subprocess.Popen) -> None:
|
||||||
try:
|
try:
|
||||||
s.proc.terminate()
|
|
||||||
try:
|
try:
|
||||||
s.proc.wait(timeout=3)
|
proc.wait(timeout=3)
|
||||||
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
|
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
|
||||||
# seconds gets KILL, which is exactly what this branch does.
|
# seconds gets KILL, which is exactly what this branch does.
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
s.proc.kill()
|
proc.kill()
|
||||||
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
||||||
# Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
|
# Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
|
||||||
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
|
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
|
||||||
# `_sessions` rather than processes. One line is the only way that ever surfaces.
|
# `_sessions` rather than processes. One line is the only way that ever surfaces.
|
||||||
|
log.info(
|
||||||
|
"could not stop the transcode process",
|
||||||
|
extra=logs.event("plex.stream.kill_failed", key=key, reason=type(exc).__name__),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detach(s: HlsSession) -> tuple[HlsSession, Path | None]:
|
||||||
|
"""Caller holds _lock and has taken `s` out of `_sessions`. Hand the result to `_finish` once the
|
||||||
|
lock is released."""
|
||||||
|
try:
|
||||||
|
s.proc.terminate()
|
||||||
|
except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
|
||||||
log.info(
|
log.info(
|
||||||
"could not stop the transcode process",
|
"could not stop the transcode process",
|
||||||
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
|
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
|
||||||
)
|
)
|
||||||
|
_dying[s.dir] = s.proc
|
||||||
|
tomb = s.dir.with_name(f"{s.dir.name}{_TOMB}{secrets.token_hex(4)}")
|
||||||
|
try:
|
||||||
|
s.dir.rename(tomb)
|
||||||
|
# silent-ok: nothing on disk (ffmpeg died before writing, or a sweep took it) — nothing to delete.
|
||||||
|
except FileNotFoundError:
|
||||||
|
return s, None
|
||||||
|
except OSError as exc:
|
||||||
|
# Cannot be moved aside, so it cannot be deleted outside the lock either: the path may be a
|
||||||
|
# new session's a moment from now. Deleted here instead, the old way.
|
||||||
|
log.info(
|
||||||
|
"a session directory could not be moved aside; deleted under the lock",
|
||||||
|
extra=logs.event("plex.stream.tomb_failed", key=s.key, reason=type(exc).__name__),
|
||||||
|
)
|
||||||
shutil.rmtree(s.dir, ignore_errors=True)
|
shutil.rmtree(s.dir, ignore_errors=True)
|
||||||
|
return s, None
|
||||||
|
return s, tomb
|
||||||
|
|
||||||
|
|
||||||
def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
def _finish(doomed: "list[tuple[HlsSession, Path | None]]") -> None:
|
||||||
"""Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
|
"""The slow half of a teardown, called WITHOUT `_lock`."""
|
||||||
least recently accessed sessions over it:
|
for s, tomb in doomed:
|
||||||
|
_await_exit(s.key, s.proc)
|
||||||
|
with _lock:
|
||||||
|
if _dying.get(s.dir) is s.proc:
|
||||||
|
del _dying[s.dir]
|
||||||
|
if tomb is not None:
|
||||||
|
shutil.rmtree(tomb, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_caps(
|
||||||
|
user_id: int, max_transcodes: int, max_sessions: int
|
||||||
|
) -> "list[tuple[HlsSession, Path | None]]":
|
||||||
|
"""Caller holds _lock, and `_finish`es what this returns once it has released it. Three bounds,
|
||||||
|
applied after a session is registered, each dropping the least recently accessed sessions over it:
|
||||||
|
|
||||||
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
* this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
|
||||||
box evicts itself before anyone else;
|
box evicts itself before anyone else;
|
||||||
@@ -676,6 +733,8 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
|||||||
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
|
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
|
||||||
rebuffering" has no other explanation anywhere, hence the line."""
|
rebuffering" has no other explanation anywhere, hence the line."""
|
||||||
|
|
||||||
|
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||||
|
|
||||||
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
|
||||||
if len(pool) <= cap:
|
if len(pool) <= cap:
|
||||||
return
|
return
|
||||||
@@ -690,12 +749,13 @@ def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
|
|||||||
sessions=len(pool),
|
sessions=len(pool),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_kill(sess)
|
|
||||||
_sessions.pop(reg, None)
|
_sessions.pop(reg, None)
|
||||||
|
doomed.append(_detach(sess))
|
||||||
|
|
||||||
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
|
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
|
||||||
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
|
||||||
cull(list(_sessions.items()), max_sessions, "total")
|
cull(list(_sessions.items()), max_sessions, "total")
|
||||||
|
return doomed
|
||||||
|
|
||||||
|
|
||||||
def start_session(
|
def start_session(
|
||||||
@@ -759,12 +819,21 @@ def start_session(
|
|||||||
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
|
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
|
||||||
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
|
||||||
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
|
||||||
|
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||||
|
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
|
||||||
|
# was moved aside under the lock is otherwise left for a restart's sweep.
|
||||||
|
try:
|
||||||
with _lock:
|
with _lock:
|
||||||
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
||||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||||
old = _sessions.pop(reg, None)
|
old = _sessions.pop(reg, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
doomed.append(_detach(old))
|
||||||
|
# An ffmpeg still exiting from THIS path (the old session, or one stopped a moment ago)
|
||||||
|
# would write into the directory about to be made; see `_dying`.
|
||||||
|
prev = _dying.pop(directory, None)
|
||||||
|
if prev is not None:
|
||||||
|
_await_exit(key, prev)
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
@@ -816,11 +885,13 @@ def start_session(
|
|||||||
)
|
)
|
||||||
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||||
# capped to zero.
|
# capped to zero.
|
||||||
_enforce_caps(
|
doomed += _enforce_caps(
|
||||||
user_id,
|
user_id,
|
||||||
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||||
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
_finish(doomed)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
s = _spawn(*_select_encode(db, item, transcode, src))
|
s = _spawn(*_select_encode(db, item, transcode, src))
|
||||||
@@ -974,6 +1045,11 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
|
|||||||
# session, absent for a single-audio one.
|
# session, absent for a single-audio one.
|
||||||
if variant not in (s.variants or (None,)):
|
if variant not in (s.variants or (None,)):
|
||||||
return False
|
return False
|
||||||
|
# And the name must be the one ffmpeg SPELLS for that sequence: `int()` also reads `seg_007.ts`
|
||||||
|
# and a non-ASCII digit run as a sequence the session may have reached, and neither file is
|
||||||
|
# ever written — a yes there parked the request for the whole wait.
|
||||||
|
if filename != segment_name(seq, variant):
|
||||||
|
return False
|
||||||
if seq == 0:
|
if seq == 0:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
@@ -1108,16 +1184,16 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
|||||||
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
|
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
|
||||||
one a whole segmented copy of the film on a volume shared with the download center."""
|
one a whole segmented copy of the film on a volume shared with the download center."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
dropped = 0
|
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||||
with _lock:
|
with _lock:
|
||||||
for reg, s in list(_sessions.items()):
|
for reg, s in list(_sessions.items()):
|
||||||
done = s.proc.poll() is not None
|
done = s.proc.poll() is not None
|
||||||
idle = now - s.last_access
|
idle = now - s.last_access
|
||||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||||
_kill(s)
|
|
||||||
_sessions.pop(reg, None)
|
_sessions.pop(reg, None)
|
||||||
dropped += 1
|
doomed.append(_detach(s))
|
||||||
return dropped
|
_finish(doomed)
|
||||||
|
return len(doomed)
|
||||||
|
|
||||||
|
|
||||||
def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
||||||
@@ -1132,8 +1208,9 @@ def stop_session(user_id: int, key: str, session_id: str) -> bool:
|
|||||||
s = _sessions.get((user_id, key))
|
s = _sessions.get((user_id, key))
|
||||||
if s is None or s.id != session_id:
|
if s is None or s.id != session_id:
|
||||||
return False
|
return False
|
||||||
_kill(s)
|
|
||||||
_sessions.pop((user_id, key), None)
|
_sessions.pop((user_id, key), None)
|
||||||
|
doomed = [_detach(s)]
|
||||||
|
_finish(doomed)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+31
-13
@@ -44,6 +44,7 @@ class QuotaAction:
|
|||||||
VIDEOS_ENRICH = "videos_enrich"
|
VIDEOS_ENRICH = "videos_enrich"
|
||||||
VIDEOS_LOOKUP = "videos_lookup"
|
VIDEOS_LOOKUP = "videos_lookup"
|
||||||
VIDEOS_SEARCH = "videos_search"
|
VIDEOS_SEARCH = "videos_search"
|
||||||
|
VIDEOS_RELATED = "videos_related"
|
||||||
CHANNELS_DISCOVER = "channels_discover"
|
CHANNELS_DISCOVER = "channels_discover"
|
||||||
CHANNELS_EXPLORE = "channels_explore"
|
CHANNELS_EXPLORE = "channels_explore"
|
||||||
CHANNELS_SUBSCRIBE = "channels_subscribe"
|
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
|
condition for every backfill/explore path (recent, deep, immediate full-history, explore, and
|
||||||
rolling revalidation).
|
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
|
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
|
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
|
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")
|
return remaining_today(qs) <= sysconfig.get_int(qs, "backfill_quota_reserve")
|
||||||
|
|
||||||
|
|
||||||
def log_action(user_id: int, action: str) -> None:
|
def claim_action(user_id: int, action: str, limit: int) -> int | None:
|
||||||
"""Record a zero-cost action event so per-user daily caps still count it.
|
"""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
|
The ONE way to count a capped action: a claim is a zero-unit event, and `actions_today` counts
|
||||||
quota yet must still be rate-limited per user, so it logs its event here directly.
|
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
|
||||||
Writes in its OWN session (like record_usage) — quota accounting is durable independent of the
|
accounting is durable whatever the caller's transaction does next, and must never commit it."""
|
||||||
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)."""
|
|
||||||
with SessionLocal() as qs:
|
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()
|
qs.commit()
|
||||||
|
|
||||||
|
|
||||||
def actions_today(db: Session, user_id: int, action: str) -> int:
|
def actions_today(db: Session, user_id: int, action: str) -> int:
|
||||||
"""How many quota events of `action` this user has logged so far in the current Pacific
|
"""How many slots of `action` this user has claimed so far in the current Pacific day — for
|
||||||
day — for per-user, per-action daily caps (e.g. the live-search limit). Counts events,
|
per-user, per-action daily caps (the live search, the related lists). Counts the zero-unit
|
||||||
not units, so it only works for actions charged exactly once per user action."""
|
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 (
|
return (
|
||||||
db.scalar(
|
db.scalar(
|
||||||
select(func.count())
|
select(func.count())
|
||||||
@@ -176,6 +193,7 @@ def actions_today(db: Session, user_id: int, action: str) -> int:
|
|||||||
.where(
|
.where(
|
||||||
owned_by(QuotaEvent.user_id, user_id),
|
owned_by(QuotaEvent.user_id, user_id),
|
||||||
QuotaEvent.action == action,
|
QuotaEvent.action == action,
|
||||||
|
QuotaEvent.units == 0,
|
||||||
QuotaEvent.created_at >= pacific_day_start_utc(),
|
QuotaEvent.created_at >= pacific_day_start_utc(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]:
|
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,
|
"""These videos serialized with the feed's projection (their channel, this user's watch state,
|
||||||
saved), in the order given."""
|
saved), in the order given."""
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from typing import cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
|
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -301,12 +301,17 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
|
|||||||
# --- playlists -------------------------------------------------------------------------------
|
# --- playlists -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
|
||||||
|
# frontend's backendIds contract, which reads this Literal).
|
||||||
|
MoveReason = Literal["same_playlist", "not_in_playlist"]
|
||||||
|
|
||||||
|
|
||||||
class MoveRefused(Exception):
|
class MoveRefused(Exception):
|
||||||
"""A move between playlists that cannot happen: `reason` is the code the UI translates."""
|
"""A move between playlists that cannot happen: `reason` is the code the UI translates."""
|
||||||
|
|
||||||
def __init__(self, reason: str):
|
def __init__(self, reason: MoveReason):
|
||||||
super().__init__(reason)
|
super().__init__(reason)
|
||||||
self.reason = reason
|
self.reason: MoveReason = reason
|
||||||
|
|
||||||
|
|
||||||
def touch(pl: Playlist) -> None:
|
def touch(pl: Playlist) -> None:
|
||||||
|
|||||||
+20
-10
@@ -1,29 +1,39 @@
|
|||||||
"""A request the app refuses, said in a way the UI can put in the reader's language.
|
"""A request the app refuses, said in a way the UI can put in the reader's language.
|
||||||
|
|
||||||
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
|
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
|
||||||
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
|
translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI
|
||||||
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
|
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
|
||||||
whatever sentence the route happened to hand-write. `refused` is the generic family
|
whatever sentence the route happened to hand-write. `refused` is the generic family
|
||||||
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
|
||||||
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
|
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
|
||||||
|
|
||||||
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
|
A test (`test_http_details.py`) reads every place an `HTTPException` or a `JSONResponse` sends a detail
|
||||||
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
|
and fails on one the client cannot say: a sentence, a dict without a `code`, a variable, none at all.
|
||||||
languages — so the rule is enforced, not described.
|
`tests/refusal_sites.py` reads every `{"code": …}` detail from the syntax tree, written where it is sent
|
||||||
|
(or returned by a factory that only ever is), with any `**` spread before its `code` and `reason`, and
|
||||||
|
follows each reason along a closed list of shapes: a literal, a `Literal` type on the parameter or
|
||||||
|
attribute it travels in, every call of a helper like `refused` below, a map's values; anything else is
|
||||||
|
reported, never guessed. The frontend's contract test
|
||||||
|
(`i18n/backendIds.contract.test.ts`) runs it and requires each code in one record of
|
||||||
|
`lib/api/refusalCodes.ts`, and each reason's sentence in both languages; a code or reason it cannot
|
||||||
|
follow fails it, unless no screen shows that code. So the rule is enforced, not described.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.sysconfig import InvalidSetting, SettingReason
|
||||||
|
|
||||||
|
|
||||||
def refused(status: int, reason: str, **params: object) -> HTTPException:
|
def refused(status: int, reason: str, **params: object) -> HTTPException:
|
||||||
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
|
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
|
||||||
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see."""
|
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.
|
||||||
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
|
They are spread first, so a `code` or `reason` among them could never replace these two."""
|
||||||
|
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's
|
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the refusal
|
||||||
# contract test can read every reason this can produce.
|
# scan reads every reason this can produce, and holds its keys to `SettingReason`.
|
||||||
_SETTING_REASONS = {
|
_SETTING_REASONS: dict[SettingReason, str] = {
|
||||||
"not_a_number": "setting_not_a_number",
|
"not_a_number": "setting_not_a_number",
|
||||||
"too_low": "setting_too_low",
|
"too_low": "setting_too_low",
|
||||||
"too_high": "setting_too_high",
|
"too_high": "setting_too_high",
|
||||||
@@ -32,6 +42,6 @@ _SETTING_REASONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def setting_refused(exc) -> HTTPException:
|
def setting_refused(exc: InvalidSetting) -> HTTPException:
|
||||||
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
|
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
|
||||||
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
|
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
|
||||||
|
|||||||
+19
-10
@@ -976,7 +976,16 @@ def _resend_verify_if_eligible(email: str) -> None:
|
|||||||
send_verification_link(db, user)
|
send_verification_link(db, user)
|
||||||
|
|
||||||
|
|
||||||
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
|
# Every refusal a password sign-in answers with; the UI names each one (`welcome.auth.blocked.<reason>`,
|
||||||
|
# held by the frontend's backendIds contract, which reads this Literal).
|
||||||
|
LoginRefusal = Literal[
|
||||||
|
"rate_limited", "invalid", "suspended", "email_unverified", "pending_approval"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _login_refused(
|
||||||
|
status: int, reason: LoginRefusal, user: User | None = None, **extra
|
||||||
|
) -> HTTPException:
|
||||||
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
||||||
shape so the SPA can say it in the user's own language (the old bare English strings were
|
shape so the SPA can say it in the user's own language (the old bare English strings were
|
||||||
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
||||||
@@ -992,15 +1001,15 @@ def _login_refused(status: int, reason: str, user: User | None = None, **extra)
|
|||||||
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
|
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
|
||||||
rather than assuming a sequence.
|
rather than assuming a sequence.
|
||||||
"""
|
"""
|
||||||
detail: dict = {"code": "login", "reason": reason}
|
account = (
|
||||||
if user is not None:
|
{"email": user.email, "email_verified": user.email_verified, "approved": user.is_active}
|
||||||
detail |= {
|
if user is not None
|
||||||
"email": user.email,
|
else {}
|
||||||
"email_verified": user.email_verified,
|
)
|
||||||
"approved": user.is_active,
|
given = {k: v for k, v in extra.items() if v is not None}
|
||||||
}
|
return HTTPException(
|
||||||
detail |= {k: v for k, v in extra.items() if v is not None}
|
status_code=status, detail={**account, **given, "code": "login", "reason": reason}
|
||||||
return HTTPException(status_code=status, detail=detail)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password-login")
|
@router.post("/password-login")
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ def set_credential(
|
|||||||
try:
|
try:
|
||||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||||
except dlcreds.InvalidCookies as e:
|
except dlcreds.InvalidCookies as e:
|
||||||
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e
|
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": e.reason}) from e
|
||||||
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
dlcreds.upsert_credential(db, user.id, host_key, cleaned)
|
||||||
audit.record(
|
audit.record(
|
||||||
db,
|
db,
|
||||||
@@ -359,8 +359,9 @@ def enqueue_clip(
|
|||||||
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
||||||
except clipmod.ClipError as e:
|
except clipmod.ClipError as e:
|
||||||
status = 404 if e.reason == "not_found" else 422
|
status = 404 if e.reason == "not_found" else 422
|
||||||
detail = {"code": "clip", "reason": e.reason, **e.params}
|
raise HTTPException(
|
||||||
raise HTTPException(status_code=status, detail=detail) from e
|
status_code=status, detail={**e.params, "code": "clip", "reason": e.reason}
|
||||||
|
) from e
|
||||||
except quota.QuotaExceeded as e:
|
except quota.QuotaExceeded as e:
|
||||||
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -249,6 +249,21 @@ def set_video_progress(
|
|||||||
return {"ok": True, "video_id": video_id, "position_seconds": position}
|
return {"ok": True, "video_id": video_id, "position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/videos/{video_id}/row")
|
||||||
|
def get_video_row(
|
||||||
|
video_id: str = Path(max_length=32),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""One catalog video as the feed lists it (its channel, this user's watch state and resume point,
|
||||||
|
saved): what a player opened from an address starts from. The catalog only, so it costs nothing;
|
||||||
|
every video a player can open (a list's row, a related one) is in it."""
|
||||||
|
rows = queries.feed_rows_by_ids(db, user, [video_id])
|
||||||
|
if not rows:
|
||||||
|
raise not_found("video")
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos/{video_id}")
|
@router.get("/videos/{video_id}")
|
||||||
def get_video_detail(
|
def get_video_detail(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
|
|||||||
|
|
||||||
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||||
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
|
||||||
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
|
pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail()."""
|
||||||
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ def get_me(
|
|||||||
# Which live-search source runs, so the search button can say what a search costs: "scrape"
|
# Which live-search source runs, so the search button can say what a search costs: "scrape"
|
||||||
# spends no API quota, "api" spends 100 units a page.
|
# spends no API quota, "api" spends 100 units a page.
|
||||||
"search_source": sysconfig.get_str(db, "search_source"),
|
"search_source": sysconfig.get_str(db, "search_source"),
|
||||||
|
# Whether live search and related videos are on: an admin turns either off with a daily
|
||||||
|
# limit of 0, and the UI then leaves out what would only be refused.
|
||||||
|
"search_enabled": sysconfig.get_int(db, "search_daily_limit_per_user") > 0,
|
||||||
|
"related_enabled": sysconfig.get_int(db, "related_daily_limit_per_user") > 0,
|
||||||
"can_read": has_read_scope(user),
|
"can_read": has_read_scope(user),
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
"pending_invites": pending_invites,
|
"pending_invites": pending_invites,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
|
|||||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -53,7 +53,22 @@ def _check_pull_cooldown(user: User) -> None:
|
|||||||
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
|
||||||
|
|
||||||
|
|
||||||
def _refused(status: int, reason: str) -> HTTPException:
|
# Every playlist action the server refuses; the UI names each one (`playlists.errors.<reason>`, held by
|
||||||
|
# the frontend's backendIds contract, which reads this Literal).
|
||||||
|
PlaylistRefusal = Literal[
|
||||||
|
"name_required",
|
||||||
|
"not_on_youtube",
|
||||||
|
"quota_exceeded",
|
||||||
|
"sync_all_busy",
|
||||||
|
"sync_busy",
|
||||||
|
"watch_later_not_pushable",
|
||||||
|
"watch_later_undeletable",
|
||||||
|
"youtube_editing_off",
|
||||||
|
"youtube_read_scope",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _refused(status: int, reason: PlaylistRefusal) -> HTTPException:
|
||||||
"""A playlist action the server refuses, as a reason the UI translates
|
"""A playlist action the server refuses, as a reason the UI translates
|
||||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
||||||
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
|
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
return imgcache.response(data, ct)
|
return imgcache.response(data, ct)
|
||||||
|
|
||||||
|
|
||||||
|
def _supersede_resume_pushes(user_id: int, item_id: int, now: datetime) -> None:
|
||||||
|
"""A watched mark supersedes every resume push still in flight for the item: stamping it as the
|
||||||
|
latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep the map
|
||||||
|
small) removed the very guard, and an older resume push could then land at Plex AFTER the
|
||||||
|
scrobble and revive a resume point on a watched item. Eviction bounds the map."""
|
||||||
|
with _push_throttle_lock:
|
||||||
|
_last_push_at[(user_id, item_id)] = now
|
||||||
|
|
||||||
|
|
||||||
def _push_resume_guarded(
|
def _push_resume_guarded(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
item_id: int,
|
item_id: int,
|
||||||
@@ -329,12 +338,7 @@ def item_progress(
|
|||||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||||
if near_end:
|
if near_end:
|
||||||
flipped = service.mark_finished(db, user.id, it.id, now)
|
flipped = service.mark_finished(db, user.id, it.id, now)
|
||||||
# The finish supersedes every resume push still in flight for this item: stamping it as the
|
_supersede_resume_pushes(user.id, it.id, now)
|
||||||
# latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep
|
|
||||||
# the map small) removed the very guard, and an older resume push could then land at Plex
|
|
||||||
# AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.
|
|
||||||
with _push_throttle_lock:
|
|
||||||
_last_push_at[(user.id, it.id)] = now
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
|
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
|
||||||
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
|
||||||
@@ -352,7 +356,11 @@ def item_progress(
|
|||||||
push_now = _should_push_resume(user.id, it.id, now, final)
|
push_now = _should_push_resume(user.id, it.id, now, final)
|
||||||
# Marked dirty before the (background) push so the reconcile knows to re-push if it fails, and so
|
# Marked dirty before the (background) push so the reconcile knows to re-push if it fails, and so
|
||||||
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
|
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
|
||||||
service.checkpoint(db, user, it.id, st, position, now, dirty=push_now)
|
if not service.checkpoint(db, user, it.id, st, position, now, dirty=push_now):
|
||||||
|
# Marked watched a moment ago: this is the finishing player's late tick (see
|
||||||
|
# `service.checkpoint`), and the answer is the same one a near-end checkpoint gets.
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
db.commit()
|
db.commit()
|
||||||
if push_now:
|
if push_now:
|
||||||
_push_watch(
|
_push_watch(
|
||||||
@@ -408,6 +416,8 @@ def item_state(
|
|||||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||||
return {"ok": True, "status": "new"}
|
return {"ok": True, "status": "new"}
|
||||||
service.set_status(db, user, it.id, st, status)
|
service.set_status(db, user, it.id, st, status)
|
||||||
|
if status == "watched":
|
||||||
|
_supersede_resume_pushes(user.id, it.id, datetime.now(timezone.utc))
|
||||||
db.commit()
|
db.commit()
|
||||||
if status == "watched" and prev_status != "watched":
|
if status == "watched" and prev_status != "watched":
|
||||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||||
@@ -427,6 +437,10 @@ def _bulk_state(
|
|||||||
always reads the committed marks."""
|
always reads the committed marks."""
|
||||||
pushable = plex_watch.link_for_push(db, user_id) is not None
|
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||||
to_push = service.bulk_state(db, user_id, eps, watched)
|
to_push = service.bulk_state(db, user_id, eps, watched)
|
||||||
|
if watched:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for item_id, _rating_key, _action in to_push:
|
||||||
|
_supersede_resume_pushes(user_id, item_id, now)
|
||||||
if pushable and to_push:
|
if pushable and to_push:
|
||||||
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||||
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||||
@@ -632,9 +646,10 @@ def stream_hls_file(
|
|||||||
try:
|
try:
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
# The session was torn down between the wait and the read — `_kill` rmtrees the whole
|
# The session was torn down between the wait and the read — teardown moves the whole
|
||||||
# directory, and nothing holds `_lock` across those two steps. It has to present as the same
|
# directory aside and deletes it, and nothing holds `_lock` across those two steps. It has
|
||||||
# 404 a gone session does: the player's recovery is keyed on the STATUS (`code === 404`), so
|
# to present as the same 404 a gone session does: the player's recovery is keyed on the
|
||||||
|
# STATUS (`code === 404`), so
|
||||||
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
|
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
|
||||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
||||||
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ def update_view(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "view_name_taken", name=view.name) from None
|
# The name asked for: the rollback has put the view's old name back on `view`.
|
||||||
|
raise refused(409, "view_name_taken", name=name) from None
|
||||||
return _serialize(view)
|
return _serialize(view)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+283
-76
@@ -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
|
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
|
||||||
dropped from the search response up front).
|
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
|
This is the HTTP side — the paging, the caps and the refusals; what a page of results leaves in the
|
||||||
catalog is `catalog/search`."""
|
catalog is `catalog/search`."""
|
||||||
|
|
||||||
from 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 pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -26,8 +34,10 @@ from app.catalog import queries
|
|||||||
from app.catalog import search as catalog_search
|
from app.catalog import search as catalog_search
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.ratelimit import RateLimiter
|
||||||
from app.reader import queries as reader_queries
|
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.youtube.client import YouTubeClient, YouTubeError
|
||||||
from app.refusal import refused
|
from app.refusal import refused
|
||||||
from app.routes._common import youtube_refusal
|
from app.routes._common import youtube_refusal
|
||||||
@@ -37,6 +47,115 @@ router = APIRouter(prefix="/api/search", tags=["search"])
|
|||||||
|
|
||||||
# search.list costs this many quota units per page.
|
# search.list costs this many quota units per page.
|
||||||
SEARCH_COST = 100
|
SEARCH_COST = 100
|
||||||
|
# The scrape source reads at most this many pages for one search.
|
||||||
|
SEARCH_MAX_PAGES = 12
|
||||||
|
|
||||||
|
# A related list is filled to this many unseen videos, reading at most this many pages (about 20
|
||||||
|
# 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")
|
@router.get("/youtube")
|
||||||
@@ -59,85 +178,89 @@ def search_youtube(
|
|||||||
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
|
||||||
source = sysconfig.get_str(db, "search_source")
|
source = sysconfig.get_str(db, "search_source")
|
||||||
|
|
||||||
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
|
# Every request takes a slot of the per-user daily cap, a cursor's too (anti-abuse); with
|
||||||
# since the search itself spends no quota.
|
# scrape it's the only guard since the search itself spends no quota. A limit of 0 turns live
|
||||||
|
# search off on this instance — a refusal tomorrow will not change.
|
||||||
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
daily_limit = sysconfig.get_int(db, "search_daily_limit_per_user")
|
||||||
used = quota.actions_today(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
if daily_limit <= 0:
|
||||||
if used >= daily_limit:
|
raise refused(409, "search_disabled")
|
||||||
# The 429 is in the request line either way; which of the two caps produced it is not, and
|
# The 100-unit budget pre-check only applies to the API source. It is asked again under the
|
||||||
# they are answered by different things — this one by tomorrow, the other by the admin.
|
# lock, right before the call (`fetch`); this one refuses before a slot is taken.
|
||||||
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 source == "api" and not quota.can_spend(db, SEARCH_COST):
|
if source == "api" and not quota.can_spend(db, SEARCH_COST):
|
||||||
log.info(
|
_budget_spent()
|
||||||
"a live search was refused: the shared YouTube budget is spent",
|
|
||||||
extra=logs.event("search.refused", reason="shared_quota", cost=SEARCH_COST),
|
|
||||||
)
|
|
||||||
raise refused(429, "quota_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.
|
# Channels this user has blocked are dropped before ingestion — never shown, never stored.
|
||||||
blocked = queries.blocked_ids(db, user)
|
blocked = queries.blocked_ids(db, user)
|
||||||
|
|
||||||
collected: list[str] = []
|
# Whether search.list was paid for: YouTube charges a call it refuses too, so once the client
|
||||||
next_cursor = cursor
|
# recorded its units — or the request went out and no answer came back — the search has spent
|
||||||
MAX_PAGES = 12 # scrape safety bound
|
# the shared budget. One that never reached YouTube (no connection, no token) has not.
|
||||||
with YouTubeClient(db, user) as yt:
|
charged = False
|
||||||
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
|
|
||||||
|
|
||||||
|
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.
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
candidates = [
|
return [
|
||||||
it
|
it
|
||||||
for it in page["items"]
|
for it in items
|
||||||
if it["live_broadcast"] == "none"
|
if it["live_broadcast"] == "none"
|
||||||
and it["channel_id"]
|
and it["channel_id"]
|
||||||
and it["channel_id"] not in blocked
|
and it["channel_id"] not in blocked
|
||||||
]
|
]
|
||||||
# Ingest (DB writes + enrich) runs outside the page-fetch try above; guard it too so a
|
|
||||||
# write/shape failure degrades to a 422 with a message on this JSON endpoint instead of a
|
def scrape_failed(exc: Exception) -> None:
|
||||||
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
|
log.warning(
|
||||||
try:
|
"the YouTube search scrape failed",
|
||||||
collected.extend(catalog_search.ingest_candidates(db, yt, user, candidates))
|
extra=logs.event("search.scrape_failed", error=str(exc)),
|
||||||
except Exception as exc: # any ingest failure, not just YouTubeError
|
)
|
||||||
db.rollback()
|
|
||||||
# The term is NOT in this line. A search string is exactly what the privacy
|
def ingest_failed(candidates: int, collected: int) -> None:
|
||||||
# policy keeps out of the log unless the user turned Teljes/TRACE on for
|
# The term is NOT in this line. A search string is exactly what the privacy policy keeps
|
||||||
# themselves, and `redact_text` does not cover it — it scrubs addresses and
|
# out of the log unless the user turned Teljes/TRACE on for themselves, and `redact_text`
|
||||||
# credential params, not what someone typed into the search box. What the
|
# does not cover it — it scrubs addresses and credential params, not what someone typed
|
||||||
# ERROR line carries instead is the shape of the failure, which is what tells
|
# into the search box. What the ERROR line carries instead is the shape of the failure,
|
||||||
# an ingest bug from an upstream one; the term follows at TRACE, and only
|
# which is what tells an ingest bug from an upstream one; the term follows at TRACE, and
|
||||||
# under this user's own consent — not whenever the process runs at TRACE.
|
# only under this user's own consent — not whenever the process runs at TRACE.
|
||||||
log.exception(
|
log.exception(
|
||||||
"live search ingest failed",
|
"live search ingest failed",
|
||||||
extra=logs.event(
|
extra=logs.event(
|
||||||
"search.live.ingest_failed",
|
"search.live.ingest_failed",
|
||||||
source=source,
|
source=source,
|
||||||
candidates=len(candidates),
|
candidates=candidates,
|
||||||
collected=len(collected),
|
collected=collected,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if logs.consented_trace():
|
if logs.consented_trace():
|
||||||
@@ -147,21 +270,30 @@ def search_youtube(
|
|||||||
# log-pii-ok: only under this user's own Teljes consent
|
# log-pii-ok: only under this user's own Teljes consent
|
||||||
extra=logs.event("search.live.term", term=term),
|
extra=logs.event("search.live.term", term=term),
|
||||||
)
|
)
|
||||||
if collected:
|
|
||||||
break # keep what earlier pages already gathered
|
|
||||||
raise youtube_refusal(exc) from exc
|
|
||||||
next_cursor = page["next_page_token"]
|
|
||||||
# The API source pages cost 100 units each — never auto-page; one page per request.
|
|
||||||
if source == "api" or len(collected) >= limit or not next_cursor:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Count this as ONE per-user search against the daily cap. The scrape source spends no
|
try:
|
||||||
# quota, so it never logs an event on its own; the API source already logged exactly one
|
with YouTubeClient(db, user) as yt:
|
||||||
# (via record_usage — it never auto-pages). Logging once here, after a search that produced
|
collected, next_cursor = _gather(
|
||||||
# at least one page (a total failure raises 502 above and never reaches this), keeps the cap
|
db,
|
||||||
# counting user searches instead of internal continuation pages.
|
yt,
|
||||||
if source != "api":
|
user,
|
||||||
quota.log_action(user.id, quota.QuotaAction.VIDEOS_SEARCH)
|
cursor=cursor,
|
||||||
|
# The API source pages cost 100 units each — never auto-page; one page per request.
|
||||||
|
max_pages=1 if source == "api" else SEARCH_MAX_PAGES,
|
||||||
|
fetch=fetch,
|
||||||
|
candidates_of=candidates_of,
|
||||||
|
enough=lambda got: len(got) >= limit,
|
||||||
|
scrape_failed=scrape_failed,
|
||||||
|
ingest_failed=ingest_failed,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# A search that showed the user nothing does not count against them, 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]
|
ordered = collected[:limit]
|
||||||
|
|
||||||
@@ -201,3 +333,78 @@ def clear_search(
|
|||||||
removed = catalog_search.clear(db, user, ids)
|
removed = catalog_search.clear(db, user, ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "removed": removed}
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ def update_tag(
|
|||||||
db.commit()
|
db.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise refused(409, "tag_name_taken", name=tag.name) from None
|
# The name asked for: the rollback has put the tag's old name back on `tag`.
|
||||||
|
raise refused(409, "tag_name_taken", name=name) from None
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"id": tag.id,
|
"id": tag.id,
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
max=100_000,
|
max=100_000,
|
||||||
applies=LIVE,
|
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).
|
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
|
||||||
ConfigSpec("search_source", "choice", "quota", "search_source", applies=LIVE),
|
ConfigSpec("search_source", "choice", "quota", "search_source", applies=LIVE),
|
||||||
ConfigSpec(
|
ConfigSpec(
|
||||||
@@ -434,15 +443,20 @@ def _default(s: ConfigSpec):
|
|||||||
return getattr(settings, s.default_attr)
|
return getattr(settings, s.default_attr)
|
||||||
|
|
||||||
|
|
||||||
|
# Every reason `set_value` refuses with. `refusal.setting_refused` looks each one up in its map, which
|
||||||
|
# the refusal scan holds to exactly these keys: a reason the map lacks would answer a 500.
|
||||||
|
SettingReason = Literal["not_a_number", "too_low", "too_high", "not_a_choice", "no_encryption_key"]
|
||||||
|
|
||||||
|
|
||||||
class InvalidSetting(ValueError):
|
class InvalidSetting(ValueError):
|
||||||
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
|
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
|
||||||
plus what the sentence needs — the English message is for logs only. A ValueError still, so a
|
plus what the sentence needs — the English message is for logs only. A ValueError still, so a
|
||||||
caller that only needs "refused or not" keeps working."""
|
caller that only needs "refused or not" keeps working."""
|
||||||
|
|
||||||
def __init__(self, key: str, reason: str, **params: object):
|
def __init__(self, key: str, reason: SettingReason, **params: object):
|
||||||
super().__init__(f"{key}: {reason} {params}")
|
super().__init__(f"{key}: {reason} {params}")
|
||||||
self.key = key
|
self.key = key
|
||||||
self.reason = reason
|
self.reason: SettingReason = reason
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,12 +30,26 @@ class YouTubeError(Exception):
|
|||||||
message is prose, it changes under us, and it is deliberately the poorer of the two here.
|
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
|
`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)
|
super().__init__(message)
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.path = path
|
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:
|
def _error_reason(resp: httpx.Response) -> str:
|
||||||
@@ -162,6 +176,9 @@ def best_thumbnail(thumbnails: dict | None) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
class YouTubeClient:
|
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):
|
def __init__(self, db, user: User):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.user = user
|
self.user = user
|
||||||
@@ -189,9 +206,16 @@ class YouTubeClient:
|
|||||||
return self._http.request(method, url, **kwargs)
|
return self._http.request(method, url, **kwargs)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise YouTubeError(
|
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
|
) 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 ---
|
# --- auth ---
|
||||||
def _access_token(self) -> str:
|
def _access_token(self) -> str:
|
||||||
tok = self.user.token
|
tok = self.user.token
|
||||||
@@ -269,7 +293,7 @@ class YouTubeClient:
|
|||||||
else:
|
else:
|
||||||
headers["Authorization"] = f"Bearer {self._access_token()}"
|
headers["Authorization"] = f"Bearer {self._access_token()}"
|
||||||
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
|
resp = self._send("GET", f"{API_BASE}/{path}", params=p, headers=headers)
|
||||||
quota.record_usage(cost)
|
self._spend(cost)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise _upstream_failure("GET", path, resp, expected)
|
raise _upstream_failure("GET", path, resp, expected)
|
||||||
return resp.json()
|
return resp.json()
|
||||||
@@ -401,7 +425,7 @@ class YouTubeClient:
|
|||||||
params={"id": subscription_id},
|
params={"id": subscription_id},
|
||||||
headers={"Authorization": f"Bearer {self._access_token()}"},
|
headers={"Authorization": f"Bearer {self._access_token()}"},
|
||||||
)
|
)
|
||||||
quota.record_usage(50)
|
self._spend(50)
|
||||||
if resp.status_code not in (200, 204):
|
if resp.status_code not in (200, 204):
|
||||||
raise _upstream_failure("DELETE", "subscriptions", resp)
|
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:
|
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
|
||||||
headers = {"Authorization": f"Bearer {self._access_token()}"}
|
headers = {"Authorization": f"Bearer {self._access_token()}"}
|
||||||
resp = self._send(method, f"{API_BASE}/{path}", params=params, json=json, headers=headers)
|
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):
|
if resp.status_code not in (200, 204):
|
||||||
raise _upstream_failure(method, path, resp)
|
raise _upstream_failure(method, path, resp)
|
||||||
return resp.json() if resp.content else {}
|
return resp.json() if resp.content else {}
|
||||||
|
|||||||
@@ -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" # gitleaks:allow -- the public WEB key, in every results page
|
||||||
|
_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
|
||||||
@@ -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}
|
||||||
@@ -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,
|
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.
|
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
|
The request itself (the live key, the consent cookie) is `innertube`'s. This deliberately scrapes an
|
||||||
bounced to consent.youtube.com. This deliberately scrapes an undocumented endpoint: if YouTube
|
undocumented endpoint: if YouTube changes the JSON shape or blocks the host, an admin can flip
|
||||||
changes the JSON shape or blocks the host, an admin can flip ``search_source`` back to ``api``.
|
``search_source`` back to ``api``.
|
||||||
|
|
||||||
The returned page mirrors :meth:`YouTubeClient.search_videos` exactly
|
The returned page mirrors :meth:`YouTubeClient.search_videos` exactly
|
||||||
(``{"items": [...], "next_page_token": str | None}``) so the route is source-agnostic — the
|
(``{"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"``
|
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)."""
|
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==).
|
# sp / params value selecting the "type: video" search filter (base64 protobuf EgIQAQ==).
|
||||||
_TYPE_VIDEO = "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)
|
# The top-level keys a first page (contents) and a continuation page (onResponseReceivedCommands)
|
||||||
# put their result list under.
|
# put their result list under.
|
||||||
_RESULT_FRAMES = {"contents", "onResponseReceivedCommands"}
|
_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:
|
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):
|
if isinstance(obj, dict):
|
||||||
vr = obj.get("videoRenderer")
|
vr = obj.get("videoRenderer")
|
||||||
if isinstance(vr, dict):
|
if isinstance(vr, dict):
|
||||||
item = _video_item(vr)
|
item = video_renderer(vr)
|
||||||
if item and item["id"] not in seen:
|
if item and item["id"] not in seen:
|
||||||
seen.add(item["id"])
|
seen.add(item["id"])
|
||||||
out.append(item)
|
out.append(item)
|
||||||
@@ -144,9 +50,7 @@ def _collect_videos(obj, out: list[dict], seen: set[str]) -> None:
|
|||||||
def _find_continuation(obj) -> str | None:
|
def _find_continuation(obj) -> str | None:
|
||||||
"""First continuationItemRenderer token (the 'load more' cursor), or None at the end."""
|
"""First continuationItemRenderer token (the 'load more' cursor), or None at the end."""
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
cir = obj.get("continuationItemRenderer")
|
tok = continuation_token(obj.get("continuationItemRenderer"))
|
||||||
if isinstance(cir, dict):
|
|
||||||
tok = cir.get("continuationEndpoint", {}).get("continuationCommand", {}).get("token")
|
|
||||||
if tok:
|
if tok:
|
||||||
return tok
|
return tok
|
||||||
for v in obj.values():
|
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
|
`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
|
page). Returns ``{"items": [...], "next_page_token": str | None}`` — the same shape as
|
||||||
YouTubeClient.search_videos so the route handles both sources identically."""
|
YouTubeClient.search_videos so the route handles both sources identically."""
|
||||||
with httpx.Client(timeout=20.0, headers=_HEADERS) as client:
|
body: dict = (
|
||||||
api_key, client_version = _get_config(client)
|
{"continuation": continuation} if continuation else {"query": q, "params": _TYPE_VIDEO}
|
||||||
context = {
|
)
|
||||||
"client": {
|
data = post("search", body)
|
||||||
"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
|
|
||||||
|
|
||||||
# Even a search with no results carries its result list's frame. Without one this is not "no
|
# 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.
|
# 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")
|
raise ScrapeError("InnerTube returned an unexpected shape")
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
_collect_videos(data, items, set())
|
_collect_videos(data, items, set())
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
# bumped in place instead of a full regeneration, which would move every other package too — then
|
# bumped in place instead of a full regeneration, which would move every other package too — then
|
||||||
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
|
||||||
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
|
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
|
||||||
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322).
|
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322);
|
||||||
|
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284).
|
||||||
APScheduler==3.11.3
|
APScheduler==3.11.3
|
||||||
Authlib==1.7.2
|
Authlib==1.9.1
|
||||||
Mako==1.4.1
|
Mako==1.4.1
|
||||||
MarkupSafe==3.0.3
|
MarkupSafe==3.0.3
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
@@ -38,7 +39,7 @@ httptools==0.8.0
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
idna==3.18
|
idna==3.18
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
joserfc==1.7.4
|
joserfc==1.7.6
|
||||||
markdown-it-py==4.2.0
|
markdown-it-py==4.2.0
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
mutagen==1.48.1
|
mutagen==1.48.1
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
"""Every refusal the backend can answer, read from its syntax tree: each `{"code": …, "reason": …}` detail,
|
||||||
|
with every reason its code can be sent with.
|
||||||
|
|
||||||
|
The frontend's contract (`i18n/backendIds.contract.test.ts`) runs this and requires a sentence for each
|
||||||
|
code and reason in both languages. A scan that tries to follow every shape opens a silent gap with each one
|
||||||
|
it learns, and following how a value flows through Python (variables, scopes, aliases, a dict changed in
|
||||||
|
place) never closes. So the CODE keeps to a narrow form, and this reads a CLOSED list of it, reporting
|
||||||
|
everything else, never guessing:
|
||||||
|
|
||||||
|
- where a detail stands: where it is sent — `HTTPException`'s second argument or `detail=`, the `"detail"`
|
||||||
|
value of a `JSONResponse`'s content dict — through the arms of `a if c else b`, or as what a module-level
|
||||||
|
factory returns when every return of it is a detail and every reference to it is a call sent as one. A
|
||||||
|
detail held in a variable is not followed;
|
||||||
|
- the code: one literal. A `**` spread, or a key that is not a literal, stands BEFORE a literal `code` and
|
||||||
|
`reason`, both of which the detail then carries: the later key wins, so no spread can change them;
|
||||||
|
- a reason: a string literal (`None` sends none: the site counts as reasonless); both arms of
|
||||||
|
`a if c else b`; a function's local bound only by plain `x = …`; a module constant reached as `mod.NAME`;
|
||||||
|
a call's `Literal` return annotation;
|
||||||
|
- a parameter typed with a `Literal` (basedpyright holds every value passed in), or a `str` one of a
|
||||||
|
module-level helper (`refused`, `not_found`), followed to every call, each of which must pass it by
|
||||||
|
position or by name; a parameter its body binds again is unread;
|
||||||
|
- an attribute: `e.x` of the exception an `except C as e` catches, or of a parameter typed `C`, when a
|
||||||
|
method of `C` declares `self.x: <Literal>`;
|
||||||
|
- a map lookup (`_SETTING_REASONS[exc.reason]`): the map's values, and its keys must be exactly what the
|
||||||
|
index can be.
|
||||||
|
|
||||||
|
A name bound twice at module level, inside a module-level `if`/`try`/loop, named by a `global`, or imported
|
||||||
|
from two places is not looked up; nor is a local a lambda or a comprehension binds, or one a `nonlocal` or
|
||||||
|
`global` names. A module constant or map that anything stores into, deletes from or (a map) uses but by
|
||||||
|
`map[k]` or a read method is unread. A reference named like a followed helper that the scan cannot resolve
|
||||||
|
(`import app.x`, a relative import, an app module that does not bind it) is unread. Anything else is UNREAD,
|
||||||
|
or an unread code, and the contract fails on it unless no screen shows that code. Stdlib only: it runs on
|
||||||
|
the host, beside the frontend's tests (`python -I backend/tests/refusal_sites.py` → JSON).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
|
|
||||||
|
# Files whose `"code"` dicts are data, not refusals: a subtitle track's status payload, TMDB country codes.
|
||||||
|
NOT_REFUSALS = {
|
||||||
|
"plex/subtitles.py": "a subtitle track's status payload (its own `code`)",
|
||||||
|
"plex/tmdb.py": "a production country's ISO code",
|
||||||
|
}
|
||||||
|
|
||||||
|
# What a map may be used by besides `map[k]`: a method that only reads it.
|
||||||
|
READS = {"get", "items", "keys", "values"}
|
||||||
|
|
||||||
|
Node = ast.AST
|
||||||
|
FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
||||||
|
SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
||||||
|
COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
||||||
|
COMPOUND = (
|
||||||
|
ast.If,
|
||||||
|
ast.Try,
|
||||||
|
ast.TryStar,
|
||||||
|
ast.For,
|
||||||
|
ast.AsyncFor,
|
||||||
|
ast.While,
|
||||||
|
ast.With,
|
||||||
|
ast.AsyncWith,
|
||||||
|
ast.Match,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Res:
|
||||||
|
values: set[str] = field(default_factory=set)
|
||||||
|
unread: list[str] = field(default_factory=list)
|
||||||
|
none: bool = False # an arm sends no reason
|
||||||
|
|
||||||
|
def __or__(self, other: "Res") -> "Res":
|
||||||
|
return Res(self.values | other.values, self.unread + other.unread, self.none or other.none)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Module:
|
||||||
|
path: str # relative to app/, posix
|
||||||
|
name: str # dotted, `app.…`
|
||||||
|
tree: ast.Module
|
||||||
|
parents: dict[Node, Node]
|
||||||
|
# local name → (module, symbol or None for the module)
|
||||||
|
imports: dict[str, tuple[str, str | None]]
|
||||||
|
top: dict[str, Node] # module-level bindings: def, class, or the assignment
|
||||||
|
ambiguous: set[
|
||||||
|
str
|
||||||
|
] # bound more than once, conditionally, by a `global`, or imported from two places
|
||||||
|
slots: set[int] # the ids of the nodes sent as a detail
|
||||||
|
|
||||||
|
|
||||||
|
def _own_scope(node: Node) -> Iterator[Node]:
|
||||||
|
"""`node` and what it holds in its own scope: a nested def, class or lambda is itself, not its body."""
|
||||||
|
stack = [node]
|
||||||
|
while stack:
|
||||||
|
n = stack.pop()
|
||||||
|
yield n
|
||||||
|
if n is not node and isinstance(n, SCOPES):
|
||||||
|
continue
|
||||||
|
stack.extend(ast.iter_child_nodes(n))
|
||||||
|
|
||||||
|
|
||||||
|
def _stored(target: Node) -> set[str]:
|
||||||
|
"""The names an assignment target binds, not the ones it reads (`LABELS[k] = v` binds none)."""
|
||||||
|
return {
|
||||||
|
n.id for n in ast.walk(target) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _binds(node: Node) -> set[str]:
|
||||||
|
"""The names one node binds in the scope it sits in."""
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
return set().union(*(_stored(t) for t in node.targets))
|
||||||
|
if isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.For, ast.AsyncFor, ast.NamedExpr)):
|
||||||
|
return _stored(node.target)
|
||||||
|
if isinstance(node, (ast.With, ast.AsyncWith)):
|
||||||
|
return set().union(*(_stored(i.optional_vars) for i in node.items if i.optional_vars))
|
||||||
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
|
return {(a.asname or a.name).split(".")[0] for a in node.names}
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
return {node.name}
|
||||||
|
if isinstance(node, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)) and node.name:
|
||||||
|
return {node.name}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _sender(call: ast.Call) -> str | None:
|
||||||
|
return getattr(call.func, "id", None) or getattr(call.func, "attr", None)
|
||||||
|
|
||||||
|
|
||||||
|
def detail_slots(tree: Node) -> Iterator[tuple[ast.Call, Node | None]]:
|
||||||
|
"""Every place a detail is sent from, with what it holds: `HTTPException`'s second argument or
|
||||||
|
`detail=` (None: sent without one), and the `"detail"` value of a `JSONResponse`'s content dict (a
|
||||||
|
content without one is an answer, not a refusal)."""
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
kw = {k.arg: k.value for k in node.keywords}
|
||||||
|
if _sender(node) == "HTTPException":
|
||||||
|
yield node, kw.get("detail", node.args[1] if len(node.args) > 1 else None)
|
||||||
|
elif _sender(node) == "JSONResponse":
|
||||||
|
content = kw.get("content", node.args[0] if node.args else None)
|
||||||
|
if isinstance(content, ast.Dict):
|
||||||
|
for k, v in zip(content.keys, content.values, strict=True):
|
||||||
|
if isinstance(k, ast.Constant) and k.value == "detail":
|
||||||
|
yield node, v
|
||||||
|
|
||||||
|
|
||||||
|
def _code_dict(node: Node) -> bool:
|
||||||
|
"""A `{"code": …}` dict, or a `dict(code=…)` call."""
|
||||||
|
if isinstance(node, ast.Dict):
|
||||||
|
return any(isinstance(k, ast.Constant) and k.value == "code" for k in node.keys)
|
||||||
|
return (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and getattr(node.func, "id", None) == "dict"
|
||||||
|
and any(k.arg == "code" for k in node.keywords)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_name(path: str) -> str:
|
||||||
|
parts = path.removesuffix(".py").split("/")
|
||||||
|
if parts[-1] == "__init__":
|
||||||
|
parts = parts[:-1]
|
||||||
|
return ".".join(["app", *parts])
|
||||||
|
|
||||||
|
|
||||||
|
def _load(sources: dict[str, str]) -> dict[str, Module]:
|
||||||
|
mods: dict[str, Module] = {}
|
||||||
|
for path, src in sources.items():
|
||||||
|
tree = ast.parse(src)
|
||||||
|
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
|
||||||
|
origins: dict[str, set[tuple[str, str | None]]] = {}
|
||||||
|
ambiguous: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
||||||
|
for a in node.names:
|
||||||
|
origins.setdefault(a.asname or a.name, set()).add((node.module, a.name))
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for a in node.names:
|
||||||
|
if a.asname:
|
||||||
|
origins.setdefault(a.asname, set()).add((a.name, None))
|
||||||
|
elif isinstance(node, ast.Global):
|
||||||
|
ambiguous |= set(node.names)
|
||||||
|
top: dict[str, Node] = {}
|
||||||
|
count: dict[str, int] = {}
|
||||||
|
ambiguous |= {name for name, o in origins.items() if len(o) > 1}
|
||||||
|
for stmt in tree.body:
|
||||||
|
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
top[stmt.name] = stmt
|
||||||
|
elif isinstance(stmt, ast.Assign):
|
||||||
|
for t in stmt.targets:
|
||||||
|
if isinstance(t, ast.Name):
|
||||||
|
top[t.id] = stmt
|
||||||
|
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
||||||
|
top[stmt.target.id] = stmt
|
||||||
|
for n in [stmt] if isinstance(stmt, SCOPES) else _own_scope(stmt):
|
||||||
|
for name in _binds(n):
|
||||||
|
count[name] = count.get(name, 0) + 1
|
||||||
|
if isinstance(stmt, COMPOUND):
|
||||||
|
ambiguous.add(name)
|
||||||
|
ambiguous |= {name for name, c in count.items() if c > 1}
|
||||||
|
imports = {name: next(iter(o)) for name, o in origins.items() if name not in ambiguous}
|
||||||
|
slots = {id(d) for _, d in detail_slots(tree) if d is not None}
|
||||||
|
name = _module_name(path)
|
||||||
|
mods[name] = Module(path, name, tree, parents, imports, top, ambiguous, slots)
|
||||||
|
return mods
|
||||||
|
|
||||||
|
|
||||||
|
class Scan:
|
||||||
|
def __init__(self, sources: dict[str, str]):
|
||||||
|
self.mods = _load(sources)
|
||||||
|
self.errors: list[str] = []
|
||||||
|
self._following: set[tuple[str, str, str]] = set()
|
||||||
|
self._followed: dict[tuple[str, str, str], Res] = {}
|
||||||
|
self._resolving: set[int] = set()
|
||||||
|
self._factory: dict[int, bool] = {}
|
||||||
|
self._changed: dict[int, bool] = {}
|
||||||
|
|
||||||
|
# --- names ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lookup(
|
||||||
|
self, m: Module, name: str, seen: frozenset[tuple[str, str]] = frozenset()
|
||||||
|
) -> tuple[Module, Node] | tuple[str, None] | None:
|
||||||
|
"""What a module-level name is: (its module, its binding), or (a module's name, None)."""
|
||||||
|
if name in m.ambiguous:
|
||||||
|
return None
|
||||||
|
if name in m.top:
|
||||||
|
return m, m.top[name]
|
||||||
|
if name in m.imports:
|
||||||
|
mod, sym = m.imports[name]
|
||||||
|
if sym is None:
|
||||||
|
return (mod, None) if mod in self.mods else None
|
||||||
|
if f"{mod}.{sym}" in self.mods:
|
||||||
|
return f"{mod}.{sym}", None
|
||||||
|
if mod in self.mods and (mod, sym) not in seen:
|
||||||
|
return self.lookup(self.mods[mod], sym, seen | {(mod, sym)})
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lookup_expr(self, m: Module, e: Node) -> tuple[Module, Node] | tuple[str, None] | None:
|
||||||
|
if isinstance(e, ast.Name):
|
||||||
|
return None if self.binder(m, e) is not None else self.lookup(m, e.id)
|
||||||
|
if isinstance(e, ast.Attribute):
|
||||||
|
base = self.lookup_expr(m, e.value)
|
||||||
|
if base and base[1] is None and isinstance(base[0], str):
|
||||||
|
return self.lookup(self.mods[base[0]], e.attr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def binder(self, m: Module, e: ast.Name) -> Node | None:
|
||||||
|
"""The innermost scope `e` is bound in: a function (a parameter, or a binding in its own body), a
|
||||||
|
lambda (a parameter), a comprehension (its target); None: a module-level name."""
|
||||||
|
node = m.parents.get(e)
|
||||||
|
while node is not None:
|
||||||
|
if isinstance(node, (*FUNCS, ast.Lambda)):
|
||||||
|
a = node.args
|
||||||
|
params = [*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg]
|
||||||
|
if e.id in {p.arg for p in params if p}:
|
||||||
|
return node
|
||||||
|
if isinstance(node, FUNCS) and self.assignments(node, e.id):
|
||||||
|
return node
|
||||||
|
elif isinstance(node, COMPREHENSIONS) and any(
|
||||||
|
e.id in _stored(g.target) for g in node.generators
|
||||||
|
):
|
||||||
|
return node
|
||||||
|
node = m.parents.get(node)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def at(self, m: Module, e: Node) -> str:
|
||||||
|
return f"{m.path}:{getattr(e, 'lineno', '?')}: {ast.unparse(e)}"
|
||||||
|
|
||||||
|
def enclosing(self, m: Module, e: Node, kind: type | tuple[type, ...]) -> Node | None:
|
||||||
|
node = m.parents.get(e)
|
||||||
|
while node is not None and not isinstance(node, kind):
|
||||||
|
node = m.parents.get(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
def assignments(self, fn: Node, name: str) -> list[Node]:
|
||||||
|
"""What binds `name` in this function's own body (not in a nested one)."""
|
||||||
|
return [n for n in _own_scope(fn) if n is not fn and name in _binds(n)]
|
||||||
|
|
||||||
|
def caught_as(self, m: Module, e: Node, name: str) -> ast.ExceptHandler | None:
|
||||||
|
node = m.parents.get(e)
|
||||||
|
while node is not None and not isinstance(node, FUNCS):
|
||||||
|
if isinstance(node, ast.ExceptHandler) and node.name == name:
|
||||||
|
return node
|
||||||
|
node = m.parents.get(node)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def references(
|
||||||
|
self, m: Module, target: Node
|
||||||
|
) -> Iterator[tuple[Module, Node, ast.Call | None, bool]]:
|
||||||
|
"""Every reference to a module-level def or class: the call it makes (None: not a call), and
|
||||||
|
whether it resolved. A reference named like it that the scan cannot resolve is yielded too."""
|
||||||
|
name = getattr(target, "name", None)
|
||||||
|
for cm in self.mods.values():
|
||||||
|
for node in ast.walk(cm.tree):
|
||||||
|
if not isinstance(node, (ast.Name, ast.Attribute)) or not isinstance(
|
||||||
|
node.ctx, ast.Load
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if (node.id if isinstance(node, ast.Name) else node.attr) != name:
|
||||||
|
continue
|
||||||
|
parent = cm.parents.get(node)
|
||||||
|
if isinstance(parent, ast.Attribute) and parent.value is node:
|
||||||
|
continue
|
||||||
|
found = self.lookup_expr(cm, node)
|
||||||
|
if found:
|
||||||
|
if not (found[0] is m and found[1] is target):
|
||||||
|
continue
|
||||||
|
elif not self.unresolved(cm, node):
|
||||||
|
continue
|
||||||
|
call = parent if isinstance(parent, ast.Call) and parent.func is node else None
|
||||||
|
yield cm, node, call, found is not None
|
||||||
|
|
||||||
|
def unresolved(self, m: Module, node: Node) -> bool:
|
||||||
|
"""A name, or a dotted path, the app could mean that the scan cannot resolve: not a local, nor an
|
||||||
|
outside module's (an attribute of an object the function holds is not one)."""
|
||||||
|
root = node
|
||||||
|
while isinstance(root, ast.Attribute):
|
||||||
|
root = root.value
|
||||||
|
if not isinstance(root, ast.Name) or self.binder(m, root) is not None:
|
||||||
|
return False
|
||||||
|
if root.id in m.ambiguous or (root.id not in m.top and root.id not in m.imports):
|
||||||
|
return True
|
||||||
|
if root.id in m.top:
|
||||||
|
return False
|
||||||
|
if root is node: # imported from an app module that does not bind it where the scan can see
|
||||||
|
mod = m.imports[root.id][0]
|
||||||
|
return (mod == "app" or mod.startswith("app.")) and self.lookup(m, root.id) is None
|
||||||
|
found = self.lookup(m, root.id)
|
||||||
|
return (
|
||||||
|
found is not None and found[1] is None
|
||||||
|
) # an app module whose dotted path went nowhere
|
||||||
|
|
||||||
|
def changed(self, m: Module, stmt: Node, name: str, *, only_read: bool) -> bool:
|
||||||
|
"""Something in the app stores into, deletes or rebinds the module-level `name` that `stmt` binds,
|
||||||
|
or (`only_read`, a map) uses it but by `map[k]` or a method that only reads it."""
|
||||||
|
key = id(stmt) * 2 + only_read
|
||||||
|
if key in self._changed:
|
||||||
|
return self._changed[key]
|
||||||
|
own = stmt.targets if isinstance(stmt, ast.Assign) else [getattr(stmt, "target", None)]
|
||||||
|
out = False
|
||||||
|
for cm in self.mods.values():
|
||||||
|
for node in ast.walk(cm.tree):
|
||||||
|
if out:
|
||||||
|
break
|
||||||
|
if not (
|
||||||
|
isinstance(node, ast.Name)
|
||||||
|
and node.id == name
|
||||||
|
or isinstance(node, ast.Attribute)
|
||||||
|
and node.attr == name
|
||||||
|
) or (cm is m and node in own):
|
||||||
|
continue
|
||||||
|
found = self.lookup_expr(cm, node)
|
||||||
|
if not (found and found[0] is m and found[1] is stmt):
|
||||||
|
continue
|
||||||
|
if not isinstance(node.ctx, ast.Load):
|
||||||
|
out = True
|
||||||
|
elif only_read:
|
||||||
|
p = cm.parents.get(node)
|
||||||
|
out = not (
|
||||||
|
isinstance(p, ast.Subscript)
|
||||||
|
and p.value is node
|
||||||
|
and isinstance(p.ctx, ast.Load)
|
||||||
|
or isinstance(p, ast.Attribute)
|
||||||
|
and p.attr in READS
|
||||||
|
)
|
||||||
|
self._changed[key] = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- types ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ann_values(self, m: Module, ann: Node | None, depth: int = 0) -> set[str] | None:
|
||||||
|
"""The values a `Literal[…]` annotation, or an alias of one, allows; None when it is not one."""
|
||||||
|
if isinstance(ann, ast.Subscript) and getattr(ann.value, "id", None) == "Literal":
|
||||||
|
items = ann.slice.elts if isinstance(ann.slice, ast.Tuple) else [ann.slice]
|
||||||
|
if all(isinstance(i, ast.Constant) and isinstance(i.value, str) for i in items):
|
||||||
|
return {i.value for i in items}
|
||||||
|
return None
|
||||||
|
if isinstance(ann, (ast.Name, ast.Attribute)) and depth < 8:
|
||||||
|
found = self.lookup_expr(m, ann)
|
||||||
|
if found and isinstance(found[0], Module) and isinstance(found[1], ast.Assign):
|
||||||
|
return self.ann_values(found[0], found[1].value, depth + 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def classes(self, m: Module, e: Node | None) -> list[tuple[Module, ast.ClassDef]] | None:
|
||||||
|
found = self.lookup_expr(m, e) if e is not None else None
|
||||||
|
if found and isinstance(found[0], Module) and isinstance(found[1], ast.ClassDef):
|
||||||
|
return [(found[0], found[1])]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def typed(self, m: Module, at: Node, name: str) -> list[tuple[Module, ast.ClassDef]] | None:
|
||||||
|
"""The class `name` holds where `at` sits: the one an `except C as name` catches, or a parameter's
|
||||||
|
annotation (a parameter its body binds again holds no known class)."""
|
||||||
|
handler = self.caught_as(m, at, name)
|
||||||
|
if handler is not None:
|
||||||
|
return self.classes(m, handler.type)
|
||||||
|
fn = self.enclosing(m, at, FUNCS)
|
||||||
|
if fn is None:
|
||||||
|
return None
|
||||||
|
params = {a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]}
|
||||||
|
if name in params and not self.assignments(fn, name):
|
||||||
|
return self.classes(m, params[name].annotation)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def attr_values(self, cm: Module, cls: ast.ClassDef, attr: str) -> set[str] | None:
|
||||||
|
"""An attribute's `Literal` values, from `self.attr: T` in a method of the class."""
|
||||||
|
for node in ast.walk(cls):
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.AnnAssign)
|
||||||
|
and isinstance(node.target, ast.Attribute)
|
||||||
|
and node.target.attr == attr
|
||||||
|
and getattr(node.target.value, "id", None) == "self"
|
||||||
|
):
|
||||||
|
return self.ann_values(cm, node.annotation)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- reasons -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def resolve(self, m: Module, e: Node) -> Res:
|
||||||
|
if isinstance(e, ast.Constant):
|
||||||
|
if e.value is None:
|
||||||
|
return Res(none=True)
|
||||||
|
if isinstance(e.value, str):
|
||||||
|
return Res({e.value})
|
||||||
|
if isinstance(e, ast.IfExp):
|
||||||
|
return self.resolve(m, e.body) | self.resolve(m, e.orelse)
|
||||||
|
if isinstance(e, ast.Name):
|
||||||
|
return self.resolve_name(m, e)
|
||||||
|
if isinstance(e, ast.Attribute):
|
||||||
|
return self.resolve_attr(m, e)
|
||||||
|
if isinstance(e, ast.Call):
|
||||||
|
found = self.lookup_expr(m, e.func)
|
||||||
|
if found and isinstance(found[0], Module) and isinstance(found[1], FUNCS):
|
||||||
|
v = self.ann_values(found[0], found[1].returns)
|
||||||
|
if v is not None:
|
||||||
|
return Res(v)
|
||||||
|
if isinstance(e, ast.Subscript):
|
||||||
|
return self.resolve_lookup(m, e)
|
||||||
|
return Res(unread=[self.at(m, e)])
|
||||||
|
|
||||||
|
def resolve_name(self, m: Module, e: ast.Name) -> Res:
|
||||||
|
if self.caught_as(m, e, e.id) is not None:
|
||||||
|
return Res(unread=[self.at(m, e)]) # the exception itself, not a reason
|
||||||
|
fn = self.binder(m, e)
|
||||||
|
if fn is None:
|
||||||
|
return Res(unread=[self.at(m, e)])
|
||||||
|
if not isinstance(fn, FUNCS):
|
||||||
|
return Res(unread=[f"{self.at(m, e)} (bound by a lambda or a comprehension)"])
|
||||||
|
if any(isinstance(n, (ast.Nonlocal, ast.Global)) and e.id in n.names for n in ast.walk(fn)):
|
||||||
|
return Res(unread=[f"{self.at(m, e)} (named by a nonlocal or a global)"])
|
||||||
|
a = fn.args
|
||||||
|
params = {p.arg: p for p in [*a.posonlyargs, *a.args, *a.kwonlyargs]}
|
||||||
|
assigned = self.assignments(fn, e.id)
|
||||||
|
if e.id in params:
|
||||||
|
if assigned:
|
||||||
|
return Res(unread=[f"{self.at(m, e)} (a parameter its body binds again)"])
|
||||||
|
return self.resolve_param(m, fn, params[e.id], e)
|
||||||
|
out = Res(unread=[] if assigned else [self.at(m, e)]) # `*args`, `**kwargs`
|
||||||
|
for s in assigned:
|
||||||
|
if not (isinstance(s, ast.Assign) and all(isinstance(t, ast.Name) for t in s.targets)):
|
||||||
|
out = out | Res(unread=[self.at(m, e)])
|
||||||
|
elif id(s) in self._resolving:
|
||||||
|
out = out | Res(unread=[f"{self.at(m, e)} (reached again while it is resolved)"])
|
||||||
|
else:
|
||||||
|
self._resolving.add(id(s))
|
||||||
|
out = out | self.resolve(m, s.value)
|
||||||
|
self._resolving.discard(id(s))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def resolve_param(self, m: Module, fn: Node, param: ast.arg, e: Node) -> Res:
|
||||||
|
v = self.ann_values(m, param.annotation)
|
||||||
|
if v is not None:
|
||||||
|
return Res(v)
|
||||||
|
if param.annotation is not None and getattr(param.annotation, "id", None) != "str":
|
||||||
|
return Res(unread=[self.at(m, e)])
|
||||||
|
return self.follow(m, fn, param)
|
||||||
|
|
||||||
|
def follow(self, m: Module, fn: Node, param: ast.arg) -> Res:
|
||||||
|
"""A `str` parameter of a helper: what every call of the helper passes it."""
|
||||||
|
assert isinstance(fn, FUNCS)
|
||||||
|
key = (m.name, fn.name, param.arg)
|
||||||
|
if key in self._followed:
|
||||||
|
return self._followed[key]
|
||||||
|
where = f"{m.path}:{fn.lineno}: {fn.name}({param.arg})"
|
||||||
|
if key in self._following:
|
||||||
|
return Res(unread=[f"{where} reached again while it is followed"])
|
||||||
|
if m.top.get(fn.name) is not fn or fn.name in m.ambiguous:
|
||||||
|
return Res(unread=[f"{where} is not a module-level helper"])
|
||||||
|
self._following.add(key)
|
||||||
|
positional = [a.arg for a in [*fn.args.posonlyargs, *fn.args.args]]
|
||||||
|
by_name = param not in fn.args.posonlyargs
|
||||||
|
out = Res()
|
||||||
|
for cm, node, call, resolved in self.references(m, fn):
|
||||||
|
if not resolved:
|
||||||
|
out = out | Res(
|
||||||
|
unread=[f"{self.at(cm, node)} (a reference the scan cannot resolve)"]
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if call is None:
|
||||||
|
out = out | Res(unread=[f"{self.at(cm, node)} (the helper used as a value)"])
|
||||||
|
continue
|
||||||
|
kw = {k.arg: k.value for k in call.keywords}
|
||||||
|
given = param.arg in positional and positional.index(param.arg) < len(call.args)
|
||||||
|
if by_name and param.arg in kw:
|
||||||
|
out = out | self.resolve(cm, kw[param.arg])
|
||||||
|
elif any(isinstance(a, ast.Starred) for a in call.args):
|
||||||
|
out = out | Res(unread=[self.at(cm, call)])
|
||||||
|
elif given:
|
||||||
|
out = out | self.resolve(cm, call.args[positional.index(param.arg)])
|
||||||
|
else:
|
||||||
|
out = out | Res(unread=[f"{self.at(cm, call)} (does not pass {param.arg})"])
|
||||||
|
self._followed[key] = out # once followed, its key answers from here: never reached again
|
||||||
|
return out
|
||||||
|
|
||||||
|
def resolve_attr(self, m: Module, e: ast.Attribute) -> Res:
|
||||||
|
base = e.value
|
||||||
|
if isinstance(base, ast.Name):
|
||||||
|
typed = self.typed(m, e, base.id)
|
||||||
|
if typed is not None:
|
||||||
|
out = Res()
|
||||||
|
for cm, cls in typed:
|
||||||
|
v = self.attr_values(cm, cls, e.attr)
|
||||||
|
out = out | (
|
||||||
|
Res(v)
|
||||||
|
if v is not None
|
||||||
|
else Res(
|
||||||
|
unread=[f"{self.at(m, e)} ({cls.name}.{e.attr} has no Literal type)"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
found = self.lookup_expr(m, e)
|
||||||
|
if (
|
||||||
|
found
|
||||||
|
and isinstance(found[0], Module)
|
||||||
|
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
|
||||||
|
and isinstance(found[1].value, ast.Constant)
|
||||||
|
):
|
||||||
|
if self.changed(found[0], found[1], e.attr, only_read=False):
|
||||||
|
return Res(unread=[f"{self.at(m, e)} (bound again or deleted elsewhere)"])
|
||||||
|
return self.resolve(found[0], found[1].value)
|
||||||
|
return Res(unread=[self.at(m, e)])
|
||||||
|
|
||||||
|
def resolve_lookup(self, m: Module, e: ast.Subscript) -> Res:
|
||||||
|
found = self.lookup_expr(m, e.value)
|
||||||
|
if not (
|
||||||
|
found
|
||||||
|
and isinstance(found[0], Module)
|
||||||
|
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
|
||||||
|
):
|
||||||
|
return Res(unread=[self.at(m, e)])
|
||||||
|
table = found[1].value
|
||||||
|
if not isinstance(table, ast.Dict) or not all(
|
||||||
|
isinstance(k, ast.Constant) and isinstance(k.value, str) for k in table.keys
|
||||||
|
):
|
||||||
|
return Res(unread=[self.at(m, e)])
|
||||||
|
name = e.value.attr if isinstance(e.value, ast.Attribute) else getattr(e.value, "id", "")
|
||||||
|
if self.changed(found[0], found[1], name, only_read=True):
|
||||||
|
return Res(unread=[f"{self.at(m, e)} (the map is changed after it is built)"])
|
||||||
|
out = Res()
|
||||||
|
for v in table.values:
|
||||||
|
out = out | self.resolve(found[0], v)
|
||||||
|
index = self.resolve(m, e.slice)
|
||||||
|
keys = {k.value for k in table.keys if isinstance(k, ast.Constant)}
|
||||||
|
if index.unread or index.none or index.values != keys:
|
||||||
|
self.errors.append(
|
||||||
|
f"{self.at(m, e)}: the map's keys {sorted(keys)} are not what its index can be "
|
||||||
|
f"{sorted(index.values)}{' + unread ' + str(index.unread) if index.unread else ''}"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# --- sites ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_detail(self, node: Node | None) -> bool:
|
||||||
|
if isinstance(node, ast.IfExp):
|
||||||
|
return self.is_detail(node.body) and self.is_detail(node.orelse)
|
||||||
|
return _code_dict(node)
|
||||||
|
|
||||||
|
def factory(self, m: Module, fn: Node) -> bool:
|
||||||
|
"""A module-level function each return of which is a detail, and each reference to which is a
|
||||||
|
call sent as one (`quota_detail`)."""
|
||||||
|
assert isinstance(fn, FUNCS)
|
||||||
|
if id(fn) in self._factory:
|
||||||
|
return self._factory[id(fn)]
|
||||||
|
returns = [n for n in _own_scope(fn) if isinstance(n, ast.Return)]
|
||||||
|
ok = (
|
||||||
|
m.top.get(fn.name) is fn # a name bound twice: its calls are unresolved below
|
||||||
|
and bool(returns)
|
||||||
|
and all(self.is_detail(r.value) for r in returns)
|
||||||
|
)
|
||||||
|
for cm, _, call, resolved in self.references(m, fn) if ok else []:
|
||||||
|
ok = resolved and id(call) in cm.slots # a bare reference is no call: never sent
|
||||||
|
if not ok:
|
||||||
|
break
|
||||||
|
self._factory[id(fn)] = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def placed(self, m: Module, d: Node) -> bool:
|
||||||
|
"""`d` is sent as a detail: from a sender's slot, through the arms of `a if c else b`, or as what a
|
||||||
|
factory returns."""
|
||||||
|
holder, p = d, m.parents.get(d)
|
||||||
|
while isinstance(p, ast.IfExp) and holder is not p.test:
|
||||||
|
holder, p = p, m.parents.get(p)
|
||||||
|
if id(holder) in m.slots:
|
||||||
|
return True
|
||||||
|
fn = self.enclosing(m, p, FUNCS) if isinstance(p, ast.Return) else None
|
||||||
|
return fn is not None and self.factory(m, fn)
|
||||||
|
|
||||||
|
def sayable(self, m: Module, d: Node | None) -> bool:
|
||||||
|
"""What a slot holds is a detail the client can say: a code dict, or a factory's call."""
|
||||||
|
if isinstance(d, ast.IfExp):
|
||||||
|
return self.sayable(m, d.body) and self.sayable(m, d.orelse)
|
||||||
|
if _code_dict(d):
|
||||||
|
return True
|
||||||
|
found = self.lookup_expr(m, d.func) if isinstance(d, ast.Call) else None
|
||||||
|
return bool(
|
||||||
|
found
|
||||||
|
and isinstance(found[0], Module)
|
||||||
|
and isinstance(found[1], FUNCS)
|
||||||
|
and self.factory(found[0], found[1])
|
||||||
|
)
|
||||||
|
|
||||||
|
def entries(self, d: Node) -> list[tuple[str | None, Node]]:
|
||||||
|
"""A dict's (key, value) pairs: None for a `**` spread, "?" for a key that is not a literal."""
|
||||||
|
if isinstance(d, ast.Dict):
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
k.value
|
||||||
|
if isinstance(k, ast.Constant) and isinstance(k.value, str)
|
||||||
|
else None
|
||||||
|
if k is None
|
||||||
|
else "?",
|
||||||
|
v,
|
||||||
|
)
|
||||||
|
for k, v in zip(d.keys, d.values, strict=True)
|
||||||
|
]
|
||||||
|
assert isinstance(d, ast.Call)
|
||||||
|
return [(None, a) for a in d.args] + [(k.arg, k.value) for k in d.keywords]
|
||||||
|
|
||||||
|
def site(self, m: Module, d: Node, out: dict) -> None:
|
||||||
|
items = self.entries(d)
|
||||||
|
code = Res()
|
||||||
|
for v in (v for k, v in items if k == "code"):
|
||||||
|
code = code | self.resolve(m, v)
|
||||||
|
problems = [
|
||||||
|
f"{self.at(m, v)} (a spread in dict())"
|
||||||
|
for k, v in items
|
||||||
|
if k is None and isinstance(d, ast.Call)
|
||||||
|
] + [
|
||||||
|
f"{self.at(m, v)} (a spread or a computed key the code and the reason do not follow)"
|
||||||
|
for i, (k, v) in enumerate(items)
|
||||||
|
if k in (None, "?") and not {"code", "reason"} <= {k2 for k2, _ in items[i + 1 :]}
|
||||||
|
]
|
||||||
|
if not self.placed(m, d):
|
||||||
|
problems.append(f"{self.at(m, d)} (a detail put where the scan cannot follow it)")
|
||||||
|
if code.unread or code.none or len(code.values) != 1 or problems:
|
||||||
|
out["unreadCodes"] += problems or [self.at(m, d)]
|
||||||
|
return
|
||||||
|
entry = out["codes"].setdefault(
|
||||||
|
next(iter(code.values)), {"reasons": set(), "unread": [], "sites": 0, "reasonless": []}
|
||||||
|
)
|
||||||
|
entry["sites"] += 1
|
||||||
|
res, has_reason = Res(), False
|
||||||
|
for k, v in items:
|
||||||
|
if k == "reason":
|
||||||
|
res, has_reason = res | self.resolve(m, v), True
|
||||||
|
if not has_reason or res.none:
|
||||||
|
entry["reasonless"].append(f"{m.path}:{d.lineno}")
|
||||||
|
entry["reasons"] |= res.values
|
||||||
|
entry["unread"] += res.unread
|
||||||
|
|
||||||
|
def other_details(self) -> list[tuple[str, int, str | None]]:
|
||||||
|
"""Each slot that sends what the client cannot say — nothing, or anything but a code dict or a
|
||||||
|
factory's call: (its file, its line, the function it is sent from)."""
|
||||||
|
out = []
|
||||||
|
for m in self.mods.values():
|
||||||
|
for call, d in detail_slots(m.tree):
|
||||||
|
if self.sayable(m, d):
|
||||||
|
continue
|
||||||
|
fn = self.enclosing(m, call, FUNCS)
|
||||||
|
out.append((m.path, call.lineno, getattr(fn, "name", None)))
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
def run(self) -> dict:
|
||||||
|
out: dict = {
|
||||||
|
"codes": {},
|
||||||
|
"unreadCodes": [],
|
||||||
|
"errors": self.errors,
|
||||||
|
"excluded": {},
|
||||||
|
"aliases": {},
|
||||||
|
"notRefusals": sorted(NOT_REFUSALS),
|
||||||
|
}
|
||||||
|
for m in self.mods.values():
|
||||||
|
for name, stmt in m.top.items():
|
||||||
|
if (
|
||||||
|
isinstance(stmt, ast.Assign)
|
||||||
|
and isinstance(stmt.value, ast.Subscript)
|
||||||
|
and (v := self.ann_values(m, stmt.value))
|
||||||
|
):
|
||||||
|
out["aliases"].setdefault(m.path, {})[name] = sorted(v)
|
||||||
|
for node in ast.walk(m.tree):
|
||||||
|
if not _code_dict(node):
|
||||||
|
continue
|
||||||
|
if m.path in NOT_REFUSALS:
|
||||||
|
out["excluded"][m.path] = out["excluded"].get(m.path, 0) + 1
|
||||||
|
continue
|
||||||
|
self.site(m, node, out)
|
||||||
|
for entry in out["codes"].values():
|
||||||
|
entry["reasons"] = sorted(entry["reasons"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scan(sources: dict[str, str]) -> dict:
|
||||||
|
return Scan(sources).run()
|
||||||
|
|
||||||
|
|
||||||
|
def app_sources(app: pathlib.Path = APP) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
p.relative_to(app).as_posix(): p.read_text(encoding="utf-8")
|
||||||
|
for p in sorted(app.rglob("*.py"))
|
||||||
|
if "__pycache__" not in p.parts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_app(app: pathlib.Path = APP) -> dict:
|
||||||
|
return scan(app_sources(app))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
json.dump(
|
||||||
|
scan_app(pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else APP),
|
||||||
|
sys.stdout,
|
||||||
|
indent=1,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
@@ -1105,10 +1105,16 @@ def test_two_overlapping_reorders_never_share_a_slot(db, as_user):
|
|||||||
|
|
||||||
worker = threading.Thread(target=second_tab)
|
worker = threading.Thread(target=second_tab)
|
||||||
worker.start()
|
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
|
waiting = 0
|
||||||
for _ in range(200):
|
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()
|
probe.rollback()
|
||||||
if waiting:
|
if waiting:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class _Proc:
|
|||||||
|
|
||||||
class _Session:
|
class _Session:
|
||||||
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
||||||
|
self.key = name
|
||||||
self.dir = tmp_path / name
|
self.dir = tmp_path / name
|
||||||
self.dir.mkdir()
|
self.dir.mkdir()
|
||||||
self.proc = _Proc(finished)
|
self.proc = _Proc(finished)
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tearing a session down does not stall everybody else's playback.
|
||||||
|
|
||||||
|
Every segment request (`current_session`) and every checkpoint (`touch_session`) takes the one
|
||||||
|
registry `_lock`. Stopping a session waits up to 3 s for its ffmpeg to exit and then deletes a whole
|
||||||
|
segmented film, ~1200 files — and that used to happen under the same lock, so one viewer's seek, a
|
||||||
|
cap cull or a reap pass held up every other viewer's HLS requests for seconds, each one a parked
|
||||||
|
threadpool worker and its pooled DB connection.
|
||||||
|
|
||||||
|
What still has to be atomic is pinned too: a session directory is reused for the same item, offset
|
||||||
|
and tag, so a new session on that path must not start before the old ffmpeg — which writes by path
|
||||||
|
— has exited.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.plex import stream
|
||||||
|
|
||||||
|
|
||||||
|
class _SlowProc:
|
||||||
|
"""An ffmpeg that takes a while to exit after TERM: `wait` blocks until `exited` is set."""
|
||||||
|
|
||||||
|
def __init__(self, order: list[str], name: str):
|
||||||
|
self.order = order
|
||||||
|
self.name = name
|
||||||
|
self.waiting = threading.Event()
|
||||||
|
self.exited = threading.Event()
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return 0 if self.exited.is_set() else None
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.exited.set()
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
self.waiting.set()
|
||||||
|
if not self.exited.wait(timeout):
|
||||||
|
raise stream.subprocess.TimeoutExpired("ffmpeg", timeout)
|
||||||
|
self.order.append(f"{self.name} exited")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self, directory, proc, key: str):
|
||||||
|
self.id = f"sid-{key}"
|
||||||
|
self.key = key
|
||||||
|
self.dir = directory
|
||||||
|
self.proc = proc
|
||||||
|
self.last_access = time.time()
|
||||||
|
self.transcode = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sessions(monkeypatch):
|
||||||
|
live: dict = {}
|
||||||
|
monkeypatch.setattr(stream, "_sessions", live)
|
||||||
|
monkeypatch.setattr(stream, "_dying", {}, raising=False)
|
||||||
|
return live
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopping_a_session_does_not_hold_up_another_viewers_requests(tmp_path, sessions):
|
||||||
|
order: list[str] = []
|
||||||
|
slow = _SlowProc(order, "a")
|
||||||
|
d = tmp_path / "1_u1_0_0_+0.00"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "seg_0.ts").write_bytes(b"x")
|
||||||
|
sessions[(1, "a")] = _Session(d, slow, "a")
|
||||||
|
sessions[(2, "b")] = _Session(tmp_path / "2_u2_0_0_+0.00", _SlowProc(order, "b"), "b")
|
||||||
|
|
||||||
|
stopper = threading.Thread(target=stream.stop_session, args=(1, "a", "sid-a"))
|
||||||
|
stopper.start()
|
||||||
|
try:
|
||||||
|
assert slow.waiting.wait(2.0), "the stop never waited for its ffmpeg"
|
||||||
|
got: list = []
|
||||||
|
reader = threading.Thread(target=lambda: got.append(stream.current_session(2, "b")))
|
||||||
|
reader.start()
|
||||||
|
reader.join(1.0)
|
||||||
|
assert not reader.is_alive(), "a segment request waited on another viewer's teardown"
|
||||||
|
assert got and got[0] is sessions[(2, "b")]
|
||||||
|
finally:
|
||||||
|
slow.exited.set()
|
||||||
|
stopper.join(5.0)
|
||||||
|
# The teardown still finishes: the session is gone and nothing of its directory is left behind.
|
||||||
|
assert (1, "a") not in sessions
|
||||||
|
assert sorted(p.name for p in tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reap_pass_does_not_hold_up_another_viewers_requests(tmp_path, sessions):
|
||||||
|
order: list[str] = []
|
||||||
|
slow = _SlowProc(order, "a")
|
||||||
|
d = tmp_path / "1_u1_0_0_+0.00"
|
||||||
|
d.mkdir()
|
||||||
|
gone = _Session(d, slow, "a")
|
||||||
|
gone.last_access = time.time() - stream._SESSION_IDLE_S - 5
|
||||||
|
sessions[(1, "a")] = gone
|
||||||
|
sessions[(2, "b")] = _Session(tmp_path / "2_u2_0_0_+0.00", _SlowProc(order, "b"), "b")
|
||||||
|
|
||||||
|
reaper = threading.Thread(target=stream.reap_idle)
|
||||||
|
reaper.start()
|
||||||
|
try:
|
||||||
|
assert slow.waiting.wait(2.0), "the reap never waited for its ffmpeg"
|
||||||
|
toucher = threading.Thread(target=stream.touch_session, args=(2, "b"))
|
||||||
|
toucher.start()
|
||||||
|
toucher.join(1.0)
|
||||||
|
assert not toucher.is_alive(), "a checkpoint waited on another viewer's teardown"
|
||||||
|
finally:
|
||||||
|
slow.exited.set()
|
||||||
|
reaper.join(5.0)
|
||||||
|
assert list(sessions) == [(2, "b")]
|
||||||
|
assert not d.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeItem:
|
||||||
|
rating_key = "12345"
|
||||||
|
file_path = "/media/movie.mkv"
|
||||||
|
playable = "remux"
|
||||||
|
codec_video = "h264"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_session_on_a_dying_ones_path_starts_only_after_it_exited(
|
||||||
|
tmp_path, sessions, monkeypatch
|
||||||
|
):
|
||||||
|
"""The same item at the same second gets the same directory. The old ffmpeg writes by PATH, so
|
||||||
|
while it is still winding down after TERM it can write a segment — or, on a clean exit, a final
|
||||||
|
playlist with ENDLIST — into the NEW session's directory."""
|
||||||
|
order: list[str] = []
|
||||||
|
procs = iter([_SlowProc(order, "old"), _SlowProc(order, "new")])
|
||||||
|
|
||||||
|
def popen(*a, **k):
|
||||||
|
order.append("popen")
|
||||||
|
return next(procs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path)
|
||||||
|
src = tmp_path / "movie.mkv"
|
||||||
|
src.write_bytes(b"x")
|
||||||
|
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
|
||||||
|
monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2)
|
||||||
|
monkeypatch.setattr(stream.sysconfig, "get_bool", lambda db, k: False)
|
||||||
|
monkeypatch.setattr(stream.subprocess, "Popen", popen)
|
||||||
|
monkeypatch.setattr(stream, "wait_for", lambda *a, **k: True)
|
||||||
|
|
||||||
|
first = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||||
|
assert first is not None
|
||||||
|
stopper = threading.Thread(target=stream.stop_session, args=(7, "12345", first.id))
|
||||||
|
stopper.start()
|
||||||
|
assert first.proc.waiting.wait(2.0)
|
||||||
|
threading.Timer(0.3, first.proc.exited.set).start()
|
||||||
|
second = stream.start_session(db=None, user_id=7, item=_FakeItem(), start_s=0.0)
|
||||||
|
stopper.join(5.0)
|
||||||
|
assert second is not None and second.dir == first.dir
|
||||||
|
assert order.index("old exited") < order.index("popen", 1)
|
||||||
|
assert second.dir.is_dir(), "the old teardown deleted the new session's directory"
|
||||||
|
second.proc.exited.set()
|
||||||
@@ -1,53 +1,88 @@
|
|||||||
"""No refusal answers in a hand-written English sentence.
|
"""No refusal answers in a way the client cannot say.
|
||||||
|
|
||||||
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
|
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
|
||||||
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
|
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
|
||||||
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
|
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
|
||||||
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources.
|
became; this test is what keeps a new one from being written the old way. It reads the same slots the
|
||||||
|
refusal scan does (`refusal_sites.detail_slots`), so the two cannot disagree on where a detail is sent.
|
||||||
|
Pure: it reads the sources.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ast
|
import textwrap
|
||||||
import pathlib
|
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
from tests.refusal_sites import Scan, app_sources
|
||||||
|
|
||||||
|
# Answers that are no refusal of the app's, by the function they are sent from: the client shows a
|
||||||
|
# detail only when it is an object (`lib/api/core.ts`), so these read as its generic line, and no screen
|
||||||
|
# needs more from them.
|
||||||
|
NO_REFUSAL = {
|
||||||
|
("main.py", "setup_gate"): "the 503 before setup: the setup screen is what the user sees",
|
||||||
|
("main.py", "spa_fallback"): "a bare 404 for an api path no route matches: no screen calls one",
|
||||||
|
("main.py", "_validation_error"): "FastAPI's own 422 list: a body the client itself got wrong",
|
||||||
|
("routes/diagnostics.py", "_parsed_events"): "the client-events beacon's 422: nothing shows it",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_text(node: ast.AST) -> bool:
|
def _offenders(sources: dict[str, str]) -> list[str]:
|
||||||
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
|
return [
|
||||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
f"{path}:{line}"
|
||||||
return True
|
for path, line, fn in Scan(sources).other_details()
|
||||||
if isinstance(node, ast.JoinedStr):
|
if (path, fn) not in NO_REFUSAL
|
||||||
return True
|
]
|
||||||
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
|
|
||||||
return True
|
|
||||||
if isinstance(node, ast.BoolOp):
|
|
||||||
return any(_is_text(v) for v in node.values)
|
|
||||||
if isinstance(node, ast.BinOp):
|
|
||||||
return _is_text(node.left) or _is_text(node.right)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_http_exception_carries_a_sentence():
|
def test_every_detail_is_one_the_client_can_say():
|
||||||
offenders = []
|
offenders = _offenders(app_sources())
|
||||||
for path in sorted(APP.rglob("*.py")):
|
|
||||||
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
|
||||||
if not (
|
|
||||||
isinstance(node, ast.Call)
|
|
||||||
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
kw = {k.arg: k.value for k in node.keywords}
|
|
||||||
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
|
|
||||||
if detail is not None and _is_text(detail):
|
|
||||||
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
|
|
||||||
assert offenders == [], (
|
assert offenders == [], (
|
||||||
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
|
"a refusal the client can only answer with its generic line — send `{code, reason}` "
|
||||||
f"(app/refusal.py) and name the reason in both locales: {offenders}"
|
f"(`refused(status, reason)`), or list it in NO_REFUSAL with why: {offenders}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_scan_sees_a_sentence_when_there_is_one():
|
def test_the_detail_scan_sees_each_shape_the_client_cannot_say():
|
||||||
"""A guard that matches nothing is green over nothing."""
|
"""A guard that matches nothing is green over nothing."""
|
||||||
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
|
src = textwrap.dedent(
|
||||||
assert _is_text(sample.keywords[1].value)
|
"""
|
||||||
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
|
def f(x):
|
||||||
|
raise HTTPException(409, detail={"reason": "view_name_taken"})
|
||||||
|
def g(x):
|
||||||
|
raise HTTPException(409, dict(reason="taken"))
|
||||||
|
def h(x):
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
def i(x):
|
||||||
|
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
|
||||||
|
def j(x):
|
||||||
|
return JSONResponse(content={"detail": {"reason": "x"}}, status_code=409)
|
||||||
|
def k(x):
|
||||||
|
raise HTTPException(400, detail=f"bad {x}")
|
||||||
|
def l(x):
|
||||||
|
raise HTTPException(404, None)
|
||||||
|
def n(x, msg):
|
||||||
|
raise HTTPException(400, detail=msg)
|
||||||
|
def o(x):
|
||||||
|
raise HTTPException(400, detail=["a sentence"])
|
||||||
|
def p(x):
|
||||||
|
raise HTTPException(400, detail=describe(x))
|
||||||
|
def q(x):
|
||||||
|
raise HTTPException(400, detail={"code": "a"} if x else "text")
|
||||||
|
def r(x):
|
||||||
|
raise HTTPException(400, detail=nothing(x))
|
||||||
|
def nothing(x):
|
||||||
|
pass
|
||||||
|
def ok(x):
|
||||||
|
raise HTTPException(409, {"code": "refused", "reason": "x"})
|
||||||
|
raise HTTPException(409, detail=quota(x))
|
||||||
|
raise HTTPException(409, detail={"code": "a"} if x else {"code": "b"})
|
||||||
|
return JSONResponse({"detail": {"code": "too_large"}}, status_code=413)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
def quota(x):
|
||||||
|
return {"code": "quota", "reason": "files"}
|
||||||
|
def setup_gate(x):
|
||||||
|
return JSONResponse({"detail": "listed"}, status_code=503)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
lines = (3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25)
|
||||||
|
assert _offenders({"main.py": src}) == [f"main.py:{n}" for n in lines]
|
||||||
|
# A file whose code dicts are data still answers its refusals in a way the client can say.
|
||||||
|
data = 'def f():\n raise HTTPException(400, "text")\n'
|
||||||
|
assert _offenders({"plex/tmdb.py": data}) == ["plex/tmdb.py:2"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""`/api/me` names the live-search source, so the search button's tooltip can say what a search costs
|
"""`/api/me` names the live-search source, so the search button's tooltip can say what a search costs
|
||||||
under the source that actually runs (the default website source spends no API quota)."""
|
under the source that actually runs (the default website source spends no API quota), and says
|
||||||
|
whether live search and related videos are on at all."""
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
||||||
@@ -24,3 +25,24 @@ def test_me_names_the_running_search_source(client, db, make_user):
|
|||||||
sysconfig.set_value(db, "search_source", "api")
|
sysconfig.set_value(db, "search_source", "api")
|
||||||
db.commit()
|
db.commit()
|
||||||
assert _me(client)["search_source"] == "api"
|
assert _me(client)["search_source"] == "api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_says_whether_live_search_and_related_videos_are_on(client, db, make_user):
|
||||||
|
# A daily limit of 0 turns the feature off; the UI leaves it out instead of offering a refusal.
|
||||||
|
user = make_user(password=_PW)
|
||||||
|
login = client.post("/auth/password-login", json={"email": user.email, "password": _PW})
|
||||||
|
assert login.status_code == 200
|
||||||
|
|
||||||
|
me = _me(client)
|
||||||
|
assert (me["search_enabled"], me["related_enabled"]) == (True, True)
|
||||||
|
|
||||||
|
sysconfig.set_value(db, "search_daily_limit_per_user", 0)
|
||||||
|
db.commit()
|
||||||
|
me = _me(client)
|
||||||
|
assert (me["search_enabled"], me["related_enabled"]) == (False, True)
|
||||||
|
|
||||||
|
sysconfig.set_value(db, "search_daily_limit_per_user", 5)
|
||||||
|
sysconfig.set_value(db, "related_daily_limit_per_user", 0)
|
||||||
|
db.commit()
|
||||||
|
me = _me(client)
|
||||||
|
assert (me["search_enabled"], me["related_enabled"]) == (True, False)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""A rename onto a name already taken says WHICH name is taken: the one asked for, not the row's
|
||||||
|
old name (the rollback that follows the conflict puts the old name back on the row). DB lane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.state as state
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _client(db, monkeypatch, override_dep):
|
||||||
|
u = User(email="taken@example.com")
|
||||||
|
db.add(u)
|
||||||
|
db.commit()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
state, "_configured_cache", True
|
||||||
|
) # the request gate 503s an unconfigured app
|
||||||
|
override_dep(get_db, db.info["request"])
|
||||||
|
override_dep(current_user, lambda: u)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_renaming_a_view_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
|
||||||
|
c = _client(db, monkeypatch, override_dep)
|
||||||
|
gaming = c.post("/api/saved-views", json={"name": "gaming", "filters": {}, "source": "feed"})
|
||||||
|
assert gaming.status_code == 200
|
||||||
|
hh = c.post("/api/saved-views", json={"name": "hh", "filters": {}, "source": "feed"}).json()
|
||||||
|
|
||||||
|
r = c.patch(f"/api/saved-views/{hh['id']}", json={"name": "gaming"})
|
||||||
|
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == {"code": "refused", "reason": "view_name_taken", "name": "gaming"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_renaming_a_tag_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
|
||||||
|
c = _client(db, monkeypatch, override_dep)
|
||||||
|
assert c.post("/api/tags", json={"name": "music"}).status_code == 200
|
||||||
|
news = c.post("/api/tags", json={"name": "news"}).json()
|
||||||
|
|
||||||
|
r = c.patch(f"/api/tags/{news['id']}", json={"name": "music"})
|
||||||
|
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == {"code": "refused", "reason": "tag_name_taken", "name": "music"}
|
||||||
@@ -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):
|
def test_todays_actions_count_only_mine(db, make_user):
|
||||||
me, other = make_user(), make_user()
|
me, other = make_user(), make_user()
|
||||||
quota.log_action(me.id, quota.QuotaAction.VIDEOS_SEARCH)
|
quota.claim_action(me.id, quota.QuotaAction.VIDEOS_SEARCH, 5)
|
||||||
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
|
quota.claim_action(other.id, quota.QuotaAction.VIDEOS_SEARCH, 5)
|
||||||
quota.log_action(other.id, quota.QuotaAction.VIDEOS_SEARCH)
|
quota.claim_action(other.id, quota.QuotaAction.VIDEOS_SEARCH, 5)
|
||||||
assert quota.actions_today(db, me.id, quota.QuotaAction.VIDEOS_SEARCH) == 1
|
assert quota.actions_today(db, me.id, quota.QuotaAction.VIDEOS_SEARCH) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,13 @@ def test_a_claim_is_released_when_its_block_raises(db, make_user):
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
|
# 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(
|
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},
|
{"id": pl.id},
|
||||||
)
|
)
|
||||||
assert held == 0
|
assert held == 0
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.plex.models import PlexItem, PlexLibrary, PlexState
|
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
||||||
from app.plex import service as plex_service
|
from app.plex import service as plex_service
|
||||||
from app.routes.plex import playback
|
from app.routes.plex import playback
|
||||||
|
|
||||||
@@ -81,3 +81,150 @@ def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_u
|
|||||||
)
|
)
|
||||||
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_checkpoint_sent_before_the_finish_does_not_land_on_the_watched_row(
|
||||||
|
db, as_user, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
"""The 10 s tick posted at 2699 of 3000 s (not near the end) races the credits auto-skip's
|
||||||
|
"watched". When the mark commits first, the late checkpoint used to write its position onto the
|
||||||
|
watched row and schedule a resume push after the scrobble: Plex showed the item in progress, and
|
||||||
|
a Plex-authoritative pull imported it back."""
|
||||||
|
user = make_user()
|
||||||
|
item = _item(db)
|
||||||
|
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
||||||
|
client = as_user(user)
|
||||||
|
assert client.post("/api/plex/item/m1/state", json={"status": "watched"}).status_code == 200
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/plex/item/m1/progress", json={"position_seconds": 2699, "duration_seconds": 3000}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == {"ok": True, "status": "watched", "position_seconds": 0}
|
||||||
|
db.expire_all()
|
||||||
|
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
|
||||||
|
assert (st.status, st.position_seconds) == ("watched", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_rewatch_records_its_position_once_the_finish_is_behind_it(
|
||||||
|
db, as_user, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
"""The other half: a watched item played again is still checkpointed — only a position arriving
|
||||||
|
right on the heels of the mark is read as that mark's late tick."""
|
||||||
|
user = make_user()
|
||||||
|
item = _item(db)
|
||||||
|
long_ago = datetime.now(timezone.utc) - timedelta(days=3)
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=item.id, status="watched", watched_at=long_ago))
|
||||||
|
db.commit()
|
||||||
|
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda uid, key: None)
|
||||||
|
r = as_user(user).post(
|
||||||
|
"/api/plex/item/m1/progress", json={"position_seconds": 600, "duration_seconds": 3000}
|
||||||
|
)
|
||||||
|
assert r.json() == {"ok": True, "position_seconds": 600}
|
||||||
|
db.expire_all()
|
||||||
|
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
|
||||||
|
assert (st.status, st.position_seconds) == ("watched", 600)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marking_watched_supersedes_a_resume_push_still_in_flight(
|
||||||
|
db, as_user, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
"""The same guard the near-end finish has: a resume push scheduled before the mark must not reach
|
||||||
|
Plex after its scrobble."""
|
||||||
|
user = make_user()
|
||||||
|
item = _item(db)
|
||||||
|
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||||
|
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
|
||||||
|
r = as_user(user).post("/api/plex/item/m1/state", json={"status": "watched"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|
||||||
|
|
||||||
|
|
||||||
|
def _state(db, user, item):
|
||||||
|
db.expire_all()
|
||||||
|
st = (
|
||||||
|
db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == item.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
return st.status, st.position_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_mark_committed_after_the_checkpoints_read_still_refuses_it(db, make_user):
|
||||||
|
"""The race itself, not its sequential shadow: the route read the row while it was still in
|
||||||
|
progress, and the finish committed from another session before the checkpoint wrote. Only a write
|
||||||
|
conditional on the row as it is NOW refuses that position."""
|
||||||
|
user = make_user()
|
||||||
|
item = _item(db)
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=2690))
|
||||||
|
db.commit()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
read = plex_service.state_row(db, user, item.id) # what the checkpoint's route read: "new"
|
||||||
|
assert read.status == "new"
|
||||||
|
|
||||||
|
other = SessionLocal()
|
||||||
|
try:
|
||||||
|
assert plex_service.mark_finished(other, user.id, item.id, now) is True
|
||||||
|
other.commit()
|
||||||
|
finally:
|
||||||
|
other.close()
|
||||||
|
|
||||||
|
assert plex_service.checkpoint(db, user, item.id, read, 2699, now, dirty=True) is False
|
||||||
|
db.commit()
|
||||||
|
assert _state(db, user, item) == ("watched", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tick_seconds_behind_the_mark_is_refused_a_rewatch_a_minute_later_is_not(db, make_user):
|
||||||
|
"""A position right behind the mark is that mark's late tick; one a minute later is a rewatch.
|
||||||
|
Literal times on purpose: a window widened to an hour must fail here, not move with it."""
|
||||||
|
user = make_user()
|
||||||
|
item = _item(db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
st = PlexState(user_id=user.id, item_id=item.id, status="watched", watched_at=now)
|
||||||
|
db.add(st)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
st.watched_at = now - timedelta(seconds=20)
|
||||||
|
db.commit()
|
||||||
|
assert plex_service.checkpoint(db, user, item.id, st, 600, now, dirty=True) is False
|
||||||
|
db.commit()
|
||||||
|
st.watched_at = now - timedelta(seconds=60)
|
||||||
|
db.commit()
|
||||||
|
assert plex_service.checkpoint(db, user, item.id, st, 600, now, dirty=True) is True
|
||||||
|
db.commit()
|
||||||
|
assert _state(db, user, item) == ("watched", 600)
|
||||||
|
|
||||||
|
|
||||||
|
def test_marking_a_season_watched_supersedes_its_episodes_resume_pushes(db, as_user, make_user):
|
||||||
|
"""The bulk mark has the same guard as one item's: an episode's resume push scheduled before
|
||||||
|
the season was marked must not reach Plex after the scrobble."""
|
||||||
|
user = make_user()
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
sh = PlexShow(rating_key="sh1", library_id=lib.id, title="Show")
|
||||||
|
db.add(sh)
|
||||||
|
db.flush()
|
||||||
|
se = PlexSeason(rating_key="se1", show_id=sh.id, season_number=1)
|
||||||
|
db.add(se)
|
||||||
|
db.flush()
|
||||||
|
ep = PlexItem(
|
||||||
|
rating_key="e1",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="episode",
|
||||||
|
title="E1",
|
||||||
|
show_id=sh.id,
|
||||||
|
season_id=se.id,
|
||||||
|
season_number=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
|
||||||
|
assert playback._should_push_resume(user.id, ep.id, earlier, final=True)
|
||||||
|
|
||||||
|
r = as_user(user).post("/api/plex/season/se1/state", json={"watched": True})
|
||||||
|
assert r.json() == {"ok": True, "changed": 1, "watched": True}
|
||||||
|
assert playback._resume_push_superseded(user.id, ep.id, earlier) is True
|
||||||
|
|||||||
@@ -198,6 +198,31 @@ def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessi
|
|||||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a playlist this session never writes"
|
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a playlist this session never writes"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("name", "audio_langs"),
|
||||||
|
[
|
||||||
|
("seg_00.ts", None), # `int()` reads it as segment 0, which needs no predecessor
|
||||||
|
("seg_007.ts", None), # …as segment 7, whose predecessor seg_6.ts is on disk
|
||||||
|
("seg_٧.ts", None), # an Arabic-Indic 7: `\d` and `int()` both take it
|
||||||
|
("seg_a0_07.ts", ["hu", "en"]),
|
||||||
|
("seg_0_00.ts", ["hu", "en"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_segment_name_ffmpeg_never_spells_is_refused(tmp_path, name, audio_langs):
|
||||||
|
"""ffmpeg writes `seg_%d.ts`: no padding, ASCII digits. A name that only PARSES to a sequence the
|
||||||
|
session has reached is still a file that will never appear, and saying yes to it parked the
|
||||||
|
request for the full 20 s wait — the parking this guard exists to end."""
|
||||||
|
d = tmp_path / "m1_0_0_+0.00"
|
||||||
|
d.mkdir()
|
||||||
|
for v in plex_stream.session_variants(audio_langs) or (None,):
|
||||||
|
(d / plex_stream.segment_name(6, v)).write_bytes(b"x")
|
||||||
|
sess = _Session("sid-a", d, running=True, audio_langs=audio_langs)
|
||||||
|
assert not plex_stream.artifact_may_arrive(sess, name)
|
||||||
|
# The canonical spelling of the same next segment is still accepted.
|
||||||
|
v = (plex_stream.session_variants(audio_langs) or (None,))[0]
|
||||||
|
assert plex_stream.artifact_may_arrive(sess, plex_stream.segment_name(7, v))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("digits", [100, 5000])
|
@pytest.mark.parametrize("digits", [100, 5000])
|
||||||
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
|
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
|
||||||
"""`int()` on an unbounded digit run raises ValueError past 4300 digits, and only OSError was
|
"""`int()` on an unbounded digit run raises ValueError past 4300 digits, and only OSError was
|
||||||
@@ -213,7 +238,7 @@ def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions,
|
|||||||
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
||||||
client, monkeypatch, tmp_path, sessions
|
client, monkeypatch, tmp_path, sessions
|
||||||
):
|
):
|
||||||
"""`_kill` rmtrees the whole directory and nothing holds `_lock` across the wait and the read,
|
"""Teardown moves the whole directory aside and nothing holds `_lock` across the wait and the read,
|
||||||
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
|
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
|
||||||
session before destroying the old hls instance. The player's recovery is keyed on the 404
|
session before destroying the old hls instance. The player's recovery is keyed on the 404
|
||||||
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
|
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
|
||||||
|
|||||||
@@ -348,7 +348,8 @@ def test_an_early_checkpoint_drops_a_bare_state_and_zeroes_a_marked_one(db, make
|
|||||||
user = make_user()
|
user = make_user()
|
||||||
lib = _lib(db)
|
lib = _lib(db)
|
||||||
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid")
|
bare, hid = _movie(db, lib, "bare"), _movie(db, lib, "hid")
|
||||||
st = service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
|
assert service.checkpoint(db, user, bare.id, None, 120, NOW, dirty=True)
|
||||||
|
st = queries.state(db, user, bare.id)
|
||||||
assert (st.position_seconds, st.synced_to_plex) == (120, False)
|
assert (st.position_seconds, st.synced_to_plex) == (120, False)
|
||||||
_state(db, user, hid, status="hidden", position_seconds=40)
|
_state(db, user, hid, status="hidden", position_seconds=40)
|
||||||
service.clear_progress(db, queries.state(db, user, bare.id), NOW)
|
service.clear_progress(db, queries.state(db, user, bare.id), NOW)
|
||||||
@@ -429,7 +430,8 @@ def test_a_first_write_that_lost_the_race_updates_the_winners_row(db, make_user)
|
|||||||
user = make_user()
|
user = make_user()
|
||||||
it = _movie(db, _lib(db), "m-race")
|
it = _movie(db, _lib(db), "m-race")
|
||||||
_state(db, user, it, position_seconds=10) # the request that won, committed
|
_state(db, user, it, position_seconds=10) # the request that won, committed
|
||||||
st = service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
assert service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
||||||
|
st = queries.state(db, user, it.id)
|
||||||
assert (st.position_seconds, st.synced_to_plex) == (42, False)
|
assert (st.position_seconds, st.synced_to_plex) == (42, False)
|
||||||
st = service.set_status(db, user, it.id, None, "hidden")
|
st = service.set_status(db, user, it.id, None, "hidden")
|
||||||
assert st.status == "hidden"
|
assert st.status == "hidden"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class _Proc:
|
|||||||
class _Session:
|
class _Session:
|
||||||
def __init__(self, session_id: str, directory):
|
def __init__(self, session_id: str, directory):
|
||||||
self.id = session_id
|
self.id = session_id
|
||||||
|
self.key = "m1"
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = _Proc()
|
self.proc = _Proc()
|
||||||
self.last_access = time.time()
|
self.last_access = time.time()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ from app.plex.stream import _SESSION_DIR_RE
|
|||||||
"12345_0_2_+0.00", # current shape, explicit audio_ord
|
"12345_0_2_+0.00", # current shape, explicit audio_ord
|
||||||
"12345_842_multi_-1.25", # multi-audio, negative offset
|
"12345_842_multi_-1.25", # multi-audio, negative offset
|
||||||
"12345_842_multi_+12.50",
|
"12345_842_multi_+12.50",
|
||||||
|
# a torn-down session's directory, moved aside to be deleted outside the lock — left behind
|
||||||
|
# only if the process died between the rename and the delete
|
||||||
|
"12345_u7_842_multi_+12.50.dead-0123abcd",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_our_session_directories_are_swept(name):
|
def test_our_session_directories_are_swept(name):
|
||||||
@@ -47,6 +50,9 @@ def test_our_session_directories_are_swept(name):
|
|||||||
"snapshot_2024_backup_+1.00", # session-ish tail, but the key isn't a rating_key
|
"snapshot_2024_backup_+1.00", # session-ish tail, but the key isn't a rating_key
|
||||||
"backup_2024_multi_+0.00",
|
"backup_2024_multi_+0.00",
|
||||||
".config",
|
".config",
|
||||||
|
"12345_u7_0_0_+0.00.dead-", # a tombstone suffix with no id
|
||||||
|
"12345_u7_0_0_+0.00.dead-notours!",
|
||||||
|
"backup_u7_0_0_+0.00.dead-0123abcd",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_foreign_directories_are_left_alone(name):
|
def test_foreign_directories_are_left_alone(name):
|
||||||
@@ -159,7 +165,7 @@ class TestSweepOrphans:
|
|||||||
|
|
||||||
|
|
||||||
class _FakeProc:
|
class _FakeProc:
|
||||||
"""Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later)."""
|
"""Stand-in for the ffmpeg Popen — start_session only stores it (and teardown/reap poll it later)."""
|
||||||
|
|
||||||
def poll(self):
|
def poll(self):
|
||||||
return None
|
return None
|
||||||
@@ -1052,3 +1058,63 @@ class TestSessionsPerViewer:
|
|||||||
self._start(monkeypatch, user_id=uid)
|
self._start(monkeypatch, user_id=uid)
|
||||||
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
|
assert len(stream._sessions) == 3 and (1, "12345") not in stream._sessions
|
||||||
assert oldest.proc.killed and not oldest.transcode
|
assert oldest.proc.killed and not oldest.transcode
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeardownFinishes:
|
||||||
|
"""A session taken down by a cap or replaced by its own viewer is only renamed aside under the
|
||||||
|
lock; it is deleted after. The rename alone already moves the directory and `terminate()` alone
|
||||||
|
already marks the proc killed — so what is pinned here is the tomb: a whole segmented copy of the
|
||||||
|
film that only a restart's sweep would otherwise reclaim."""
|
||||||
|
|
||||||
|
capturing_env = TestTranscodeStartSession.capturing_env
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _left_behind(root):
|
||||||
|
return sorted(p.name for p in root.iterdir() if stream._TOMB in p.name) + sorted(
|
||||||
|
str(p) for p in stream._dying
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_culled_session_is_deleted_not_only_moved_aside(self, capturing_env, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_dying", {})
|
||||||
|
first = TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="1")
|
||||||
|
first.last_access -= 60
|
||||||
|
TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="2")
|
||||||
|
TestSessionsPerViewer._start(monkeypatch, user_id=1, rating_key="3")
|
||||||
|
assert first.proc.killed and not first.dir.exists()
|
||||||
|
assert self._left_behind(capturing_env) == []
|
||||||
|
|
||||||
|
def test_a_viewers_restart_deletes_the_session_it_replaces(self, capturing_env, monkeypatch):
|
||||||
|
monkeypatch.setattr(stream, "_dying", {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_probe_first_pts", lambda seg: None
|
||||||
|
) # no real segment to probe
|
||||||
|
old = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=0.0)
|
||||||
|
new = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=600.0)
|
||||||
|
assert old.proc.killed and new.dir.exists() and not old.dir.exists()
|
||||||
|
assert self._left_behind(capturing_env) == []
|
||||||
|
|
||||||
|
def test_a_restart_that_fails_to_start_still_deletes_what_it_replaced(
|
||||||
|
self, capturing_env, monkeypatch
|
||||||
|
):
|
||||||
|
# The new ffmpeg cannot start (a full disk, no ffmpeg, no memory): the old session is already
|
||||||
|
# moved aside, and nothing else in this process would ever delete it.
|
||||||
|
monkeypatch.setattr(stream, "_dying", {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stream, "_probe_first_pts", lambda seg: None
|
||||||
|
) # no real segment to probe
|
||||||
|
old = stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=0.0)
|
||||||
|
|
||||||
|
def no_ffmpeg(*args, **kwargs):
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
|
monkeypatch.setattr(stream.subprocess, "Popen", no_ffmpeg)
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
stream.start_session(db=None, user_id=1, item=_FakeItem(), start_s=600.0)
|
||||||
|
assert old.proc.killed and not old.dir.exists()
|
||||||
|
assert self._left_behind(capturing_env) == []
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ def test_reads_see_separate_session_spend_mid_transaction(db):
|
|||||||
assert after - before == 7
|
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")
|
user = User(email="searcher@example.com")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit() # a committed user to attribute the (free) action to
|
db.commit() # a committed user to attribute the (free) action to
|
||||||
db.add(User(email="pending@example.com")) # uncommitted caller work
|
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()
|
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()
|
events = db.execute(select(QuotaEvent)).scalars().all()
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0].user_id == user.id
|
assert events[0].user_id == user.id
|
||||||
|
|||||||
@@ -0,0 +1,770 @@
|
|||||||
|
"""The refusal scan (`refusal_sites.py`) on small sources: each shape on its closed list is followed to its
|
||||||
|
values, and each exit from that list is reported, unread or as an unread code. A scan that silently drops a
|
||||||
|
shape is a guard green over nothing, so every exit is pinned here, not only the paths the app takes. Pure:
|
||||||
|
no app import, no database."""
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from tests.refusal_sites import scan, scan_app
|
||||||
|
|
||||||
|
|
||||||
|
def _scan(**files: str) -> dict:
|
||||||
|
return scan(
|
||||||
|
{f"{name.replace('__', '/')}.py": textwrap.dedent(src) for name, src in files.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _code(result: dict, code: str) -> dict:
|
||||||
|
return result["codes"][code]
|
||||||
|
|
||||||
|
|
||||||
|
def _unread(result: dict, code: str) -> list[str]:
|
||||||
|
return [u.split(": ", 1)[1] for u in _code(result, code)["unread"]]
|
||||||
|
|
||||||
|
|
||||||
|
REFUSAL = """
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
def refused(status: int, reason: str, **params: object) -> HTTPException:
|
||||||
|
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_literal_wherever_it_sits_in_the_dict():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def f(x):
|
||||||
|
raise HTTPException(409, {"code": "clip", "limit_s": 5, "reason": "too_long"})
|
||||||
|
def g(x):
|
||||||
|
raise HTTPException(409, {"reason": "before", "code": "clip"})
|
||||||
|
def h(x):
|
||||||
|
raise HTTPException(409, {
|
||||||
|
"code": "clip", # a comment between
|
||||||
|
"reason": "after_comment",
|
||||||
|
})
|
||||||
|
def i(x):
|
||||||
|
raise HTTPException(409, detail=dict(code="clip", reason="by_call"))
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "clip")["reasons"] == ["after_comment", "before", "by_call", "too_long"]
|
||||||
|
assert _code(r, "clip")["unread"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_arms_of_a_conditional_and_a_none_arm_makes_the_site_reasonless():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def f(x):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": "a" if x else "b"})
|
||||||
|
def g(x):
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": "lit" if x else None})
|
||||||
|
def h(x):
|
||||||
|
raise HTTPException(409, {"code": "busy", "reason": None})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "edit")["reasons"] == ["a", "b"] and _code(r, "edit")["reasonless"] == []
|
||||||
|
assert _code(r, "playlist")["reasons"] == ["lit"]
|
||||||
|
assert _code(r, "playlist")["reasonless"] == ["a.py:5"]
|
||||||
|
assert _code(r, "busy")["reasonless"] == ["a.py:7"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_what_the_list_does_not_hold_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def f(x, svc):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": x.why or "c"})
|
||||||
|
def g(svc):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": svc.reason()})
|
||||||
|
def h():
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": 5})
|
||||||
|
def i(e):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": f"x{e}"})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _unread(r, "edit") == ["x.why or 'c'", "svc.reason()", "5", "f'x{e}'"]
|
||||||
|
assert _code(r, "edit")["reasons"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_helper_is_followed_to_every_call_however_it_is_written():
|
||||||
|
r = _scan(
|
||||||
|
refusal=REFUSAL,
|
||||||
|
routes__a="""
|
||||||
|
from http import HTTPStatus
|
||||||
|
from app import refusal
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
|
def f(status):
|
||||||
|
raise refused(status, "by_name_status")
|
||||||
|
def g():
|
||||||
|
raise refused(HTTPStatus.CONFLICT, reason="by_keyword")
|
||||||
|
def h():
|
||||||
|
raise refusal.refused(409, "qualified")
|
||||||
|
def i(x):
|
||||||
|
raise refused(409, "a" if x else "second_arm")
|
||||||
|
""",
|
||||||
|
routes__b="""
|
||||||
|
from other.lib import refused
|
||||||
|
|
||||||
|
def f():
|
||||||
|
refused("an outside library's, not the app's")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _code(r, "refused")["reasons"] == [
|
||||||
|
"a",
|
||||||
|
"by_keyword",
|
||||||
|
"by_name_status",
|
||||||
|
"qualified",
|
||||||
|
"second_arm",
|
||||||
|
]
|
||||||
|
assert _code(r, "refused")["unread"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_call_the_helper_cannot_be_followed_through_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
refusal=REFUSAL,
|
||||||
|
routes__a="""
|
||||||
|
from app.refusal import refused
|
||||||
|
|
||||||
|
def f(args, kw):
|
||||||
|
pick = refused
|
||||||
|
raise refused(*args)
|
||||||
|
def g(kw):
|
||||||
|
raise refused(409, **kw)
|
||||||
|
def h():
|
||||||
|
raise refused(409)
|
||||||
|
""",
|
||||||
|
routes__b="""
|
||||||
|
import app.refusal
|
||||||
|
|
||||||
|
def f():
|
||||||
|
raise app.refusal.refused(409, "dotted")
|
||||||
|
""",
|
||||||
|
routes__c="""
|
||||||
|
from ..refusal import refused
|
||||||
|
|
||||||
|
def f():
|
||||||
|
raise refused(409, "relative")
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
unread = _unread(r, "refused")
|
||||||
|
assert len(unread) == 6
|
||||||
|
assert sum("the helper used as a value" in u for u in unread) == 1
|
||||||
|
assert sum("a reference the scan cannot resolve" in u for u in unread) == 2
|
||||||
|
assert sum("does not pass reason" in u for u in unread) == 2 # `**kw`, and nothing at all
|
||||||
|
assert any(u == "refused(*args)" for u in unread)
|
||||||
|
assert _code(r, "refused")["reasons"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_default_is_not_followed_a_call_must_pass_the_reason():
|
||||||
|
r = _scan(
|
||||||
|
routes___common="""
|
||||||
|
def not_found(what: str = "generic"):
|
||||||
|
return HTTPException(404, {"code": "not_found", "reason": what})
|
||||||
|
|
||||||
|
def get_or_404(db, id_, *, what: str = "generic"):
|
||||||
|
raise not_found(what)
|
||||||
|
""",
|
||||||
|
routes__a="""
|
||||||
|
from app.routes._common import get_or_404, not_found
|
||||||
|
|
||||||
|
def f(db):
|
||||||
|
get_or_404(db, 1, what="user")
|
||||||
|
get_or_404(db, 2)
|
||||||
|
raise not_found("debug_session")
|
||||||
|
def g():
|
||||||
|
raise not_found()
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _code(r, "not_found")["reasons"] == ["debug_session", "user"]
|
||||||
|
assert [u.endswith("(does not pass what)") for u in _unread(r, "not_found")] == [True, True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_positional_only_reason_passed_by_name_is_not_bound_to_it():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def deny(reason: str, /, **params):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
|
||||||
|
def f():
|
||||||
|
raise deny("by_position")
|
||||||
|
def g():
|
||||||
|
raise deny(reason="lands_in_params")
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "playlist")["reasons"] == ["by_position"]
|
||||||
|
assert _unread(r, "playlist") == ["deny(reason='lands_in_params') (does not pass reason)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_helper_reaching_two_sites_gives_both_its_reasons_and_one_reached_again_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def deny(reason: str, admin):
|
||||||
|
if admin:
|
||||||
|
return HTTPException(409, {"code": "activate", "reason": reason})
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
|
||||||
|
def again(reason: str, n):
|
||||||
|
if n:
|
||||||
|
return again(reason, n - 1)
|
||||||
|
return HTTPException(409, {"code": "edit", "reason": reason})
|
||||||
|
|
||||||
|
def f():
|
||||||
|
raise deny("foo", True)
|
||||||
|
def g():
|
||||||
|
raise again("bar", 3)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "activate")["reasons"] == ["foo"]
|
||||||
|
assert _code(r, "playlist")["reasons"] == ["foo"]
|
||||||
|
assert _code(r, "edit")["reasons"] == ["bar"]
|
||||||
|
assert any("reached again while it is followed" in u for u in _unread(r, "edit"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_parameter_its_body_binds_again_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
def refused(status, reason: str = ""):
|
||||||
|
reason = reason or "fallback"
|
||||||
|
return HTTPException(status, {"code": "refused", "reason": reason})
|
||||||
|
|
||||||
|
def typed(reason: Literal["a"]):
|
||||||
|
if not reason:
|
||||||
|
reason = "b"
|
||||||
|
return HTTPException(409, {"code": "edit", "reason": reason})
|
||||||
|
|
||||||
|
def f():
|
||||||
|
raise refused(409, "x")
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "refused")["reasons"] == []
|
||||||
|
assert _unread(r, "refused") == ["reason (a parameter its body binds again)"]
|
||||||
|
assert _unread(r, "edit") == ["reason (a parameter its body binds again)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_local_is_followed_only_through_plain_assignments():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
def f(x):
|
||||||
|
why = "first"
|
||||||
|
if x:
|
||||||
|
why = "second" if x > 1 else "third"
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": why})
|
||||||
|
def g(svc):
|
||||||
|
hit, why = svc.fetch()
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": why})
|
||||||
|
def h(items):
|
||||||
|
for why in items:
|
||||||
|
pass
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": why})
|
||||||
|
def i():
|
||||||
|
why: Literal["typed"] = pick()
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": why})
|
||||||
|
def j(items):
|
||||||
|
LABELS[why] = "NotAReason"
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": why})
|
||||||
|
def k(*why):
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": why})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "edit")["reasons"] == ["first", "second", "third"]
|
||||||
|
assert _code(r, "playlist")["reasons"] == []
|
||||||
|
assert _unread(r, "playlist") == [
|
||||||
|
"why",
|
||||||
|
"why",
|
||||||
|
"why",
|
||||||
|
"why",
|
||||||
|
"why",
|
||||||
|
] # unpacked, a loop, annotated, unbound, `*args`
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_call_answers_with_its_literal_return_type():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
def why(x) -> Literal["quota", "gone"]:
|
||||||
|
return "quota"
|
||||||
|
def how(x) -> str:
|
||||||
|
return "x"
|
||||||
|
|
||||||
|
def f(x):
|
||||||
|
raise HTTPException(409, {"code": "youtube", "reason": why(x)})
|
||||||
|
def g(x):
|
||||||
|
raise HTTPException(409, {"code": "youtube", "reason": how(x)})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "youtube")["reasons"] == ["gone", "quota"]
|
||||||
|
assert _unread(r, "youtube") == ["how(x)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_module_constant_is_read_only_as_mod_name_and_only_when_bound_once():
|
||||||
|
r = _scan(
|
||||||
|
urlguard="""
|
||||||
|
REASON_MALFORMED = "malformed"
|
||||||
|
TWICE = "a"
|
||||||
|
TWICE = "b"
|
||||||
|
if FLAG:
|
||||||
|
LATER = "c"
|
||||||
|
""",
|
||||||
|
routes__a="""
|
||||||
|
from app import urlguard
|
||||||
|
|
||||||
|
BARE = "bare"
|
||||||
|
|
||||||
|
def f():
|
||||||
|
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.REASON_MALFORMED})
|
||||||
|
def g():
|
||||||
|
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.TWICE})
|
||||||
|
def h():
|
||||||
|
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.LATER})
|
||||||
|
def i():
|
||||||
|
raise HTTPException(409, {"code": "url_blocked", "reason": BARE})
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _code(r, "url_blocked")["reasons"] == ["malformed"]
|
||||||
|
assert _unread(r, "url_blocked") == ["urlguard.TWICE", "urlguard.LATER", "BARE"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_name_imported_from_two_places_or_under_an_if_is_not_looked_up():
|
||||||
|
r = _scan(
|
||||||
|
m1='R = Literal["one"]',
|
||||||
|
m2='R = Literal["two"]',
|
||||||
|
a="""
|
||||||
|
def f():
|
||||||
|
from app.m1 import R
|
||||||
|
def g():
|
||||||
|
from app.m2 import R
|
||||||
|
def h(reason: R):
|
||||||
|
return HTTPException(409, {"code": "edit", "reason": reason})
|
||||||
|
""",
|
||||||
|
b="""
|
||||||
|
if FLAG:
|
||||||
|
from app.m1 import R
|
||||||
|
def h(reason: R):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
""",
|
||||||
|
c="""
|
||||||
|
from app.m1 import R
|
||||||
|
def h(reason: R):
|
||||||
|
return HTTPException(409, {"code": "clip", "reason": reason})
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _code(r, "edit")["reasons"] == [] and _unread(r, "edit") == ["reason"]
|
||||||
|
assert _code(r, "playlist")["reasons"] == [] and _unread(r, "playlist") == ["reason"]
|
||||||
|
assert _code(r, "clip")["reasons"] == ["one"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_caught_exception_answers_with_the_literal_its_class_declares_on_self():
|
||||||
|
r = _scan(
|
||||||
|
errors="""
|
||||||
|
from typing import Literal
|
||||||
|
ClipReason = Literal["bad_range", "too_short"]
|
||||||
|
EditReason = Literal["not_media"]
|
||||||
|
|
||||||
|
class ClipError(Exception):
|
||||||
|
def __init__(self, reason: ClipReason, **params: float):
|
||||||
|
self.reason: ClipReason = reason
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
class EditError(Exception):
|
||||||
|
reason: EditReason
|
||||||
|
|
||||||
|
class Untyped(Exception):
|
||||||
|
def __init__(self, reason: str):
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
class Narrower(ClipError):
|
||||||
|
pass
|
||||||
|
""",
|
||||||
|
routes__a="""
|
||||||
|
from app import errors
|
||||||
|
|
||||||
|
def f():
|
||||||
|
try:
|
||||||
|
pass
|
||||||
|
except errors.ClipError as e:
|
||||||
|
raise HTTPException(422, {"code": "clip", "reason": e.reason})
|
||||||
|
except (errors.ClipError, errors.EditError) as e:
|
||||||
|
raise HTTPException(422, {"code": "clip", "reason": e.reason})
|
||||||
|
except errors.EditError as e:
|
||||||
|
raise HTTPException(422, {"code": "clip", "reason": e.reason})
|
||||||
|
except errors.Untyped as e:
|
||||||
|
raise HTTPException(422, {"code": "clip", "reason": e.reason})
|
||||||
|
except errors.Narrower as e:
|
||||||
|
raise HTTPException(422, {"code": "clip", "reason": e.reason})
|
||||||
|
except errors.ClipError as e:
|
||||||
|
raise HTTPException(422, {"code": "clip", "reason": e})
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _code(r, "clip")["reasons"] == ["bad_range", "too_short"]
|
||||||
|
assert _unread(r, "clip") == [
|
||||||
|
"e.reason", # a tuple of classes
|
||||||
|
"e.reason (EditError.reason has no Literal type)", # declared on the class body only
|
||||||
|
"e.reason (Untyped.reason has no Literal type)",
|
||||||
|
"e.reason (Narrower.reason has no Literal type)", # a subclass: not looked up the bases
|
||||||
|
"e", # the exception itself
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_parameter_answers_with_its_literal_or_its_class_and_any_other_type_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
from typing import Literal
|
||||||
|
Why = Literal["one", "two"]
|
||||||
|
|
||||||
|
class Err(Exception):
|
||||||
|
def __init__(self, reason: Literal["three"]):
|
||||||
|
self.reason: Literal["three"] = reason
|
||||||
|
|
||||||
|
def typed(reason: Why):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
def inline(reason: Literal["four"]):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
def by_class(exc: Err):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": exc.reason})
|
||||||
|
def rebound(exc: Err):
|
||||||
|
exc = other()
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": exc.reason})
|
||||||
|
def optional(reason: Literal["five"] | None = None):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
def quoted(reason: "Why"):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
def number(reason: int):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
|
||||||
|
class C:
|
||||||
|
def method(self, reason: str):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "playlist")["reasons"] == ["four", "one", "three", "two"]
|
||||||
|
unread = _unread(r, "playlist")
|
||||||
|
# a parameter bound again, `| None`, a quoted annotation, `int`
|
||||||
|
assert unread[:4] == ["exc.reason", "reason", "reason", "reason"]
|
||||||
|
assert "method(reason) is not a module-level helper" in unread[4]
|
||||||
|
assert len(unread) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_str_helper_nobody_calls_sends_nothing():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def helper(reason: str):
|
||||||
|
return HTTPException(409, {"code": "playlist", "reason": reason})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "playlist") == {"reasons": [], "unread": [], "sites": 1, "reasonless": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_map_lookup_is_its_values_and_its_keys_must_be_exactly_what_the_index_can_be():
|
||||||
|
src = """
|
||||||
|
from typing import Literal
|
||||||
|
SettingReason = Literal["too_low", "too_high"]
|
||||||
|
|
||||||
|
class InvalidSetting(ValueError):
|
||||||
|
def __init__(self, key: str, reason: SettingReason, **params: object):
|
||||||
|
self.reason: SettingReason = reason
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
_MAP: dict[SettingReason, str] = {{{keys}}}
|
||||||
|
|
||||||
|
def refused(status: int, reason: str, **params: object):
|
||||||
|
return HTTPException(status, {{**params, "code": "refused", "reason": reason}})
|
||||||
|
|
||||||
|
def setting_refused(exc: InvalidSetting):
|
||||||
|
return refused(400, _MAP[{index}], **exc.params)
|
||||||
|
"""
|
||||||
|
both = '"too_low": "setting_too_low", "too_high": "setting_too_high"'
|
||||||
|
whole = _scan(a=src.format(keys=both, index="exc.reason"))
|
||||||
|
assert _code(whole, "refused")["reasons"] == ["setting_too_high", "setting_too_low"]
|
||||||
|
assert whole["errors"] == [] and whole["unreadCodes"] == []
|
||||||
|
for keys, index in [
|
||||||
|
('"too_low": "setting_too_low"', "exc.reason"), # a key short
|
||||||
|
(both + ', "gone": "setting_gone"', "exc.reason"), # a key no index can be
|
||||||
|
(both, "exc.key"), # an index the scan cannot read
|
||||||
|
]:
|
||||||
|
r = _scan(a=src.format(keys=keys, index=index))
|
||||||
|
assert len(r["errors"]) == 1 and "not what its index can be" in r["errors"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_lookup_in_anything_but_a_module_dict_of_literal_keys_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
BUILT = make()
|
||||||
|
NUMBERED = {1: "one"}
|
||||||
|
|
||||||
|
def f(table, k):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": table[k]})
|
||||||
|
def g(k):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": BUILT[k]})
|
||||||
|
def h(k):
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": NUMBERED[k]})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _unread(r, "edit") == ["table[k]", "BUILT[k]", "NUMBERED[k]"]
|
||||||
|
assert r["errors"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_code_must_be_one_literal():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
BUSY = "busy"
|
||||||
|
def f(code):
|
||||||
|
raise HTTPException(409, {"code": "busy"})
|
||||||
|
def g(code):
|
||||||
|
raise HTTPException(409, {"code": code})
|
||||||
|
def h():
|
||||||
|
raise HTTPException(409, {"code": BUSY})
|
||||||
|
def i(x):
|
||||||
|
raise HTTPException(409, {"code": "a" if x else "b"})
|
||||||
|
def j(x):
|
||||||
|
raise HTTPException(409, {"code": "a" if x else None})
|
||||||
|
def k(key):
|
||||||
|
raise HTTPException(409, {"code": "edit", key: "late"})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert r["codes"]["busy"]["reasonless"] == ["a.py:4"]
|
||||||
|
assert set(r["codes"]) == {"busy"}
|
||||||
|
assert len(r["unreadCodes"]) == 5
|
||||||
|
assert r["unreadCodes"][-1].endswith(
|
||||||
|
"(a spread or a computed key the code and the reason do not follow)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_spread_or_a_computed_key_stands_before_both_the_code_and_the_reason():
|
||||||
|
def unread_codes(detail: str) -> list[str]:
|
||||||
|
return _scan(a=f"def f(x, more, key):\n raise HTTPException(409, {detail})\n")[
|
||||||
|
"unreadCodes"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert unread_codes('{**more, "code": "refused", "reason": "a"}') == []
|
||||||
|
assert unread_codes('{key: 1, **more, "reason": "a", "code": "refused"}') == []
|
||||||
|
for late in [
|
||||||
|
'{"code": "refused", "reason": "a", **more}', # the spread may replace both
|
||||||
|
'{"code": "refused", **more, "reason": "a"}', # … the code
|
||||||
|
'{"reason": "a", **more, "code": "refused"}', # … the reason
|
||||||
|
'{**more, "code": "refused"}', # no reason after it: the spread may carry one
|
||||||
|
'{"code": "refused", "reason": "a", key: "x"}', # a key that may be either
|
||||||
|
'dict(**more, code="refused", reason="a")', # dict() raises on a repeated key
|
||||||
|
'dict(more, code="refused", reason="a")',
|
||||||
|
]:
|
||||||
|
assert unread_codes(late), late
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_detail_stands_only_where_it_is_sent():
|
||||||
|
def result(body: str, extra: str = "") -> dict:
|
||||||
|
return _scan(a=f"def f(x, cached, fix):\n{textwrap.indent(body, ' ')}\n{extra}")
|
||||||
|
|
||||||
|
for body in [
|
||||||
|
'detail = {"code": "login", "reason": "a"}\nraise HTTPException(401, detail)',
|
||||||
|
'if (detail := {"code": "login", "reason": "a"}):\n raise HTTPException(401, detail)',
|
||||||
|
'raise HTTPException(401, {"code": "login", "reason": "a"}.copy())',
|
||||||
|
'raise HTTPException(401, fix({"code": "login", "reason": "a"}))',
|
||||||
|
'raise HTTPException(401, cached or {"code": "login", "reason": "a"})',
|
||||||
|
'return JSONResponse({"code": "login", "reason": "a"}, status_code=401)', # the whole body
|
||||||
|
'raise HTTPException(401, None, {"code": "login", "reason": "a"})', # the headers
|
||||||
|
'raise HTTPException(401, headers={"code": "login", "reason": "a"})',
|
||||||
|
'raise HTTPException(401, "a" if {"code": "login", "reason": "a"} else "b")',
|
||||||
|
]:
|
||||||
|
r = result(body)
|
||||||
|
assert r["unreadCodes"] and "login" not in r["codes"], body
|
||||||
|
assert "(a detail put where the scan cannot follow it)" in r["unreadCodes"][0], body
|
||||||
|
held = _scan(a='class C:\n detail = {"code": "login"}\n')
|
||||||
|
assert "(a detail put where the scan cannot follow it)" in held["unreadCodes"][0]
|
||||||
|
|
||||||
|
sent = result(
|
||||||
|
'raise HTTPException(401, {"code": "login", "reason": "a"} if x else {"code": "login"})\n'
|
||||||
|
'raise HTTPException(401, detail=dict(code="login", reason="b"))\n'
|
||||||
|
'return JSONResponse(content={"detail": {"code": "login", "reason": "c"}})'
|
||||||
|
)
|
||||||
|
assert sent["unreadCodes"] == [] and _code(sent, "login")["reasons"] == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_factory_is_a_detail_only_when_each_return_is_one_and_each_call_is_sent():
|
||||||
|
factory = """
|
||||||
|
def quota(e) -> dict:
|
||||||
|
return {"code": "quota", "reason": "files"}
|
||||||
|
"""
|
||||||
|
sent = "def f(e):\n raise HTTPException(422, detail=quota(e))\n"
|
||||||
|
assert _scan(a=factory + sent)["codes"]["quota"]["reasons"] == ["files"]
|
||||||
|
for change in [
|
||||||
|
"def g(e):\n d = quota(e)\n d['code'] = 'other'\n raise HTTPException(422, d)\n",
|
||||||
|
"def g(e):\n raise HTTPException(422, detail=quota(e).copy())\n",
|
||||||
|
"pick = quota\n",
|
||||||
|
]:
|
||||||
|
r = _scan(a=factory + sent + change)
|
||||||
|
assert r["unreadCodes"] and "quota" not in r["codes"], change
|
||||||
|
twice = _scan(a="quota = None\n" + factory + sent) # the def is the last of two bindings
|
||||||
|
assert twice["unreadCodes"] and "quota" not in twice["codes"]
|
||||||
|
for other in [
|
||||||
|
" if e:\n return None\n",
|
||||||
|
" if e:\n return cached\n",
|
||||||
|
" if e:\n return {'code': 'quota', 'reason': 'x'} if e > 1 else None\n",
|
||||||
|
" log({'code': 'quota', 'reason': 'logged'})\n", # a detail it does not return
|
||||||
|
]:
|
||||||
|
bent = factory.replace("-> dict:\n", "-> dict:\n" + other)
|
||||||
|
assert _scan(a=bent + sent)["unreadCodes"], other
|
||||||
|
method = "class C:\n def quota(self):\n return {'code': 'quota', 'reason': 'm'}\n"
|
||||||
|
assert _scan(a=method)["unreadCodes"]
|
||||||
|
dotted = _scan(
|
||||||
|
a=factory,
|
||||||
|
routes__b="import app.a\n\ndef g(e):\n raise HTTPException(422, detail=app.a.quota(e))\n",
|
||||||
|
)
|
||||||
|
assert dotted["unreadCodes"] and "quota" not in dotted["codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_name_a_lambda_a_comprehension_a_nonlocal_or_a_global_binds_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def f(svc):
|
||||||
|
why = "invalid"
|
||||||
|
make = lambda why: HTTPException(401, {"code": "login", "reason": why})
|
||||||
|
raise make(svc.state)
|
||||||
|
def g(svc):
|
||||||
|
why = "invalid"
|
||||||
|
raise [HTTPException(401, {"code": "login", "reason": why}) for why in svc.all][0]
|
||||||
|
def h(svc):
|
||||||
|
why = "invalid"
|
||||||
|
def inner():
|
||||||
|
nonlocal why
|
||||||
|
why = svc.reason
|
||||||
|
inner()
|
||||||
|
raise HTTPException(401, {"code": "login", "reason": why})
|
||||||
|
def i(svc):
|
||||||
|
global why
|
||||||
|
why = "invalid"
|
||||||
|
raise HTTPException(401, {"code": "login", "reason": why})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "login")["reasons"] == []
|
||||||
|
assert sorted(_unread(r, "login")) == [
|
||||||
|
"why (bound by a lambda or a comprehension)",
|
||||||
|
"why (bound by a lambda or a comprehension)",
|
||||||
|
"why (named by a nonlocal or a global)",
|
||||||
|
"why (named by a nonlocal or a global)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_local_that_reaches_itself_is_unread():
|
||||||
|
r = _scan(
|
||||||
|
a="""
|
||||||
|
def f(x):
|
||||||
|
why = "a"
|
||||||
|
if x:
|
||||||
|
why = why if x > 1 else "b"
|
||||||
|
raise HTTPException(409, {"code": "edit", "reason": why})
|
||||||
|
def g(x):
|
||||||
|
a = "z"
|
||||||
|
b = a
|
||||||
|
a = b
|
||||||
|
raise HTTPException(409, {"code": "playlist", "reason": a})
|
||||||
|
def h(x):
|
||||||
|
why = "c"
|
||||||
|
if x:
|
||||||
|
raise HTTPException(409, {"code": "busy", "reason": why})
|
||||||
|
raise HTTPException(409, {"code": "busy", "reason": why})
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert _code(r, "busy")["reasons"] == ["c"] and _code(r, "busy")["unread"] == []
|
||||||
|
assert _code(r, "edit")["reasons"] == ["a", "b"]
|
||||||
|
assert _unread(r, "edit") == ["why (reached again while it is resolved)"]
|
||||||
|
assert _code(r, "playlist")["reasons"] == ["z"]
|
||||||
|
assert any("reached again while it is resolved" in u for u in _unread(r, "playlist"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_module_constant_or_a_map_changed_elsewhere_is_unread():
|
||||||
|
def reasons(urlguard: str, route: str = "", **more: str) -> tuple[list[str], list[str]]:
|
||||||
|
r = _scan(
|
||||||
|
**more,
|
||||||
|
urlguard="REASON = 'malformed'\n_MAP = {'a': 'map_a'}\n" + urlguard,
|
||||||
|
routes__a="from app import urlguard\n"
|
||||||
|
+ route
|
||||||
|
+ "\ndef f(k: Literal['a']):\n"
|
||||||
|
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
|
||||||
|
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard._MAP[k]})\n",
|
||||||
|
)
|
||||||
|
return _code(r, "url_blocked")["reasons"], _unread(r, "url_blocked")
|
||||||
|
|
||||||
|
assert reasons("def get(k):\n return _MAP.get(k)\n") == (["malformed", "map_a"], [])
|
||||||
|
other = "from app import other\ndef g():\n other.REASON = 'x'\n" # another module's REASON
|
||||||
|
assert reasons("", other, other="REASON = 'y'\n") == (["malformed", "map_a"], [])
|
||||||
|
shadow = (
|
||||||
|
"def h(*urlguard):\n"
|
||||||
|
" raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
|
||||||
|
)
|
||||||
|
assert reasons("", shadow)[1] == ["urlguard.REASON"]
|
||||||
|
for urlguard, route in [
|
||||||
|
("def reset():\n global REASON\n REASON = 'x'\n", ""),
|
||||||
|
("", "def g():\n urlguard.REASON = 'x'\n"),
|
||||||
|
("", "def g():\n del urlguard.REASON\n"),
|
||||||
|
]:
|
||||||
|
assert reasons(urlguard, route)[0] == ["map_a"], (urlguard, route)
|
||||||
|
for urlguard in [
|
||||||
|
"_MAP['a'] = compute()\n",
|
||||||
|
"def init(x):\n _MAP['a'] = x\n",
|
||||||
|
"_MAP.update(a='other')\n",
|
||||||
|
"def drop():\n del _MAP['a']\n",
|
||||||
|
"register(_MAP)\n",
|
||||||
|
]:
|
||||||
|
got, unread = reasons(urlguard)
|
||||||
|
assert got == ["malformed"] and "(the map is changed after it is built)" in unread[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_helper_bound_twice_is_not_followed():
|
||||||
|
r = _scan(
|
||||||
|
refusal="refused = None\n" + textwrap.dedent(REFUSAL),
|
||||||
|
routes__a="from app.refusal import refused\n\ndef f():\n raise refused(409, 'lost')\n",
|
||||||
|
)
|
||||||
|
assert _code(r, "refused")["reasons"] == []
|
||||||
|
assert _unread(r, "refused")[0].endswith("refused(reason) is not a module-level helper")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dotted_path_through_an_app_module_that_goes_nowhere_is_unread():
|
||||||
|
r = scan(
|
||||||
|
{
|
||||||
|
"__init__.py": "",
|
||||||
|
"refusal.py": textwrap.dedent(REFUSAL),
|
||||||
|
"routes/a.py": "import app as a\n\ndef f():\n raise a.refusal.refused(409, 'dotted')\n",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert _unread(r, "refused") == ["a.refusal.refused (a reference the scan cannot resolve)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_helper_reached_through_an_app_module_that_does_not_bind_it_is_unread():
|
||||||
|
for shim in [
|
||||||
|
"from app.refusal import *\n",
|
||||||
|
"try:\n from app.refusal import refused\nexcept ImportError:\n refused = None\n",
|
||||||
|
]:
|
||||||
|
r = _scan(
|
||||||
|
refusal=REFUSAL,
|
||||||
|
shim=shim,
|
||||||
|
routes__a="from app.shim import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
|
||||||
|
)
|
||||||
|
assert _unread(r, "refused") == ["refused (a reference the scan cannot resolve)"], shim
|
||||||
|
package = scan(
|
||||||
|
{
|
||||||
|
"pkg/refusal.py": textwrap.dedent(REFUSAL),
|
||||||
|
"pkg/__init__.py": "from .refusal import refused\n",
|
||||||
|
"routes/a.py": "from app.pkg import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert _unread(package, "refused") == ["refused (a reference the scan cannot resolve)"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_data_files_are_excluded_by_name_only():
|
||||||
|
r = _scan(
|
||||||
|
plex__tmdb='def f():\n return {"code": "HU", "name": "Hungary"}',
|
||||||
|
plex__other='def f():\n return {"code": "HU"}',
|
||||||
|
)
|
||||||
|
assert r["excluded"] == {"plex/tmdb.py": 1}
|
||||||
|
assert "HU" in r["codes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_app_scans():
|
||||||
|
"""The real tree, on the interpreter the backend runs: every refusal it holds is read (the
|
||||||
|
frontend's contract judges what is read)."""
|
||||||
|
result = scan_app()
|
||||||
|
assert {"refused", "not_found", "login", "clip"} <= set(result["codes"])
|
||||||
|
assert result["errors"] == [] and result["unreadCodes"] == []
|
||||||
@@ -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®ion=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,
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -291,6 +291,23 @@ def test_search_results_cleared_through_the_route_are_gone(db, make_user, as_use
|
|||||||
assert db.execute(select(SearchFind)).first() is None
|
assert db.execute(select(SearchFind)).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_leaves_a_video_the_caller_never_found(db, make_user, as_user):
|
||||||
|
# A related list ingests its rows with no find: they are in someone's list right now, and an id
|
||||||
|
# any user could name must not delete them from under it.
|
||||||
|
user = make_user()
|
||||||
|
_video(db, "s1", via_search=True)
|
||||||
|
_video(db, "s2", via_search=True)
|
||||||
|
db.add(SearchFind(user_id=user.id, video_id="s1"))
|
||||||
|
db.commit()
|
||||||
|
r = as_user(user).post("/api/search/clear", json={"video_ids": ["s1", "s2"]})
|
||||||
|
assert r.json() == {"ok": True, "removed": 1}
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Video, "s1") is None and db.get(Video, "s2") is not None
|
||||||
|
# Naming only ids it never found removes nothing.
|
||||||
|
r = as_user(user).post("/api/search/clear", json={"video_ids": ["s2"]})
|
||||||
|
assert r.json() == {"ok": True, "removed": 0}
|
||||||
|
|
||||||
|
|
||||||
# --- Plex show and season marks ---------------------------------------------------------------
|
# --- Plex show and season marks ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,7 +9,7 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.youtube import search_scrape
|
from app.youtube import innertube, search_scrape
|
||||||
|
|
||||||
RESULTS_HTML = (
|
RESULTS_HTML = (
|
||||||
'<script>{"INNERTUBE_API_KEY":"live-key","INNERTUBE_CLIENT_VERSION":"2.20261001"}</script>'
|
'<script>{"INNERTUBE_API_KEY":"live-key","INNERTUBE_CLIENT_VERSION":"2.20261001"}</script>'
|
||||||
@@ -79,11 +79,11 @@ def net(monkeypatch):
|
|||||||
|
|
||||||
real = httpx.Client
|
real = httpx.Client
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
search_scrape.httpx,
|
innertube.httpx,
|
||||||
"Client",
|
"Client",
|
||||||
lambda **kw: real(transport=httpx.MockTransport(transport), **kw),
|
lambda **kw: real(transport=httpx.MockTransport(transport), **kw),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(search_scrape, "_config", None)
|
monkeypatch.setattr(innertube, "_config", None)
|
||||||
state["calls"] = calls
|
state["calls"] = calls
|
||||||
return state
|
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."""
|
with no error anywhere."""
|
||||||
net["handler"] = _serve(results_html=results_html)
|
net["handler"] = _serve(results_html=results_html)
|
||||||
search_scrape.search("a")
|
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()
|
net["handler"] = _serve()
|
||||||
search_scrape.search("b")
|
search_scrape.search("b")
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ a placeholder never becomes the video's permanent date.
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.catalog.models import Video
|
from app.catalog.models import Video
|
||||||
from app.catalog.search import ingest_candidates
|
from app.catalog.search import ingest_candidates
|
||||||
|
from app.youtube.client import YouTubeError
|
||||||
|
|
||||||
|
|
||||||
class _FakeYT:
|
class _FakeYT:
|
||||||
@@ -97,6 +100,25 @@ def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
|
|||||||
assert c.enriched_at is None
|
assert c.enriched_at is None
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingYT(_FakeYT):
|
||||||
|
def get_videos(self, ids):
|
||||||
|
raise YouTubeError("videos -> 403", reason="quotaExceeded", path="videos")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_videos_list_failure_raises_and_keeps_the_stubs(db):
|
||||||
|
# Swallowed, it answered as a page with nothing on it — "no results" for a search whose lookup
|
||||||
|
# never ran. The stubs stay: the FK targets, backfilled later.
|
||||||
|
user = User(email="searcher3@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(YouTubeError):
|
||||||
|
ingest_candidates(db, _FailingYT({}), user, [_candidate("vid_ddddddd", "UC_chan_d")])
|
||||||
|
|
||||||
|
db.rollback() # what the caller does: the stubs were committed before the lookup
|
||||||
|
assert db.get(Video, "vid_ddddddd") is not None
|
||||||
|
|
||||||
|
|
||||||
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep):
|
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, override_dep):
|
||||||
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
|
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
|
||||||
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
|
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""A catalog video as the feed lists it, the row a player opened from an address starts from. DB lane."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.catalog.models import Channel, Video
|
||||||
|
from app.reader.models import VideoState
|
||||||
|
|
||||||
|
|
||||||
|
def _video(db, vid="v1"):
|
||||||
|
db.add(Channel(id="UCo", title="Owner", handle="@owner"))
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Video(
|
||||||
|
id=vid,
|
||||||
|
title="A talk",
|
||||||
|
channel_id="UCo",
|
||||||
|
published_at=datetime.now(timezone.utc),
|
||||||
|
duration_seconds=3600,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_video_comes_as_the_feed_row_with_my_own_state(db, make_user, as_user):
|
||||||
|
me, other = make_user(), make_user()
|
||||||
|
_video(db)
|
||||||
|
db.add(VideoState(user_id=me.id, video_id="v1", status="new", position_seconds=600))
|
||||||
|
db.add(VideoState(user_id=other.id, video_id="v1", status="watched", position_seconds=90))
|
||||||
|
db.commit()
|
||||||
|
r = as_user(me).get("/api/videos/v1/row")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
row = r.json()
|
||||||
|
assert (row["id"], row["title"], row["channel_title"]) == ("v1", "A talk", "Owner")
|
||||||
|
assert (row["status"], row["position_seconds"]) == ("new", 600)
|
||||||
|
assert row["watch_url"] == "https://www.youtube.com/watch?v=v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_video_never_opened_has_no_state(db, make_user, as_user):
|
||||||
|
_video(db)
|
||||||
|
row = as_user(make_user()).get("/api/videos/v1/row").json()
|
||||||
|
assert (row["status"], row["position_seconds"], row["saved"]) == ("new", 0, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_video_outside_the_catalog_is_not_found(db, make_user, as_user):
|
||||||
|
r = as_user(make_user()).get("/api/videos/nope/row")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["detail"]["code"] == "not_found"
|
||||||
@@ -193,3 +193,45 @@ def test_any_other_token_refusal_keeps_the_grant(monkeypatch):
|
|||||||
c._access_token()
|
c._access_token()
|
||||||
assert tok.scopes == "s"
|
assert tok.scopes == "s"
|
||||||
assert refusal_reason(caught.value) == "unavailable"
|
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)
|
||||||
|
|||||||
+40
-6
@@ -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
|
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
|
`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
|
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
|
`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]
|
it. [WCAG 4.1.2]
|
||||||
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
- **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.
|
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
|
||||||
[Nielsen #1]
|
[Nielsen #1]
|
||||||
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
- **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
|
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
|
||||||
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
|
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)
|
## Surfaces and page chrome — _Decided_ (batch B)
|
||||||
|
|
||||||
@@ -370,7 +378,11 @@ Which actions are visible and which live in a menu.
|
|||||||
managing the shares, re-download, the copies, cancel / delete / remove
|
managing the shares, re-download, the copies, cancel / delete / remove
|
||||||
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
||||||
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
||||||
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
|
"…" holds find / undo for an activity notice. The notification center lists what asks for the
|
||||||
|
reader first ("Needs you · n": an unread row, a notice waiting on an action — one read during the
|
||||||
|
visit keeps its place there until Mark all read), then one stream of both sources, newest first,
|
||||||
|
and folds read rows older than a week under "Earlier (n)", a heading-style disclosure, closed — Back to a row
|
||||||
|
that folded away since opens it on that row. Its band keeps Mark all read
|
||||||
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
|
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
|
||||||
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
|
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
|
||||||
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
|
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
|
||||||
@@ -498,7 +510,10 @@ of a full-width panel and nothing on the right.
|
|||||||
## Interaction rules — _Decided_
|
## Interaction rules — _Decided_
|
||||||
|
|
||||||
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
|
- **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
|
- **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
|
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
|
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 +571,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 +
|
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.
|
"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
|
- **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
|
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
|
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
|
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
|
||||||
@@ -564,6 +580,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
|
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
|
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]
|
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,
|
- **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
|
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
|
player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the
|
||||||
@@ -795,3 +823,9 @@ 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-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 | 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-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 |
|
||||||
|
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
|
||||||
|
|||||||
+18
-3
@@ -45,7 +45,18 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
|
|||||||
page renders into the floating header itself through `PageHeader`; the shell knows no page.
|
page renders into the floating header itself through `PageHeader`; the shell knows no page.
|
||||||
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
|
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
|
||||||
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
|
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
|
||||||
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily.
|
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every
|
||||||
|
address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside
|
||||||
|
its codec; the shell clears them on a page switch and opens a page without another page's keys.
|
||||||
|
Every setting the page shows — a tab, a sort, a filter, a search, the item open in it — goes there: a codec
|
||||||
|
in `lib/<id>UrlState.ts` (read the params into the view, write the view as updates, a default left out —
|
||||||
|
or always written, when an address naming none falls back to a view remembered elsewhere), written with
|
||||||
|
`useUrlMirror` (a sub-view through `useHistorySubview`'s `url`, an overlay through `useBackToClose`'s),
|
||||||
|
always as the codec's result: a writer outside `lib/` computes no key of its own. Read back first on mount
|
||||||
|
(a link wins over anything remembered). A key that carries typed text — a search box's, a column filter
|
||||||
|
map with a text filter — is also in `typedUrlKeys` (the client log keeps what was typed out). Add its round
|
||||||
|
trip to `e2e/bookmark.spec.ts`, tagged with the page (`{ tag: "@<id>" }`). Only what an address cannot promise to
|
||||||
|
reopen stays out, said in its codec (Home's draw, `lib/homeUrlState`).
|
||||||
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
|
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
|
||||||
hooks). Another folder reaches the module through this file or not at all.
|
hooks). Another folder reaches the module through this file or not at all.
|
||||||
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
|
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
|
||||||
@@ -58,7 +69,10 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
|
|||||||
returns to `ui/FilterRail`; tsc names each table still missing the new id.
|
returns to `ui/FilterRail`; tsc names each table still missing the new id.
|
||||||
|
|
||||||
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
|
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
|
||||||
`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go
|
`moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key
|
||||||
|
belongs to one module and matches what its codec writes; a writer outside `lib/` is handed a literal or a `lib/`
|
||||||
|
function's result, never a key of its own; no key literal written or read from the live query undeclared; every key
|
||||||
|
sorted as typed or not; every page with keys in a tagged bookmark round trip), the `local/layers` lint rule (imports only go
|
||||||
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
|
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
|
||||||
i18n parity test.
|
i18n parity test.
|
||||||
|
|
||||||
@@ -130,7 +144,8 @@ npm run typecheck # tsc x3 (app / node / e2e)
|
|||||||
npm run lint # eslint (typed rules included; warnings fail)
|
npm run lint # eslint (typed rules included; warnings fail)
|
||||||
npm run format:check # prettier
|
npm run format:check # prettier
|
||||||
npm run knip # unused files/exports/deps
|
npm run knip # unused files/exports/deps
|
||||||
npm run test # vitest (incl. the compiler and gate-register contracts)
|
npm run test # vitest (incl. the compiler and gate-register contracts; the refusal contract runs
|
||||||
|
# the host's `python` on backend/tests/refusal_sites.py, stdlib only)
|
||||||
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
|
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
|
||||||
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
clickVisibleChannelLink,
|
||||||
|
expectContentType,
|
||||||
|
gotoPage,
|
||||||
|
openApp,
|
||||||
|
openFirstVideo,
|
||||||
|
toggleContentType,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
// The address is the view: a view set through the UI, its address opened as a bookmark — in a NEW
|
||||||
|
// browser context, so nothing this tab remembered (its history entries, sessionStorage, the view
|
||||||
|
// storage it wrote) can stand in for what the address says. One round trip per page that names its
|
||||||
|
// view, tagged with the page (moduleUrl.contract.test holds the tags to the module definitions),
|
||||||
|
// plus the overlays the shell opens over any page (the channel page, the player) and the live
|
||||||
|
// search. Not Plex: what it shows is the instance's own library, which a test stack may not have.
|
||||||
|
|
||||||
|
const STORAGE_STATE = "e2e/.auth/state.json";
|
||||||
|
|
||||||
|
/** The page's address, opened as a bookmark in a fresh context of the same account. */
|
||||||
|
async function bookmarked(browser: Browser, page: Page, prepare?: (p: Page) => Promise<void>) {
|
||||||
|
const url = page.url();
|
||||||
|
const ctx = await browser.newContext({ storageState: STORAGE_STATE, locale: "en-US" });
|
||||||
|
const fresh = await ctx.newPage();
|
||||||
|
await prepare?.(fresh);
|
||||||
|
await openApp(fresh, url);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = (p: Page, name: string) => p.getByRole("tab", { name, exact: true });
|
||||||
|
const column = (p: Page, name: string) =>
|
||||||
|
p.getByRole("columnheader", { name: new RegExp(`^${name}`) });
|
||||||
|
|
||||||
|
async function gotoAdminPage(page: Page, id: string): Promise<void> {
|
||||||
|
await page.getByTestId("nav-admin").click();
|
||||||
|
await page.getByTestId(`nav-admin-${id}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("a bookmark reopens the view it was taken of", () => {
|
||||||
|
test("the feed's filters", { tag: "@feed" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await toggleContentType(page, "includeLive");
|
||||||
|
await expect(page).toHaveURL(/[?&]live=/);
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expectContentType(fresh, "includeLive", true);
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Home's time budget", { tag: "@home" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=home");
|
||||||
|
await page.getByRole("button", { name: "An evening", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/[?&]hb=long/);
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(fresh.getByRole("button", { name: "An evening", exact: true })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Settings tab", { tag: "@settings" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoPage(page, "settings");
|
||||||
|
await tab(page, "Notifications").click();
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(tab(fresh, "Notifications")).toHaveAttribute("aria-selected", "true");
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an admin page's tab", { tag: "@users" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoAdminPage(page, "users");
|
||||||
|
await tab(page, "Access requests").click();
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(tab(fresh, "Access requests")).toHaveAttribute("aria-selected", "true");
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Configuration group", { tag: "@config" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoAdminPage(page, "config");
|
||||||
|
const second = page.getByRole("tab").nth(1);
|
||||||
|
const name = (await second.textContent()) ?? "";
|
||||||
|
await second.click();
|
||||||
|
await expect(page).toHaveURL(/[?&]cfgtab=/);
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(fresh.getByRole("tab", { name, exact: true })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Activity tab and its time window", { tag: "@activity" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoAdminPage(page, "activity");
|
||||||
|
await tab(page, "Server log").click();
|
||||||
|
await page.getByRole("button", { name: "30 days", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/[?&]aw=30d/);
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(tab(fresh, "Server log")).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(fresh.getByRole("button", { name: "30 days", exact: true })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a playlist and its sort", { tag: "@playlists" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoPage(page, "playlists");
|
||||||
|
await expect(column(page, "Title")).toBeVisible();
|
||||||
|
await column(page, "Title").getByRole("button").first().click();
|
||||||
|
await expect(page).toHaveURL(/[?&]plsort=/);
|
||||||
|
const sorted = await column(page, "Title").getAttribute("aria-sort");
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(column(fresh, "Title")).toHaveAttribute("aria-sort", sorted ?? "");
|
||||||
|
expect(new URL(fresh.url()).searchParams.get("pl")).toBe(
|
||||||
|
new URL(page.url()).searchParams.get("pl"),
|
||||||
|
);
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"the Channels manager's tab and name search",
|
||||||
|
{ tag: "@channels" },
|
||||||
|
async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoPage(page, "channels");
|
||||||
|
await tab(page, "Hidden").click();
|
||||||
|
await page.getByPlaceholder("Search channels…").fill("zz");
|
||||||
|
await expect(page).toHaveURL(/zz/); // written once the typing settles
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(tab(fresh, "Hidden")).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(fresh.getByPlaceholder("Search channels…")).toHaveValue("zz");
|
||||||
|
await fresh.context().close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the Files search", { tag: "@files" }, async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await gotoPage(page, "files");
|
||||||
|
await page.getByPlaceholder("Search files…").fill("zz");
|
||||||
|
await expect(page).toHaveURL(/zz/);
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(fresh.getByPlaceholder("Search files…")).toHaveValue("zz");
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A file of the test's own, uploaded for it and deleted after, and the account's sort put back:
|
||||||
|
// the account is left as it was found.
|
||||||
|
test(
|
||||||
|
"the Files sort, and the file open in the viewer",
|
||||||
|
{ tag: "@files" },
|
||||||
|
async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
const NAME = "bookmark-note";
|
||||||
|
// One a run cut short left behind goes first.
|
||||||
|
const left = (await (await page.request.get("/api/files")).json()) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>[];
|
||||||
|
for (const f of left.filter((f) => JSON.stringify(f).includes(`"${NAME}`)))
|
||||||
|
await page.request.delete(`/api/files/${String(f.id)}`);
|
||||||
|
const up = await page.request.post("/api/files/upload", {
|
||||||
|
headers: { "X-Upload-Filename": `${NAME}.txt` },
|
||||||
|
data: Buffer.from("a note the bookmark reopens\n"),
|
||||||
|
});
|
||||||
|
expect(up.ok(), await up.text()).toBeTruthy();
|
||||||
|
const { id } = (await up.json()) as { id: number };
|
||||||
|
// The account's own sort, put back after: a header press cycles it (asc, desc, none), so the
|
||||||
|
// case starts from none, as the address names it, whatever an earlier run left.
|
||||||
|
const me = (await (await page.request.get("/api/me")).json()) as {
|
||||||
|
preferences?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const ownSort = me.preferences?.filesSort ?? null;
|
||||||
|
try {
|
||||||
|
await openApp(page, "/?page=files&fsort=none");
|
||||||
|
await column(page, "Name").getByRole("button").first().click();
|
||||||
|
await expect(page).toHaveURL(/[?&]fsort=name/);
|
||||||
|
const sorted = await column(page, "Name").getAttribute("aria-sort");
|
||||||
|
await page.getByText(NAME, { exact: true }).first().click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`[?&]fview=${id}`));
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(fresh.getByRole("dialog", { name: NAME })).toBeVisible();
|
||||||
|
await fresh.keyboard.press("Escape");
|
||||||
|
await expect(column(fresh, "Name")).toHaveAttribute("aria-sort", sorted ?? "");
|
||||||
|
await fresh.context().close();
|
||||||
|
} finally {
|
||||||
|
await page.request.delete(`/api/files/${id}`);
|
||||||
|
await page.request.put("/api/me/preferences", { data: { filesSort: ownSort } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||||
|
await clickVisibleChannelLink(page);
|
||||||
|
const title = await page.getByTestId("channel-title").textContent();
|
||||||
|
await tab(page, "About").click();
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(fresh.getByTestId("channel-title")).toHaveText(title ?? "");
|
||||||
|
await expect(tab(fresh, "About")).toHaveAttribute("aria-selected", "true");
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the player and the video it plays", async ({ page, browser }) => {
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await openFirstVideo(page);
|
||||||
|
await expect(page).toHaveURL(/[?&]watch=/);
|
||||||
|
const watch = new URL(page.url()).searchParams.get("watch");
|
||||||
|
const fresh = await bookmarked(browser, page);
|
||||||
|
await expect(fresh.getByTestId("player-modal")).toBeVisible();
|
||||||
|
expect(new URL(fresh.url()).searchParams.get("watch")).toBe(watch);
|
||||||
|
await fresh.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The search itself never reaches YouTube here: the answer is the server's shape, served by the
|
||||||
|
// test, and the request it answers is what the address named. The instance's search settings are
|
||||||
|
// pinned too (the free source, switched on): a stack left on the API source reopens the search
|
||||||
|
// waiting for its press, by design, and one with search off offers none.
|
||||||
|
test(
|
||||||
|
"a live YouTube search, its term and its count",
|
||||||
|
{ tag: "@feed" },
|
||||||
|
async ({ page, browser }) => {
|
||||||
|
const answer = async (p: Page, seen: string[]) => {
|
||||||
|
await p.route("**/api/me", async (route) => {
|
||||||
|
const me = (await (await route.fetch()).json()) as Record<string, unknown>;
|
||||||
|
return route.fulfill({ json: { ...me, search_source: "scrape", search_enabled: true } });
|
||||||
|
});
|
||||||
|
await p.route("**/api/search/youtube?**", (route) => {
|
||||||
|
seen.push(route.request().url());
|
||||||
|
return route.fulfill({ json: { items: [], next_cursor: null, source: "scrape" } });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await answer(page, []);
|
||||||
|
await openApp(page, "/?page=feed");
|
||||||
|
await page.getByPlaceholder("Search your subscriptions…").fill("lofi");
|
||||||
|
await page.getByRole("button", { name: "Search YouTube", exact: true }).click();
|
||||||
|
await expect(page.getByText("YouTube results for “lofi”")).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/[?&]yt=lofi/); // named as it opens, not only once the count is set
|
||||||
|
await page.getByRole("button", { name: "40", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/[?&]ytn=40/);
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
const fresh = await bookmarked(browser, page, (p) => answer(p, seen));
|
||||||
|
await expect(fresh.getByText("YouTube results for “lofi”")).toBeVisible();
|
||||||
|
await expect(fresh.getByRole("button", { name: "40", exact: true })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect.poll(() => seen.length).toBeGreaterThan(0);
|
||||||
|
const asked = new URL(seen[0]!).searchParams;
|
||||||
|
expect([asked.get("q"), asked.get("limit")]).toEqual(["lofi", "40"]);
|
||||||
|
await fresh.context().close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
|
|||||||
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
|
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
|
||||||
// view would move the feed before navigating, so Back would restore that moved position, not
|
// view would move the feed before navigating, so Back would restore that moved position, not
|
||||||
// the one under test. (This is what made the test fail while the app was in fact correct.)
|
// the one under test. (This is what made the test fail while the app was in fact correct.)
|
||||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
await clickVisibleChannelLink(page);
|
||||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||||
await page.getByTestId("channel-back").click();
|
await page.getByTestId("channel-back").click();
|
||||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||||
|
|||||||
+27
-9
@@ -33,23 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
|
|||||||
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
|
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card
|
||||||
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
|
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
|
||||||
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
|
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
|
||||||
* So the link must be the element hit at its own centre. Returns false if none found.
|
* So the link must be the element hit at its own centre; none within the wait fails the test, with
|
||||||
|
* the poll's own message. Looked for again until one is, not once: a fresh load is still settling
|
||||||
|
* when the first card shows (the links move down ~55px), and webkit can answer the hit test at a
|
||||||
|
* link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes
|
||||||
|
* away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs.
|
||||||
*/
|
*/
|
||||||
export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
export async function clickVisibleChannelLink(page: Page): Promise<void> {
|
||||||
const links = page.getByTestId("video-card-channel").filter({ visible: true });
|
const links = page.getByTestId("video-card-channel").filter({ visible: true });
|
||||||
|
let index = -1;
|
||||||
|
const findHittable = async () => {
|
||||||
const count = await links.count();
|
const count = await links.count();
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const hittable = await links.nth(i).evaluate((el) => {
|
const hittable = await links
|
||||||
|
.nth(i)
|
||||||
|
.evaluate(
|
||||||
|
(el) => {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
return hit !== null && el.contains(hit);
|
return hit !== null && el.contains(hit);
|
||||||
});
|
},
|
||||||
if (hittable) {
|
undefined,
|
||||||
await links.nth(i).click();
|
{ timeout: 1000 },
|
||||||
return true;
|
)
|
||||||
|
.catch(() => false);
|
||||||
|
if (hittable) return i;
|
||||||
}
|
}
|
||||||
}
|
return -1;
|
||||||
return false;
|
};
|
||||||
|
await expect
|
||||||
|
.poll(async () => (index = await findHittable()), {
|
||||||
|
message: "a channel link hit at its own centre",
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(-1);
|
||||||
|
await links.nth(index).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -898,7 +898,8 @@ export default tseslint.config(
|
|||||||
plugins: { playwright },
|
plugins: { playwright },
|
||||||
rules: {
|
rules: {
|
||||||
"playwright/no-focused-test": "error",
|
"playwright/no-focused-test": "error",
|
||||||
"playwright/no-skipped-test": "error",
|
// fixme too: a test Playwright never runs, which the bookmark net would still count.
|
||||||
|
"playwright/no-skipped-test": ["error", { disallowFixme: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
const { notify } = vi.hoisted(() => ({
|
const { notify } = vi.hoisted(() => ({
|
||||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
notify: vi.fn((_n: { level: string; message: NotifText }) => 0),
|
||||||
}));
|
}));
|
||||||
vi.mock("./lib/notifications", async (importOriginal) => ({
|
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||||
@@ -34,6 +34,8 @@ import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
|
|||||||
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
||||||
import TagManager from "./features/channel/TagManager";
|
import TagManager from "./features/channel/TagManager";
|
||||||
import { api, HttpError, type PlexCard } from "./lib/api";
|
import { api, HttpError, type PlexCard } from "./lib/api";
|
||||||
|
import { qk } from "./lib/queryKeys";
|
||||||
|
import type { NotifText } from "./lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -97,7 +99,7 @@ describe("the Plex playlist page", () => {
|
|||||||
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
||||||
"plex.playlist.removeFromPlaylist",
|
"plex.playlist.removeFromPlaylist",
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -118,7 +120,7 @@ describe("the Plex playlist page", () => {
|
|||||||
const field = await screen.findByDisplayValue("Films");
|
const field = await screen.findByDisplayValue("Films");
|
||||||
fireEvent.change(field, { target: { value: "Movies" } });
|
fireEvent.change(field, { target: { value: "Movies" } });
|
||||||
fireEvent.keyDown(field, { key: "Enter" });
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||||
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
|
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,10 +155,56 @@ describe("the add-to-playlist popover", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
|
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
|
||||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
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 () => {
|
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
|
||||||
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
|
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
|
||||||
render(
|
render(
|
||||||
@@ -235,7 +283,7 @@ describe("the add-to-playlist popover", () => {
|
|||||||
fireEvent.click(await screen.findByText("Gone"));
|
fireEvent.click(await screen.findByText("Gone"));
|
||||||
// The server's own localized reason wins over the caller's fallback when the detail is
|
// The server's own localized reason wins over the caller's fallback when the detail is
|
||||||
// STRUCTURED — same as the twin above. What is under test is that anything is said at all.
|
// STRUCTURED — same as the twin above. What is under test is that anything is said at all.
|
||||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
|
||||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -253,7 +301,7 @@ describe("the tag manager", () => {
|
|||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByLabelText("tagManager.delete"));
|
fireEvent.click(await screen.findByLabelText("tagManager.delete"));
|
||||||
await waitFor(() => expect(errors()).toEqual(["gone tag"]));
|
await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }]));
|
||||||
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
|
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
const { notify } = vi.hoisted(() => ({
|
const { notify } = vi.hoisted(() => ({
|
||||||
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
|
||||||
}));
|
}));
|
||||||
vi.mock("./lib/notifications", async (importOriginal) => ({
|
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||||
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
|
|||||||
import UploadDialog from "./modules/files/UploadDialog";
|
import UploadDialog from "./modules/files/UploadDialog";
|
||||||
import DownloadDialog from "./features/downloads/DownloadDialog";
|
import DownloadDialog from "./features/downloads/DownloadDialog";
|
||||||
import { api, HttpError } from "./lib/api";
|
import { api, HttpError } from "./lib/api";
|
||||||
|
import { msg, type NotifText } from "./lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
|
|||||||
);
|
);
|
||||||
mount();
|
mount();
|
||||||
fireEvent.click(await screen.findByText("Marathon"));
|
fireEvent.click(await screen.findByText("Marathon"));
|
||||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so when enabling editing is refused", async () => {
|
it("says so when enabling editing is refused", async () => {
|
||||||
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
|
|||||||
);
|
);
|
||||||
mount();
|
mount();
|
||||||
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
|
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
|
||||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says so when a CONFIRMED delete does not happen", async () => {
|
it("says so when a CONFIRMED delete does not happen", async () => {
|
||||||
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
|
|||||||
);
|
);
|
||||||
mount();
|
mount();
|
||||||
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
|
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
|
||||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"]));
|
await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ describe("the upload dialog", () => {
|
|||||||
mount();
|
mount();
|
||||||
pick();
|
pick();
|
||||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||||
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"]));
|
await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says nothing when the user cancelled it themselves", async () => {
|
it("says nothing when the user cancelled it themselves", async () => {
|
||||||
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
|
|||||||
);
|
);
|
||||||
await screen.findByText("Best");
|
await screen.findByText("Best");
|
||||||
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||||
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
|
await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
it("a toggle announces aria-pressed and paints the pressed state", () => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<Button variant="outline" pressed={false}>
|
<Button variant="outline" pressed={false}>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
||||||
pressed?: boolean;
|
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`). */
|
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||||
"aria-pressed"?: never;
|
"aria-pressed"?: never;
|
||||||
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
|
/** 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,
|
label,
|
||||||
loading = false,
|
loading = false,
|
||||||
pressed,
|
pressed,
|
||||||
|
lit,
|
||||||
round = false,
|
round = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
className,
|
className,
|
||||||
@@ -84,9 +88,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
|||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={pressed}
|
aria-pressed={pressed}
|
||||||
|
data-lit={lit || undefined}
|
||||||
title={label}
|
title={label}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
|
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed, lit }),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ describe("IconButton", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true");
|
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", () => {
|
it("loading swaps the glyph for a spinner and swallows the click", () => {
|
||||||
const onClick = vi.fn();
|
const onClick = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export interface IconButtonProps
|
|||||||
/** A toggle: sets aria-pressed and tints the glyph with the chosen colour (the accent; the media
|
/** A toggle: sets aria-pressed and tints the glyph with the chosen colour (the accent; the media
|
||||||
* amber on the player chrome). */
|
* amber on the player chrome). */
|
||||||
pressed?: boolean;
|
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`). */
|
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||||
"aria-pressed"?: never;
|
"aria-pressed"?: never;
|
||||||
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
|
/** 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",
|
variant = "ghost",
|
||||||
size = "sm",
|
size = "sm",
|
||||||
pressed,
|
pressed,
|
||||||
|
lit,
|
||||||
round = false,
|
round = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
noTitle = false,
|
noTitle = false,
|
||||||
@@ -56,8 +60,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|||||||
busy={loading}
|
busy={loading}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={pressed}
|
aria-pressed={pressed}
|
||||||
|
data-lit={lit || undefined}
|
||||||
title={noTitle ? undefined : label}
|
title={noTitle ? undefined : label}
|
||||||
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
|
className={clsx(iconButtonClass({ variant, size, round, pressed, lit }), className)}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="animate-spin" /> : children}
|
{loading ? <Loader2 className="animate-spin" /> : children}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import KeyboardHelp from "./KeyboardHelp";
|
|||||||
import { useContextMenu } from "./useContextMenu";
|
import { useContextMenu } from "./useContextMenu";
|
||||||
import {
|
import {
|
||||||
closeKeyboardHelp,
|
closeKeyboardHelp,
|
||||||
|
setScopeCoveredProbe,
|
||||||
useGlobalKeymap,
|
useGlobalKeymap,
|
||||||
useHelpConditions,
|
useHelpConditions,
|
||||||
useKeyScope,
|
useKeyScope,
|
||||||
@@ -16,7 +17,8 @@ import {
|
|||||||
} from "../../lib/keymap";
|
} from "../../lib/keymap";
|
||||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
import { useMediaKeys } from "../../lib/mediaKeys";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -144,6 +146,67 @@ describe("KeyboardHelp", () => {
|
|||||||
expect(descriptions()).toContain("keys.help");
|
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.
|
// 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", () => {
|
it("lists the context-menu chords once however many surfaces register", () => {
|
||||||
function WithMenu() {
|
function WithMenu() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useHelpConditionValues,
|
useHelpConditionValues,
|
||||||
useKeyboardHelpOpen,
|
useKeyboardHelpOpen,
|
||||||
useKeyboardHelpOverLayer,
|
useKeyboardHelpOverLayer,
|
||||||
|
useKeyboardHelpCovered,
|
||||||
useNavigablePages,
|
useNavigablePages,
|
||||||
} from "../../lib/keymap";
|
} from "../../lib/keymap";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default function KeyboardHelp() {
|
|||||||
const scopes = useActiveScopes();
|
const scopes = useActiveScopes();
|
||||||
const conditions = useHelpConditionValues();
|
const conditions = useHelpConditionValues();
|
||||||
const overLayer = useKeyboardHelpOverLayer();
|
const overLayer = useKeyboardHelpOverLayer();
|
||||||
|
const covered = useKeyboardHelpCovered();
|
||||||
const pages = useNavigablePages();
|
const pages = useNavigablePages();
|
||||||
|
|
||||||
if (!open) return null;
|
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
|
// 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
|
// 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.
|
// 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
|
// The context-menu key goes too, with the page's lists. Over a layer that holds a list of its own
|
||||||
// focus Shift+F10 needs is inside the overlay (found in UAT).
|
// (the player's related videos) the list keys and the menu key work on THAT list, so they stay —
|
||||||
const live = overLayer
|
// but not Delete: no list inside a layer takes it, and a page's remover behind does not reach.
|
||||||
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
|
const inLayer = (s: (typeof scopes)[number]) =>
|
||||||
: scopes;
|
(!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 lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
|
||||||
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
|
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||||
|
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import { clearAll, notify } from "../../lib/notifications";
|
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||||
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
|
||||||
import { flushSync } from "react-dom";
|
import { createPortal, flushSync } from "react-dom";
|
||||||
|
|
||||||
// Fire a keydown on window (where Modal installs its handler), inside act.
|
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||||
const press = (key: string, shiftKey = false) =>
|
const press = (key: string, shiftKey = false) =>
|
||||||
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
|||||||
<Body />
|
<Body />
|
||||||
</Modal>,
|
</Modal>,
|
||||||
);
|
);
|
||||||
act(() => void notify({ level: "info", message: "Saved" }));
|
act(() => void notify({ level: "info", message: raw("Saved") }));
|
||||||
try {
|
try {
|
||||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||||
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
|
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
|
||||||
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
|||||||
<Body />
|
<Body />
|
||||||
</Modal>,
|
</Modal>,
|
||||||
);
|
);
|
||||||
act(() => void notify({ level: "error", message: "Nope" }));
|
act(() => void notify({ level: "error", message: raw("Nope") }));
|
||||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||||
dismissToast.focus();
|
dismissToast.focus();
|
||||||
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
|
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
|
||||||
@@ -311,6 +311,100 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
|||||||
document.body.removeChild(trigger);
|
document.body.removeChild(trigger);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands its trigger to the dialog left standing above it, which restores it on close", () => {
|
||||||
|
// The standing upper dialog captured ITS restore target while the focus was inside the lower
|
||||||
|
// one — a control that is gone once the lower closes. So the lower skipping its own restore
|
||||||
|
// (above) handed the trigger to no one, and closing the upper left the focus on <body>.
|
||||||
|
// The arrangement keyboard help or the error dialog opened over a dialog actually has.
|
||||||
|
const trigger = document.createElement("button");
|
||||||
|
document.body.appendChild(trigger);
|
||||||
|
trigger.focus();
|
||||||
|
function Siblings() {
|
||||||
|
const [lower, setLower] = useState(true);
|
||||||
|
const [upper, setUpper] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lower && (
|
||||||
|
<Modal initialFocus="close" title="lower" onClose={() => {}}>
|
||||||
|
<button onClick={() => setUpper(true)}>open upper</button>
|
||||||
|
<button onClick={() => setLower(false)}>close lower</button>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{upper && (
|
||||||
|
<Modal initialFocus="close" title="upper" onClose={() => {}}>
|
||||||
|
<button onClick={() => setUpper(false)}>close upper</button>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Siblings />);
|
||||||
|
screen.getByText("open upper").focus();
|
||||||
|
act(() => void screen.getByText("open upper").click()); // opened from inside the lower dialog
|
||||||
|
const inUpper = screen.getByText("close upper");
|
||||||
|
inUpper.focus();
|
||||||
|
// The lower closes on its own under the standing upper (`.click()` moves no focus).
|
||||||
|
act(() => void screen.getByText("close lower").click());
|
||||||
|
expect(screen.queryByText("close lower")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(inUpper);
|
||||||
|
act(() => void inUpper.click());
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
document.body.removeChild(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the trigger when it closes with the focus in one of its menus", () => {
|
||||||
|
// The popover engine portals a menu out of the dialog's root (it carries no aria-modal), so a
|
||||||
|
// focus on a menu row read as "the user walked away": Back with a menu open inside a dialog
|
||||||
|
// left the focus on <body>. The portal marker is the one `ui/AnchoredPopover` reads too.
|
||||||
|
const trigger = document.createElement("button");
|
||||||
|
document.body.appendChild(trigger);
|
||||||
|
trigger.focus();
|
||||||
|
const portal = document.createElement("div");
|
||||||
|
portal.setAttribute("data-floating-ui-portal", "");
|
||||||
|
document.body.appendChild(portal);
|
||||||
|
const { unmount } = render(
|
||||||
|
<Modal initialFocus="close" title="t" onClose={() => {}}>
|
||||||
|
<Body />
|
||||||
|
{createPortal(<button>menu-row</button>, portal)}
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
screen.getByText("menu-row").focus();
|
||||||
|
unmount();
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
document.body.removeChild(trigger);
|
||||||
|
document.body.removeChild(portal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not pull the focus out of a menu that stays open when it closes on its own", () => {
|
||||||
|
// The other side of counting a menu row as held: a menu that is NOT this dialog's — a page's
|
||||||
|
// "…" left open while a dialog finishes in the background — stays standing, and a dialog that
|
||||||
|
// closes on its own (a write's success) must not take the focus from it to its trigger.
|
||||||
|
const trigger = document.createElement("button");
|
||||||
|
document.body.appendChild(trigger);
|
||||||
|
trigger.focus();
|
||||||
|
const portal = document.createElement("div");
|
||||||
|
portal.setAttribute("data-floating-ui-portal", "");
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.textContent = "page-menu-row";
|
||||||
|
portal.appendChild(row);
|
||||||
|
document.body.appendChild(portal);
|
||||||
|
function SelfClosing() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return open ? (
|
||||||
|
<Modal initialFocus="close" title="t" onClose={() => {}}>
|
||||||
|
<button onClick={() => setOpen(false)}>finish</button>
|
||||||
|
</Modal>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
render(<SelfClosing />);
|
||||||
|
row.focus();
|
||||||
|
act(() => void screen.getByText("finish").click()); // `.click()` moves no focus
|
||||||
|
expect(screen.queryByText("finish")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(row);
|
||||||
|
document.body.removeChild(trigger);
|
||||||
|
document.body.removeChild(portal);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves the focus alone when the user moved it out of the dialog themselves", () => {
|
it("leaves the focus alone when the user moved it out of the dialog themselves", () => {
|
||||||
// The other half of the same rule, and the branch nothing drove: taking focus back from where
|
// The other half of the same rule, and the branch nothing drove: taking focus back from where
|
||||||
// the user deliberately put it is the rudest thing a closing dialog can do.
|
// the user deliberately put it is the rudest thing a closing dialog can do.
|
||||||
@@ -361,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
|
|||||||
<Body />
|
<Body />
|
||||||
</Modal>,
|
</Modal>,
|
||||||
);
|
);
|
||||||
act(() => void notify({ level: "error", message: "Couldn't share it." }));
|
act(() => void notify({ level: "error", message: raw("Couldn't share it.") }));
|
||||||
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
|
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export default function Modal({
|
|||||||
placement = "center",
|
placement = "center",
|
||||||
initialFocus,
|
initialFocus,
|
||||||
headerActions,
|
headerActions,
|
||||||
|
url,
|
||||||
}: {
|
}: {
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
initialFocus: ModalFocus;
|
initialFocus: ModalFocus;
|
||||||
@@ -81,10 +82,14 @@ export default function Modal({
|
|||||||
placement?: "center" | "drawer" | "drawer-start" | "top";
|
placement?: "center" | "drawer" | "drawer-start" | "top";
|
||||||
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
|
/** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
|
/** What the dialog names in the address while it is open, when it is part of the view (a viewer, a
|
||||||
|
* player): written into its own history entry, so a reload or a bookmark reopens it and Back lands
|
||||||
|
* on the address without it (lib/history). */
|
||||||
|
url?: Record<string, string | null>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose, url);
|
||||||
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
|
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
|
||||||
// element — and attached before the children commit, so an `autoFocus` child
|
// element — and attached before the children commit, so an `autoFocus` child
|
||||||
// still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it.
|
// still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it.
|
||||||
@@ -147,7 +152,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.
|
// An effect event, so a new handler each render never re-registers: the stack id is assigned once per mount.
|
||||||
const close = useEffectEvent(onClose);
|
const close = useEffectEvent(onClose);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = pushLayer();
|
const id = pushLayer({ root: () => rootRef.current });
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (takeEscape(e, id)) {
|
if (takeEscape(e, id)) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
import PageScroller from "./PageScroller";
|
import PageScroller from "./PageScroller";
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
import { notifyNav } from "../../lib/history";
|
import { notifyNav } from "../../lib/history";
|
||||||
|
import { revealedNavItem } from "../../lib/listNav";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||||
@@ -125,6 +126,38 @@ describe("PageScroller scroll memory", () => {
|
|||||||
expect(main.scrollTop).toBe(450);
|
expect(main.scrollTop).toBe(450);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Back focuses an item the restore did not expect — one that moved to another list while the page
|
||||||
|
// was away — and scrolls it into view: the restore's next frame put the old offset back over it.
|
||||||
|
it("gives the page up to an item brought into view while it restores", () => {
|
||||||
|
let frames: FrameRequestCallback[] = [];
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
||||||
|
const flush = (n: number) => {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const run = frames;
|
||||||
|
frames = [];
|
||||||
|
for (const cb of run) cb(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setEntry(1);
|
||||||
|
const { container } = render(
|
||||||
|
<PageScroller scrollKey="e:k1">
|
||||||
|
<div id="moved" />
|
||||||
|
</PageScroller>,
|
||||||
|
);
|
||||||
|
const main = sizedMain(container);
|
||||||
|
main.scrollTop = 450;
|
||||||
|
fireEvent.scroll(main);
|
||||||
|
setEntry(2);
|
||||||
|
notifyNav();
|
||||||
|
flush(1);
|
||||||
|
setEntry(1);
|
||||||
|
notifyNav(); // Back: the restore to 450 waits for its first frame
|
||||||
|
main.scrollTop = 120; // the item scrolled into view
|
||||||
|
revealedNavItem(container.querySelector("#moved")!);
|
||||||
|
flush(5);
|
||||||
|
expect(main.scrollTop).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
// Landing at the end shows Back to top, whose spacer then brings the spot within reach.
|
// Landing at the end shows Back to top, whose spacer then brings the spot within reach.
|
||||||
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
|
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
|
||||||
let frames: FrameRequestCallback[] = [];
|
let frames: FrameRequestCallback[] = [];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { StickyTopProvider } from "./stickyTop";
|
|||||||
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
||||||
import { CONTENT_ID } from "./landmarks";
|
import { CONTENT_ID } from "./landmarks";
|
||||||
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
||||||
|
import { NAV_REVEAL_EVENT } from "../../lib/listNav";
|
||||||
|
|
||||||
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
||||||
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
|
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
|
||||||
@@ -63,11 +64,15 @@ function restoreScroll(el: HTMLElement, top: number): () => void {
|
|||||||
el.removeEventListener("wheel", stop);
|
el.removeEventListener("wheel", stop);
|
||||||
el.removeEventListener("touchstart", stop);
|
el.removeEventListener("touchstart", stop);
|
||||||
el.removeEventListener("pointerdown", stop);
|
el.removeEventListener("pointerdown", stop);
|
||||||
|
el.removeEventListener(NAV_REVEAL_EVENT, stop);
|
||||||
document.removeEventListener("keydown", stop, true);
|
document.removeEventListener("keydown", stop, true);
|
||||||
};
|
};
|
||||||
el.addEventListener("wheel", stop, { passive: true });
|
el.addEventListener("wheel", stop, { passive: true });
|
||||||
el.addEventListener("touchstart", stop, { passive: true });
|
el.addEventListener("touchstart", stop, { passive: true });
|
||||||
el.addEventListener("pointerdown", stop);
|
el.addEventListener("pointerdown", stop);
|
||||||
|
// So does an item the keyboard brought into view (`lib/listNav`): Back focusing one that is not
|
||||||
|
// where the restored offset shows.
|
||||||
|
el.addEventListener(NAV_REVEAL_EVENT, stop);
|
||||||
document.addEventListener("keydown", stop, true);
|
document.addEventListener("keydown", stop, true);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { MEDIA_CHOSEN } from "./mediaChrome";
|
|||||||
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
|
||||||
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
|
||||||
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
|
||||||
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats):
|
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings) and names it in
|
||||||
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every
|
// the address: a reload keeps the user where they were, a link opens its tab, and a fresh tab opens on
|
||||||
|
// the first sub-tab. Not every
|
||||||
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
|
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
|
||||||
// and the player settings start on their first tab each time.
|
// and the player settings start on their first tab each time.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import Toaster from "./Toaster";
|
import Toaster from "./Toaster";
|
||||||
import { clearAll, notify } from "../../lib/notifications";
|
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||||
import { dockUnderToasts } from "../../lib/toastDock";
|
import { dockUnderToasts } from "../../lib/toastDock";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
|
||||||
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
||||||
|
|
||||||
const overlay = render(
|
const overlay = render(
|
||||||
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
|
|||||||
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
|
it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
|
||||||
const Docked = () => <button>dock</button>;
|
const Docked = () => <button>dock</button>;
|
||||||
const stop = dockUnderToasts(Docked);
|
const stop = dockUnderToasts(Docked);
|
||||||
notify({ level: "info", message: "hello" });
|
notify({ level: "info", message: raw("hello") });
|
||||||
render(<Toaster />);
|
render(<Toaster />);
|
||||||
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
|
const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
|
||||||
// The empty part of the region (beside and above a docked control) must not swallow the page's
|
// The empty part of the region (beside and above a docked control) must not swallow the page's
|
||||||
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
|
|||||||
<Toaster overlay />
|
<Toaster overlay />
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
act(() => void notify({ level: "error", message: "boom" }));
|
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||||
fireEvent.click(screen.getByText("boom"));
|
fireEvent.click(screen.getByText("boom"));
|
||||||
expect(backdrop).not.toHaveBeenCalled();
|
expect(backdrop).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
|
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
|
||||||
// assertively, each time a player (or any dialog) opened or closed.
|
// assertively, each time a player (or any dialog) opened or closed.
|
||||||
render(<Toaster />);
|
render(<Toaster />);
|
||||||
act(() => void notify({ level: "error", message: "old news" }));
|
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||||
render(<Toaster overlay />);
|
render(<Toaster overlay />);
|
||||||
const old = screen.getByText("old news").closest(".toast-card")!;
|
const old = screen.getByText("old news").closest(".toast-card")!;
|
||||||
expect(old.getAttribute("role")).toBeNull();
|
expect(old.getAttribute("role")).toBeNull();
|
||||||
act(() => void notify({ level: "error", message: "fresh" }));
|
act(() => void notify({ level: "error", message: raw("fresh") }));
|
||||||
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
// host last showed — re-read on every render, this host's own "first" turned silent the moment
|
// host last showed — re-read on every render, this host's own "first" turned silent the moment
|
||||||
// "second" arrived (its own screen had become the handover by then).
|
// "second" arrived (its own screen had become the handover by then).
|
||||||
render(<Toaster />);
|
render(<Toaster />);
|
||||||
act(() => void notify({ level: "error", message: "first" }));
|
act(() => void notify({ level: "error", message: raw("first") }));
|
||||||
act(() => void notify({ level: "error", message: "second" }));
|
act(() => void notify({ level: "error", message: raw("second") }));
|
||||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||||
expect(card("first").getAttribute("role")).toBe("alert");
|
expect(card("first").getAttribute("role")).toBe("alert");
|
||||||
expect(card("second").getAttribute("role")).toBe("alert");
|
expect(card("second").getAttribute("role")).toBe("alert");
|
||||||
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
||||||
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
||||||
render(<Toaster />);
|
render(<Toaster />);
|
||||||
act(() => void notify({ level: "info", message: "saved" }));
|
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||||
const overlay = render(<Toaster overlay />);
|
const overlay = render(<Toaster overlay />);
|
||||||
const region = overlay.container.querySelector("[aria-live]")!;
|
const region = overlay.container.querySelector("[aria-live]")!;
|
||||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||||
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
<Toaster overlay />
|
<Toaster overlay />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
notify({ level: "success", message: "upload queued" });
|
notify({ level: "success", message: raw("upload queued") });
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
render(<Stage />);
|
render(<Stage />);
|
||||||
act(() => void notify({ level: "error", message: "old news" }));
|
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||||
fireEvent.click(screen.getByText("upload"));
|
fireEvent.click(screen.getByText("upload"));
|
||||||
|
|
||||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||||
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
<Toaster overlay order={2} />
|
<Toaster overlay order={2} />
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
act(() => void notify({ level: "error", message: "boom" }));
|
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||||
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
||||||
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
||||||
player.rerender(
|
player.rerender(
|
||||||
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
||||||
render(<Toaster />);
|
render(<Toaster />);
|
||||||
act(() => void notify({ level: "info", message: "saved" }));
|
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||||
now.mockReturnValue(t0 + 4000);
|
now.mockReturnValue(t0 + 4000);
|
||||||
render(<Toaster overlay />);
|
render(<Toaster overlay />);
|
||||||
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
|
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
|
||||||
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
|||||||
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
|
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
|
||||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||||
render(<Toaster overlay />);
|
render(<Toaster overlay />);
|
||||||
act(() => void notify({ level: "error", message: "boom" }));
|
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||||
const host = hosts()[0]!.closest("[data-toast-region]")!;
|
const host = hosts()[0]!.closest("[data-toast-region]")!;
|
||||||
expect(host.className).toContain("bottom-24");
|
expect(host.className).toContain("bottom-24");
|
||||||
expect(host.className).toContain("pointer-events-none");
|
expect(host.className).toContain("pointer-events-none");
|
||||||
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
|
|||||||
() =>
|
() =>
|
||||||
void notify({
|
void notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: "Downloaded",
|
message: raw("Downloaded"),
|
||||||
action: { label: "Copy link", onClick: copy },
|
action: { label: raw("Copy link"), onClick: copy },
|
||||||
secondaryAction: { label: "Open", onClick: open },
|
secondaryAction: { label: raw("Open"), onClick: open },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
|
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A notice is said in the reader's language when it is shown, not in the one it was raised in. The
|
||||||
|
// text used to be translated by the caller and kept so: after a language switch every toast and every
|
||||||
|
// row of the notification center stayed in the old language, and so did the history after a reload.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import i18n from "../../i18n";
|
||||||
|
import Toaster from "./Toaster";
|
||||||
|
import { clearAll, msg, notify, raw, sayNotif } from "../../lib/notifications";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
cleanup();
|
||||||
|
act(() => clearAll());
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a notice speaks the reader's language of the moment", () => {
|
||||||
|
it("a toast raised in English reads Hungarian after the switch", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(() => void notify({ level: "success", message: msg("common.copy.done") }));
|
||||||
|
const en = i18n.getFixedT("en")("common.copy.done");
|
||||||
|
const hu = i18n.getFixedT("hu")("common.copy.done");
|
||||||
|
expect(en).not.toBe(hu);
|
||||||
|
expect(screen.getByText(en)).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||||
|
expect(screen.getByText(hu)).toBeTruthy();
|
||||||
|
expect(screen.queryByText(en)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("its button is said again too, beside the text it acts on", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(
|
||||||
|
() =>
|
||||||
|
void notify({
|
||||||
|
message: msg("common.copy.done"),
|
||||||
|
action: { label: msg("common.undo"), onClick: () => {} },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||||
|
expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a server refusal is said again in the language of the moment", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(
|
||||||
|
() =>
|
||||||
|
void notify({
|
||||||
|
level: "error",
|
||||||
|
message: { detail: { code: "not_found", reason: "view" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => void (await i18n.changeLanguage("hu")));
|
||||||
|
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
|
||||||
|
const text = msg("settings.takeout.read", {
|
||||||
|
name: raw("watch-history.json"),
|
||||||
|
count: 12345,
|
||||||
|
n: { number: 12345 },
|
||||||
|
});
|
||||||
|
const en = sayNotif(text);
|
||||||
|
await i18n.changeLanguage("hu");
|
||||||
|
const hu = sayNotif(text);
|
||||||
|
expect(en).toContain("12,345");
|
||||||
|
expect(hu).not.toContain("12,345");
|
||||||
|
expect(hu).toContain("watch-history.json");
|
||||||
|
expect(hu).not.toBe(en);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the history kept from before", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a notice stored as text as it was stored", async () => {
|
||||||
|
localStorage.setItem("siftlode.owner", "5");
|
||||||
|
localStorage.setItem(
|
||||||
|
"siftlode.notifications",
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const notifications = await import("../../lib/notifications");
|
||||||
|
const { claimLocalData } = await import("../../lib/storage");
|
||||||
|
claimLocalData(5);
|
||||||
|
const n = notifications.getNotifications()[0]!;
|
||||||
|
expect(n.message).toEqual(raw("Old text"));
|
||||||
|
expect(n.title).toEqual(raw("Old title"));
|
||||||
|
expect(notifications.sayNotif(n.message)).toBe("Old text");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
import Toaster from "./Toaster";
|
import Toaster from "./Toaster";
|
||||||
import { useOverlayOrder } from "./overlayOrder";
|
import { useOverlayOrder } from "./overlayOrder";
|
||||||
import { clearAll, notify } from "../../lib/notifications";
|
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -37,7 +37,7 @@ function Harness() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
notify({ level: "success", message: "Uploaded" });
|
notify({ level: "success", message: raw("Uploaded") });
|
||||||
setOpen(false); // …in the SAME handler: one batch, one commit
|
setOpen(false); // …in the SAME handler: one batch, one commit
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications";
|
import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
|
||||||
import { createStore } from "../../lib/store";
|
import { createStore } from "../../lib/store";
|
||||||
import { useToastDock } from "../../lib/toastDock";
|
import { useToastDock } from "../../lib/toastDock";
|
||||||
import { useOverlayOrder } from "./overlayOrder";
|
import { useOverlayOrder } from "./overlayOrder";
|
||||||
@@ -162,9 +162,11 @@ export default function Toaster({
|
|||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
{toast.title && (
|
||||||
|
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
|
||||||
|
)}
|
||||||
<div className="text-sm wrap-break-word">
|
<div className="text-sm wrap-break-word">
|
||||||
{toast.message}
|
{sayNotif(toast.message, t)}
|
||||||
{toast.repeat > 1 && (
|
{toast.repeat > 1 && (
|
||||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||||
×{toast.repeat}
|
×{toast.repeat}
|
||||||
@@ -174,10 +176,10 @@ export default function Toaster({
|
|||||||
{(toast.action || toast.secondaryAction) && (
|
{(toast.action || toast.secondaryAction) && (
|
||||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||||
{[toast.action, toast.secondaryAction].map(
|
{[toast.action, toast.secondaryAction].map(
|
||||||
(action) =>
|
(action, i) =>
|
||||||
action && (
|
action && (
|
||||||
<Button
|
<Button
|
||||||
key={action.label}
|
key={i}
|
||||||
variant="link"
|
variant="link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
action.onClick();
|
action.onClick();
|
||||||
@@ -185,7 +187,7 @@ export default function Toaster({
|
|||||||
}}
|
}}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
{action.label}
|
{sayNotif(action.label, t)}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
// 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 =
|
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
|
// 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
|
// 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
|
// 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.
|
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action. Important
|
||||||
const ICON_PRESSED = "aria-pressed:text-chosen";
|
// 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> = {
|
const ICON_SIZES: Record<IconButtonSize, string> = {
|
||||||
sm: "size-7 [&>svg]:size-4",
|
sm: "size-7 [&>svg]:size-4",
|
||||||
@@ -138,17 +147,20 @@ export function buttonClass({
|
|||||||
round = false,
|
round = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
pressed,
|
pressed,
|
||||||
|
lit,
|
||||||
}: {
|
}: {
|
||||||
variant: ButtonVariant;
|
variant: ButtonVariant;
|
||||||
size: ButtonSize;
|
size: ButtonSize;
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
|
lit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return clsx(
|
return clsx(
|
||||||
BASE,
|
BASE,
|
||||||
BUTTON_VARIANTS[variant],
|
BUTTON_VARIANTS[variant],
|
||||||
pressed !== undefined && PRESSED,
|
pressed !== undefined && PRESSED,
|
||||||
|
lit !== undefined && LIT,
|
||||||
SIZES[size],
|
SIZES[size],
|
||||||
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
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.
|
// 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,
|
size,
|
||||||
round = false,
|
round = false,
|
||||||
pressed,
|
pressed,
|
||||||
|
lit,
|
||||||
}: {
|
}: {
|
||||||
variant: IconButtonVariant;
|
variant: IconButtonVariant;
|
||||||
size: IconButtonSize;
|
size: IconButtonSize;
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
|
lit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return clsx(
|
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",
|
"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],
|
ICON_VARIANTS[variant],
|
||||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||||
pressed !== undefined && ICON_PRESSED,
|
pressed !== undefined && ICON_PRESSED,
|
||||||
|
lit !== undefined && ICON_LIT,
|
||||||
ICON_SIZES[size],
|
ICON_SIZES[size],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Copy, Link2 } from "lucide-react";
|
import { Copy, Link2 } from "lucide-react";
|
||||||
|
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||||
import { type ActionMenuItem } from "./ActionMenu";
|
import { type ActionMenuItem } from "./ActionMenu";
|
||||||
|
|
||||||
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
||||||
@@ -17,12 +17,12 @@ type Named = "title" | "name";
|
|||||||
|
|
||||||
export function useCopyItems() {
|
export function useCopyItems() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const copy = (text: string, done: string) => {
|
const copy = (text: string, done: NotifText) => {
|
||||||
void copyText(text).then((ok) =>
|
void copyText(text).then((ok) =>
|
||||||
notify(
|
notify(
|
||||||
ok
|
ok
|
||||||
? { level: "success", message: done }
|
? { level: "success", message: done }
|
||||||
: { level: "error", message: t("common.copy.failed") },
|
: { level: "error", message: msg("common.copy.failed") },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -43,7 +43,7 @@ export function useCopyItems() {
|
|||||||
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
|
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
|
||||||
icon: <Copy className="w-4 h-4" />,
|
icon: <Copy className="w-4 h-4" />,
|
||||||
inGesture: true,
|
inGesture: true,
|
||||||
onSelect: () => copy(text, t("common.copy.done")),
|
onSelect: () => copy(text, msg("common.copy.done")),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -54,7 +54,7 @@ export function useCopyItems() {
|
|||||||
label: linkLabel ?? t("common.copy.link"),
|
label: linkLabel ?? t("common.copy.link"),
|
||||||
icon: <Link2 className="w-4 h-4" />,
|
icon: <Link2 className="w-4 h-4" />,
|
||||||
inGesture: true,
|
inGesture: true,
|
||||||
onSelect: () => copy(link, t("common.copy.linkDone")),
|
onSelect: () => copy(link, msg("common.copy.linkDone")),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ export function lockBodyScroll(): () => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A popover engine surface (`ui/AnchoredPopover`): portalled out of the dialog's root and carrying no
|
||||||
|
// aria-modal, so a focus on one of a dialog's menu rows is inside the dialog all the same.
|
||||||
|
const FLOATING = "[data-floating-ui-portal]";
|
||||||
|
|
||||||
|
// Restore targets handed to a dialog left standing above one that closed (see the cleanup below):
|
||||||
|
// that dialog's own target was captured while the focus was in the one that closed, and is gone.
|
||||||
|
// Keyed by the standing dialog's aria-modal element; a WeakMap, so a dialog that never closes
|
||||||
|
// through this hook leaves nothing behind.
|
||||||
|
const handedRestore = new WeakMap<Element, HTMLElement>();
|
||||||
|
|
||||||
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
||||||
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
||||||
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
||||||
@@ -128,6 +138,11 @@ export function useDialogChrome(
|
|||||||
// Today that reads the same either way (a detached owner and a null one both fail every
|
// Today that reads the same either way (a detached owner and a null one both fail every
|
||||||
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
|
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
|
||||||
const ownerAtOpen = ownerRef?.current ?? scope;
|
const ownerAtOpen = ownerRef?.current ?? scope;
|
||||||
|
// This dialog's aria-modal element, the key a closing dialog under it hands its target by.
|
||||||
|
const modalAtOpen =
|
||||||
|
ownerAtOpen?.closest('[aria-modal="true"]') ??
|
||||||
|
ownerAtOpen?.querySelector('[aria-modal="true"]') ??
|
||||||
|
null;
|
||||||
if (scope && !scope.contains(document.activeElement)) {
|
if (scope && !scope.contains(document.activeElement)) {
|
||||||
const back = focusedRef.current;
|
const back = focusedRef.current;
|
||||||
const target =
|
const target =
|
||||||
@@ -144,10 +159,12 @@ export function useDialogChrome(
|
|||||||
// it — and while it held the focus we read that as the user walking away. Then both closing
|
// it — and while it held the focus we read that as the user walking away. Then both closing
|
||||||
// together left the focus on <body>: ours skipped the restore, and the child's own
|
// together left the focus on <body>: ours skipped the restore, and the child's own
|
||||||
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
|
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
|
||||||
// and it is the ordinary case — an error dialog raised over a dialog.
|
// and it is the ordinary case — an error dialog raised over a dialog. And one of its own
|
||||||
|
// menus (see FLOATING): Back with a menu open closed the dialog with the focus on a menu row.
|
||||||
heldRef.current =
|
heldRef.current =
|
||||||
(owner?.contains(active) ?? false) ||
|
(owner?.contains(active) ?? false) ||
|
||||||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
|
(active instanceof Element &&
|
||||||
|
(active.closest('[aria-modal="true"]') != null || active.closest(FLOATING) != null));
|
||||||
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
|
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
|
||||||
// gone by then, so restoring onto it would strand the focus exactly as before.
|
// gone by then, so restoring onto it would strand the focus exactly as before.
|
||||||
if (scopeRef.current?.contains(document.activeElement))
|
if (scopeRef.current?.contains(document.activeElement))
|
||||||
@@ -172,16 +189,28 @@ export function useDialogChrome(
|
|||||||
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
|
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
|
||||||
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
||||||
// scrim — where Enter still activated it.
|
// scrim — where Enter still activated it.
|
||||||
|
//
|
||||||
|
// Skipped, the trigger is handed to that standing dialog instead: ITS target was captured
|
||||||
|
// while the focus was in ours, so it is gone now, and it falls back to the one handed to it.
|
||||||
|
// A popover still standing (a menu of a dialog that stays open) is not ours to take from
|
||||||
|
// either, though there is no dialog to hand the trigger to.
|
||||||
const owner = ownerAtOpen;
|
const owner = ownerAtOpen;
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
|
const target = restoreTo?.isConnected
|
||||||
|
? restoreTo
|
||||||
|
: modalAtOpen
|
||||||
|
? handedRestore.get(modalAtOpen)
|
||||||
|
: undefined;
|
||||||
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
||||||
const standingAbove =
|
const standingAbove =
|
||||||
above !== null &&
|
above !== null &&
|
||||||
above.isConnected &&
|
above.isConnected &&
|
||||||
!above.contains(owner as Node | null) &&
|
!above.contains(owner as Node | null) &&
|
||||||
!(owner?.contains(above) ?? false);
|
!(owner?.contains(above) ?? false);
|
||||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
const standingMenu = active instanceof Element && active.closest(FLOATING)?.isConnected;
|
||||||
restoreTo.focus();
|
if (!heldRef.current || !target?.isConnected) return;
|
||||||
|
if (standingAbove) handedRestore.set(above, target);
|
||||||
|
else if (!standingMenu) target.focus();
|
||||||
};
|
};
|
||||||
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||||
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
||||||
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
|
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||||
|
"modules/notifications/NotificationsPanel.tsx": [
|
||||||
|
1,
|
||||||
|
"the Earlier (n) fold's heading (a disclosure: its text is its label)",
|
||||||
|
],
|
||||||
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||||
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const files = readSources(__dirname, "./**/*.{ts,tsx}");
|
const files = readSources(__dirname, "./**/*.{ts,tsx}");
|
||||||
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
|
// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
|
||||||
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
|
// namespace is everything before the first dot.
|
||||||
|
const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g;
|
||||||
|
|
||||||
describe("components/ui/ speaks only platform i18n namespaces", () => {
|
describe("components/ui/ speaks only platform i18n namespaces", () => {
|
||||||
it("names no feature namespace", () => {
|
it("names no feature namespace", () => {
|
||||||
@@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => {
|
|||||||
|
|
||||||
it("sees a feature namespace when one is used", () => {
|
it("sees a feature namespace when one is used", () => {
|
||||||
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||||
|
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
|
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
|
||||||
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||||
|
notify: h.notify,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
import { useCopyFeedback } from "./useCopyFeedback";
|
import { useCopyFeedback } from "./useCopyFeedback";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
|
|||||||
await act(() => result.current.copy("sfl_x"));
|
await act(() => result.current.copy("sfl_x"));
|
||||||
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
||||||
expect(result.current.copied).toBe(true);
|
expect(result.current.copied).toBe(true);
|
||||||
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
|
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
|
||||||
act(() => void vi.advanceTimersByTime(2000));
|
act(() => void vi.advanceTimersByTime(2000));
|
||||||
expect(result.current.copied).toBe(false);
|
expect(result.current.copied).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
|
|||||||
const { result } = renderHook(() => useCopyFeedback());
|
const { result } = renderHook(() => useCopyFeedback());
|
||||||
await act(() => result.current.copy("sfl_x"));
|
await act(() => result.current.copy("sfl_x"));
|
||||||
expect(result.current.copied).toBe(false);
|
expect(result.current.copied).toBe(false);
|
||||||
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
|
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, notify } from "../../lib/notifications";
|
||||||
|
|
||||||
// How long a Copy button reads "Copied" after it worked.
|
// How long a Copy button reads "Copied" after it worked.
|
||||||
const COPIED_MS = 2000;
|
const COPIED_MS = 2000;
|
||||||
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
|
|||||||
*
|
*
|
||||||
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
|
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
|
||||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
|
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
|
||||||
const timer = useRef<number | undefined>(undefined);
|
const timer = useRef<number | undefined>(undefined);
|
||||||
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
|
|||||||
const copy = async (value: string) => {
|
const copy = async (value: string) => {
|
||||||
if (await copyText(value)) {
|
if (await copyText(value)) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
notify({ level: "success", message: t("common.copy.done") });
|
notify({ level: "success", message: msg("common.copy.done") });
|
||||||
window.clearTimeout(timer.current);
|
window.clearTimeout(timer.current);
|
||||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
notify({ level: "warning", message: t("common.copy.failed") });
|
notify({ level: "warning", message: msg("common.copy.failed") });
|
||||||
};
|
};
|
||||||
return { copied, copy };
|
return { copied, copy };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,73 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.114.3",
|
||||||
|
"date": "2026-11-01",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Editing a file that is neither video nor audio now says why it can't be done, instead of a generic error."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.114.2",
|
||||||
|
"date": "2026-10-30",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Security: a library the sign-in relies on is updated, closing two published vulnerabilities."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.114.1",
|
||||||
|
"date": "2026-10-30",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.114.0",
|
||||||
|
"date": "2026-10-29",
|
||||||
|
"new": [
|
||||||
|
"Notifications: what asks for you comes first, then everything else as one stream, newest first. Read notices older than a week fold under Earlier, and Back returns to the row you left."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Plex: a progress save sent just before an episode ends no longer leaves it unwatched, and a refused finish no longer sticks.",
|
||||||
|
"After a dialog closes, the keyboard focus returns to where it was, also inside a menu that stays open."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.113.0",
|
||||||
|
"date": "2026-10-28",
|
||||||
|
"new": [
|
||||||
|
"The address names what you see: tabs, list views, sorts and searches (the live YouTube search too), the YouTube player and the file viewer. A reload, a bookmark or a shared link opens the same view.",
|
||||||
|
"A link to a video reopens the player over its page once; Back closes it. A video the page no longer lists plays on its own and says so."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Back and Forward step through the views as you saw them, across dialogs, players and reloads: a reload under an open player no longer leaves a Back step that does nothing.",
|
||||||
|
"Signed out on a Plex page, the sign-in card could show a button with no text.",
|
||||||
|
"The channel page's address names the channel and its own view, not the page under it.",
|
||||||
|
"A reload during a Plex binge reopens the episode playing now, not the one it started from."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.112.1",
|
||||||
|
"date": "2026-10-27",
|
||||||
|
"new": [],
|
||||||
|
"fixed": ["Security: the sign-in library is updated, closing two published vulnerabilities."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.111.0",
|
||||||
"date": "2026-10-24",
|
"date": "2026-10-24",
|
||||||
|
|||||||
@@ -1,4 +1,75 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.114.3",
|
||||||
|
"date": "2026-11-01",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Ha nem videó- vagy hangfájlt próbálsz szerkeszteni, az alkalmazás most megmondja, miért nem lehet, általános hibaüzenet helyett."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.114.2",
|
||||||
|
"date": "2026-10-30",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Biztonság: frissült egy könyvtár, amelyre a bejelentkezés épül, így két nyilvános sebezhetőség záródott be."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.114.1",
|
||||||
|
"date": "2026-10-30",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.114.0",
|
||||||
|
"date": "2026-10-29",
|
||||||
|
"new": [
|
||||||
|
"Értesítések: elöl az, ami rád vár, utána minden más egy folyamban, a legújabb elöl. Az egy hétnél régebbi olvasott értesítések a Korábbiak alá csukódnak, és a Vissza arra a sorra tér vissza, ahonnan elmentél."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Plex: az epizód vége előtt közvetlenül elküldött mentés már nem hagyja megnézetlenül, és egy elutasított befejezés sem ragad be.",
|
||||||
|
"Párbeszédablak bezárása után a billentyűfókusz oda tér vissza, ahol volt, egy nyitva maradó menüben is."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.113.0",
|
||||||
|
"date": "2026-10-28",
|
||||||
|
"new": [
|
||||||
|
"A cím megnevezi, amit látsz: a füleket, a listanézeteket, a rendezést és a kereséseket (az élő YouTube-keresést is), a YouTube-lejátszót és a fájlnézőt. Újratöltés, könyvjelző vagy megosztott link ugyanazt a nézetet nyitja meg.",
|
||||||
|
"Egy videóra mutató link egyszer újranyitja a lejátszót az oldala fölött, a Vissza bezárja. Ha az oldal már nem listázza a videót, a lejátszó csak azt az egyet játssza le, és ezt jelzi is."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"A Vissza és az Előre úgy lép végig a nézeteken, ahogy láttad őket, párbeszédablakokon, lejátszókon és újratöltésen át: nyitott lejátszó alatti újratöltés után nem marad semmit sem csináló Vissza-lépés.",
|
||||||
|
"Plex-oldalon kijelentkezve a bejelentkező kártyán felirat nélküli gomb jelenhetett meg.",
|
||||||
|
"A csatornaoldal címe a csatornát és annak saját nézetét nevezi meg, nem az alatta lévő oldalt.",
|
||||||
|
"Plexben sorozatnézés közben az újratöltés az épp futó epizódot nyitja meg, nem azt, amelyikkel kezdted."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.112.1",
|
||||||
|
"date": "2026-10-27",
|
||||||
|
"new": [],
|
||||||
|
"fixed": [
|
||||||
|
"Biztonság: frissült a bejelentkezési könyvtár, ami két nyilvánosan ismert sérülékenységet zár be."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.111.0",
|
||||||
"date": "2026-10-24",
|
"date": "2026-10-24",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { api, type Tag } from "../../lib/api";
|
import { api, type Tag } from "../../lib/api";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, type NotifText, notify, raw } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { useScrollFade } from "../../lib/useScrollFade";
|
import { useScrollFade } from "../../lib/useScrollFade";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
@@ -137,22 +137,22 @@ export default function TagManager({
|
|||||||
});
|
});
|
||||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||||
const onFailed = (e: unknown, fallback: string) => {
|
const onFailed = (e: unknown, fallback: NotifText) => {
|
||||||
notifyActionError(e, fallback);
|
notifyActionError(e, fallback);
|
||||||
invalidate();
|
invalidate();
|
||||||
};
|
};
|
||||||
const rename = useMutation({
|
const rename = useMutation({
|
||||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
|
||||||
});
|
});
|
||||||
const del = useMutation({
|
const del = useMutation({
|
||||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||||
onSuccess: (_d, v) => {
|
onSuccess: (_d, v) => {
|
||||||
invalidate();
|
invalidate();
|
||||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) });
|
||||||
},
|
},
|
||||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
|
|||||||
import { plexLang } from "../../i18n";
|
import { plexLang } from "../../i18n";
|
||||||
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
|
|
||||||
@@ -81,9 +81,9 @@ export interface ComfortPin {
|
|||||||
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
|
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
|
||||||
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
|
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
|
||||||
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
|
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
|
||||||
remove: (pinId: number, title: string) => void;
|
remove: (pinId: number, title: NotifText) => void;
|
||||||
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
|
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
|
||||||
item: (target: PinTarget | null, title: string) => ActionMenuItem[];
|
item: (target: PinTarget | null, title: NotifText) => ActionMenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,37 +134,37 @@ export function useComfortPin(open: boolean): ComfortPin {
|
|||||||
}, [qc]);
|
}, [qc]);
|
||||||
|
|
||||||
const add = useCallback(
|
const add = useCallback(
|
||||||
async (target: PinTarget, title: string) => {
|
async (target: PinTarget, title: NotifText) => {
|
||||||
try {
|
try {
|
||||||
await api.addComfortPin(target.kind, target.ref);
|
await api.addComfortPin(target.kind, target.ref);
|
||||||
// The shelf lives on another page, so without this the click has no visible consequence at
|
// The shelf lives on another page, so without this the click has no visible consequence at
|
||||||
// all from the feed — the one place a toast genuinely carries the whole feedback.
|
// all from the feed — the one place a toast genuinely carries the whole feedback.
|
||||||
notify({ level: "success", message: t("home.pin.added", { title }) });
|
notify({ level: "success", message: msg("home.pin.added", { title }) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("home.pin.addFailed"));
|
notifyActionError(e, msg("home.pin.addFailed"));
|
||||||
}
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
[invalidate, t],
|
[invalidate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
(pinId: number, title: string) => {
|
(pinId: number, title: NotifText) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await api.removeComfortPin(pinId);
|
await api.removeComfortPin(pinId);
|
||||||
notify({ level: "success", message: t("home.pin.removed", { title }) });
|
notify({ level: "success", message: msg("home.pin.removed", { title }) });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("home.pin.removeFailed"));
|
notifyActionError(e, msg("home.pin.removeFailed"));
|
||||||
}
|
}
|
||||||
invalidate();
|
invalidate();
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[invalidate, t],
|
[invalidate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
(target: PinTarget, title: string) => {
|
(target: PinTarget, title: NotifText) => {
|
||||||
const pinId = pinIdOf(target);
|
const pinId = pinIdOf(target);
|
||||||
if (pinId == null) void add(target, title);
|
if (pinId == null) void add(target, title);
|
||||||
else remove(pinId, title);
|
else remove(pinId, title);
|
||||||
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const item = useCallback(
|
const item = useCallback(
|
||||||
(target: PinTarget | null, title: string): ActionMenuItem[] => {
|
(target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
|
||||||
if (!target) return [];
|
if (!target) return [];
|
||||||
const pinId = pinIdOf(target);
|
const pinId = pinIdOf(target);
|
||||||
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
|
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "../../lib/debugRecorder";
|
} from "../../lib/debugRecorder";
|
||||||
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
||||||
import { useLayer } from "../../lib/layerStack";
|
import { useLayer } from "../../lib/layerStack";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, notify } from "../../lib/notifications";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { dockUnderToasts } from "../../lib/toastDock";
|
import { dockUnderToasts } from "../../lib/toastDock";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
|
|||||||
if (!id || !until) return;
|
if (!id || !until) return;
|
||||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
|
notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
|
||||||
openComposer(id);
|
openComposer(id);
|
||||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||||
}, wait);
|
}, wait);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
|||||||
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
|
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
|
||||||
import { closeComposer } from "../../lib/debugRecorder";
|
import { closeComposer } from "../../lib/debugRecorder";
|
||||||
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
|
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, notify } from "../../lib/notifications";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import {
|
import {
|
||||||
draftSteps,
|
draftSteps,
|
||||||
@@ -217,7 +217,7 @@ function ComposerBody({
|
|||||||
meta: { failTitle: "recorder.composer.sendFailed" },
|
meta: { failTitle: "recorder.composer.sendFailed" },
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
qc.setQueryData(qk.debugSession(session.id), r.session);
|
qc.setQueryData(qk.debugSession(session.id), r.session);
|
||||||
notify({ level: "success", message: t("recorder.composer.sentOk") });
|
notify({ level: "success", message: msg("recorder.composer.sentOk") });
|
||||||
},
|
},
|
||||||
// A failure half-way still created the issue: show what the server now has.
|
// A failure half-way still created the issue: show what the server now has.
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
} from "../../lib/debugRecorder";
|
} from "../../lib/debugRecorder";
|
||||||
import { flushNow, liveSession } from "../../lib/diagnostics";
|
import { flushNow, liveSession } from "../../lib/diagnostics";
|
||||||
import { log } from "../../lib/log";
|
import { log } from "../../lib/log";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, notify } from "../../lib/notifications";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { updateMeCache, useMe } from "../../lib/useMe";
|
import { updateMeCache, useMe } from "../../lib/useMe";
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
|
|||||||
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
||||||
* answer, so a reload, another tab or the cap all show the same thing. */
|
* answer, so a reload, another tab or the cap all show the same thing. */
|
||||||
export function useDebugRecording() {
|
export function useDebugRecording() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const session = liveSession(me?.debug_session);
|
const session = liveSession(me?.debug_session);
|
||||||
@@ -54,7 +52,7 @@ export function useDebugRecording() {
|
|||||||
meta: { failTitle: "recorder.pill.stopFailed" },
|
meta: { failTitle: "recorder.pill.stopFailed" },
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
setSession(null);
|
setSession(null);
|
||||||
notify({ level: "success", message: t("recorder.pill.stopped") });
|
notify({ level: "success", message: msg("recorder.pill.stopped") });
|
||||||
openComposer(r.session.id);
|
openComposer(r.session.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -78,7 +76,7 @@ export function useDebugRecording() {
|
|||||||
);
|
);
|
||||||
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
// In the evidence too, where the step list is drafted from: which step it belongs to.
|
||||||
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
|
||||||
notify({ level: "success", message: t("recorder.pill.shotSaved") });
|
notify({ level: "success", message: msg("recorder.pill.shotSaved") });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,9 +93,9 @@ export function useDebugRecording() {
|
|||||||
const n = nextStep(sessionId);
|
const n = nextStep(sessionId);
|
||||||
const text = label.trim();
|
const text = label.trim();
|
||||||
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
|
||||||
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
|
notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
|
||||||
},
|
},
|
||||||
[sessionId, t],
|
[sessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
|
||||||
@@ -111,7 +109,7 @@ export function useDebugRecording() {
|
|||||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
image = await captureTab();
|
image = await captureTab();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
notifyActionError(err, msg("recorder.pill.shotFailed"));
|
||||||
}
|
}
|
||||||
hide(false);
|
hide(false);
|
||||||
if (image) shoot.mutate({ id: session.id, image });
|
if (image) shoot.mutate({ id: session.id, image });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
|
|||||||
|
|
||||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||||
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
|
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, notify } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { showInTray } from "../../lib/downloadQueue";
|
import { showInTray } from "../../lib/downloadQueue";
|
||||||
import { profileName } from "../../lib/vocab";
|
import { profileName } from "../../lib/vocab";
|
||||||
@@ -63,15 +63,15 @@ export default function DownloadDialog({
|
|||||||
setLastProfile(chosen);
|
setLastProfile(chosen);
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t("downloads.dialog.added"),
|
message: msg("downloads.dialog.added"),
|
||||||
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A quota or validation refusal (422/409) is already in the global error dialog, which the
|
// A quota or validation refusal (422/409) is already in the global error dialog, which the
|
||||||
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
||||||
// open with nothing said.
|
// open with nothing said.
|
||||||
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import DownloadTray from "./DownloadTray";
|
import DownloadTray from "./DownloadTray";
|
||||||
|
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||||
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
||||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
|
|||||||
poll([job(1, "done")], view);
|
poll([job(1, "done")], view);
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
const notice = h.notify.mock.calls[0]![0] as {
|
const notice = h.notify.mock.calls[0]![0] as {
|
||||||
title: string;
|
title: NotifText;
|
||||||
message: string;
|
message: NotifText;
|
||||||
action: { label: string; onClick: () => void };
|
action: { label: NotifText; onClick: () => void };
|
||||||
secondaryAction: { label: string; onClick: () => void };
|
secondaryAction: { label: NotifText; onClick: () => void };
|
||||||
};
|
};
|
||||||
expect(notice.title).toBe("tray.done.title:Clip 1");
|
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
|
||||||
expect(notice.message).toBe("tray.done.phone");
|
expect(notice.message).toEqual(msg("tray.done.phone"));
|
||||||
expect(notice.action.label).toBe("tray.done.copyLink");
|
expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
|
||||||
|
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
|
||||||
notice.action.onClick();
|
notice.action.onClick();
|
||||||
expect(h.share).toHaveBeenCalled();
|
expect(h.share).toHaveBeenCalled();
|
||||||
notice.secondaryAction.onClick();
|
notice.secondaryAction.onClick();
|
||||||
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
|
|||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||||
poll([job(1, "done"), job(2, "done")], view);
|
poll([job(1, "done"), job(2, "done")], view);
|
||||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
||||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
|
expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
|
||||||
"tray.done.title:Clip 1",
|
msg("tray.done.title", { title: raw("Clip 1") }),
|
||||||
"tray.done.title:Clip 2",
|
msg("tray.done.title", { title: raw("Clip 2") }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
|
|||||||
const view = mount();
|
const view = mount();
|
||||||
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
|
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })),
|
expect(h.notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: msg("tray.done.file") }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,7 +261,9 @@ describe("DownloadTray", () => {
|
|||||||
poll([job(1, "done", { source_key: "upload" })], view);
|
poll([job(1, "done", { source_key: "upload" })], view);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(h.notify).toHaveBeenCalledWith(
|
expect(h.notify).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }),
|
expect.objectContaining({
|
||||||
|
title: msg("tray.done.uploadedTitle", { title: raw("Clip 1") }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user