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 |
@@ -7,6 +7,55 @@ 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
|
## 0.112.0 — 2026-10-25
|
||||||
|
|
||||||
### 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 |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+152
-75
@@ -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__),
|
||||||
)
|
)
|
||||||
shutil.rmtree(s.dir, ignore_errors=True)
|
_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)
|
||||||
|
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,68 +819,79 @@ 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
|
||||||
with _lock:
|
doomed: list[tuple[HlsSession, Path | None]] = []
|
||||||
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
# Finished even when the new session fails to start (no space, no ffmpeg, a DB error): what
|
||||||
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
# was moved aside under the lock is otherwise left for a restart's sweep.
|
||||||
old = _sessions.pop(reg, None)
|
try:
|
||||||
if old is not None:
|
with _lock:
|
||||||
_kill(old)
|
if replace_only is not None and _sessions.get(reg) is not replace_only:
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
return None # superseded/culled while we waited out of lock — don't clobber the newer one
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
old = _sessions.pop(reg, None)
|
||||||
proc = subprocess.Popen(
|
if old is not None:
|
||||||
_ffmpeg_cmd(
|
doomed.append(_detach(old))
|
||||||
src,
|
# An ffmpeg still exiting from THIS path (the old session, or one stopped a moment ago)
|
||||||
start_s,
|
# 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)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
_ffmpeg_cmd(
|
||||||
|
src,
|
||||||
|
start_s,
|
||||||
|
directory,
|
||||||
|
audio_ord,
|
||||||
|
aoff,
|
||||||
|
audio_langs if is_multi else None,
|
||||||
|
transcode=transcode,
|
||||||
|
hw=hw,
|
||||||
|
tonemap=tonemap,
|
||||||
|
preset=preset,
|
||||||
|
crf=crf,
|
||||||
|
max_height=max_height,
|
||||||
|
loudnorm=loudnorm,
|
||||||
|
),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
preexec_fn=preexec,
|
||||||
|
)
|
||||||
|
s = HlsSession(
|
||||||
|
key,
|
||||||
directory,
|
directory,
|
||||||
audio_ord,
|
proc,
|
||||||
aoff,
|
start_s,
|
||||||
audio_langs if is_multi else None,
|
variants,
|
||||||
transcode=transcode,
|
transcode=transcode,
|
||||||
hw=hw,
|
hw=hw,
|
||||||
tonemap=tonemap,
|
tonemap=tonemap,
|
||||||
preset=preset,
|
user_id=user_id,
|
||||||
crf=crf,
|
)
|
||||||
max_height=max_height,
|
_sessions[reg] = s
|
||||||
loudnorm=loudnorm,
|
# The one line that says which pipeline a session actually got. Everything downstream
|
||||||
),
|
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
|
||||||
stdout=subprocess.DEVNULL,
|
# now it was only inferable from the ffmpeg command nobody keeps.
|
||||||
stderr=subprocess.DEVNULL,
|
log.info(
|
||||||
preexec_fn=preexec,
|
"an HLS session started",
|
||||||
)
|
extra=logs.event(
|
||||||
s = HlsSession(
|
"plex.stream.session_started",
|
||||||
key,
|
key=key,
|
||||||
directory,
|
transcode=transcode,
|
||||||
proc,
|
hw=hw,
|
||||||
start_s,
|
tonemap=tonemap,
|
||||||
variants,
|
variants=len(variants or ()),
|
||||||
transcode=transcode,
|
start_s=round(start_s, 1),
|
||||||
hw=hw,
|
),
|
||||||
tonemap=tonemap,
|
)
|
||||||
user_id=user_id,
|
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
||||||
)
|
# capped to zero.
|
||||||
_sessions[reg] = s
|
doomed += _enforce_caps(
|
||||||
# The one line that says which pipeline a session actually got. Everything downstream
|
user_id,
|
||||||
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
|
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
||||||
# now it was only inferable from the ffmpeg command nobody keeps.
|
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
||||||
log.info(
|
)
|
||||||
"an HLS session started",
|
finally:
|
||||||
extra=logs.event(
|
_finish(doomed)
|
||||||
"plex.stream.session_started",
|
|
||||||
key=key,
|
|
||||||
transcode=transcode,
|
|
||||||
hw=hw,
|
|
||||||
tonemap=tonemap,
|
|
||||||
variants=len(variants or ()),
|
|
||||||
start_s=round(start_s, 1),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
|
|
||||||
# capped to zero.
|
|
||||||
_enforce_caps(
|
|
||||||
user_id,
|
|
||||||
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
|
|
||||||
max(1, sysconfig.get_int(db, "plex_max_sessions")),
|
|
||||||
)
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -443,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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) == []
|
||||||
|
|||||||
@@ -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,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"
|
||||||
+6
-1
@@ -378,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
|
||||||
@@ -824,3 +828,4 @@ of a full-width panel and nothing on the right.
|
|||||||
| 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 | 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 | 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-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();
|
||||||
|
|||||||
+32
-14
@@ -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 });
|
||||||
const count = await links.count();
|
let index = -1;
|
||||||
for (let i = 0; i < count; i++) {
|
const findHittable = async () => {
|
||||||
const hittable = await links.nth(i).evaluate((el) => {
|
const count = await links.count();
|
||||||
const r = el.getBoundingClientRect();
|
for (let i = 0; i < count; i++) {
|
||||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
const hittable = await links
|
||||||
return hit !== null && el.contains(hit);
|
.nth(i)
|
||||||
});
|
.evaluate(
|
||||||
if (hittable) {
|
(el) => {
|
||||||
await links.nth(i).click();
|
const r = el.getBoundingClientRect();
|
||||||
return true;
|
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||||
|
return hit !== null && el.contains(hit);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 1000 },
|
||||||
|
)
|
||||||
|
.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")>()),
|
||||||
@@ -35,6 +35,7 @@ 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 { qk } from "./lib/queryKeys";
|
||||||
|
import type { NotifText } from "./lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -98,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();
|
||||||
});
|
});
|
||||||
@@ -119,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
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +155,7 @@ 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));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -300,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")]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,59 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.112.0",
|
||||||
"date": "2026-10-25",
|
"date": "2026-10-25",
|
||||||
|
|||||||
@@ -1,4 +1,61 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.112.0",
|
||||||
"date": "2026-10-25",
|
"date": "2026-10-25",
|
||||||
|
|||||||
@@ -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") }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
} from "../../lib/downloadQueue";
|
} from "../../lib/downloadQueue";
|
||||||
import { anyBlockingLayer } from "../../lib/layerStack";
|
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||||
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
||||||
import { notify } from "../../lib/notifications";
|
import { msg, notify, raw } from "../../lib/notifications";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { LS } from "../../lib/storage";
|
import { LS } from "../../lib/storage";
|
||||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||||
@@ -191,16 +191,16 @@ export default function DownloadTray() {
|
|||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
// An upload the worker had to convert first is done here too — it was never downloaded.
|
// An upload the worker had to convert first is done here too — it was never downloaded.
|
||||||
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
|
title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
|
||||||
title,
|
title: raw(title),
|
||||||
}),
|
}),
|
||||||
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
||||||
action: {
|
action: {
|
||||||
label: t("tray.done.copyLink"),
|
label: msg("tray.done.copyLink"),
|
||||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||||
},
|
},
|
||||||
secondaryAction: {
|
secondaryAction: {
|
||||||
label: t("tray.done.open"),
|
label: msg("tray.done.open"),
|
||||||
onClick: () => openFile(job.id, job.folder_id),
|
onClick: () => openFile(job.id, job.folder_id),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -298,7 +298,7 @@ export default function DownloadTray() {
|
|||||||
id: "copyLink",
|
id: "copyLink",
|
||||||
label: t("tray.done.copyLink"),
|
label: t("tray.done.copyLink"),
|
||||||
icon: <Link className="w-4 h-4" />,
|
icon: <Link className="w-4 h-4" />,
|
||||||
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const menuItems = menu.target
|
const menuItems = menu.target
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
|||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { menuGroups } from "../../components/ui/menuGroups";
|
import { menuGroups } from "../../components/ui/menuGroups";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
||||||
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
||||||
@@ -56,7 +57,7 @@ export function useDownloadActions() {
|
|||||||
? api.resumeDownload(id)
|
? api.resumeDownload(id)
|
||||||
: api.cancelDownload(id),
|
: api.cancelDownload(id),
|
||||||
onSuccess: () => invalidateDownloads(qc),
|
onSuccess: () => invalidateDownloads(qc),
|
||||||
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
|
onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
|||||||
import { ownKey } from "../../lib/storage";
|
import { ownKey } from "../../lib/storage";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
|
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
|
||||||
|
import { msg, type NotifText } from "../../lib/notifications";
|
||||||
|
|
||||||
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
|
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
|
||||||
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
|
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
|
||||||
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
|||||||
const isDirty = displayView != null && activeView == null;
|
const isDirty = displayView != null && activeView == null;
|
||||||
|
|
||||||
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
|
||||||
let ok = true;
|
let ok = true;
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
|||||||
if (!clean) return false;
|
if (!clean) return false;
|
||||||
return runViewMutation(
|
return runViewMutation(
|
||||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||||
t("views.actionFailed"),
|
msg("views.actionFailed"),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
rename: async (id, name) => {
|
rename: async (id, name) => {
|
||||||
const next = name.trim();
|
const next = name.trim();
|
||||||
if (!next) return false;
|
if (!next) return false;
|
||||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
|
||||||
},
|
},
|
||||||
updateFilters: (id, filters) =>
|
updateFilters: (id, filters) =>
|
||||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
|
||||||
saveCurrent: async (id) => {
|
saveCurrent: async (id) => {
|
||||||
const filters = cfg.stripForSave(cfg.filters);
|
const filters = cfg.stripForSave(cfg.filters);
|
||||||
const ok = await runViewMutation(
|
const ok = await runViewMutation(
|
||||||
() => api.updateView(id, { filters }),
|
() => api.updateView(id, { filters }),
|
||||||
t("views.actionFailed"),
|
msg("views.actionFailed"),
|
||||||
);
|
);
|
||||||
return ok ? filters : null;
|
return ok ? filters : null;
|
||||||
},
|
},
|
||||||
toggleDefault: (v) =>
|
toggleDefault: (v) =>
|
||||||
runViewMutation(
|
runViewMutation(
|
||||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||||
t("views.actionFailed"),
|
msg("views.actionFailed"),
|
||||||
).then(() => undefined),
|
).then(() => undefined),
|
||||||
remove: async (v) => {
|
remove: async (v) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
|||||||
});
|
});
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
if (stickyId === v.id) setStickyId(null);
|
if (stickyId === v.id) setStickyId(null);
|
||||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
|
||||||
},
|
},
|
||||||
reorder: async (nextIds) => {
|
reorder: async (nextIds) => {
|
||||||
qc.setQueryData<SavedView[]>(
|
qc.setQueryData<SavedView[]>(
|
||||||
qk.savedViews(source),
|
qk.savedViews(source),
|
||||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||||
);
|
);
|
||||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
import { NavigationProvider } from "./NavigationProvider";
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
|
import { urlPage } from "./urlPage";
|
||||||
|
import { channelUrl } from "../../lib/urlState";
|
||||||
import { useBackToClose } from "../../lib/history";
|
import { useBackToClose } from "../../lib/history";
|
||||||
import { PageHeading } from "../../components/ui/Headings";
|
import { PageHeading } from "../../components/ui/Headings";
|
||||||
|
|
||||||
@@ -167,6 +169,19 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
|||||||
expect(window.location.search).toBe("?page=plex&keep=1");
|
expect(window.location.search).toBe("?page=plex&keep=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => {
|
||||||
|
// Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key
|
||||||
|
// missing from a hand-kept list rode along onto the next page's entry.
|
||||||
|
window.history.replaceState(
|
||||||
|
{ sfPage: "files" },
|
||||||
|
"",
|
||||||
|
"/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1",
|
||||||
|
);
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.setPage("plex"));
|
||||||
|
expect(window.location.search).toBe("?page=plex&keep=1");
|
||||||
|
});
|
||||||
|
|
||||||
it("pushes nothing when it is already that page", () => {
|
it("pushes nothing when it is already that page", () => {
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
act(() => result.current.setPage("plex"));
|
act(() => result.current.setPage("plex"));
|
||||||
@@ -195,6 +210,93 @@ describe("NavigationProvider · setPage's same-page guard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("NavigationProvider · a link carrying another page's keys", () => {
|
||||||
|
it("opens with only its own page's keys: what the page does not show, its address does not say", () => {
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
"/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1",
|
||||||
|
);
|
||||||
|
render();
|
||||||
|
expect(window.location.search).toBe("?page=activity&at=logs&keep=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the channel page open over any page", () => {
|
||||||
|
window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all");
|
||||||
|
const { result } = render();
|
||||||
|
expect(window.location.search).toBe("?page=stats&chan=UC1");
|
||||||
|
expect(result.current.channelView?.id).toBe("UC1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a channel link with only the channel page's keys, even over the page they belong to", () => {
|
||||||
|
window.history.replaceState({}, "", "/?q=music&sort=oldest&chan=UC1&chtab=about&chtypes=ns");
|
||||||
|
render();
|
||||||
|
expect(window.location.search).toBe("?chan=UC1&chtab=about&chtypes=ns&page=feed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The channel page has an address of its own (the owner, 2026-10-05): it used to take the feed's
|
||||||
|
// filters along (`?q=music&sort=oldest&chan=…`), naming a sort the channel did not show.
|
||||||
|
describe("NavigationProvider · the channel page's address", () => {
|
||||||
|
it("names only the channel; the page under it keeps its own address for Back", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?q=music&sort=oldest");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openChannel("UC1", "One"));
|
||||||
|
expect(window.location.search).toBe("?page=feed&chan=UC1");
|
||||||
|
act(() => window.history.back());
|
||||||
|
await vi.waitFor(() => expect(result.current.channelView).toBeNull());
|
||||||
|
expect(window.location.search).toBe("?q=music&sort=oldest&page=feed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says no page owns the address while the channel stands over it", () => {
|
||||||
|
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.page).toBe("plex");
|
||||||
|
expect(urlPage(result.current)).toBe("plex");
|
||||||
|
act(() => result.current.openChannel("UC1", "One"));
|
||||||
|
expect(urlPage(result.current)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("another channel opened from it starts fresh", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openChannel("UC1", "One"));
|
||||||
|
window.history.replaceState(window.history.state, "", "/?chan=UC1&chtab=about&chsort=oldest");
|
||||||
|
act(() => result.current.openChannel("UC2", "Two"));
|
||||||
|
expect(window.location.search).toBe("?chan=UC2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a page switch takes the channel page's keys with it", () => {
|
||||||
|
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chsort=oldest&chtypes=n");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.setPage("stats"));
|
||||||
|
expect(window.location.search).toBe("?page=stats");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing it in place takes its keys with it", () => {
|
||||||
|
window.history.replaceState({}, "", "/?chan=UC1&chtab=about&chtypes=n");
|
||||||
|
const { result } = render();
|
||||||
|
stubNavigation([false]);
|
||||||
|
act(() => result.current.closeChannel());
|
||||||
|
expect(window.location.search).toBe("?page=feed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The YouTube player opens over any page and names itself in its own entry (lib/playerUrlState).
|
||||||
|
describe("NavigationProvider · the player's address", () => {
|
||||||
|
it("a page switch takes a player the address still names with it", () => {
|
||||||
|
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.setPage("stats"));
|
||||||
|
expect(window.location.search).toBe("?page=stats");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a channel link names no player: opening the channel closes it", () => {
|
||||||
|
window.history.replaceState({}, "", "/?q=music&watch=v1&wrel=v2&wtab=related");
|
||||||
|
render();
|
||||||
|
expect(channelUrl("UC1")).toBe("/?page=feed&chan=UC1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("NavigationProvider · Back", () => {
|
describe("NavigationProvider · Back", () => {
|
||||||
// A tab that had a since-removed module open, reloaded onto the new build: its older history
|
// A tab that had a since-removed module open, reloaded onto the new build: its older history
|
||||||
// entries still name that page, and Back lands on one of them.
|
// entries still name that page, and Back lands on one of them.
|
||||||
@@ -208,7 +310,7 @@ describe("NavigationProvider · Back", () => {
|
|||||||
expect(result.current.page).toBe("feed");
|
expect(result.current.page).toBe("feed");
|
||||||
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
|
// …and the entry says so too, so the address bar and a later Feed click agree with the screen.
|
||||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||||
expect(window.location.search).toBe("?keep=1");
|
expect(window.location.search).toBe("?page=feed&keep=1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lands where a retired page's content went, and the entry names that page", () => {
|
it("lands where a retired page's content went, and the entry names that page", () => {
|
||||||
@@ -325,7 +427,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
|||||||
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
|
window.history.replaceState(null, "", "/?page=scheduler&keep=1");
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
expect(result.current.page).toBe("feed");
|
expect(result.current.page).toBe("feed");
|
||||||
expect(window.location.search).toBe("?keep=1");
|
expect(window.location.search).toBe("?page=feed&keep=1");
|
||||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,7 +463,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
|||||||
qc.setQueryData(qk.me(), member);
|
qc.setQueryData(qk.me(), member);
|
||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
expect(window.location.search).toBe("");
|
expect(window.location.search).toBe("?page=feed");
|
||||||
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
expect(window.history.state).toMatchObject({ sfPage: "feed" });
|
||||||
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
|
expect(getAccountRaw(PAGE_KEY)).toBe("feed");
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -381,7 +483,7 @@ describe("NavigationProvider · a page the account cannot open", () => {
|
|||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
expect(result.current.channelView).toBeNull();
|
expect(result.current.channelView).toBeNull();
|
||||||
expect(window.location.search).toBe("");
|
expect(window.location.search).toBe("?page=feed");
|
||||||
expect(window.history.state?._chan).toBeUndefined();
|
expect(window.history.state?._chan).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A reload under a dialog that does not reopen (About) loads on the dialog's entry, its overlay mark
|
||||||
|
// still on it. A view rewritten in place there (the search refined) is another view: the next overlay
|
||||||
|
// pushes its own entry over it rather than taking this one over — taken over, closing it stepped back
|
||||||
|
// onto the entry below, and the screen went back to the search before the refine. Its own file: the
|
||||||
|
// entry is read as the history module loads, so it is set up before the import.
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("a view rewritten in place on the entry a reload under a dialog started from", () => {
|
||||||
|
it("is where an overlay over it closes to", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed", _yt: "dogs" }, "", "/?page=feed&yt=dogs");
|
||||||
|
window.history.pushState(
|
||||||
|
{ sfPage: "feed", _yt: "dogs", _ov: 1, _ovd: 1 },
|
||||||
|
"",
|
||||||
|
"/?page=feed&yt=dogs",
|
||||||
|
);
|
||||||
|
const { NavigationProvider } = await import("./NavigationProvider");
|
||||||
|
const { useNavigation, useNavigationActions } = await import("./navigationContext");
|
||||||
|
const { useBackToClose } = await import("../../lib/history");
|
||||||
|
const { qk } = await import("../../lib/queryKeys");
|
||||||
|
const { claimLocalData } = await import("../../lib/storage");
|
||||||
|
const qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, role: "admin" });
|
||||||
|
claimLocalData(1);
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<NavigationProvider>{children}</NavigationProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const nav = renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||||
|
act(() => nav.result.current.enterYtSearch("cats"));
|
||||||
|
|
||||||
|
const depth = window.history.length;
|
||||||
|
const overlay = renderHook(() => useBackToClose(() => {}));
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(window.history.length).toBe(depth + 1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const landed = new Promise((res) => window.addEventListener("popstate", res, { once: true }));
|
||||||
|
overlay.unmount();
|
||||||
|
await landed;
|
||||||
|
});
|
||||||
|
expect(new URLSearchParams(window.location.search).get("yt")).toBe("cats");
|
||||||
|
expect(nav.result.current.ytSearch).toBe("cats");
|
||||||
|
}, 30_000); // the provider's whole import graph loads inside the case (see the header)
|
||||||
|
});
|
||||||
@@ -2,8 +2,13 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react
|
|||||||
import { useLatest } from "../../lib/useLatest";
|
import { useLatest } from "../../lib/useLatest";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
|
import {
|
||||||
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
channelUrl,
|
||||||
|
resolvePage,
|
||||||
|
retiredParams,
|
||||||
|
urlWithParams,
|
||||||
|
type Page,
|
||||||
|
} from "../../lib/urlState";
|
||||||
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
||||||
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +17,17 @@ import {
|
|||||||
nextNavId,
|
nextNavId,
|
||||||
notifyNav,
|
notifyNav,
|
||||||
rememberNavPosition,
|
rememberNavPosition,
|
||||||
|
replaceView,
|
||||||
} from "../../lib/history";
|
} from "../../lib/history";
|
||||||
import { pageTitleKey, reachablePage } from "./moduleRegistry";
|
import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||||
|
import { CHANNEL_PAGE_KEYS } from "../../lib/channelPageUrlState";
|
||||||
|
import { PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||||
|
import {
|
||||||
|
DEFAULT_YT_COUNT,
|
||||||
|
ytCountFrom,
|
||||||
|
ytSearchFrom,
|
||||||
|
ytSearchParams,
|
||||||
|
} from "../../lib/ytSearchUrlState";
|
||||||
import {
|
import {
|
||||||
channelViewFrom,
|
channelViewFrom,
|
||||||
loadInitialPage,
|
loadInitialPage,
|
||||||
@@ -37,24 +51,35 @@ import {
|
|||||||
//
|
//
|
||||||
// The contexts, their value types and the hooks live in navigationContext.ts.
|
// The contexts, their value types and the hooks live in navigationContext.ts.
|
||||||
|
|
||||||
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */
|
/** The address bar of `page`'s root: the page — the feed too: a bare address is the landing (Home, or
|
||||||
|
* the remembered page), so a feed left unnamed reloaded or bookmarked as another page — no channel or
|
||||||
|
* player over it, and every module's own keys cleared — a page switch resets to that module's root,
|
||||||
|
* and its store mirrors what it holds back in. */
|
||||||
function pageRootParams(page: Page): Record<string, string | null> {
|
function pageRootParams(page: Page): Record<string, string | null> {
|
||||||
return {
|
return {
|
||||||
page: page === "feed" ? null : page,
|
page,
|
||||||
chan: null,
|
...closedChannelParams(),
|
||||||
v: null,
|
...nulls(PLAYER_KEYS),
|
||||||
pl: null,
|
...nulls(moduleUrlKeys()),
|
||||||
pscope: null,
|
|
||||||
psort: null,
|
|
||||||
pshow: null,
|
|
||||||
pf: null,
|
|
||||||
ctab: null,
|
|
||||||
csort: null,
|
|
||||||
cf: null,
|
|
||||||
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The address of the page under a live search, the search ended: no search, player or channel. */
|
||||||
|
const searchEndParams = (): Record<string, string | null> => ({
|
||||||
|
...closedChannelParams(),
|
||||||
|
...nulls(PLAYER_KEYS),
|
||||||
|
...ytSearchParams(null, DEFAULT_YT_COUNT),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nulls = (keys: readonly string[]) => Object.fromEntries(keys.map((k) => [k, null]));
|
||||||
|
|
||||||
|
/** No channel page: neither `chan` nor its view's keys, which the page itself writes (ChannelPage) —
|
||||||
|
* a closed channel leaves none behind. (An opened one gets its address from `channelUrl`.) */
|
||||||
|
const closedChannelParams = (): Record<string, string | null> => ({
|
||||||
|
chan: null,
|
||||||
|
...nulls(CHANNEL_PAGE_KEYS),
|
||||||
|
});
|
||||||
|
|
||||||
export function NavigationProvider({ children }: { children: ReactNode }) {
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
|
// Held here as well as in Settings: this is the instance mounted on every boot, so a value only
|
||||||
@@ -66,18 +91,30 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// and the address bar (stamped from it below) agree.
|
// and the address bar (stamped from it below) agree.
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
const page = reachablePage(chosenPage, me);
|
const page = reachablePage(chosenPage, me);
|
||||||
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
|
// The live YouTube search: named in the address (`yt`, and `ytn` for its count, lib/ytSearchUrlState)
|
||||||
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
|
// so a reload or a link opens it again; its own history entry, so Back steps player → search → feed.
|
||||||
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
|
// Whether it RUNS by itself is not the address's to say: `ytAsked` is the term this document was
|
||||||
// Back steps player → search → feed instead of the search vanishing without a history entry.
|
// asked to search for (sent from the search box, or "Search YouTube" pressed), on the entries
|
||||||
const [ytSearch, setYtSearch] = useState<string | null>(() => {
|
// it has stayed on since — leaving the search ends it. Anything else — a reload, a link, a Back or
|
||||||
const st = window.history.state;
|
// a Forward onto a search — runs only when it costs no quota (Feed decides).
|
||||||
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
const [ytSearch, setYtSearch] = useState<string | null>(() =>
|
||||||
});
|
page === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null,
|
||||||
|
);
|
||||||
|
const [ytCount, setYtCountState] = useState(() =>
|
||||||
|
ytCountFrom(new URLSearchParams(window.location.search)),
|
||||||
|
);
|
||||||
|
const [ytAsked, setYtAsked] = useState<string | null>(null);
|
||||||
|
const ytSearchRef = useLatest(ytSearch);
|
||||||
|
const ytCountRef = useLatest(ytCount);
|
||||||
|
// The search ends: off the screen, and no longer asked for.
|
||||||
|
const endYtSearch = useCallback(() => {
|
||||||
|
setYtSearch(null);
|
||||||
|
setYtAsked(null);
|
||||||
|
}, []);
|
||||||
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
||||||
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
||||||
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
|
// it, and a bookmarked ?chan link opens it. Reload of an in-app entry reads history.state._chan; a
|
||||||
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
// fresh bookmark has only the ?chan param.
|
||||||
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
|
||||||
channelViewFrom(window.history.state, window.location.search),
|
channelViewFrom(window.history.state, window.location.search),
|
||||||
);
|
);
|
||||||
@@ -107,31 +144,36 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
closeOverlaysThenNavigate(() => {
|
closeOverlaysThenNavigate(() => {
|
||||||
// A channel page overlays the content column, so a nav click must close it even when the
|
// A channel page overlays the content column, so a nav click must close it even when the
|
||||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||||
if (next === pageRef.current && !channelViewRef.current) {
|
// So must a live search on it, though that press ends only the search: the page's view stays.
|
||||||
|
const samePage = next === pageRef.current && !channelViewRef.current;
|
||||||
|
if (samePage && ytSearchRef.current == null) {
|
||||||
then?.();
|
then?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||||
|
// A live search under that channel ends too: the page opens at its root, which the address
|
||||||
|
// below names — the Feed press over a channel opened from a search included.
|
||||||
|
endYtSearch();
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
setAccountRaw(PAGE_KEY, next);
|
setAccountRaw(PAGE_KEY, next);
|
||||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||||
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
||||||
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
// root, and the address bar reflects the page (the feed too) while clearing `chan` and
|
||||||
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
// every module's own keys (pageRootParams) — a page switch resets to that module's root, and
|
||||||
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
|
// each module's store mirrors its state back in on its page. The item it was opened from is
|
||||||
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
|
// still focused: the state setters above have not re-rendered anything yet.
|
||||||
// their page. The item it was opened from is still focused: the state setters above have not
|
// The press on a search over its own page ends only the search: the page's view is the one
|
||||||
// re-rendered anything yet.
|
// on screen and stays named — its store, unchanged, writes nothing back in.
|
||||||
rememberNavPosition();
|
rememberNavPosition();
|
||||||
window.history.pushState(
|
window.history.pushState(
|
||||||
{ sfPage: next, sfScroll: nextNavId() },
|
{ sfPage: next, sfScroll: nextNavId() },
|
||||||
"",
|
"",
|
||||||
urlWithParams(pageRootParams(next)),
|
urlWithParams(samePage ? searchEndParams() : pageRootParams(next)),
|
||||||
);
|
);
|
||||||
notifyNav();
|
notifyNav();
|
||||||
then?.();
|
then?.();
|
||||||
}),
|
}),
|
||||||
[pageRef, channelViewRef],
|
[pageRef, channelViewRef, ytSearchRef, endYtSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
||||||
@@ -139,10 +181,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// inside one.
|
// inside one.
|
||||||
const openChannel = useCallback((id: string, name?: string) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
closeOverlaysThenNavigate(() => {
|
closeOverlaysThenNavigate(() => {
|
||||||
const url = urlWithParams({ chan: id });
|
// The channel's address names the channel alone: the page under it keeps its own keys on its
|
||||||
|
// own entry, where Back finds them, and writes none while the channel stands over it (urlPage).
|
||||||
|
const url = channelUrl(id);
|
||||||
if (window.history.state?._chan) {
|
if (window.history.state?._chan) {
|
||||||
const st = window.history.state;
|
const st = window.history.state;
|
||||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
replaceView({ ...st, _chan: id, _chanName: name ?? null }, url);
|
||||||
} else {
|
} else {
|
||||||
const st = rememberNavPosition();
|
const st = rememberNavPosition();
|
||||||
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||||
@@ -157,36 +201,67 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
|
||||||
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
|
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
|
||||||
// entry's channel marks and the ?chan param.
|
// entry's channel marks and the ?chan param.
|
||||||
|
// A live search under it (the channel opened from its results) is on screen again, and named
|
||||||
|
// again: the channel's address carried none of it.
|
||||||
setChannelView(null);
|
setChannelView(null);
|
||||||
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
|
||||||
window.history.replaceState(st, "", urlWithParams({ chan: null }));
|
replaceView(
|
||||||
|
st,
|
||||||
|
urlWithParams({
|
||||||
|
...closedChannelParams(),
|
||||||
|
...ytSearchParams(ytSearchRef.current, ytCountRef.current),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ytSearchRef, ytCountRef]);
|
||||||
|
|
||||||
const enterYtSearch = useCallback((q: string) => {
|
const enterYtSearch = useCallback((q: string) => {
|
||||||
setYtSearch(q);
|
setYtSearch(q);
|
||||||
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
setYtAsked(q);
|
||||||
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
const st = window.history.state ?? {};
|
||||||
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
const here = new URLSearchParams(window.location.search);
|
||||||
if (st._yt)
|
if (st._yt || ytSearchFrom(here) != null) {
|
||||||
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
// Refine the search on screen, in place, at the count it shows (one a link opened too: it
|
||||||
else {
|
// is the first entry of this document, so its new `_yt` mark has nothing of ours below it).
|
||||||
// New sub-view entry.
|
replaceView({ ...st, _yt: q }, urlWithParams(ytSearchParams(q, ytCountFrom(here))));
|
||||||
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
|
} else {
|
||||||
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
|
// New sub-view entry, at the default count — ours (`_ytOwn`): leaving it steps back off it.
|
||||||
|
setYtCountState(DEFAULT_YT_COUNT);
|
||||||
|
const left = rememberNavPosition();
|
||||||
|
window.history.pushState(
|
||||||
|
{ ...left, sfPage: "feed", _yt: q, _ytOwn: 1 },
|
||||||
|
"",
|
||||||
|
urlWithParams(ytSearchParams(q, DEFAULT_YT_COUNT)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The press that runs the search on screen, where it did not run by itself.
|
||||||
|
const askYtSearch = useCallback(() => setYtAsked(ytSearchRef.current), [ytSearchRef]);
|
||||||
|
|
||||||
|
const setYtCount = useCallback(
|
||||||
|
(n: number) => {
|
||||||
|
setYtCountState(n);
|
||||||
|
replaceView(
|
||||||
|
window.history.state ?? {},
|
||||||
|
urlWithParams(ytSearchParams(ytSearchRef.current, n)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[ytSearchRef],
|
||||||
|
);
|
||||||
|
|
||||||
const exitYtSearch = useCallback(() => {
|
const exitYtSearch = useCallback(() => {
|
||||||
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
|
||||||
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
|
// one — a search a link or a reload opened is marked (`_yt`) but was pushed by nobody: below it is
|
||||||
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
|
// another site, or nothing — or there is no entry of this document below it (a full tab, see
|
||||||
|
// backStaysInDocument).
|
||||||
|
if (window.history.state?._ytOwn && backStaysInDocument()) window.history.back();
|
||||||
else {
|
else {
|
||||||
setYtSearch(null);
|
endYtSearch();
|
||||||
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
|
const { _yt: _q, _ytOwn: _own, ...st } = window.history.state ?? {};
|
||||||
window.history.replaceState(st, "");
|
replaceView(st, urlWithParams(ytSearchParams(null, DEFAULT_YT_COUNT)));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [endYtSearch]);
|
||||||
|
|
||||||
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
|
||||||
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
// DURING the render that changes the page, against the page we were last on: as an effect it ran
|
||||||
@@ -196,7 +271,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
|
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
|
||||||
if (pageSearchWasOn !== page) {
|
if (pageSearchWasOn !== page) {
|
||||||
setPageSearchWasOn(page);
|
setPageSearchWasOn(page);
|
||||||
if (page !== "feed") setYtSearch(null);
|
if (page !== "feed") endYtSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
||||||
@@ -233,25 +308,33 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||||
// the popstate handler only needs to read history.state.
|
// the popstate handler only needs to read history.state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
// The entry of a search the address names carries its `_yt` mark, whichever way it opened (sent
|
||||||
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
// from the box, a reload, a link): a channel opened from its results reads the search under it
|
||||||
// are always kept so a channel page survives F5.
|
// from that mark. A mark left without one (an older build's entry) goes. _chan/_chanName are
|
||||||
|
// always kept so a channel page survives F5.
|
||||||
const st = window.history.state || {};
|
const st = window.history.state || {};
|
||||||
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
// Mirror the resolved initial page to the address bar (the feed too) so a page reached from the
|
||||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams keeps the
|
||||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
// shell's keys (?chan, a player's) and the page's own; other pages' go (below).
|
||||||
// A retired page's link — or the remembered last page, when that is what opened — also opens its
|
// A retired page's link — or the remembered last page, when that is what opened — also opens its
|
||||||
// part of the page that replaced it (an `at` tab).
|
// part of the page that replaced it (an `at` tab).
|
||||||
const linked = new URLSearchParams(window.location.search).get("page");
|
const linked = new URLSearchParams(window.location.search).get("page");
|
||||||
const remembered = getAccountRaw(PAGE_KEY);
|
const remembered = getAccountRaw(PAGE_KEY);
|
||||||
|
// A link can carry another page's keys (hand-edited, or written by an older build): the page
|
||||||
|
// opens without them, so its address names only what it shows — and a channel page open over it
|
||||||
|
// shows none of the page's own (an older build's channel links carried the feed's filters).
|
||||||
|
const shown = channelViewFrom(st, window.location.search);
|
||||||
const url = urlWithParams({
|
const url = urlWithParams({
|
||||||
|
...nulls(shown ? moduleUrlKeys() : foreignUrlKeys(mountPage)),
|
||||||
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
|
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
|
||||||
page: mountPage === "feed" ? null : mountPage,
|
page: mountPage,
|
||||||
});
|
});
|
||||||
if (st._yt && st._ytScrape) {
|
const named =
|
||||||
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
|
mountPage === "feed" ? ytSearchFrom(new URLSearchParams(window.location.search)) : null;
|
||||||
|
if (named != null) {
|
||||||
|
window.history.replaceState({ ...st, _yt: named, sfPage: mountPage }, "", url);
|
||||||
} else {
|
} else {
|
||||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
const { _yt: _staleYt, _ytScrape: _staleScrape, _ytOwn: _staleOwn, ...rest } = st;
|
||||||
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
|
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
|
||||||
}
|
}
|
||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
@@ -267,13 +350,22 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{ ...window.history.state, sfPage: p },
|
{ ...window.history.state, sfPage: p },
|
||||||
"",
|
"",
|
||||||
urlWithParams({ ...retiredParams(sf), page: p === "feed" ? null : p }),
|
urlWithParams({ ...retiredParams(sf), page: p }),
|
||||||
);
|
);
|
||||||
setPageState(p);
|
setPageState(p);
|
||||||
setAccountRaw(PAGE_KEY, p);
|
setAccountRaw(PAGE_KEY, p);
|
||||||
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
// The YouTube search is read from the address the browser restored, so Back/Forward opens or
|
||||||
// restores or clears them to match the entry we landed on.
|
// clears it to match the entry we landed on. One landed on without it has ended: a Forward back
|
||||||
setYtSearch((e.state?._yt as string) ?? null);
|
// onto it is not asked for, and an API search waits for a press.
|
||||||
|
// A channel page opened from its results stands over it: its entry carries the search's mark
|
||||||
|
// (its address does not), and the search stays under it — closing a player over that channel
|
||||||
|
// lands there, and is no leaving of the search.
|
||||||
|
const here = new URLSearchParams(window.location.search);
|
||||||
|
const under = e.state?._chan && typeof e.state._yt === "string" ? e.state._yt : null;
|
||||||
|
const landed = p === "feed" ? (ytSearchFrom(here) ?? under) : null;
|
||||||
|
setYtSearch(landed);
|
||||||
|
if (landed == null) setYtAsked(null);
|
||||||
|
setYtCountState(ytCountFrom(here));
|
||||||
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
|
||||||
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
|
||||||
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
|
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
|
||||||
@@ -285,12 +377,26 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [mountPage]);
|
}, [mountPage]);
|
||||||
|
|
||||||
const state = useMemo<NavigationState>(
|
const state = useMemo<NavigationState>(
|
||||||
() => ({ page, channelView, ytSearch }),
|
() => ({
|
||||||
[page, channelView, ytSearch],
|
page,
|
||||||
|
channelView,
|
||||||
|
ytSearch,
|
||||||
|
ytCount,
|
||||||
|
ytSearchAsked: ytSearch != null && ytAsked === ytSearch,
|
||||||
|
}),
|
||||||
|
[page, channelView, ytSearch, ytCount, ytAsked],
|
||||||
);
|
);
|
||||||
const actions = useMemo<NavigationActions>(
|
const actions = useMemo<NavigationActions>(
|
||||||
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
|
() => ({
|
||||||
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
|
setPage,
|
||||||
|
openChannel,
|
||||||
|
closeChannel,
|
||||||
|
enterYtSearch,
|
||||||
|
exitYtSearch,
|
||||||
|
askYtSearch,
|
||||||
|
setYtCount,
|
||||||
|
}),
|
||||||
|
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch, askYtSearch, setYtCount],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The live YouTube search in the address: sent from the search box it gets its own entry naming the
|
||||||
|
// term (and its count), a reload or a link opens it again, Back/Forward read it from the address the
|
||||||
|
// browser restored — and only a search asked for in this document counts as asked (an API search
|
||||||
|
// reopened any other way waits for a press, Feed's part).
|
||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
|
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
import { claimLocalData } from "../../lib/storage";
|
||||||
|
|
||||||
|
let qc = new QueryClient();
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<NavigationProvider>{children}</NavigationProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const render = () =>
|
||||||
|
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||||
|
const search = () => new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
/** No entry of this document under the current one (a bookmark opened in a new tab). */
|
||||||
|
function stubNoEntryBelow() {
|
||||||
|
Object.defineProperty(window, "navigation", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
currentEntry: { index: 1 },
|
||||||
|
entries: () => [{ sameDocument: false }, { sameDocument: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
qc = new QueryClient();
|
||||||
|
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
|
||||||
|
localStorage.clear();
|
||||||
|
claimLocalData(1);
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Reflect.deleteProperty(window, "navigation");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NavigationProvider · the YouTube search in the address", () => {
|
||||||
|
it("names a search sent from the box on an entry of its own, over the feed's", () => {
|
||||||
|
const { result } = render();
|
||||||
|
const depth = window.history.length;
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
expect(window.history.length).toBe(depth + 1);
|
||||||
|
expect(search().get("yt")).toBe("lofi");
|
||||||
|
expect(search().get("q")).toBe("lofi"); // the feed's own keys stay
|
||||||
|
expect(search().get("ytn")).toBeNull(); // the default count is left out
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
expect(result.current.ytSearchAsked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refines the search on screen in place, at the count it shows", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.setYtCount(60));
|
||||||
|
expect(search().get("ytn")).toBe("60");
|
||||||
|
const depth = window.history.length;
|
||||||
|
act(() => result.current.enterYtSearch("lofi beats"));
|
||||||
|
expect(window.history.length).toBe(depth);
|
||||||
|
expect(search().get("yt")).toBe("lofi beats");
|
||||||
|
expect(search().get("ytn")).toBe("60");
|
||||||
|
expect(result.current.ytCount).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a new search starts at the default count", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.setYtCount(40));
|
||||||
|
stubNoEntryBelow(); // left in place, so the next search is a new entry
|
||||||
|
vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||||
|
act(() => result.current.exitYtSearch());
|
||||||
|
act(() => result.current.enterYtSearch("jazz"));
|
||||||
|
expect(result.current.ytCount).toBe(20);
|
||||||
|
expect(search().get("ytn")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the count, and clears it back at the default", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.setYtCount(100));
|
||||||
|
expect(search().get("ytn")).toBe("100");
|
||||||
|
act(() => result.current.setYtCount(20));
|
||||||
|
expect(search().get("ytn")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens the search a reload or a link names, not as asked", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
expect(result.current.ytCount).toBe(40);
|
||||||
|
expect(result.current.ytSearchAsked).toBe(false);
|
||||||
|
expect(search().get("yt")).toBe("lofi"); // the mount's own stamp keeps it
|
||||||
|
act(() => result.current.askYtSearch());
|
||||||
|
expect(result.current.ytSearchAsked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refines a search a link opened in place too, at the count it shows", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
|
||||||
|
const { result } = render();
|
||||||
|
const depth = window.history.length;
|
||||||
|
act(() => result.current.enterYtSearch("jazz"));
|
||||||
|
expect(window.history.length).toBe(depth);
|
||||||
|
expect([search().get("yt"), search().get("ytn")]).toEqual(["jazz", "40"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Back onto an entry whose address alone names the search reopens it", () => {
|
||||||
|
const { result } = render();
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the search mark of an entry the reload started from", () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
|
||||||
|
render();
|
||||||
|
expect(window.history.state?._yt).toBe("lofi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the entry of a search the address opened (a reload, a link)", () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||||
|
render();
|
||||||
|
expect(window.history.state?._yt).toBe("lofi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an older build's search mark the address does not name", () => {
|
||||||
|
window.history.replaceState(
|
||||||
|
{ sfPage: "feed", _yt: "lofi", _ytScrape: true },
|
||||||
|
"",
|
||||||
|
"/?page=feed",
|
||||||
|
);
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(window.history.state?._yt).toBeUndefined();
|
||||||
|
expect(window.history.state?._ytScrape).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names no search under another page", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=plex&yt=lofi");
|
||||||
|
const { result } = render();
|
||||||
|
expect(result.current.page).toBe("plex");
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Back/Forward read the search from the address the browser restored, not as asked", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
window.history.replaceState({ sfPage: "feed", _yt: "jazz" }, "", "/?page=feed&yt=jazz&ytn=60");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearch).toBe("jazz");
|
||||||
|
expect(result.current.ytCount).toBe(60);
|
||||||
|
expect(result.current.ytSearchAsked).toBe(false);
|
||||||
|
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaving a search with no entry of this document below clears it from the address", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=feed&yt=lofi&ytn=40");
|
||||||
|
const { result } = render();
|
||||||
|
stubNoEntryBelow();
|
||||||
|
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||||
|
act(() => result.current.exitYtSearch());
|
||||||
|
expect(back).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
expect(search().get("ytn")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a search left by Back and reached again by Forward is not asked for", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
expect(result.current.ytSearchAsked).toBe(true);
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&q=lofi");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
expect(result.current.ytSearchAsked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a search left in place is not asked for when it is opened again by a link", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.askYtSearch());
|
||||||
|
stubNoEntryBelow();
|
||||||
|
act(() => result.current.exitYtSearch());
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearchAsked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Feed press over a channel opened from the search opens the plain feed", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.openChannel("UC1"));
|
||||||
|
act(() => result.current.setPage("feed"));
|
||||||
|
expect(result.current.channelView).toBeNull();
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Feed press on the search itself opens the plain feed, as over a channel", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
const depth = window.history.length;
|
||||||
|
act(() => result.current.setPage("feed"));
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
expect(window.history.length).toBe(depth + 1); // its own entry: Back returns to the search
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the Feed press on the search keeps the feed's own view in the address", () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&show=all&sort=oldest");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.setPage("feed"));
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
expect([search().get("show"), search().get("sort")]).toEqual(["all", "oldest"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a search sent from the box is left by a step back off its own entry", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||||
|
act(() => result.current.exitYtSearch());
|
||||||
|
expect(back).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a search a link opened is left in place, not by a step back off the document", () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=feed&yt=lofi");
|
||||||
|
const { result } = render();
|
||||||
|
const back = vi.spyOn(window.history, "back");
|
||||||
|
act(() => result.current.exitYtSearch());
|
||||||
|
expect(back).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a landing on a channel opened from the search (an overlay over it closing) keeps the search", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.openChannel("UC1"));
|
||||||
|
// A player over the channel closed: the step back lands on the channel's own entry.
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
expect(result.current.ytSearchAsked).toBe(true);
|
||||||
|
// Back off the channel: the search it stood over is still the one asked for.
|
||||||
|
window.history.replaceState({ sfPage: "feed", _yt: "lofi" }, "", "/?page=feed&yt=lofi");
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearchAsked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a landing on a channel opened from a search the address opened keeps the search", () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed&yt=lofi");
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.openChannel("UC1"));
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||||
|
});
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a channel closed in place over the search names the search again", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.setYtCount(40));
|
||||||
|
act(() => result.current.openChannel("UC1"));
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
stubNoEntryBelow();
|
||||||
|
act(() => result.current.closeChannel());
|
||||||
|
expect(result.current.ytSearch).toBe("lofi");
|
||||||
|
expect([search().get("yt"), search().get("ytn")]).toEqual(["lofi", "40"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a page switch takes the search off the address", () => {
|
||||||
|
const { result } = render();
|
||||||
|
act(() => result.current.enterYtSearch("lofi"));
|
||||||
|
act(() => result.current.setPage("home"));
|
||||||
|
expect(result.current.ytSearch).toBeNull();
|
||||||
|
expect(search().get("yt")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The channel page opens over any page, and its address names the channel alone (the owner, 2026-10-05:
|
||||||
|
// it used to carry the feed's filters, naming a sort the channel did not show). The page under it is
|
||||||
|
// still mounted — its store lives at the root — and a change in it while the channel stands over it
|
||||||
|
// (a background refetch, the header search) must not write its keys onto the channel's address. Closing
|
||||||
|
// the channel brings them back. The real provider and the real stores; only the network is absent.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
|
import { ModuleStores } from "./ModuleStores";
|
||||||
|
import { useNavigationActions, type NavigationActions } from "./navigationContext";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "../../modules/feed";
|
||||||
|
import { usePlexActions } from "../../modules/plex";
|
||||||
|
import { useChannelsActions } from "../../modules/channels";
|
||||||
|
import { useFilesActions } from "../../modules/files";
|
||||||
|
import { usePlaylistsActions } from "../../modules/playlists";
|
||||||
|
import { claimLocalData } from "../../lib/storage";
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
nav: NavigationActions;
|
||||||
|
change: Record<string, () => void>;
|
||||||
|
};
|
||||||
|
const probe: { current: Probe | null } = { current: null };
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const nav = useNavigationActions();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
|
const plex = usePlexActions();
|
||||||
|
const channels = useChannelsActions();
|
||||||
|
const files = useFilesActions();
|
||||||
|
const playlists = usePlaylistsActions();
|
||||||
|
// Handed out after each render (an effect, not the render itself, may write outside it).
|
||||||
|
useEffect(() => {
|
||||||
|
probe.current = {
|
||||||
|
nav,
|
||||||
|
change: {
|
||||||
|
feed: () => setFilters({ ...filters, sort: "oldest" }),
|
||||||
|
plex: () => plex.setScope("movie"),
|
||||||
|
channels: () => channels.setView("hidden"),
|
||||||
|
files: () => files.setView({ kind: "folder", id: 3 }),
|
||||||
|
playlists: () => playlists.setSelectedId(5),
|
||||||
|
"plex search": () => plex.setQ("poirot"),
|
||||||
|
"channels search": () => channels.setSearch("tom"),
|
||||||
|
"files search": () => files.setSearch("notes"),
|
||||||
|
"playlists search": () => playlists.setSearch("jazz"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What each change above writes, once its page is what the address shows: [page, key, value]. A
|
||||||
|
// search is written once the typing pauses.
|
||||||
|
const WRITTEN: Record<string, [string, string, string]> = {
|
||||||
|
feed: ["feed", "sort", "oldest"],
|
||||||
|
plex: ["plex", "pscope", "movie"],
|
||||||
|
channels: ["channels", "ctab", "hidden"],
|
||||||
|
files: ["files", "folder", "3"],
|
||||||
|
playlists: ["playlists", "pl", "5"],
|
||||||
|
"plex search": ["plex", "pq", "poirot"],
|
||||||
|
"channels search": ["channels", "cq", "tom"],
|
||||||
|
"files search": ["files", "fq", "notes"],
|
||||||
|
"playlists search": ["playlists", "plq", "jazz"],
|
||||||
|
};
|
||||||
|
const PAUSE = () => new Promise((r) => setTimeout(r, 400));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
claimLocalData(3);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = () => new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
describe.each(Object.keys(WRITTEN))("a channel page over %s", (change) => {
|
||||||
|
it("keeps the page's keys off its address, and they come back when it closes", async () => {
|
||||||
|
const [page, key, value] = WRITTEN[change]!;
|
||||||
|
// The page named even for the feed: a bare address opens on Home (the account's default).
|
||||||
|
window.history.replaceState({ sfPage: page }, "", `/?page=${page}`);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<NavigationProvider>
|
||||||
|
<ModuleStores>
|
||||||
|
<Probe />
|
||||||
|
</ModuleStores>
|
||||||
|
</NavigationProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
act(() => probe.current!.nav.openChannel("UC1", "One"));
|
||||||
|
act(() => probe.current!.change[change]!());
|
||||||
|
await act(PAUSE);
|
||||||
|
expect(search().get(key)).toBeNull();
|
||||||
|
expect(search().get("chan")).toBe("UC1");
|
||||||
|
act(() => probe.current!.nav.closeChannel());
|
||||||
|
await waitFor(() => expect(search().get("chan")).toBeNull());
|
||||||
|
await waitFor(() => expect(search().get(key)).toBe(value));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,21 @@ export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id);
|
|||||||
|
|
||||||
const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
|
const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
|
||||||
|
|
||||||
|
// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and
|
||||||
|
// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys
|
||||||
|
// are cleared the day it declares them.
|
||||||
|
const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
|
||||||
|
export function moduleUrlKeys(): readonly string[] {
|
||||||
|
return ALL_URL_KEYS;
|
||||||
|
}
|
||||||
|
/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */
|
||||||
|
export function typedUrlKeys(): readonly string[] {
|
||||||
|
return ALL.flatMap((m) => m.typedUrlKeys ?? []);
|
||||||
|
}
|
||||||
|
export function foreignUrlKeys(page: Page): readonly string[] {
|
||||||
|
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
|
||||||
|
}
|
||||||
|
|
||||||
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
||||||
export function moduleDef(id: Page): ModuleDef {
|
export function moduleDef(id: Page): ModuleDef {
|
||||||
return BY_ID[id];
|
return BY_ID[id];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import type { Page } from "../../lib/urlState";
|
import type { Page } from "../../lib/urlState";
|
||||||
|
import { DEFAULT_YT_COUNT } from "../../lib/ytSearchUrlState";
|
||||||
import type { ChannelView } from "./navigationInit";
|
import type { ChannelView } from "./navigationInit";
|
||||||
|
|
||||||
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
|
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
|
||||||
@@ -15,8 +16,13 @@ export interface NavigationState {
|
|||||||
page: Page;
|
page: Page;
|
||||||
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
|
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
|
||||||
channelView: ChannelView | null;
|
channelView: ChannelView | null;
|
||||||
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
|
/** Live YouTube search term (null = normal feed). A feed sub-view, named in the address (`yt`). */
|
||||||
ytSearch: string | null;
|
ytSearch: string | null;
|
||||||
|
/** How many results the search gathers (`ytn`). */
|
||||||
|
ytCount: number;
|
||||||
|
/** The search on screen was asked for in this document (sent from the search box, or run again by
|
||||||
|
* a press), not only reopened by a reload, a link or Back. An API search runs only when asked. */
|
||||||
|
ytSearchAsked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationActions {
|
export interface NavigationActions {
|
||||||
@@ -33,12 +39,17 @@ export interface NavigationActions {
|
|||||||
closeChannel: () => void;
|
closeChannel: () => void;
|
||||||
enterYtSearch: (q: string) => void;
|
enterYtSearch: (q: string) => void;
|
||||||
exitYtSearch: () => void;
|
exitYtSearch: () => void;
|
||||||
|
/** Run the search on screen: the "Search YouTube" press. */
|
||||||
|
askYtSearch: () => void;
|
||||||
|
setYtCount: (n: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationStateContext = createContext<NavigationState>({
|
export const NavigationStateContext = createContext<NavigationState>({
|
||||||
page: "feed",
|
page: "feed",
|
||||||
channelView: null,
|
channelView: null,
|
||||||
ytSearch: null,
|
ytSearch: null,
|
||||||
|
ytCount: DEFAULT_YT_COUNT,
|
||||||
|
ytSearchAsked: false,
|
||||||
});
|
});
|
||||||
export const NavigationActionsContext = createContext<NavigationActions>({
|
export const NavigationActionsContext = createContext<NavigationActions>({
|
||||||
setPage: () => {},
|
setPage: () => {},
|
||||||
@@ -46,6 +57,8 @@ export const NavigationActionsContext = createContext<NavigationActions>({
|
|||||||
closeChannel: () => {},
|
closeChannel: () => {},
|
||||||
enterYtSearch: () => {},
|
enterYtSearch: () => {},
|
||||||
exitYtSearch: () => {},
|
exitYtSearch: () => {},
|
||||||
|
askYtSearch: () => {},
|
||||||
|
setYtCount: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useNavigation(): NavigationState {
|
export function useNavigation(): NavigationState {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Page } from "../../lib/urlState";
|
||||||
|
import type { NavigationState } from "./navigationContext";
|
||||||
|
|
||||||
|
/** The page whose own keys the address bar carries: none while the channel page stands over it, so
|
||||||
|
* every module store writes its keys (`useUrlMirror`) only while its page is what the screen shows.
|
||||||
|
* A plain function beside the context, not a hook in it: the stores' tests mock that module. */
|
||||||
|
export function urlPage(nav: Pick<NavigationState, "page" | "channelView">): Page | null {
|
||||||
|
return nav.channelView ? null : nav.page;
|
||||||
|
}
|
||||||
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
|
|||||||
const notifyActionError = vi.fn();
|
const notifyActionError = vi.fn();
|
||||||
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
|
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
|
||||||
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
|
return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
|
||||||
});
|
});
|
||||||
|
|
||||||
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
|
||||||
|
import { msg, type NotifText } from "../../lib/notifications";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
|
|||||||
display_name: "The bit",
|
display_name: "The bit",
|
||||||
});
|
});
|
||||||
expect(notify).toHaveBeenCalledWith(
|
expect(notify).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ level: "success", message: "clip.queued" }),
|
expect.objectContaining({ level: "success", message: msg("clip.queued") }),
|
||||||
);
|
);
|
||||||
// Its View opens the tray on the new clip.
|
// Its View opens the tray on the new clip.
|
||||||
(
|
(
|
||||||
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||||
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
||||||
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
|
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
|
||||||
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
|
expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
|
|||||||
import { api, type ClipRequest } from "../../lib/api";
|
import { api, type ClipRequest } from "../../lib/api";
|
||||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||||
import { invalidateDownloads } from "../../lib/downloads";
|
import { invalidateDownloads } from "../../lib/downloads";
|
||||||
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 { log } from "../../lib/log";
|
import { log } from "../../lib/log";
|
||||||
@@ -197,14 +197,14 @@ export default function ClipPanel({
|
|||||||
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
message: t("clip.queued"),
|
message: msg("clip.queued"),
|
||||||
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
|
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
|
||||||
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
|
||||||
notifyActionError(err, t("clip.failed"));
|
notifyActionError(err, msg("clip.failed"));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { lazy, Suspense, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { type Video, type VideoStatus } from "../../lib/api";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
|
import { useStableCallback } from "../../lib/useLatest";
|
||||||
|
import { writeVideoState } from "../video/videoWrites";
|
||||||
|
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
|
|
||||||
|
/** The shell's player, for an address that names one over a page that hosts none of its own (the
|
||||||
|
* channel manager, where the watched-videos dialog played it; any page a link names): the video
|
||||||
|
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||||
|
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [own] = useState(!hosted);
|
||||||
|
const [video, setVideo] = useState<Video | null>(null);
|
||||||
|
useOpenLinkedPlayer(
|
||||||
|
own,
|
||||||
|
() => undefined,
|
||||||
|
(v) => setVideo(v),
|
||||||
|
);
|
||||||
|
const onState = useStableCallback((id: string, status: VideoStatus) => {
|
||||||
|
const write = writeVideoState(qc, id, status);
|
||||||
|
write
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
||||||
|
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
|
||||||
|
return write;
|
||||||
|
});
|
||||||
|
const close = useStableCallback(() => setVideo(null));
|
||||||
|
if (!video) return null;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PlayerModal video={video} startAt={null} onClose={close} onState={onState} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
|||||||
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
|
import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
|
||||||
import { clearAll, notify } from "../../lib/notifications";
|
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { writeVideoState } from "../video/videoWrites";
|
import { writeVideoState } from "../video/videoWrites";
|
||||||
|
|
||||||
@@ -111,6 +111,7 @@ function renderModal(ui: ReactNode) {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
started.length = 0;
|
started.length = 0;
|
||||||
loaded.length = 0;
|
loaded.length = 0;
|
||||||
seeks.length = 0;
|
seeks.length = 0;
|
||||||
@@ -313,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
|||||||
onState={() => Promise.resolve()}
|
onState={() => Promise.resolve()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
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("player-modal").textContent).toContain("Couldn't remove the video.");
|
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
|||||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
||||||
try {
|
try {
|
||||||
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
||||||
act(() => void notify({ level: "error", message: "Connection lost" }));
|
act(() => void notify({ level: "error", message: raw("Connection lost") }));
|
||||||
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
|
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
|
||||||
} finally {
|
} finally {
|
||||||
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||||
import { useBackToClose } from "../../lib/history";
|
import { useBackToClose } from "../../lib/history";
|
||||||
|
import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib/playerUrlState";
|
||||||
import ChannelNameLink from "../video/ChannelNameLink";
|
import ChannelNameLink from "../video/ChannelNameLink";
|
||||||
import Toaster from "../../components/ui/Toaster";
|
import Toaster from "../../components/ui/Toaster";
|
||||||
import { useOverlayOrder } from "../../components/ui/overlayOrder";
|
import { useOverlayOrder } from "../../components/ui/overlayOrder";
|
||||||
@@ -222,8 +223,6 @@ export default function PlayerModal({
|
|||||||
const { openChannel } = useNavigationActions();
|
const { openChannel } = useNavigationActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
|
const { ref: descFadeRef, style: descFadeStyle } = useScrollFade();
|
||||||
// Browser/mouse Back closes the player instead of leaving the page.
|
|
||||||
useBackToClose(onClose);
|
|
||||||
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
|
||||||
// opened with, for its whole session. The live feed refetches and reorders/drops items under
|
// opened with, for its whole session. The live feed refetches and reorders/drops items under
|
||||||
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
|
// us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
|
||||||
@@ -277,6 +276,25 @@ export default function PlayerModal({
|
|||||||
setRelatedOf(active.id);
|
setRelatedOf(active.id);
|
||||||
setRelated(null);
|
setRelated(null);
|
||||||
}
|
}
|
||||||
|
// An address that names the item this player opens on (a reload, a bookmark) names its view too: the
|
||||||
|
// related video playing in place over it and the panel's tab come back with it. Read once.
|
||||||
|
const [linked] = useState(() => {
|
||||||
|
const here = linkedPlayer();
|
||||||
|
return here && here.watch === video.id ? here : null;
|
||||||
|
});
|
||||||
|
const linkedRelated = useQuery({
|
||||||
|
queryKey: qk.videoRow(linked?.related ?? ""),
|
||||||
|
queryFn: () => api.videoRow(linked!.related!),
|
||||||
|
enabled: !!linked?.related,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const [relatedAdopted, setRelatedAdopted] = useState(false);
|
||||||
|
if (!relatedAdopted && linkedRelated.data) {
|
||||||
|
setRelatedAdopted(true);
|
||||||
|
// Only over the item it was named with: a step of the queue before it arrived left that one.
|
||||||
|
if (active.id === linked?.watch && !related)
|
||||||
|
setRelated({ video: linkedRelated.data, startAt: null });
|
||||||
|
}
|
||||||
const subject = related?.video ?? active;
|
const subject = related?.video ?? active;
|
||||||
const subjectResume = related
|
const subjectResume = related
|
||||||
? (related.startAt ?? related.video.position_seconds ?? 0)
|
? (related.startAt ?? related.video.position_seconds ?? 0)
|
||||||
@@ -753,7 +771,7 @@ export default function PlayerModal({
|
|||||||
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
|
// take the side panel's place of the description, a tab beside it; stacked, a drawer over the video
|
||||||
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
|
// opened from a button beside the ⓘ. The list is the OPENED video's: one played from it leaves the
|
||||||
// list in place, to try the next one (the owner's pick).
|
// list in place, to try the next one (the owner's pick).
|
||||||
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
|
const [panelTab, setPanelTab] = useState<PlayerPanelTab>(linked?.panel ?? "description");
|
||||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||||
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
|
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
|
||||||
// tab instead. Left open, it reopened unasked when the window narrowed again.
|
// tab instead. Left open, it reopened unasked when the window narrowed again.
|
||||||
@@ -762,6 +780,17 @@ export default function PlayerModal({
|
|||||||
setPanelTab("related");
|
setPanelTab("related");
|
||||||
}
|
}
|
||||||
const panelTabs = useTabPanel();
|
const panelTabs = useTabPanel();
|
||||||
|
// Browser/mouse Back closes the player instead of leaving the page. Its entry's address names what
|
||||||
|
// it plays (the queue's item, a related one over it) and the wide panel's tab, so a reload or a
|
||||||
|
// bookmark reopens it as it is; the page's own entry under it never carries them.
|
||||||
|
useBackToClose(
|
||||||
|
onClose,
|
||||||
|
playerParamUpdates({
|
||||||
|
watch: active.id,
|
||||||
|
related: related?.video.id ?? null,
|
||||||
|
panel: wide ? panelTab : "description",
|
||||||
|
}),
|
||||||
|
);
|
||||||
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
|
||||||
// be refused.
|
// be refused.
|
||||||
const relatedOn = useMe().related_enabled;
|
const relatedOn = useMe().related_enabled;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The URL is the view: the YouTube player names what it plays in its own history entry — the item of
|
||||||
|
// the queue, a related video played in place over it, the wide panel's tab — so a reload or a bookmark
|
||||||
|
// reopens it as it was, and Back lands on the page's address without it. Its own file: the overlay
|
||||||
|
// history is module state, and these read the address the closed players of other tests step back over.
|
||||||
|
//
|
||||||
|
// `window.YT` is a fake installed before the player's API loader first runs (see PlayerModal.dom.test).
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("../navigation/navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
|
||||||
|
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
import PlayerModal from "./PlayerModal";
|
||||||
|
import { api, type Me, type Video } from "../../lib/api";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
const started: { videoId: string; start: number | undefined }[] = [];
|
||||||
|
let playerEvents: { onReady?: () => void } = {};
|
||||||
|
const fakePlayer = new Proxy(
|
||||||
|
{ getCurrentTime: () => 0, getDuration: () => 3000, getVideoData: () => null },
|
||||||
|
{ get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined) },
|
||||||
|
);
|
||||||
|
window.YT = {
|
||||||
|
Player: function (
|
||||||
|
_el: unknown,
|
||||||
|
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
|
||||||
|
) {
|
||||||
|
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||||
|
playerEvents = opts.events ?? {};
|
||||||
|
return fakePlayer;
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
const lastStart = () => started[started.length - 1];
|
||||||
|
|
||||||
|
const vid = (id: string, position_seconds = 0): Video =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
title: `Video ${id}`,
|
||||||
|
channel_id: "UCx",
|
||||||
|
channel_title: "Channel",
|
||||||
|
status: "new",
|
||||||
|
position_seconds,
|
||||||
|
duration_seconds: 3000,
|
||||||
|
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||||
|
}) as unknown as Video;
|
||||||
|
const related = (id: string, position_seconds = 0) => ({
|
||||||
|
...vid(id, position_seconds),
|
||||||
|
followed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The wide layout (the side panel with its tabs): jsdom has no matchMedia, so one is put in.
|
||||||
|
function wideWindow() {
|
||||||
|
window.matchMedia = ((q: string) => ({
|
||||||
|
matches: q === "(min-width: 1280px)",
|
||||||
|
media: q,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(video: Video, queue: Video[]) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { related_enabled: true } as Me);
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlayerModal
|
||||||
|
video={video}
|
||||||
|
queue={queue}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={() => Promise.resolve()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A closed player steps back over its entry a task later (jsdom's back(), like a browser's).
|
||||||
|
async function closeAndLand(unmount: () => void) {
|
||||||
|
const landed = new Promise((r) => window.addEventListener("popstate", r, { once: true }));
|
||||||
|
unmount();
|
||||||
|
await act(() => landed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const here = () => new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
vi.spyOn(api, "related").mockResolvedValue({
|
||||||
|
items: [related("rel1"), related("rel2", 120)],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete (window as { matchMedia?: unknown }).matchMedia;
|
||||||
|
started.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the player's address", () => {
|
||||||
|
it("names the item playing now in its own entry, a step of the queue too", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?q=talks");
|
||||||
|
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
|
||||||
|
await waitFor(() => expect(here().get("watch")).toBe("A"));
|
||||||
|
expect(here().get("q")).toBe("talks");
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||||
|
await waitFor(() => expect(here().get("watch")).toBe("B"));
|
||||||
|
await closeAndLand(r.unmount);
|
||||||
|
expect(window.location.search).toBe("?q=talks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wide: names the related video playing over it and the panel's tab, and Back drops it", async () => {
|
||||||
|
wideWindow();
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/");
|
||||||
|
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||||
|
act(() => playerEvents.onReady?.());
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||||
|
await waitFor(() => expect(here().get("wtab")).toBe("related"));
|
||||||
|
const rows = await screen.findAllByRole("link", { name: "Video rel2" });
|
||||||
|
fireEvent.click(rows.find((a) => a.hasAttribute("data-nav-open"))!);
|
||||||
|
await waitFor(() => expect(here().get("wrel")).toBe("rel2"));
|
||||||
|
expect(here().get("watch")).toBe("A");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||||
|
await waitFor(() => expect(here().has("wrel")).toBe(false));
|
||||||
|
expect(here().get("watch")).toBe("A");
|
||||||
|
await closeAndLand(r.unmount);
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wide: a link's related video and panel tab come back over the item it names", async () => {
|
||||||
|
wideWindow();
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2&wtab=related");
|
||||||
|
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
|
||||||
|
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||||
|
await waitFor(() => expect(lastStart()).toEqual({ videoId: "rel2", start: 120 }));
|
||||||
|
expect(row).toHaveBeenCalledWith("rel2");
|
||||||
|
const tab = screen.getByRole("tab", { name: "player.related.title" });
|
||||||
|
expect(tab.getAttribute("aria-selected")).toBe("true");
|
||||||
|
expect(screen.getByRole("button", { name: "player.back" })).toBeTruthy();
|
||||||
|
expect(here().get("wrel")).toBe("rel2");
|
||||||
|
await closeAndLand(r.unmount);
|
||||||
|
// The entry the link opened at named the player; once the player is gone, so is its name.
|
||||||
|
expect(window.location.search).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a link's related video that arrives after the queue stepped on stays off the next item", async () => {
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=A&wrel=rel2");
|
||||||
|
let arrive: (v: Video) => void = () => {};
|
||||||
|
vi.spyOn(api, "videoRow").mockReturnValue(new Promise<Video>((r) => (arrive = r)));
|
||||||
|
const r = renderModal(vid("A"), [vid("A"), vid("B")]);
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||||
|
await waitFor(() => expect(here().get("watch")).toBe("B"));
|
||||||
|
await act(async () => {
|
||||||
|
arrive(vid("rel2", 120));
|
||||||
|
await new Promise((r) => setTimeout(r, 50)); // the query hands its answer on a tick later
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("heading", { name: "Video B" })).toBeTruthy();
|
||||||
|
expect(here().has("wrel")).toBe(false);
|
||||||
|
await closeAndLand(r.unmount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wide: a link naming another item brings none of its view", async () => {
|
||||||
|
wideWindow();
|
||||||
|
window.history.replaceState({ sfPage: "feed" }, "", "/?watch=Z&wrel=rel2&wtab=related");
|
||||||
|
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("rel2", 120));
|
||||||
|
const r = renderModal(vid("A", 40), [vid("A", 40)]);
|
||||||
|
await waitFor(() => expect(here().get("watch")).toBe("A"));
|
||||||
|
expect(row).not.toHaveBeenCalled();
|
||||||
|
expect(here().has("wrel")).toBe(false);
|
||||||
|
expect(here().has("wtab")).toBe(false);
|
||||||
|
expect(lastStart()?.videoId).toBe("A");
|
||||||
|
await closeAndLand(r.unmount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
|
|||||||
import { VideoMenu } from "../video/VideoMenu";
|
import { VideoMenu } from "../video/VideoMenu";
|
||||||
import { announceVideoState } from "../video/stateNotice";
|
import { announceVideoState } from "../video/stateNotice";
|
||||||
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
|
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
||||||
* decided at the list, and the account keeps the last one (the owner's pick). */
|
* decided at the list, and the account keeps the last one (the owner's pick). */
|
||||||
@@ -100,7 +101,7 @@ export function RelatedVideos({
|
|||||||
announceVideoState(id, status, v, () => onState(id, "new"));
|
announceVideoState(id, status, v, () => onState(id, "new"));
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
notifyActionError(e, t("errors.writeFailed.videoState"));
|
notifyActionError(e, msg("errors.writeFailed.videoState"));
|
||||||
settle(seq);
|
settle(seq);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -118,7 +119,7 @@ export function RelatedVideos({
|
|||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
notifyActionError(
|
notifyActionError(
|
||||||
e,
|
e,
|
||||||
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||||
);
|
);
|
||||||
settle(seq);
|
settle(seq);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A reload or a bookmark of the YouTube player reopens it: the page that hosts it opens it once its list
|
||||||
|
// is in — from the list, so it plays on through it, or alone from the catalog — and a page that hosts
|
||||||
|
// none gets the shell's, the video alone. A video the catalog no longer has is said, not swallowed.
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../lib/actionErrors", () => ({ notifyActionError: vi.fn() }));
|
||||||
|
// The player itself is not what is under test: it shows what it was opened on.
|
||||||
|
vi.mock("./PlayerModal", () => ({
|
||||||
|
default: ({ video, queue }: { video: Video; queue?: Video[] }) => (
|
||||||
|
<p>
|
||||||
|
playing {video.id} {queue ? "in a list" : "alone"}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||||
|
import { LinkedPlayer } from "./LinkedPlayer";
|
||||||
|
import { api, type Video } from "../../lib/api";
|
||||||
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
|
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.mocked(notifyActionError).mockClear();
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useOpenLinkedPlayer", () => {
|
||||||
|
it("opens the item from the host's list once the list is in, and only once", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=B");
|
||||||
|
const row = vi.spyOn(api, "videoRow");
|
||||||
|
const open = vi.fn();
|
||||||
|
const list = [vid("A"), vid("B")];
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ ready, rows }) => useOpenLinkedPlayer(ready, (id) => rows.find((v) => v.id === id), open),
|
||||||
|
{ wrapper, initialProps: { ready: false, rows: list } },
|
||||||
|
);
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
rerender({ ready: true, rows: list });
|
||||||
|
await waitFor(() => expect(open).toHaveBeenCalledWith(list[1], true));
|
||||||
|
// The list refetched (new rows for the same videos): the player it opened is not opened again.
|
||||||
|
rerender({ ready: true, rows: [vid("A"), vid("B")] });
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
expect(row).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an item the list does not hold alone, its row from the catalog", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=Z");
|
||||||
|
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||||
|
const open = vi.fn();
|
||||||
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||||
|
await waitFor(() => expect(open).toHaveBeenCalledWith(vid("Z"), false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the catalog no longer has it", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=Z");
|
||||||
|
const gone = new Error("not found");
|
||||||
|
vi.spyOn(api, "videoRow").mockRejectedValue(gone);
|
||||||
|
const open = vi.fn();
|
||||||
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
|
||||||
|
);
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
|
||||||
|
expect(new URLSearchParams(window.location.search).has("watch")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a player opened while the row was on its way alone, and its address", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=Z");
|
||||||
|
let answer: (v: Video) => void = () => {};
|
||||||
|
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((r) => (answer = r)));
|
||||||
|
const open = vi.fn();
|
||||||
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||||
|
// A card clicked meanwhile: its player names itself in the address.
|
||||||
|
window.history.replaceState(null, "", "/?watch=Y");
|
||||||
|
answer(vid("Z"));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clear the address of a player opened since when its catalog read fails", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=Z");
|
||||||
|
let refuse: (e: Error) => void = () => {};
|
||||||
|
vi.spyOn(api, "videoRow").mockReturnValue(new Promise((_, r) => (refuse = r)));
|
||||||
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, vi.fn()), { wrapper });
|
||||||
|
window.history.replaceState(null, "", "/?watch=Y");
|
||||||
|
refuse(new Error("not found"));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("given up over a moved list, keeps the address of a player opened since", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=Z");
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ scope }) => useOpenLinkedPlayer(false, () => undefined, vi.fn(), scope),
|
||||||
|
{ wrapper, initialProps: { scope: 1 } },
|
||||||
|
);
|
||||||
|
window.history.replaceState(null, "", "/?watch=Y");
|
||||||
|
rerender({ scope: 2 });
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(new URLSearchParams(window.location.search).get("watch")).toBe("Y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops looking once it opened: the video leaving the list asks the catalog for nothing", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=B");
|
||||||
|
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("B"));
|
||||||
|
const find = vi.fn((id: string) => (id === "B" ? vid("B") : undefined));
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ rows }) => useOpenLinkedPlayer(true, rows ? find : () => undefined, vi.fn()),
|
||||||
|
{ wrapper, initialProps: { rows: true } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(find).toHaveBeenCalled());
|
||||||
|
find.mockClear();
|
||||||
|
rerender({ rows: false });
|
||||||
|
rerender({ rows: true });
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(find).not.toHaveBeenCalled();
|
||||||
|
expect(row).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens nothing, and asks for nothing, for an address that names no player", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?q=talks");
|
||||||
|
const row = vi.spyOn(api, "videoRow");
|
||||||
|
const open = vi.fn();
|
||||||
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(open).not.toHaveBeenCalled();
|
||||||
|
expect(row).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the shell's LinkedPlayer", () => {
|
||||||
|
it("plays the video alone over a page that hosts no player", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?page=channels&watch=Z");
|
||||||
|
vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||||
|
render(<LinkedPlayer hosted={false} />, { wrapper });
|
||||||
|
expect(await screen.findByText("playing Z alone")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves it to a page that hosts its own, even once that page has fetched the row", async () => {
|
||||||
|
// The page plays it alone, from the catalog row it asked for: the same cache entry this one reads.
|
||||||
|
window.history.replaceState(null, "", "/?page=home&watch=Z");
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.videoRow("Z"), vid("Z"));
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<LinkedPlayer hosted />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(screen.queryByText(/playing/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves it to a page that hosts its own", async () => {
|
||||||
|
window.history.replaceState(null, "", "/?watch=Z");
|
||||||
|
const row = vi.spyOn(api, "videoRow").mockResolvedValue(vid("Z"));
|
||||||
|
render(<LinkedPlayer hosted />, { wrapper });
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(row).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText(/playing/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, type Video } from "../../lib/api";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||||
|
import { clearParams } from "../../lib/urlState";
|
||||||
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
|
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
|
||||||
|
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
|
||||||
|
* item is in it (`find`), so the player plays on through the list; else alone, its row fetched from
|
||||||
|
* the catalog. A row the catalog no longer has is said, not swallowed.
|
||||||
|
*
|
||||||
|
* `scope` names the list the address put it over (a playlist, a live search): when the host moves to
|
||||||
|
* another before it opened, the address under it named something else, and the player is given up —
|
||||||
|
* opened later, it would start over a list nobody linked, unasked. So it is when a player opened
|
||||||
|
* meanwhile (a card clicked while the row was on its way): the address names that one now. */
|
||||||
|
export function useOpenLinkedPlayer(
|
||||||
|
ready: boolean,
|
||||||
|
find: (id: string) => Video | undefined,
|
||||||
|
open: (video: Video, inList: boolean) => void,
|
||||||
|
scope: string | number | null = null,
|
||||||
|
): void {
|
||||||
|
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||||
|
const [linkedScope] = useState(scope);
|
||||||
|
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
|
||||||
|
// not open it twice); the state stops the lookup and the row fetch on screen after it.
|
||||||
|
const done = useRef(linked == null);
|
||||||
|
const [settled, setSettled] = useState(linked == null);
|
||||||
|
const moved = scope !== linkedScope;
|
||||||
|
const waiting = ready && !moved && !settled && linked != null;
|
||||||
|
const listed = waiting ? find(linked) : undefined;
|
||||||
|
const row = useQuery({
|
||||||
|
queryKey: qk.videoRow(linked ?? ""),
|
||||||
|
queryFn: () => api.videoRow(linked!),
|
||||||
|
enabled: waiting && !listed,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const openIt = useEffectEvent(open);
|
||||||
|
const failed = useEffectEvent((err: unknown) => {
|
||||||
|
notifyActionError(err, msg("player.linkedFailed"));
|
||||||
|
clearParams(...PLAYER_KEYS);
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (done.current) return;
|
||||||
|
const settle = () => {
|
||||||
|
done.current = true;
|
||||||
|
// State, not derived: a one-way decision taken as the list or the row arrives. Derived from
|
||||||
|
// `listed`, the lookup and the row fetch would come back whenever the video left the list.
|
||||||
|
setSettled(true);
|
||||||
|
};
|
||||||
|
// A player opened since names its own: this one is not wanted any more.
|
||||||
|
if (linkedPlayer()?.watch !== linked) return settle();
|
||||||
|
if (moved) {
|
||||||
|
// The address goes on naming it under the new list (a reload would open it there).
|
||||||
|
clearParams(...PLAYER_KEYS);
|
||||||
|
return settle();
|
||||||
|
}
|
||||||
|
// Not before the host is ready: the row's cache entry is shared, so another opener's fetch fills
|
||||||
|
// it here too (a disabled query still reads the cache) — the shell's player over a page that
|
||||||
|
// plays its own would open a second one.
|
||||||
|
if (!ready) return;
|
||||||
|
if (listed) openIt(listed, true);
|
||||||
|
else if (row.data) openIt(row.data, false);
|
||||||
|
else if (row.isError) failed(row.error);
|
||||||
|
else return;
|
||||||
|
settle();
|
||||||
|
}, [linked, moved, ready, listed, row.data, row.isError, row.error]);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
|||||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
|
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
|
||||||
// into or out of its library's manual collections, and names a new one — created with this title in
|
// into or out of its library's manual collections, and names a new one — created with this title in
|
||||||
@@ -67,7 +68,7 @@ export default function PlexCollectionAddAt({
|
|||||||
else await api.plexCollectionAddItem(c.id, ratingKey);
|
else await api.plexCollectionAddItem(c.id, ratingKey);
|
||||||
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("plex.collection.editFailed"));
|
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
|
|||||||
made = true;
|
made = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
made = madeOnPlex(e);
|
made = madeOnPlex(e);
|
||||||
notifyActionError(e, t("plex.collection.editFailed"));
|
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
|
|||||||
import { Pressable } from "../../components/ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { Input } from "../../components/ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
|
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
|
||||||
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
|
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
|
||||||
@@ -80,7 +81,7 @@ export default function PlexPlaylistAdd({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(
|
notifyActionError(
|
||||||
e,
|
e,
|
||||||
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The create may already have landed when the add fails — `refresh()` in `finally` is what
|
// The create may already have landed when the add fails — `refresh()` in `finally` is what
|
||||||
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
|
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
|
||||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ describe("a season's name", () => {
|
|||||||
["Season 1", 1, "plex.seasonTitle(1)"],
|
["Season 1", 1, "plex.seasonTitle(1)"],
|
||||||
["Specials", 0, "plex.specials"],
|
["Specials", 0, "plex.specials"],
|
||||||
["Book One: Water", 1, "Book One: Water"],
|
["Book One: Water", 1, "Book One: Water"],
|
||||||
|
["Season 4", null, "Season 4"],
|
||||||
|
["", null, "plex.playlist.seasonUnknown"],
|
||||||
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
|
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
|
||||||
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
|
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
|
||||||
expect(screen.getByText(shown)).toBeTruthy();
|
expect(screen.getByText(shown)).toBeTruthy();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
|
|||||||
import { useCopyItems } from "../../components/ui/copyItems";
|
import { useCopyItems } from "../../components/ui/copyItems";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
|
import { raw } from "../../lib/notifications";
|
||||||
|
|
||||||
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
|
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
|
||||||
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
|
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
|
||||||
@@ -151,7 +152,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
|
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
|
||||||
...(withPin ? pin.item(pinPlex(c), c.title) : []),
|
...(withPin ? pin.item(pinPlex(c), raw(c.title)) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
|||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||||
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
|
||||||
@@ -122,7 +123,7 @@ function MembershipPanel({
|
|||||||
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
|
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -137,7 +138,7 @@ function MembershipPanel({
|
|||||||
await addPlaylistItem(qc, pl, videoId);
|
await addPlaylistItem(qc, pl, videoId);
|
||||||
made = true;
|
made = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("playlists.addFailed"));
|
notifyActionError(e, msg("playlists.addFailed"));
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ describe("ChannelNameLink", () => {
|
|||||||
it("is an anchor whose href opens this channel on the current page", () => {
|
it("is an anchor whose href opens this channel on the current page", () => {
|
||||||
const { link } = renderLink();
|
const { link } = renderLink();
|
||||||
expect(link.tagName).toBe("A");
|
expect(link.tagName).toBe("A");
|
||||||
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
|
// The same URL openChannel pushes: the current page plus ?chan=<id> — not the page's own keys,
|
||||||
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
|
// which named a view the channel does not show (`show=unwatched` here).
|
||||||
|
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
|
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
|
||||||
@@ -73,11 +74,16 @@ describe("ChannelNameLink", () => {
|
|||||||
expect(onOpenInApp).not.toHaveBeenCalled();
|
expect(onOpenInApp).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not lose the rest of the address bar", () => {
|
// The address names only what the screen shows: the channel over the page, not the page's filters
|
||||||
window.history.replaceState({}, "", "/?page=channels&q=abc");
|
// (a link's new tab once opened the channel under the feed's search and sort).
|
||||||
|
it("keeps the page and the shell's keys, and leaves the page's and another channel's behind", () => {
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
"/?page=channels&q=abc&ctab=hidden&chtab=about&m=signup#x=1",
|
||||||
|
);
|
||||||
const { link } = renderLink();
|
const { link } = renderLink();
|
||||||
expect(link.getAttribute("href")).toContain("page=channels");
|
expect(link.getAttribute("href")).toBe(`/?page=channels&chan=${CH}&m=signup#x=1`);
|
||||||
expect(link.getAttribute("href")).toContain("q=abc");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives a channel with no title yet an accessible name", () => {
|
it("gives a channel with no title yet an accessible name", () => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { forwardRef, type AnchorHTMLAttributes } from "react";
|
import { forwardRef, type AnchorHTMLAttributes } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { isPlainLeftClick } from "../../lib/nav";
|
import { isPlainLeftClick } from "../../lib/nav";
|
||||||
import { urlWithParams } from "../../lib/urlState";
|
import { channelUrl } from "../../lib/urlState";
|
||||||
|
|
||||||
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
|
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
|
||||||
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
|
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
|
||||||
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
|
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
|
||||||
// card's channel name had the same `<button>`, so it renders through here too.
|
// card's channel name had the same `<button>`, so it renders through here too.
|
||||||
//
|
//
|
||||||
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
// The href is the same URL `openChannel` pushes (`channelUrl`: the channel over the current page,
|
||||||
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
// without the page's own keys) — so a new tab lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||||
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
||||||
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
|
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
|
||||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||||
@@ -33,7 +33,7 @@ const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNam
|
|||||||
<a
|
<a
|
||||||
{...rest}
|
{...rest}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
href={urlWithParams({ chan: channelId })}
|
href={channelUrl(channelId)}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (stopPropagation) e.stopPropagation();
|
if (stopPropagation) e.stopPropagation();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { notify, resolveVideo } from "../../lib/notifications";
|
import { msg, notify, raw, resolveVideo } from "../../lib/notifications";
|
||||||
import type { Video, VideoStatus } from "../../lib/api";
|
import type { Video, VideoStatus } from "../../lib/api";
|
||||||
|
|
||||||
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */
|
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */
|
||||||
@@ -24,9 +24,9 @@ export function announceVideoState(
|
|||||||
const hidden = status === "hidden";
|
const hidden = status === "hidden";
|
||||||
notify({
|
notify({
|
||||||
message: video?.title
|
message: video?.title
|
||||||
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
|
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
|
||||||
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
|
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||||
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||||
meta: {
|
meta: {
|
||||||
kind: hidden ? "video-hidden" : "video-watched",
|
kind: hidden ? "video-hidden" : "video-watched",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
|||||||
import { useNavigationActions } from "../navigation/navigationContext";
|
import { useNavigationActions } from "../navigation/navigationContext";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
|
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
|
||||||
|
import { raw } from "../../lib/notifications";
|
||||||
|
|
||||||
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
|
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
|
||||||
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
|
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
|
||||||
@@ -198,7 +199,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
|
...(withPin ? pin.item(pinVideo(v.id), raw(v.title ?? "")) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
|
|||||||
|
|
||||||
import OnboardingWizard from "./OnboardingWizard";
|
import OnboardingWizard from "./OnboardingWizard";
|
||||||
import { api, type Me } from "../../lib/api";
|
import { api, type Me } from "../../lib/api";
|
||||||
import { clearAll, notify } from "../../lib/notifications";
|
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||||
|
|
||||||
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
|
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
|
|||||||
document.body.appendChild(outside);
|
document.body.appendChild(outside);
|
||||||
outside.focus();
|
outside.focus();
|
||||||
const { unmount } = mount();
|
const { unmount } = mount();
|
||||||
act(() => void notify({ level: "error", message: "offline" }));
|
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||||
dismissToast.focus();
|
dismissToast.focus();
|
||||||
expect(document.activeElement).toBe(dismissToast);
|
expect(document.activeElement).toBe(dismissToast);
|
||||||
@@ -126,7 +126,7 @@ describe("the onboarding wizard as a dialog", () => {
|
|||||||
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
|
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
|
||||||
mount();
|
mount();
|
||||||
const panel = screen.getByRole("dialog");
|
const panel = screen.getByRole("dialog");
|
||||||
act(() => void notify({ level: "error", message: "offline" }));
|
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||||
try {
|
try {
|
||||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||||
expect(panel.contains(dismissToast)).toBe(false);
|
expect(panel.contains(dismissToast)).toBe(false);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { RUN_STATUSES } from "../lib/activityUrlState";
|
import { RUN_STATUSES } from "../lib/activityUrlState";
|
||||||
|
import {
|
||||||
|
REFUSAL_BY_REASON,
|
||||||
|
REFUSAL_FAMILIES,
|
||||||
|
REFUSAL_FIXED,
|
||||||
|
REFUSAL_UNSHOWN,
|
||||||
|
} from "../lib/api/refusalCodes";
|
||||||
import { vocabKey } from "../lib/vocab";
|
import { vocabKey } from "../lib/vocab";
|
||||||
|
|
||||||
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
||||||
@@ -21,6 +29,25 @@ import { vocabKey } from "../lib/vocab";
|
|||||||
const backend = (file: string) =>
|
const backend = (file: string) =>
|
||||||
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
|
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
|
||||||
|
|
||||||
|
// Every refusal the backend can answer, read from its syntax tree by `backend/tests/refusal_sites.py`
|
||||||
|
// (stdlib `ast`, on the host's Python, as the ruff lane is): each code with every reason it can be sent
|
||||||
|
// with — a reason in a variable followed to its `Literal` type, its helper's every call, its map — and
|
||||||
|
// what it could not follow, which fails below. It also hands back every `Literal` alias it read.
|
||||||
|
type RefusalScan = {
|
||||||
|
codes: Record<string, { reasons: string[]; unread: string[]; reasonless: string[] }>;
|
||||||
|
unreadCodes: string[];
|
||||||
|
errors: string[];
|
||||||
|
notRefusals: string[];
|
||||||
|
excluded: Record<string, number>;
|
||||||
|
aliases: Record<string, Record<string, string[]>>;
|
||||||
|
};
|
||||||
|
const scan = JSON.parse(
|
||||||
|
execFileSync("python", ["-I", "backend/tests/refusal_sites.py"], {
|
||||||
|
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||||
|
encoding: "utf-8",
|
||||||
|
}),
|
||||||
|
) as RefusalScan;
|
||||||
|
|
||||||
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
|
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
|
||||||
string,
|
string,
|
||||||
{ default: Record<string, unknown> }
|
{ default: Record<string, unknown> }
|
||||||
@@ -48,12 +75,11 @@ const keysOf = (lang: string): Set<string> => {
|
|||||||
const specs = [
|
const specs = [
|
||||||
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
|
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
|
||||||
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
|
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
|
||||||
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …` → `<Alias> = Literal[…]`.
|
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …`, the alias's values from the scan.
|
||||||
const configPy = backend("config.py");
|
const configPy = backend("config.py");
|
||||||
const choicesOf = (attr: string): string[] => {
|
const choicesOf = (attr: string): string[] => {
|
||||||
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
|
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
|
||||||
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1];
|
return (alias && scan.aliases["config.py"]?.[alias]) || [];
|
||||||
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
|
|
||||||
};
|
};
|
||||||
const choiceIds = specs
|
const choiceIds = specs
|
||||||
.filter((s) => s.type === "choice")
|
.filter((s) => s.type === "choice")
|
||||||
@@ -84,20 +110,10 @@ const runEnum = (name: string) =>
|
|||||||
const runStatuses = runEnum("RunStatus");
|
const runStatuses = runEnum("RunStatus");
|
||||||
const runTriggers = runEnum("RunTrigger");
|
const runTriggers = runEnum("RunTrigger");
|
||||||
const quotaActions = enumValues("quota.py");
|
const quotaActions = enumValues("quota.py");
|
||||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
// The admin actions' refusals → `errors.admin.<reason>`: the codes recorded under that prefix.
|
||||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
const adminCodes = Object.entries(REFUSAL_FAMILIES)
|
||||||
// ruff wraps over several lines still matches.
|
.filter(([, f]) => f.prefix === "errors.admin")
|
||||||
const adminCodes = [
|
.map(([code]) => code);
|
||||||
...(
|
|
||||||
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
|
|
||||||
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
|
|
||||||
)?.[1] ?? ""
|
|
||||||
).matchAll(/"(\w+)"/g),
|
|
||||||
].map((m) => m[1]!);
|
|
||||||
const adminRefusals = [
|
|
||||||
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
|
|
||||||
].filter((m) => adminCodes.includes(m[1]!));
|
|
||||||
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
|
|
||||||
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
|
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
|
||||||
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
|
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
|
||||||
// rather than splitting on dots.
|
// rather than splitting on dots.
|
||||||
@@ -118,102 +134,46 @@ const failCategories = [
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
|
// Every backend source, by its path under app/.
|
||||||
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
const appPaths = (dir = ""): string[] =>
|
||||||
const routeFiles = (dir = "routes"): string[] =>
|
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
|
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
}).flatMap((e) =>
|
}).flatMap((e) =>
|
||||||
e.isDirectory()
|
e.isDirectory()
|
||||||
? routeFiles(`${dir}/${e.name}`)
|
? e.name === "__pycache__"
|
||||||
|
? []
|
||||||
|
: appPaths(`${dir}${e.name}/`)
|
||||||
: e.name.endsWith(".py")
|
: e.name.endsWith(".py")
|
||||||
? [`${dir}/${e.name}`]
|
? [`${dir}${e.name}`]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const routeSources = (): string[] => routeFiles().map(backend);
|
|
||||||
// The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler
|
// The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler
|
||||||
// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` /
|
// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` /
|
||||||
// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal
|
// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal
|
||||||
// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered.
|
// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered.
|
||||||
const appFiles = (dir: string): string[] =>
|
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
|
||||||
withFileTypes: true,
|
|
||||||
}).flatMap((e) =>
|
|
||||||
e.isDirectory()
|
|
||||||
? e.name === "routes" || e.name === "__pycache__"
|
|
||||||
? []
|
|
||||||
: appFiles(`${dir}${e.name}/`)
|
|
||||||
: e.name.endsWith(".py") && `${dir}${e.name}` !== "main.py"
|
|
||||||
? [`${dir}${e.name}`]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const skipWords = [
|
const skipWords = [
|
||||||
...new Set(
|
...new Set(
|
||||||
appFiles("").flatMap((f) =>
|
appPaths()
|
||||||
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
|
.filter((f) => !f.startsWith("routes/") && f !== "main.py")
|
||||||
),
|
.flatMap((f) =>
|
||||||
),
|
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
|
||||||
];
|
|
||||||
// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid
|
|
||||||
// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable.
|
|
||||||
const opaqueWhats = routeFiles()
|
|
||||||
.filter((f) => !f.endsWith("/_common.py"))
|
|
||||||
.flatMap((f) =>
|
|
||||||
[...backend(f).matchAll(/not_found\((?!"|\))[^)]*\)|what=(?!")\S+/g)].map(
|
|
||||||
(m) => `${f}: ${m[0]}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const goneWhats = [
|
|
||||||
...new Set(
|
|
||||||
routeSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
|
||||||
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
|
|
||||||
// `setting_refused` picks from at runtime, and every `FolderError` reason.
|
|
||||||
const appSources = (dir = ""): string[] =>
|
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
|
||||||
withFileTypes: true,
|
|
||||||
}).flatMap((e) =>
|
|
||||||
e.isDirectory()
|
|
||||||
? appSources(`${dir}${e.name}/`)
|
|
||||||
: e.name.endsWith(".py")
|
|
||||||
? [backend(`${dir}${e.name}`)]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const refusedReasons = [
|
|
||||||
...new Set([
|
|
||||||
...appSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
|
||||||
),
|
|
||||||
// A folder change the rules refuse travels as `FolderError("<reason>")` and is answered with
|
|
||||||
// `refused(409, e.reason)`, which the pattern above cannot read.
|
|
||||||
...appSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/FolderError\("(\w+)"\)/g)].map((m) => m[1]!),
|
|
||||||
),
|
|
||||||
...[
|
|
||||||
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
|
||||||
/:\s*"(\w+)"/g,
|
|
||||||
),
|
),
|
||||||
].map((m) => m[1]!),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
|
||||||
const youtubeReasons = [
|
|
||||||
...(
|
|
||||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
|
||||||
).matchAll(/"(\w+)"/g),
|
|
||||||
].map((m) => m[1]!);
|
|
||||||
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
|
|
||||||
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
|
|
||||||
const clipReasons = [
|
|
||||||
...new Set(
|
|
||||||
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const backendCodes = Object.keys(scan.codes);
|
||||||
|
const recordedCodes = [
|
||||||
|
...Object.keys(REFUSAL_FAMILIES),
|
||||||
|
...Object.keys(REFUSAL_BY_REASON),
|
||||||
|
...Object.keys(REFUSAL_FIXED),
|
||||||
|
...Object.keys(REFUSAL_UNSHOWN),
|
||||||
|
];
|
||||||
|
// The codes whose reason picks the sentence, and what each can carry as its reason.
|
||||||
|
const reasoned = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
|
||||||
|
const reasonsOf: Record<string, string[]> = Object.fromEntries(
|
||||||
|
reasoned.map((code) => [code, scan.codes[code]?.reasons ?? []]),
|
||||||
|
);
|
||||||
|
|
||||||
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
|
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
|
||||||
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
|
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
|
||||||
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
|
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
|
||||||
@@ -275,10 +235,11 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
|
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
|
||||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
||||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
expect(reasonsOf.not_found).toEqual(
|
||||||
expect(opaqueWhats).toEqual([]);
|
expect.arrayContaining(["playlist", "download", "job", "user", "debug_session"]),
|
||||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
);
|
||||||
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
expect(reasonsOf.youtube).toEqual(["gone", "quota", "reconnect", "unavailable"]);
|
||||||
|
expect(reasonsOf.clip).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
||||||
expect(crewJobs).toEqual(
|
expect(crewJobs).toEqual(
|
||||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||||
);
|
);
|
||||||
@@ -294,11 +255,46 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
"no service user",
|
"no service user",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(refusedReasons).toEqual(
|
expect(reasonsOf.refused).toEqual(
|
||||||
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
expect.arrayContaining([
|
||||||
|
"weak_password",
|
||||||
|
"setting_too_low",
|
||||||
|
"plex_write_failed",
|
||||||
|
"file_folder_gone",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
});
|
||||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
|
||||||
|
it("every refusal code the backend answers is said by exactly one record, and no record names another", () => {
|
||||||
|
expect(backendCodes).toEqual(expect.arrayContaining(["refused", "playlist", "too_large"]));
|
||||||
|
expect(backendCodes.filter((c) => recordedCodes.filter((r) => r === c).length !== 1)).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every refusal code and reason is one the scan can follow", () => {
|
||||||
|
// The modules set aside as not refusals still use the key.
|
||||||
|
expect(Object.keys(scan.excluded).sort()).toEqual(scan.notRefusals);
|
||||||
|
expect(scan.unreadCodes).toEqual([]);
|
||||||
|
expect(scan.errors).toEqual([]);
|
||||||
|
// A reason it could not follow is a sentence nobody is asked for, unless no screen says that code.
|
||||||
|
expect(
|
||||||
|
Object.entries(scan.codes)
|
||||||
|
.filter(([code]) => !(code in REFUSAL_UNSHOWN))
|
||||||
|
.flatMap(([, c]) => c.unread),
|
||||||
|
).toEqual([]);
|
||||||
|
// A code whose reason picks the sentence, sent without one, says only its fallback.
|
||||||
|
expect(reasoned.flatMap((code) => scan.codes[code]?.reasonless ?? [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every code whose reason picks the sentence is read with a reason (a scan that matches nothing is green over nothing)", () => {
|
||||||
|
expect(Object.entries(reasonsOf).filter(([, rs]) => rs.length === 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a code whose reasons share sentences names every reason it is sent with, and no other", () => {
|
||||||
|
for (const [code, { reasons }] of Object.entries(REFUSAL_BY_REASON))
|
||||||
|
expect(Object.keys(reasons).sort(), code).toEqual([...reasonsOf[code]!].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the run list's status chips are the server's run statuses", () => {
|
it("the run list's status chips are the server's run statuses", () => {
|
||||||
@@ -418,33 +414,6 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => {
|
|
||||||
expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]);
|
|
||||||
expect(
|
|
||||||
[...idsUnder(keys, "errors.notFound")].filter(
|
|
||||||
(w) => w !== "generic" && !goneWhats.includes(w),
|
|
||||||
),
|
|
||||||
).toEqual([]);
|
|
||||||
expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]);
|
|
||||||
expect(
|
|
||||||
[...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
|
|
||||||
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
|
|
||||||
expect(
|
|
||||||
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${lang}: every reason a clip is refused, and none it cannot be`, () => {
|
|
||||||
expect(missing(clipReasons.map((r) => `clip.errors.${r}`))).toEqual([]);
|
|
||||||
expect([...idsUnder(keys, "clip.errors")].filter((r) => !clipReasons.includes(r))).toEqual(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
|
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
|
||||||
for (const [group, words] of Object.entries(VOCAB)) {
|
for (const [group, words] of Object.entries(VOCAB)) {
|
||||||
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
||||||
@@ -456,11 +425,49 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`${lang}: every admin refusal reason`, () => {
|
it(`${lang}: every refusal the backend can answer has its sentence`, () => {
|
||||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
const wanted = [
|
||||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
...Object.entries(REFUSAL_FAMILIES).flatMap(([code, f]) => [
|
||||||
[],
|
...reasonsOf[code]!.map((r) => `${f.prefix}.${r}${"suffix" in f ? f.suffix : ""}`),
|
||||||
);
|
f.fallback,
|
||||||
|
]),
|
||||||
|
...Object.values(REFUSAL_BY_REASON).flatMap((f) => [
|
||||||
|
...Object.values(f.reasons),
|
||||||
|
f.fallback,
|
||||||
|
]),
|
||||||
|
...Object.values(REFUSAL_FIXED),
|
||||||
|
"errors.generic",
|
||||||
|
];
|
||||||
|
expect(missing([...new Set(wanted)])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
|
||||||
|
// By prefix: the admin actions share `errors.admin`. A suffixed family shares its prefix with more
|
||||||
|
// than its refusals — the sign-in screens' `gate`, `addressLabel` — so only an id with the
|
||||||
|
// refusal's own key counts as a reason there.
|
||||||
|
const byPrefix = new Map<
|
||||||
|
string,
|
||||||
|
{ suffix: string; reasons: Set<string>; spare: Set<string> }
|
||||||
|
>();
|
||||||
|
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
|
||||||
|
const p = byPrefix.get(f.prefix) ?? {
|
||||||
|
suffix: "suffix" in f ? f.suffix : "",
|
||||||
|
reasons: new Set<string>(),
|
||||||
|
spare: new Set<string>(),
|
||||||
|
};
|
||||||
|
for (const r of reasonsOf[code]!) p.reasons.add(r);
|
||||||
|
if (f.fallback.startsWith(`${f.prefix}.`))
|
||||||
|
p.spare.add(f.fallback.slice(f.prefix.length + 1));
|
||||||
|
byPrefix.set(f.prefix, p);
|
||||||
|
}
|
||||||
|
for (const [prefix, { suffix, reasons, spare }] of byPrefix)
|
||||||
|
expect(
|
||||||
|
[...idsUnder(keys, prefix)].filter(
|
||||||
|
(r) =>
|
||||||
|
(!suffix || keys.has(`${prefix}.${r}${suffix}`)) && !spare.has(r) && !reasons.has(r),
|
||||||
|
),
|
||||||
|
prefix,
|
||||||
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,6 +219,7 @@
|
|||||||
"edit": {
|
"edit": {
|
||||||
"empty_edit": "Choose a trim range or crop area first",
|
"empty_edit": "Choose a trim range or crop area first",
|
||||||
"source_not_ready": "The source download isn't ready to edit",
|
"source_not_ready": "The source download isn't ready to edit",
|
||||||
|
"not_media": "Only video and audio can be edited",
|
||||||
"generic": "Couldn't create that edit"
|
"generic": "Couldn't create that edit"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
|
|||||||
@@ -75,6 +75,9 @@
|
|||||||
"clearNow": "Clear now",
|
"clearNow": "Clear now",
|
||||||
"cleared_one": "Cleared {{count}} result",
|
"cleared_one": "Cleared {{count}} result",
|
||||||
"cleared_other": "Cleared {{count}} results",
|
"cleared_other": "Cleared {{count}} results",
|
||||||
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size."
|
"showHint": "How many results to fetch at a time — “Load more” pulls another batch of this size.",
|
||||||
|
"notRunTitle": "Not searched yet",
|
||||||
|
"notRunBody": "A YouTube API search spends 100 units of the shared daily quota, so a reload, a link, Back or Forward doesn't start one by itself.",
|
||||||
|
"runAgain": "Search YouTube"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user