Compare commits
35
Commits
41ad538edc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242f00671d | ||
|
|
7f8d7cbe20 | ||
|
|
8dd2f0c015 | ||
|
|
b3402d158f | ||
|
|
c910cf2f20 | ||
|
|
245c0a2019 | ||
|
|
add7f7d167 | ||
|
|
a29afd916d | ||
|
|
e9ce989998 | ||
|
|
7299e82c22 | ||
|
|
41052eb4ea | ||
|
|
85fd9b6704 | ||
|
|
e06c0688d9 | ||
|
|
15a3603513 | ||
|
|
c430558ab3 | ||
|
|
be38d652a5 | ||
|
|
31a75f4985 | ||
|
|
f8a8e905da | ||
|
|
5e8589bbf8 | ||
|
|
ceadf69e43 | ||
|
|
4379f76747 | ||
|
|
b3be568e36 | ||
|
|
a8cbc7b338 | ||
|
|
cf8dc80428 | ||
|
|
da74b8b9bf | ||
|
|
6944622085 | ||
|
|
2bdcebdb34 | ||
|
|
046dde44a4 | ||
|
|
74f58ea001 | ||
|
|
b515c16117 | ||
|
|
4a0d4b0964 | ||
|
|
cb8b99e0e7 | ||
|
|
e5c75702e8 | ||
|
|
4ef772d37b | ||
|
|
6986eb1937 |
@@ -7,6 +7,39 @@ 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.115.0 — 2026-11-01
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else.
|
||||||
|
- Channels have an Added column, and an About card opens only when the pointer rests on it.
|
||||||
|
- In Files, a press anywhere on a row plays the file.
|
||||||
|
- The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left.
|
||||||
|
- A Facebook download titled only “Facebook” now takes the post's text as its name.
|
||||||
|
- The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it.
|
||||||
|
|
||||||
|
## 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
|
## 0.114.0 — 2026-10-29
|
||||||
|
|
||||||
### 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) |
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ class SourceAdapter:
|
|||||||
"""The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's."""
|
"""The download's raw title, before `titles.normalize_title` cleans it. Generic: yt-dlp's."""
|
||||||
return info.get("title")
|
return info.get("title")
|
||||||
|
|
||||||
|
def is_placeholder_title(self, title: str | None) -> bool:
|
||||||
|
"""Whether `title` is a stand-in the source gives in place of the real one (worth one more,
|
||||||
|
signed-out read of the page). Generic: never."""
|
||||||
|
return False
|
||||||
|
|
||||||
def uploader_url(self, info: dict) -> str | None:
|
def uploader_url(self, info: dict) -> str | None:
|
||||||
"""The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives
|
"""The channel/uploader page URL. Generic: whatever yt-dlp exposes directly (YouTube gives
|
||||||
`channel_url`), else None (reddit / a raw media URL have no derivable channel page)."""
|
`channel_url`), else None (reddit / a raw media URL have no derivable channel page)."""
|
||||||
@@ -201,11 +206,17 @@ class FacebookAdapter(SourceAdapter):
|
|||||||
# Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an
|
# Measured (2026-09-14): a SIGNED-IN read gives the placeholder "Video" (plain and as Chrome), and an
|
||||||
# 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A
|
# 18+ reel only readable as Chrome gives "Facebook"; the post's text is still in `description`. A
|
||||||
# signed-out read titles the page with that same text behind the engagement counts, cut short.
|
# signed-out read titles the page with that same text behind the engagement counts, cut short.
|
||||||
|
# Measured (2026-11-01, yt-dlp 2026.08.19): since 2026-10-28 every signed-in reel read gives
|
||||||
|
# "Facebook" and NO description, while a signed-out read of the same reel still carries the text —
|
||||||
|
# so a placeholder left with nothing behind it gets one signed-out read (`backfill.public_title_info`).
|
||||||
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
|
_PLACEHOLDER_TITLES = frozenset({"video", "facebook"})
|
||||||
|
|
||||||
|
def is_placeholder_title(self, title: str | None) -> bool:
|
||||||
|
return (title or "").strip().lower() in self._PLACEHOLDER_TITLES
|
||||||
|
|
||||||
def title(self, info: dict) -> str | None:
|
def title(self, info: dict) -> str | None:
|
||||||
raw = info.get("title")
|
raw = info.get("title")
|
||||||
if (raw or "").strip().lower() not in self._PLACEHOLDER_TITLES:
|
if not self.is_placeholder_title(raw):
|
||||||
return raw
|
return raw
|
||||||
line = next(
|
line = next(
|
||||||
(ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), ""
|
(ln.strip() for ln in (info.get("description") or "").splitlines() if ln.strip()), ""
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ auto-detected channel becomes a clickable link) and `poster_path` (an ffmpeg fra
|
|||||||
thumbnail-less sources, so the card / watch page / link preview still shows an image). Best-effort
|
thumbnail-less sources, so the card / watch page / link preview still shows an image). Best-effort
|
||||||
and safe to re-run: it only touches assets still missing the field, and only re-fetches YouTube /
|
and safe to re-run: it only touches assets still missing the field, and only re-fetches YouTube /
|
||||||
Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed
|
Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed
|
||||||
URL may have expired — those just get a locally-generated poster instead).
|
URL may have expired — those just get a locally-generated poster instead). A Facebook download
|
||||||
|
titled with the source's placeholder ("Facebook", "Video") is retitled from a signed-out read of its
|
||||||
|
page, and so is each download name that only repeated the placeholder. That page read
|
||||||
|
(`public_title_info`) also titles every new download, from the worker.
|
||||||
|
|
||||||
Run once per environment:
|
Run once per environment:
|
||||||
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
|
||||||
@@ -14,7 +17,8 @@ Run once per environment:
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import logs
|
from app import logs
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -22,12 +26,14 @@ from app.db import SessionLocal
|
|||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, service
|
from app.downloads import formats, service
|
||||||
from app.downloads.models import MediaAsset
|
from app.downloads.models import DownloadJob, MediaAsset
|
||||||
|
from app.models import DISPLAY_TEXT_MAX
|
||||||
|
from app.titles import normalize_title
|
||||||
|
|
||||||
log = logs.get_logger(__name__)
|
log = logs.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
|
def extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
|
||||||
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
|
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
|
||||||
needs the POT/player-client strategy even just to extract)."""
|
needs the POT/player-client strategy even just to extract)."""
|
||||||
with formats.hiding(url):
|
with formats.hiding(url):
|
||||||
@@ -62,8 +68,43 @@ def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: in
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def public_title_info(info: dict, url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict:
|
||||||
|
"""`info` to title a download from: itself, unless its title is a source's placeholder with
|
||||||
|
nothing behind it (a signed-in Facebook read: "Facebook", no description). Then one signed-out,
|
||||||
|
metadata-only read of the page lends its text — the description, else its title — as the
|
||||||
|
description the adapter takes a placeholder's title from. Nothing better there: `info` as it was."""
|
||||||
|
if not adapter.is_placeholder_title(adapter.title(info)):
|
||||||
|
return info
|
||||||
|
public = extract_meta(url, adapter, asset_id)
|
||||||
|
text = public and (public.get("description") or public.get("title"))
|
||||||
|
return {**info, "description": text} if text else info
|
||||||
|
|
||||||
|
|
||||||
|
def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: str) -> bool:
|
||||||
|
"""Swap a placeholder title kept from an earlier download for the page's text, and the download
|
||||||
|
names that only repeated it. A name its owner gave (anything else) stays."""
|
||||||
|
if not adapter.is_placeholder_title(a.title):
|
||||||
|
return False
|
||||||
|
info = public_title_info({"title": a.title, "description": None}, url, adapter, a.id)
|
||||||
|
named = normalize_title(adapter.title(info))
|
||||||
|
if not named or adapter.is_placeholder_title(named):
|
||||||
|
return False
|
||||||
|
# One conditional write, not a read and a compare: a name its owner gives while the page is read
|
||||||
|
# is not overwritten by what was read before it. The asset first, as every other writer of the
|
||||||
|
# two locks them (`service.lock_own_job`): the session does not flush by itself.
|
||||||
|
placeholder = a.title
|
||||||
|
a.title = named
|
||||||
|
db.flush()
|
||||||
|
db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.asset_id == a.id, DownloadJob.display_name == placeholder)
|
||||||
|
.values(display_name=named[:DISPLAY_TEXT_MAX])
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def run() -> dict:
|
def run() -> dict:
|
||||||
filled_url = filled_poster = 0
|
filled_url = filled_poster = retitled = 0
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
assets = (
|
assets = (
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -78,7 +119,7 @@ def run() -> dict:
|
|||||||
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
|
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
|
||||||
if a.uploader_url is None and adapter.wants_metadata_backfill():
|
if a.uploader_url is None and adapter.wants_metadata_backfill():
|
||||||
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
||||||
info = _extract_meta(url, adapter, a.id)
|
info = extract_meta(url, adapter, a.id)
|
||||||
uu = adapter.uploader_url(info) if info else None
|
uu = adapter.uploader_url(info) if info else None
|
||||||
if uu:
|
if uu:
|
||||||
a.uploader_url = uu
|
a.uploader_url = uu
|
||||||
@@ -86,6 +127,14 @@ def run() -> dict:
|
|||||||
log.info(
|
log.info(
|
||||||
"asset %s uploader_url filled", a.id
|
"asset %s uploader_url filled", a.id
|
||||||
) # the URL names what they downloaded
|
) # the URL names what they downloaded
|
||||||
|
if adapter.wants_metadata_backfill():
|
||||||
|
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
|
||||||
|
if _retitle(db, a, adapter, url):
|
||||||
|
# Before the poster step: it can cut a frame for 30 s, and these locks held
|
||||||
|
# across it block a rename and deadlock a delete.
|
||||||
|
db.commit()
|
||||||
|
retitled += 1
|
||||||
|
log.info("asset %s retitled", a.id) # the title names what they downloaded
|
||||||
if a.poster_path is None:
|
if a.poster_path is None:
|
||||||
# Record a self-hosted poster for every download (the reliable og:image source):
|
# Record a self-hosted poster for every download (the reliable og:image source):
|
||||||
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
|
||||||
@@ -99,6 +148,7 @@ def run() -> dict:
|
|||||||
"assets": len(assets),
|
"assets": len(assets),
|
||||||
"filled_uploader_url": filled_url,
|
"filled_uploader_url": filled_url,
|
||||||
"filled_poster": filled_poster,
|
"filled_poster": filled_poster,
|
||||||
|
"retitled": retitled,
|
||||||
}
|
}
|
||||||
log.info("backfill done: %s", result)
|
log.info("backfill done: %s", result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import date, datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
from sqlalchemy import update
|
||||||
from yt_dlp.utils import download_range_func
|
from yt_dlp.utils import download_range_func
|
||||||
|
|
||||||
from app import logs, sysconfig
|
from app import logs, sysconfig
|
||||||
@@ -16,6 +17,7 @@ from app.downloads import clip as clipmod
|
|||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
|
||||||
|
from app.downloads import backfill
|
||||||
from app.downloads import service, storage
|
from app.downloads import service, storage
|
||||||
from app.downloads import urlguard
|
from app.downloads import urlguard
|
||||||
from app.downloads.jobdb import (
|
from app.downloads.jobdb import (
|
||||||
@@ -585,9 +587,14 @@ def _download(
|
|||||||
# missing duration hid the trim action. Probed before the sidecars so the .nfo runtime has it
|
# missing duration hid the trim action. Probed before the sidecars so the .nfo runtime has it
|
||||||
# too. Same probe the upload ingest uses.
|
# too. Same probe the upload ingest uses.
|
||||||
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
|
vcodec, acodec, width, height, duration_s = probe.resolve_media_facts(produced, info)
|
||||||
|
# A placeholder title with nothing behind it ("Facebook" from a signed-in read) borrows the
|
||||||
|
# page's text from one signed-out read; the download itself stays the signed-in one. The page
|
||||||
|
# yt-dlp read, not the link queued: a group post's link is rewritten to its video, and
|
||||||
|
# signed out the post itself is the login wall.
|
||||||
|
titled = backfill.public_title_info(info, info.get("webpage_url") or url, adapter, asset_id)
|
||||||
meta = storage.MediaMeta(
|
meta = storage.MediaMeta(
|
||||||
video_id=info.get("id") or source_ref,
|
video_id=info.get("id") or source_ref,
|
||||||
title=normalize_title(adapter.title(info)) or source_ref,
|
title=normalize_title(adapter.title(titled)) or source_ref,
|
||||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||||
@@ -1116,14 +1123,20 @@ def _process_clip(job_id: int, asset_id: int) -> None:
|
|||||||
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
|
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
|
||||||
placeholder = service.clip_title(None, None)
|
placeholder = service.clip_title(None, None)
|
||||||
|
named = None
|
||||||
if asset and fetched_title and asset.title == placeholder:
|
if asset and fetched_title and asset.title == placeholder:
|
||||||
named = service.clip_title(fetched_title, None)
|
named = service.clip_title(fetched_title, None)
|
||||||
asset.title = named
|
asset.title = named
|
||||||
if job_row := db.get(DownloadJob, job_id):
|
|
||||||
if job_row.display_name == placeholder:
|
|
||||||
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
|
|
||||||
title = asset.title if asset else None
|
title = asset.title if asset else None
|
||||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||||
|
if finalized and named:
|
||||||
|
# Only a name that still repeats the placeholder, in one conditional write: one its
|
||||||
|
# owner gave meanwhile stays.
|
||||||
|
db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id, DownloadJob.display_name == placeholder)
|
||||||
|
.values(display_name=named[:DISPLAY_TEXT_MAX])
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
if finalized:
|
if finalized:
|
||||||
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
_notify_download_done(db, user_id, title, job_id, kind="edit")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -255,17 +255,6 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def clear_state(db: Session, user: User, video_id: str) -> None:
|
|
||||||
"""The video back to pristine for this user: status, resume point and `watched_at` all go.
|
|
||||||
Playlist membership lives elsewhere and stays."""
|
|
||||||
lock_takeout(db, user) # as `unmark`
|
|
||||||
row = queries.video_state(db, user, video_id)
|
|
||||||
if row is not None:
|
|
||||||
db.delete(row)
|
|
||||||
_remember_unmark(db, user, video_id)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
|
def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
|
||||||
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
|
"""The player's resume point. 0 clears it: a row holding nothing else goes, one with a status
|
||||||
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
|
(watched, hidden) keeps it with the position zeroed. The status itself is never changed here.
|
||||||
@@ -301,12 +290,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")
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_
|
|||||||
"details_synced": ch.details_synced_at is not None,
|
"details_synced": ch.details_synced_at is not None,
|
||||||
"recent_synced": ch.recent_synced_at is not None,
|
"recent_synced": ch.recent_synced_at is not None,
|
||||||
"backfill_done": ch.backfill_done,
|
"backfill_done": ch.backfill_done,
|
||||||
|
# When the follow reached this list: written by the subscription import the run that
|
||||||
|
# first saw it, or by a subscribe made here — not the channel row's own date, which an
|
||||||
|
# explored or discovered channel had before it was followed.
|
||||||
|
"added_at": iso(sub.created_at),
|
||||||
}
|
}
|
||||||
for sub, ch in rows
|
for sub, ch in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -204,22 +204,21 @@ def set_video_state(
|
|||||||
return {"ok": True, "video_id": video_id, "status": status}
|
return {"ok": True, "video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{video_id}/state")
|
@router.delete("/videos/{video_id}/progress")
|
||||||
def clear_video_state(
|
def clear_video_progress(
|
||||||
video_id: str,
|
video_id: str,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Reset a video to pristine for this user — drop the whole VideoState row (status,
|
"""Forget where the user left a video: its resume point goes, its status (watched, hidden) stays.
|
||||||
watch position and watched_at), as if it had never been opened. Saved/playlist membership
|
Saved/playlist membership lives elsewhere and is untouched."""
|
||||||
lives elsewhere and is untouched."""
|
|
||||||
try:
|
try:
|
||||||
service.clear_state(db, user, video_id)
|
service.set_progress(db, user, video_id, 0)
|
||||||
db.commit()
|
db.commit()
|
||||||
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
|
# silent-ok: the row went under the write, which is the outcome the write wanted.
|
||||||
except StaleDataError:
|
except StaleDataError:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
|
return {"ok": True, "video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/progress")
|
@router.post("/videos/{video_id}/progress")
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# `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).
|
# 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.9.1
|
Authlib==1.9.1
|
||||||
Mako==1.4.1
|
Mako==1.4.1
|
||||||
@@ -39,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,
|
||||||
|
)
|
||||||
@@ -72,3 +72,23 @@ def test_the_sync_status_counts_the_callers_subscriptions(db, make_user, as_user
|
|||||||
assert (body["subscriptions"], body["channels_total"]) == (1, 1)
|
assert (body["subscriptions"], body["channels_total"]) == (1, 1)
|
||||||
mine = as_user(user).get("/api/sync/my-status").json()
|
mine = as_user(user).get("/api/sync/my-status").json()
|
||||||
assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1)
|
assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_channel_lists_when_it_entered_this_users_list_not_when_the_channel_did(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
user = make_user()
|
||||||
|
db.add(Channel(id="UCa", title="A", created_at=datetime(2026, 6, 10, tzinfo=timezone.utc)))
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Subscription(
|
||||||
|
user_id=user.id,
|
||||||
|
channel_id="UCa",
|
||||||
|
created_at=datetime(2026, 10, 12, 8, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
listed = as_user(user).get("/api/channels").json()
|
||||||
|
assert [(c["id"], c["added_at"]) for c in listed] == [("UCa", "2026-10-12T08:30:00+00:00")]
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""A Facebook download's title when the signed-in read gives only a placeholder.
|
||||||
|
|
||||||
|
Measured on prod (2026-11-01): every Facebook download since 2026-10-28 is titled "Facebook" — the
|
||||||
|
signed-in read (the owner's cookie) now gives that placeholder and NO description, so the adapter had
|
||||||
|
no post text to fall back on, and the download name copied the placeholder too. A signed-out read of
|
||||||
|
the same reel (yt-dlp 2026.08.19, as on prod) still carries the post's text in its description.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.downloads import adapters, backfill, pipeline
|
||||||
|
from app.downloads.models import DownloadJob, MediaAsset
|
||||||
|
|
||||||
|
_URL = "https://www.facebook.com/reel/1860682468422402/"
|
||||||
|
# The page yt-dlp says it read for it (`webpage_url`); a group post's link resolves the same way.
|
||||||
|
_PAGE = "https://www.facebook.com/watch/?v=1860682468422402"
|
||||||
|
# The measured signed-out read of that reel.
|
||||||
|
_PUBLIC = {
|
||||||
|
"title": "163K reactions · 14K shares | sorry not helping #workhumor #officelife | Work dot exe",
|
||||||
|
"description": "sorry not helping\n\n#workhumor #officelife",
|
||||||
|
"uploader": "Work dot exe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_ydl(monkeypatch, outcome):
|
||||||
|
"""yt-dlp faked at its `YoutubeDL` boundary: records each read (its options and its `url`),
|
||||||
|
answers `outcome`."""
|
||||||
|
seen: list[dict] = []
|
||||||
|
|
||||||
|
class FakeYDL:
|
||||||
|
def __init__(self, opts):
|
||||||
|
self.opts = opts
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_info(self, url, download):
|
||||||
|
seen.append({**self.opts, "url": url})
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", FakeYDL)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_placeholder_with_nothing_behind_it_takes_the_signed_out_pages_text(monkeypatch):
|
||||||
|
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
fb = adapters.FacebookAdapter()
|
||||||
|
|
||||||
|
info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
|
||||||
|
|
||||||
|
assert fb.title(info) == "sorry not helping"
|
||||||
|
# One metadata-only read, and signed out: no cookie goes with it.
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0]["skip_download"] is True
|
||||||
|
assert not seen[0].get("cookiefile")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_title_reads_nothing_more(monkeypatch):
|
||||||
|
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
info = {"title": "Funny Dark Humor Dad Joke!", "description": None}
|
||||||
|
|
||||||
|
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
|
||||||
|
assert seen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_placeholder_with_the_posts_text_behind_it_reads_nothing_more(monkeypatch):
|
||||||
|
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
info = {"title": "Video", "description": "Office jokes, part two"}
|
||||||
|
|
||||||
|
assert backfill.public_title_info(info, _URL, adapters.FacebookAdapter(), 7) is info
|
||||||
|
assert seen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_placeholder_the_signed_out_page_cannot_better_stays(monkeypatch):
|
||||||
|
_fake_ydl(monkeypatch, {"title": "Facebook", "description": None})
|
||||||
|
fb = adapters.FacebookAdapter()
|
||||||
|
|
||||||
|
info = backfill.public_title_info({"title": "Facebook", "description": None}, _URL, fb, 7)
|
||||||
|
|
||||||
|
assert fb.title(info) == "Facebook"
|
||||||
|
|
||||||
|
|
||||||
|
def test_another_source_is_never_read_again(monkeypatch):
|
||||||
|
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
info = {"title": "Video", "description": None}
|
||||||
|
|
||||||
|
assert backfill.public_title_info(info, _URL, adapters.SourceAdapter(), 7) is info
|
||||||
|
assert seen == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeated_it(
|
||||||
|
db, make_user, monkeypatch
|
||||||
|
):
|
||||||
|
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
owner, other = make_user(), make_user()
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
format_sig="fb",
|
||||||
|
status="ready",
|
||||||
|
rel_path="facebook/x.mp4",
|
||||||
|
poster_path="facebook/x.jpg",
|
||||||
|
uploader_url="https://www.facebook.com/1",
|
||||||
|
title="Facebook",
|
||||||
|
ref_count=2,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
copied = DownloadJob(
|
||||||
|
user_id=owner.id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
status="done",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name="Facebook",
|
||||||
|
)
|
||||||
|
renamed = DownloadJob(
|
||||||
|
user_id=other.id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
status="done",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name="Office jokes",
|
||||||
|
)
|
||||||
|
db.add_all([copied, renamed])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert backfill.run()["retitled"] == 1
|
||||||
|
assert len(seen) == 1
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
|
||||||
|
assert db.get(DownloadJob, copied.id).display_name == "sorry not helping"
|
||||||
|
assert db.get(DownloadJob, renamed.id).display_name == "Office jokes"
|
||||||
|
# Run again: nothing left to retitle, and no page read for it.
|
||||||
|
assert backfill.run()["retitled"] == 0
|
||||||
|
assert len(seen) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch):
|
||||||
|
# The owner renames the download while the backfill reads the page: the owner's name wins, it is
|
||||||
|
# not overwritten by what was read before it.
|
||||||
|
_fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
owner = make_user()
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
format_sig="fb",
|
||||||
|
status="ready",
|
||||||
|
rel_path="facebook/x.mp4",
|
||||||
|
uploader_url="https://www.facebook.com/1",
|
||||||
|
title="Facebook",
|
||||||
|
ref_count=1,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=owner.id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
status="done",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name="Facebook",
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
read_page = backfill.public_title_info
|
||||||
|
|
||||||
|
def renamed_while_reading(*args):
|
||||||
|
with SessionLocal() as other:
|
||||||
|
other.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job.id)
|
||||||
|
.values(display_name="Office jokes")
|
||||||
|
)
|
||||||
|
other.commit()
|
||||||
|
return read_page(*args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(backfill, "public_title_info", renamed_while_reading)
|
||||||
|
monkeypatch.setattr(backfill.editmod, "ensure_poster", lambda a, root: None)
|
||||||
|
|
||||||
|
assert backfill.run()["retitled"] == 1
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).display_name == "Office jokes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_poster_step_holds_no_row_lock(db, make_user, monkeypatch):
|
||||||
|
# It can cut a frame for 30 s. A delete locks the asset and then the job: with the job held here
|
||||||
|
# and the asset still to write, that is a deadlock, and a rename waits out the whole cut.
|
||||||
|
_fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
owner = make_user()
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
format_sig="fb",
|
||||||
|
status="ready",
|
||||||
|
rel_path="facebook/x.mp4",
|
||||||
|
uploader_url="https://www.facebook.com/1",
|
||||||
|
title="Facebook",
|
||||||
|
ref_count=1,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=owner.id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
status="done",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
display_name="Facebook",
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
asset_id, job_id = asset.id, job.id
|
||||||
|
|
||||||
|
def poster_finds_rows_free(a, root):
|
||||||
|
with SessionLocal() as other:
|
||||||
|
other.execute(
|
||||||
|
select(MediaAsset).where(MediaAsset.id == asset_id).with_for_update(nowait=True)
|
||||||
|
)
|
||||||
|
other.execute(
|
||||||
|
select(DownloadJob).where(DownloadJob.id == job_id).with_for_update(nowait=True)
|
||||||
|
)
|
||||||
|
other.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_finds_rows_free)
|
||||||
|
|
||||||
|
assert backfill.run()["retitled"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_download_titled_facebook_is_named_from_the_signed_out_page(
|
||||||
|
db, make_user, monkeypatch, tmp_path
|
||||||
|
):
|
||||||
|
# The whole download path with yt-dlp and the media tools faked: the signed-in read gives only
|
||||||
|
# the placeholder, and the file and the download's name get the post's text.
|
||||||
|
seen = _fake_ydl(monkeypatch, _PUBLIC)
|
||||||
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||||
|
monkeypatch.setattr(pipeline.urlguard, "check_url", lambda url: None)
|
||||||
|
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pipeline.probe, "resolve_media_facts", lambda produced, info: ("h264", "aac", 720, 1280, 9)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(pipeline.editmod, "thumbnail_to_jpg", lambda *a: None)
|
||||||
|
monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *a: None)
|
||||||
|
owner = make_user()
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind="url", source_ref=_URL, format_sig="fb", status="downloading", ref_count=1
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=owner.id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind="url",
|
||||||
|
source_ref=_URL,
|
||||||
|
status="running",
|
||||||
|
queue_pos=0,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def signed_in_read(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||||
|
f = staging / "1860682468422402.mp4"
|
||||||
|
f.write_bytes(b"v" * 100)
|
||||||
|
return {
|
||||||
|
"id": "1860682468422402",
|
||||||
|
"title": "Facebook",
|
||||||
|
"uploader": "Work dot exe",
|
||||||
|
"webpage_url": _PAGE,
|
||||||
|
"requested_downloads": [{"filepath": str(f)}],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_extract_with_fallback", signed_in_read)
|
||||||
|
pipeline._download(job.id, asset.id, "url", _URL, {}, owner.id)
|
||||||
|
|
||||||
|
# The signed-out read goes to the page yt-dlp read, not the link queued: a group post's link
|
||||||
|
# resolves to its video, and signed out the post itself is the login wall.
|
||||||
|
assert [r["url"] for r in seen] == [_PAGE]
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(DownloadJob, job.id).status == "done"
|
||||||
|
assert db.get(MediaAsset, asset.id).title == "sorry not helping"
|
||||||
|
assert db.get(DownloadJob, job.id).display_name == "sorry not helping"
|
||||||
@@ -368,7 +368,7 @@ def test_the_metadata_backfill_names_the_asset_and_not_the_download(sink, monkey
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
|
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
|
||||||
assert backfill._extract_meta(url, adapters.YouTubeAdapter(), 11) is None
|
assert backfill.extract_meta(url, adapters.YouTubeAdapter(), 11) is None
|
||||||
|
|
||||||
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
|
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
|
||||||
assert lines and all("dQw4w9WgXcQ" not in json.dumps(o) for o in lines)
|
assert lines and all("dQw4w9WgXcQ" not in json.dumps(o) for o in lines)
|
||||||
@@ -393,7 +393,7 @@ def test_the_metadata_backfill_takes_out_a_generic_name_whatever_it_holds(sink,
|
|||||||
raise yt_dlp.utils.DownloadError("ERROR: [generic] Our Trip: Day 1: Unable to download")
|
raise yt_dlp.utils.DownloadError("ERROR: [generic] Our Trip: Day 1: Unable to download")
|
||||||
|
|
||||||
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
|
monkeypatch.setattr(backfill.yt_dlp, "YoutubeDL", _Refusing)
|
||||||
assert backfill._extract_meta(url, adapters.SourceAdapter(), 11) is None
|
assert backfill.extract_meta(url, adapters.SourceAdapter(), 11) is None
|
||||||
|
|
||||||
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
|
lines = [o for o in sink.objects() if "meta extract failed" in o["msg"]]
|
||||||
assert lines and all("Day 1" not in json.dumps(o) for o in lines)
|
assert lines and all("Day 1" not in json.dumps(o) for o in lines)
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -181,7 +181,7 @@ def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user):
|
|||||||
_videos(db, "v1")
|
_videos(db, "v1")
|
||||||
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert as_user(me).delete("/api/videos/v1/state").status_code == 200
|
assert as_user(me).delete("/api/videos/v1/progress").status_code == 200
|
||||||
assert _video_state(db, other) == ("hidden", 90)
|
assert _video_state(db, other) == ("hidden", 90)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -190,9 +190,6 @@ def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db
|
|||||||
db.commit()
|
db.commit()
|
||||||
assert _state(db, user, "v1") is None
|
assert _state(db, user, "v1") is None
|
||||||
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
|
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
|
||||||
service.clear_state(db, user, "v2")
|
|
||||||
db.commit()
|
|
||||||
assert _state(db, user, "v2") is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- playlists -------------------------------------------------------------------------------
|
# --- playlists -------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
@@ -101,8 +101,10 @@ def test_a_video_state_cleared_through_the_route_is_gone(db, make_user, as_user)
|
|||||||
_video(db)
|
_video(db)
|
||||||
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
|
db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert as_user(user).delete("/api/videos/v1/state").status_code == 200
|
assert as_user(user).delete("/api/videos/v1/progress").status_code == 200
|
||||||
assert _state(db, user) is None
|
db.expire_all()
|
||||||
|
row = _state(db, user)
|
||||||
|
assert (row.status, row.position_seconds) == ("hidden", 0)
|
||||||
|
|
||||||
|
|
||||||
# --- saved views ------------------------------------------------------------------------------
|
# --- saved views ------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -292,17 +292,16 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o
|
|||||||
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
|
assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("undo", ["unmark", "clear_state"])
|
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user):
|
||||||
def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo):
|
"""Un-marking a video leaves no state row, and the import inserted a watched one over the user's
|
||||||
"""Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one
|
own choice. A view on YouTube after the un-mark is a new view, and counts."""
|
||||||
over the user's own choice. A view on YouTube after the un-mark is a new view, and counts."""
|
user = make_user("unmark@example.com")
|
||||||
user = make_user(f"{undo}@example.com")
|
|
||||||
for vid in ("undone00000", "again000000"):
|
for vid in ("undone00000", "again000000"):
|
||||||
_video(db, vid)
|
_video(db, vid)
|
||||||
db.commit()
|
db.commit()
|
||||||
for vid in ("undone00000", "again000000"):
|
for vid in ("undone00000", "again000000"):
|
||||||
service.mark(db, user, vid, "watched")
|
service.mark(db, user, vid, "watched")
|
||||||
getattr(service, undo)(db, user, vid)
|
service.unmark(db, user, vid)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert _states(db, user) == {}
|
assert _states(db, user) == {}
|
||||||
later = datetime.now(timezone.utc) + timedelta(minutes=5)
|
later = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
@@ -603,7 +602,6 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user):
|
|||||||
),
|
),
|
||||||
# Between the import's read of the un-marks and its marking, one would be overwritten.
|
# Between the import's read of the un-marks and its marking, one would be overwritten.
|
||||||
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
|
"unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"),
|
||||||
"reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"),
|
|
||||||
}
|
}
|
||||||
with SessionLocal() as holder:
|
with SessionLocal() as holder:
|
||||||
service.lock_takeout(holder, holder.get(User, user.id))
|
service.lock_takeout(holder, holder.get(User, user.id))
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
|
|||||||
# enqueue_clip slices the source's title to the same width.
|
# enqueue_clip slices the source's title to the same width.
|
||||||
# files.ingest_spooled passes through `spool_upload`'s value, bounded there.
|
# files.ingest_spooled passes through `spool_upload`'s value, bounded there.
|
||||||
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
|
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
|
||||||
# pipeline._download / _process_clip / worker._finish_from_cache
|
# pipeline._download / _process_clip / worker._finish_from_cache / backfill._retitle
|
||||||
# slice a fetched or stored title to the column's width.
|
# slice a fetched or stored title to the column's width.
|
||||||
#
|
#
|
||||||
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
|
||||||
@@ -596,6 +596,7 @@ DISPLAY_WRITE_SITES = {
|
|||||||
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
|
||||||
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
|
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
|
||||||
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
|
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
|
||||||
|
("downloads/backfill.py", "_retitle"): {"named[:DISPLAY_TEXT_MAX]"},
|
||||||
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
|
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
|
||||||
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
|
||||||
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
|
||||||
|
|||||||
+6
-1
@@ -256,7 +256,7 @@ Also in force:
|
|||||||
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
||||||
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
|
refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the
|
||||||
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
|
counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its
|
||||||
menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry
|
menu alike; clearing a video's watch progress is the eraser (`Eraser`). A failed download's Retry
|
||||||
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
|
had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
|
||||||
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
|
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
|
||||||
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
|
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
|
||||||
@@ -829,3 +829,8 @@ of a full-width panel and nothing on the right.
|
|||||||
| 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 |
|
| 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 |
|
||||||
|
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
|
||||||
|
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
||||||
|
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
||||||
|
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
|
||||||
|
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own |
|
||||||
|
|||||||
+2
-1
@@ -144,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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
|
|||||||
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
|
||||||
await openApp(page, "/?page=feed");
|
await openApp(page, "/?page=feed");
|
||||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||||
expect(await clickVisibleChannelLink(page)).toBe(true);
|
await clickVisibleChannelLink(page);
|
||||||
const title = await page.getByTestId("channel-title").textContent();
|
const title = await page.getByTestId("channel-title").textContent();
|
||||||
await tab(page, "About").click();
|
await tab(page, "About").click();
|
||||||
const fresh = await bookmarked(browser, page);
|
const fresh = await bookmarked(browser, page);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -114,7 +114,9 @@ test.describe("channel view", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await openApp(page);
|
await openApp(page);
|
||||||
await page.getByTestId("video-card-channel").first().click();
|
await page.getByTestId("video-card-channel").first().click();
|
||||||
const count = page.getByTestId("item-count");
|
// A pill of the feed's toolbar, which rides in the fixed chrome (the page has no count pill: its
|
||||||
|
// header counts the videos).
|
||||||
|
const count = page.getByTestId("feed-show");
|
||||||
await expect(count).toBeVisible();
|
await expect(count).toBeVisible();
|
||||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||||
await expect(page.getByTestId("channel-folded")).toHaveCount(0);
|
await expect(page.getByTestId("channel-folded")).toHaveCount(0);
|
||||||
|
|||||||
+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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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")]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
//
|
//
|
||||||
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
|
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
|
||||||
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
|
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
|
||||||
// card's line and on the next line of another.
|
// card's line and on the next line of another. A card is its row's press surface too, as the row is.
|
||||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, cleanup, screen } from "@testing-library/react";
|
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
@@ -43,4 +43,21 @@ describe("DataTable cards", () => {
|
|||||||
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
|
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
|
||||||
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
|
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens its object from a tap on the card, and leaves its control's tap to the control", () => {
|
||||||
|
narrow(false);
|
||||||
|
const open = vi.fn();
|
||||||
|
render(
|
||||||
|
<DataTable
|
||||||
|
rows={[{ id: "a" }]}
|
||||||
|
columns={columns}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
rowOpen={() => open}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("1 MB"), { detail: 1 });
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(screen.getByText("more"), { detail: 1 });
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign";
|
|||||||
import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||||
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||||
import { useMediaQuery } from "../../lib/useMediaQuery";
|
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||||
|
import { withRowOpen } from "./rowOpen";
|
||||||
import { SURFACE } from "./surfaces";
|
import { SURFACE } from "./surfaces";
|
||||||
import { HiddenFilterCue } from "./HiddenFilterCue";
|
import { HiddenFilterCue } from "./HiddenFilterCue";
|
||||||
import { useProgressiveRows } from "./progressiveRows";
|
import { useProgressiveRows } from "./progressiveRows";
|
||||||
@@ -57,6 +58,7 @@ interface TableRowProps<T> {
|
|||||||
index: number;
|
index: number;
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
rowProps?: (row: T) => Record<string, unknown>;
|
rowProps?: (row: T) => Record<string, unknown>;
|
||||||
|
rowOpen?: (row: T) => (() => void) | null;
|
||||||
rowClassName?: (row: T) => string;
|
rowClassName?: (row: T) => string;
|
||||||
keyboardNav: boolean;
|
keyboardNav: boolean;
|
||||||
}
|
}
|
||||||
@@ -69,14 +71,16 @@ function TableRow<T>({
|
|||||||
index,
|
index,
|
||||||
columns,
|
columns,
|
||||||
rowProps,
|
rowProps,
|
||||||
|
rowOpen,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
keyboardNav,
|
keyboardNav,
|
||||||
}: TableRowProps<T>) {
|
}: TableRowProps<T>) {
|
||||||
|
const open = rowOpen?.(row) ?? null;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
{...rowProps?.(row)}
|
{...withRowOpen(rowProps?.(row), open)}
|
||||||
{...(keyboardNav && navItemAttrs(index))}
|
{...(keyboardNav && navItemAttrs(index))}
|
||||||
className={`border-b border-border/50 state-layer transition ${rowClassName?.(row) ?? ""}`}
|
className={`border-b border-border/50 state-layer transition ${open ? "cursor-pointer " : ""}${rowClassName?.(row) ?? ""}`}
|
||||||
>
|
>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
@@ -102,6 +106,7 @@ export default function DataTable<T>({
|
|||||||
emptyText,
|
emptyText,
|
||||||
rowClassName,
|
rowClassName,
|
||||||
rowProps,
|
rowProps,
|
||||||
|
rowOpen,
|
||||||
controlsPosition = "bottom",
|
controlsPosition = "bottom",
|
||||||
controlsLeading,
|
controlsLeading,
|
||||||
controlsInBand = false,
|
controlsInBand = false,
|
||||||
@@ -137,6 +142,10 @@ export default function DataTable<T>({
|
|||||||
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
|
// answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free
|
||||||
// and never learns what a menu is.
|
// and never learns what a menu is.
|
||||||
rowProps?: (row: T) => Record<string, unknown>;
|
rowProps?: (row: T) => Record<string, unknown>;
|
||||||
|
// The row's default action (play, open), or null where a row has none: a press anywhere on the row
|
||||||
|
// or its card twin runs it, except on a control inside it or at the end of a text selection
|
||||||
|
// (`rowOpen.ts`). The keyboard's Enter keeps reaching it through the row's `data-nav-open` element.
|
||||||
|
rowOpen?: (row: T) => (() => void) | null;
|
||||||
controlsPosition?: "top" | "bottom" | "both";
|
controlsPosition?: "top" | "bottom" | "both";
|
||||||
controlsLeading?: ReactNode;
|
controlsLeading?: ReactNode;
|
||||||
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
|
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
|
||||||
@@ -414,6 +423,7 @@ export default function DataTable<T>({
|
|||||||
index={i}
|
index={i}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowProps={rowProps}
|
rowProps={rowProps}
|
||||||
|
rowOpen={rowOpen}
|
||||||
rowClassName={rowClassName}
|
rowClassName={rowClassName}
|
||||||
keyboardNav={keyboardNav}
|
keyboardNav={keyboardNav}
|
||||||
/>
|
/>
|
||||||
@@ -430,9 +440,9 @@ export default function DataTable<T>({
|
|||||||
{shown.map((row, i) => (
|
{shown.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={rowKey(row)}
|
key={rowKey(row)}
|
||||||
{...rowProps?.(row)}
|
{...withRowOpen(rowProps?.(row), rowOpen?.(row) ?? null)}
|
||||||
{...(keyboardNav && navItemAttrs(i))}
|
{...(keyboardNav && navItemAttrs(i))}
|
||||||
className={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`}
|
className={`${SURFACE.card} p-3 ${rowOpen?.(row) ? "cursor-pointer " : ""}${rowClassName?.(row) ?? ""}`}
|
||||||
>
|
>
|
||||||
{columns
|
{columns
|
||||||
.filter((c) => c.cardPrimary)
|
.filter((c) => c.cardPrimary)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { MenuCheck } from "./MenuCheck";
|
import { MenuCheck } from "./MenuCheck";
|
||||||
import { SelectTrigger } from "./SelectTrigger";
|
import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
|
||||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||||
import { SURFACE } from "./surfaces";
|
import { SURFACE } from "./surfaces";
|
||||||
@@ -27,6 +27,10 @@ export interface FilterSelectProps {
|
|||||||
/** Show only the value on the trigger — for a form row whose own caption already names the field.
|
/** Show only the value on the trigger — for a form row whose own caption already names the field.
|
||||||
* The label still names the control and its menu for assistive tech. */
|
* The label still names the control and its menu for assistive tech. */
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
|
/** A glyph that names the group in place of its word, for a toolbar where the value says enough
|
||||||
|
* once the group is recognisable (the feed's watch state: an eye). The word moves to the tooltip
|
||||||
|
* and stays in the accessible name. */
|
||||||
|
icon?: ReactNode;
|
||||||
options: readonly FilterSelectOption[];
|
options: readonly FilterSelectOption[];
|
||||||
selected: string[];
|
selected: string[];
|
||||||
onChange: (next: string[]) => void;
|
onChange: (next: string[]) => void;
|
||||||
@@ -46,6 +50,7 @@ export default function FilterSelect({
|
|||||||
multiple = false,
|
multiple = false,
|
||||||
requireOne = true,
|
requireOne = true,
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
|
icon,
|
||||||
testId,
|
testId,
|
||||||
}: FilterSelectProps) {
|
}: FilterSelectProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -95,7 +100,9 @@ export default function FilterSelect({
|
|||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
context="toolbar"
|
context="toolbar"
|
||||||
open={open}
|
open={open}
|
||||||
label={hideLabel ? undefined : label}
|
label={hideLabel || icon ? undefined : label}
|
||||||
|
icon={icon && <GroupGlyph>{icon}</GroupGlyph>}
|
||||||
|
title={icon ? label : undefined}
|
||||||
ref={setReference}
|
ref={setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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 { createPortal, flushSync } from "react-dom";
|
import { createPortal, flushSync } from "react-dom";
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -455,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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
|
|
||||||
## What lives here
|
## What lives here
|
||||||
|
|
||||||
| area | components |
|
| area | components |
|
||||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||||
|
|
||||||
## The size contract (E10 S1c)
|
## The size contract (E10 S1c)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
|||||||
// the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is
|
// the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is
|
||||||
// the accent edge and the chevron turned over. The popover wiring
|
// the accent edge and the chevron turned over. The popover wiring
|
||||||
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
|
// (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's.
|
||||||
|
/** A toolbar trigger's group glyph in place of its "Label:" word (FilterSelect's and SortControl's
|
||||||
|
* `icon`): muted like the word, and silent — the trigger's accessible name and tooltip say it. */
|
||||||
|
export function GroupGlyph({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden="true" className="flex shrink-0 text-muted [&>svg]:size-3.5">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const SelectTrigger = forwardRef<
|
export const SelectTrigger = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import { MenuCheck } from "./MenuCheck";
|
import { MenuCheck } from "./MenuCheck";
|
||||||
import { SelectTrigger } from "./SelectTrigger";
|
import { GroupGlyph, SelectTrigger } from "./SelectTrigger";
|
||||||
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
|
||||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||||
@@ -27,6 +27,7 @@ export default function SortControl({
|
|||||||
descLabel,
|
descLabel,
|
||||||
defaultLabel,
|
defaultLabel,
|
||||||
trailing,
|
trailing,
|
||||||
|
icon,
|
||||||
}: {
|
}: {
|
||||||
options: SortOption[];
|
options: SortOption[];
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
@@ -40,6 +41,9 @@ export default function SortControl({
|
|||||||
defaultLabel?: string;
|
defaultLabel?: string;
|
||||||
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
|
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
|
||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
|
/** A glyph in place of the visible "Sort:" prefix (FilterSelect's `icon`): the label moves to the
|
||||||
|
* tooltip and stays in the accessible name. */
|
||||||
|
icon?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const pop = useAnchoredPopover({
|
const pop = useAnchoredPopover({
|
||||||
@@ -71,7 +75,9 @@ export default function SortControl({
|
|||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
context="toolbar"
|
context="toolbar"
|
||||||
open={open}
|
open={open}
|
||||||
label={label}
|
label={icon ? undefined : label}
|
||||||
|
icon={icon && <GroupGlyph>{icon}</GroupGlyph>}
|
||||||
|
title={icon ? label : undefined}
|
||||||
ref={setReference}
|
ref={setReference}
|
||||||
{...pop.getReferenceProps()}
|
{...pop.getReferenceProps()}
|
||||||
// Carry the active sort key AND direction in the accessible name (the visible arrow is
|
// Carry the active sort key AND direction in the accessible name (the visible arrow is
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,18 +11,22 @@ type Side = "top" | "bottom" | "left" | "right";
|
|||||||
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
|
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
|
||||||
* and slides back inside the viewport near an edge.
|
* and slides back inside the viewport near an edge.
|
||||||
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
|
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
|
||||||
* rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel). */
|
* rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel); `stable` keeps
|
||||||
|
* the wrapper with no hint to show, so a hint that comes and goes does not remount the element and
|
||||||
|
* cost it the focus. */
|
||||||
export default function Tooltip({
|
export default function Tooltip({
|
||||||
hint,
|
hint,
|
||||||
side = "top",
|
side = "top",
|
||||||
always = false,
|
always = false,
|
||||||
openDelay = 0,
|
openDelay = 0,
|
||||||
|
stable = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
hint: string;
|
hint: string;
|
||||||
side?: Side;
|
side?: Side;
|
||||||
always?: boolean;
|
always?: boolean;
|
||||||
openDelay?: number;
|
openDelay?: number;
|
||||||
|
stable?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||||
@@ -42,7 +46,7 @@ export default function Tooltip({
|
|||||||
});
|
});
|
||||||
const { setReference } = pop.refs;
|
const { setReference } = pop.refs;
|
||||||
|
|
||||||
if (!enabled || !hint) return <>{children}</>;
|
if ((!enabled || !hint) && !stable) return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useInteractions,
|
useInteractions,
|
||||||
useFloatingNodeId,
|
useFloatingNodeId,
|
||||||
safePolygon,
|
safePolygon,
|
||||||
|
type OpenChangeReason,
|
||||||
type Placement,
|
type Placement,
|
||||||
type UseRoleProps,
|
type UseRoleProps,
|
||||||
} from "@floating-ui/react";
|
} from "@floating-ui/react";
|
||||||
@@ -32,7 +33,8 @@ type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
|||||||
|
|
||||||
export interface AnchoredPopoverOptions {
|
export interface AnchoredPopoverOptions {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean, event?: Event) => void;
|
/** `reason`: what opened or closed it (floating-ui's: "hover", "focus", "ancestor-scroll", …). */
|
||||||
|
onOpenChange: (open: boolean, event?: Event, reason?: OpenChangeReason) => void;
|
||||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||||
mode?: PopoverMode;
|
mode?: PopoverMode;
|
||||||
|
|||||||
@@ -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")),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, render } from "@testing-library/react";
|
import { cleanup, render } from "@testing-library/react";
|
||||||
import { useDialogChrome } from "./dialogFocus";
|
import { focusablesIn, useDialogChrome } from "./dialogFocus";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -36,6 +36,25 @@ describe("useDialogChrome · the page scroll lock", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("focusablesIn", () => {
|
||||||
|
// A trap that counted them had its "last" control where Tab never lands, and let the focus out.
|
||||||
|
it("leaves out what an inert part holds", () => {
|
||||||
|
const offsetParent = vi
|
||||||
|
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||||
|
.mockReturnValue(document.body);
|
||||||
|
const { container } = render(
|
||||||
|
<div>
|
||||||
|
<button>reachable</button>
|
||||||
|
<div inert>
|
||||||
|
<button>under</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
expect(focusablesIn(container).map((el) => el.textContent)).toEqual(["reachable"]);
|
||||||
|
offsetParent.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("useDialogChrome · initial focus", () => {
|
describe("useDialogChrome · initial focus", () => {
|
||||||
it("lands on the first control by default", () => {
|
it("lands on the first control by default", () => {
|
||||||
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
|
// jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable.
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "rea
|
|||||||
|
|
||||||
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||||
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||||
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
// collapsed disclosure) so the trap never lands focus on something the user can't see, and an inert
|
||||||
|
// part's are not tabbable: counted, the trap's last control is one Tab never reaches.
|
||||||
const FOCUSABLE =
|
const FOCUSABLE =
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
export const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
export const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
||||||
|
(el) => el.offsetParent !== null && !el.closest("[inert]"),
|
||||||
|
);
|
||||||
|
|
||||||
/** A form's first field — where a dialog opened to fill something in takes the focus: its first
|
/** A form's first field — where a dialog opened to fill something in takes the focus: its first
|
||||||
* control, unless one is marked `data-initial-focus`. The mark is for a form with a control above
|
* control, unless one is marked `data-initial-focus`. The mark is for a form with a control above
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { MouseEvent, PointerEvent } from "react";
|
||||||
|
import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
|
||||||
|
|
||||||
|
// A table row (or its narrow card twin) as the click and tap surface of its object's default action,
|
||||||
|
// the way a card is: a press anywhere on the row opens it, not only on the title inside it.
|
||||||
|
//
|
||||||
|
// What the row does NOT claim:
|
||||||
|
// - a press on a control inside it (its quick icons, its "…", a link, a field): that is the control's;
|
||||||
|
// - a click that ends a text selection, or a press that travelled (a drag across the row's padding):
|
||||||
|
// DESIGN.md "Text stays selectable" — no play or open fires;
|
||||||
|
// - an event from a portal (a popover a cell opened): React bubbles it through the row, the DOM does
|
||||||
|
// not, so only a target the row really contains counts;
|
||||||
|
// - a press that began while a field inside the row held the focus (its rename): that press ends the
|
||||||
|
// editing — its mousedown blurs the field, which saves — and does nothing more. Read at pointerdown,
|
||||||
|
// because by the click the blur has already put the row back as it is when nothing is edited.
|
||||||
|
// The long-press menu's trailing click is swallowed by its own trigger (`useContextMenu`), before it
|
||||||
|
// reaches the row.
|
||||||
|
|
||||||
|
// A field the focus can be editing in.
|
||||||
|
const FIELD = 'input, textarea, select, [contenteditable="true"]';
|
||||||
|
|
||||||
|
// What counts as a control of its own. `[tabindex]` covers a focusable cell (a hover card's trigger);
|
||||||
|
// the row's own roving tab stop is the row itself, which the containment check below leaves out.
|
||||||
|
const CONTROL =
|
||||||
|
'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="checkbox"], [role="switch"], [contenteditable="true"], [tabindex]';
|
||||||
|
|
||||||
|
// Where each row's press began, for the drag slop, and whether it began while a field inside the row
|
||||||
|
// was being edited. Per element: the table row and its card twin are two elements, and a press on one
|
||||||
|
// must not be measured against the other.
|
||||||
|
const pressedAt = new WeakMap<Element, { x: number; y: number; editing: boolean }>();
|
||||||
|
|
||||||
|
type Handlers = {
|
||||||
|
onPointerDown?: (e: PointerEvent<HTMLElement>) => void;
|
||||||
|
onClick?: (e: MouseEvent<HTMLElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The row's props with its default action added on top of the caller's own (a context menu's
|
||||||
|
* trigger props, which keep their handlers). `open` null: the row has no default action. */
|
||||||
|
export function withRowOpen(
|
||||||
|
props: Record<string, unknown> | undefined,
|
||||||
|
open: (() => void) | null,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const base = props ?? {};
|
||||||
|
if (!open) return base;
|
||||||
|
const { onPointerDown, onClick } = base as Handlers;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
onPointerDown: (e: PointerEvent<HTMLElement>) => {
|
||||||
|
const row = e.currentTarget;
|
||||||
|
const focused = document.activeElement;
|
||||||
|
const editing = !!focused && row.contains(focused) && focused.matches(FIELD);
|
||||||
|
pressedAt.set(row, { x: e.clientX, y: e.clientY, editing });
|
||||||
|
onPointerDown?.(e);
|
||||||
|
},
|
||||||
|
onClick: (e: MouseEvent<HTMLElement>) => {
|
||||||
|
onClick?.(e);
|
||||||
|
const claimed = !e.defaultPrevented && claims(e);
|
||||||
|
// One press, one click: a later keyboard click (no press) is not judged by this one.
|
||||||
|
pressedAt.delete(e.currentTarget);
|
||||||
|
if (claimed) open();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function claims(e: MouseEvent<HTMLElement>): boolean {
|
||||||
|
const row = e.currentTarget;
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof Element) || !row.contains(target)) return false;
|
||||||
|
const control = target.closest(CONTROL);
|
||||||
|
if (control && control !== row && row.contains(control)) return false;
|
||||||
|
const pressed = pressedAt.get(row) ?? null;
|
||||||
|
return !pressed?.editing && !endsTextSelection(e) && !movedPastSlop(pressed, e);
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** An unavailable control's props. With a reason it stays focusable (`aria-disabled`, which
|
||||||
|
* `FocusSafeButton` keeps inert to a click, and the reason read out with its name; the caller shows
|
||||||
|
* it in a tooltip — DESIGN.md "Unavailable with a reason stays focusable"); without one it is
|
||||||
|
* plainly off. */
|
||||||
|
export function unavailable(disabled: boolean | undefined, reason: string | undefined) {
|
||||||
|
if (!disabled) return {};
|
||||||
|
return reason
|
||||||
|
? { "aria-disabled": true as const, "aria-description": reason }
|
||||||
|
: { disabled: true };
|
||||||
|
}
|
||||||
@@ -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,43 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.115.0",
|
||||||
|
"date": "2026-11-01",
|
||||||
|
"new": [
|
||||||
|
"Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else.",
|
||||||
|
"Channels have an Added column, and an About card opens only when the pointer rests on it.",
|
||||||
|
"In Files, a press anywhere on a row plays the file.",
|
||||||
|
"The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left.",
|
||||||
|
"A Facebook download titled only “Facebook” now takes the post's text as its name.",
|
||||||
|
"The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.114.0",
|
||||||
"date": "2026-10-29",
|
"date": "2026-10-29",
|
||||||
|
|||||||
@@ -1,4 +1,43 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.115.0",
|
||||||
|
"date": "2026-11-01",
|
||||||
|
"new": [
|
||||||
|
"A Megtekintés visszaállítása minden YouTube-videó menüjében elérhető: a Kezdőlapon, a listákban és a lejátszó kapcsolódó videóinál. Egy megkezdett videónál törli, hol tartottál, mást nem változtat.",
|
||||||
|
"A csatornáknál van Hozzáadva oszlop, és a Leírás kártya csak akkor nyílik meg, ha a mutató megáll rajta.",
|
||||||
|
"A Fájloknál a sorban bárhová kattintva elindul a fájl.",
|
||||||
|
"A Feed eszköztárának gombjait ikon jelöli, a Rendezés és a Nézet a szűrők sorába került."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"A lejátszási sorban az Előző és a Következő átlépi a nem lejátszható videót abban az irányban, amerre haladtál, és megmondja az okát, ha már csak ilyenek maradtak.",
|
||||||
|
"A csak „Facebook” címmel letöltött Facebook-videó most a bejegyzés szövegét kapja névnek.",
|
||||||
|
"A lejátszó nagy nézetében tableten is látszik a kilépés, és a Tab már nem kerül a takart gombokra."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.114.0",
|
||||||
"date": "2026-10-29",
|
"date": "2026-10-29",
|
||||||
|
|||||||
@@ -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"));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { type Video, type VideoStatus } from "../../lib/api";
|
import { type Video, type VideoStatus } from "../../lib/api";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
|||||||
import { useStableCallback } from "../../lib/useLatest";
|
import { useStableCallback } from "../../lib/useLatest";
|
||||||
import { writeVideoState } from "../video/videoWrites";
|
import { writeVideoState } from "../video/videoWrites";
|
||||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
|||||||
* channel manager, where the watched-videos dialog played it; any page a link names): the video
|
* 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. */
|
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||||
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [own] = useState(!hosted);
|
const [own] = useState(!hosted);
|
||||||
const [video, setVideo] = useState<Video | null>(null);
|
const [video, setVideo] = useState<Video | null>(null);
|
||||||
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
|||||||
const write = writeVideoState(qc, id, status);
|
const write = writeVideoState(qc, id, status);
|
||||||
write
|
write
|
||||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
||||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
.catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
|
||||||
return write;
|
return write;
|
||||||
});
|
});
|
||||||
const close = useStableCallback(() => setVideo(null));
|
const close = useStableCallback(() => setVideo(null));
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
@@ -63,7 +63,11 @@ const started: { videoId: string; start: number | undefined }[] = [];
|
|||||||
// What the player was asked to switch to in place (a description link, a related video).
|
// What the player was asked to switch to in place (a description link, a related video).
|
||||||
const loaded: { videoId: string; startSeconds: number }[] = [];
|
const loaded: { videoId: string; startSeconds: number }[] = [];
|
||||||
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
|
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
|
||||||
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
|
let playerEvents: {
|
||||||
|
onReady?: () => void;
|
||||||
|
onStateChange?: (e: { data: number }) => void;
|
||||||
|
onError?: (e: { data: number }) => void;
|
||||||
|
} = {};
|
||||||
const base = {
|
const base = {
|
||||||
getCurrentTime: () => clock.current,
|
getCurrentTime: () => clock.current,
|
||||||
getDuration: () => clock.duration,
|
getDuration: () => clock.duration,
|
||||||
@@ -314,7 +318,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.");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,7 +340,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;
|
||||||
@@ -1046,6 +1050,73 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
|||||||
expect(lastStart()?.start ?? 0).toBe(0);
|
expect(lastStart()?.start ?? 0).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A clear from the related row is where it starts now: the position this session stored for it
|
||||||
|
// (which a plain pick resumes from) is forgotten with it, not replayed and checkpointed back.
|
||||||
|
it("wide: a related video cleared from its row starts at 0 on a plain pick", async () => {
|
||||||
|
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||||
|
video_id: id,
|
||||||
|
position_seconds: id === "rel2" ? 500 : 77,
|
||||||
|
}));
|
||||||
|
const reset = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
|
clock.current = 77;
|
||||||
|
await playRel2(() => Promise.resolve());
|
||||||
|
clock.current = 500;
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "player.back" }));
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
await settle();
|
||||||
|
const list = screen.getByTestId("player-related");
|
||||||
|
const links = () => within(list).getAllByRole("link", { name: "Video rel2" });
|
||||||
|
fireEvent.contextMenu(links()[0]!.closest("[data-video-id]")!);
|
||||||
|
fireEvent.click(
|
||||||
|
screen
|
||||||
|
.queryAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === "card.clearProgress")!,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(reset).toHaveBeenCalledWith("rel2"));
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(titleOf(links()));
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||||
|
expect(lastStart()?.start ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The queue was read when the player opened, and a video in it can be in the related list too: a
|
||||||
|
// step to it after the clear starts at 0, not at that older position, which its first checkpoint
|
||||||
|
// would then write back over the clear.
|
||||||
|
it("wide: a queue item cleared from its related row starts at 0 on a step to it", async () => {
|
||||||
|
wideWindow(true);
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
vi.spyOn(api, "related").mockResolvedValue({
|
||||||
|
items: [related("rel2", 120)],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
});
|
||||||
|
const clear = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
|
const A = vid("A", 40);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal
|
||||||
|
video={A}
|
||||||
|
queue={[A, vid("rel2", 120)]}
|
||||||
|
startIndex={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={() => Promise.resolve()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
act(() => playerEvents.onReady?.());
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||||
|
const links = await screen.findAllByRole("link", { name: "Video rel2" });
|
||||||
|
fireEvent.contextMenu(links[0]!.closest("[data-video-id]")!);
|
||||||
|
fireEvent.click(
|
||||||
|
screen
|
||||||
|
.queryAllByRole("menuitem", { hidden: true })
|
||||||
|
.find((b) => b.textContent === "card.clearProgress")!,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(clear).toHaveBeenCalledWith("rel2"));
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||||
|
expect(lastStart()?.start ?? 0).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
// Its row stays in the list while it plays; a new player for the same video is never built (one
|
// Its row stays in the list while it plays; a new player for the same video is never built (one
|
||||||
// per video), so Restart there is a seek.
|
// per video), so Restart there is a seek.
|
||||||
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
it("wide: Restart on the related video already playing seeks it to 0", async () => {
|
||||||
@@ -1644,6 +1715,137 @@ describe("PlayerModal — the docked bar's fullscreen", () => {
|
|||||||
it("is left out where it cannot", () => {
|
it("is left out where it cannot", () => {
|
||||||
expect(maximised(false)).toBe(1);
|
expect(maximised(false)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On an iPad the bar's row outgrew the screen and its last control — the way out of the big
|
||||||
|
// view — scrolled off the right edge. The way out stays put; only the controls before it scroll.
|
||||||
|
it("keeps the way out in sight: glyphs only, and out of the scrolling part", () => {
|
||||||
|
maximised(true);
|
||||||
|
const out = screen.getByRole("button", { name: "player.exitMaximize" });
|
||||||
|
const bar = out.parentElement!;
|
||||||
|
// The windowed panel keeps a Fullscreen of its own too; this is the bar's.
|
||||||
|
const full = screen.getAllByLabelText("player.fullscreen").find((b) => bar.contains(b))!;
|
||||||
|
expect(full).toBeTruthy();
|
||||||
|
// The windowed row keeps its own Mark watched; this is the bar's.
|
||||||
|
const watched = screen
|
||||||
|
.getAllByRole("button", { name: "player.markWatched" })
|
||||||
|
.find((b) => bar.contains(b))!;
|
||||||
|
for (const b of [out, full, watched]) expect(b.textContent).toBe("");
|
||||||
|
const scroller = watched.closest<HTMLElement>(".overflow-x-auto");
|
||||||
|
expect(scroller).not.toBeNull();
|
||||||
|
expect(scroller!.contains(out)).toBe(false);
|
||||||
|
expect(scroller!.contains(full)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The big view covers the card, and its controls stayed in the Tab order under it: past the bar's
|
||||||
|
// last button the focus went on, unseen, to the card's Previous, Download and Remove.
|
||||||
|
describe("PlayerModal — the card under the big view", () => {
|
||||||
|
const open = (others: Video[] = []) => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
vi.spyOn(api, "saveProgress").mockResolvedValue({ video_id: "A", position_seconds: 0 });
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal
|
||||||
|
video={A}
|
||||||
|
queue={[A, ...others]}
|
||||||
|
startIndex={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={() => Promise.resolve()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return screen.getByLabelText("player.fullscreen");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("takes the card out of reach while the view is big, and gives it back", () => {
|
||||||
|
const enter = open();
|
||||||
|
expect(enter.closest("[inert]")).toBeNull();
|
||||||
|
fireEvent.click(enter);
|
||||||
|
expect(enter.closest("[inert]")).not.toBeNull();
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
expect(enter.closest("[inert]")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks a keyboard from the card's button to the way out, and back to where it was", () => {
|
||||||
|
const enter = open();
|
||||||
|
act(() => enter.focus()); // jsdom calls every focus :focus-visible — a key's focus, here
|
||||||
|
fireEvent.click(enter);
|
||||||
|
expect(document.activeElement).toBe(screen.getByLabelText("player.exitMaximize"));
|
||||||
|
fireEvent.click(screen.getByLabelText("player.exitMaximize"));
|
||||||
|
expect(document.activeElement).toBe(enter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leave a mouse on the way out, where a Space would leave the big view", () => {
|
||||||
|
const enter = open();
|
||||||
|
const matches = vi.spyOn(enter, "matches").mockReturnValue(false);
|
||||||
|
act(() => enter.focus());
|
||||||
|
matches.mockRestore();
|
||||||
|
fireEvent.click(enter);
|
||||||
|
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||||
|
expect(document.activeElement).toBe(card);
|
||||||
|
fireEvent.click(screen.getByLabelText("player.exitMaximize"));
|
||||||
|
expect(document.activeElement).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chrome turns a clicked button :focus-visible the moment a key is pressed, so the F that opens the
|
||||||
|
// big view cannot be what tells a mouse's focus from a key's.
|
||||||
|
it("keeps a click's focus a mouse's when F opens the view", () => {
|
||||||
|
open();
|
||||||
|
const clicked = screen.getByLabelText("player.markWatched");
|
||||||
|
const matches = vi.spyOn(clicked, "matches").mockReturnValue(false);
|
||||||
|
act(() => clicked.focus());
|
||||||
|
matches.mockRestore(); // from here it answers true, as Chrome does after the key
|
||||||
|
fireEvent.keyDown(window, { key: "f" });
|
||||||
|
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||||
|
expect(document.activeElement).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A step to the queue's end in the big view turned the remembered Next off: it refuses the focus.
|
||||||
|
it("gives the card the focus when the control it came from has gone off", async () => {
|
||||||
|
open([vid("B", 0)]);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
const next = screen.getByRole("button", { name: "player.next" });
|
||||||
|
act(() => next.focus());
|
||||||
|
fireEvent.keyDown(window, { key: "f" });
|
||||||
|
expect(document.activeElement).toBe(screen.getByLabelText("player.exitMaximize"));
|
||||||
|
fireEvent.keyDown(window, { key: "ArrowRight", shiftKey: true });
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
expect((next as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
const card = screen.getByRole("dialog").querySelector<HTMLElement>("[tabindex='-1']");
|
||||||
|
expect(document.activeElement).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The headings sit in the part that goes inert, and an inert element names nothing (measured in
|
||||||
|
// Chromium's accessibility tree; jsdom's would still give the name).
|
||||||
|
it("names the dialog from outside the part that goes inert", () => {
|
||||||
|
const enter = open();
|
||||||
|
const id = screen.getByRole("dialog").getAttribute("aria-labelledby")!;
|
||||||
|
fireEvent.click(enter);
|
||||||
|
const names = document.querySelectorAll(`[id="${id}"]`);
|
||||||
|
expect(names).toHaveLength(1);
|
||||||
|
expect(names[0]!.closest("[inert]")).toBeNull();
|
||||||
|
expect(names[0]!.textContent).toBe("Video A");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A divider squeezed to nothing where the bar's row overflows and scrolls.
|
||||||
|
it("keeps the bar's dividers when its row scrolls", () => {
|
||||||
|
fireEvent.click(open([vid("B", 0)]));
|
||||||
|
const scroller = screen
|
||||||
|
.getByRole("button", { name: "player.exitMaximize" })
|
||||||
|
.parentElement!.querySelector(".overflow-x-auto")!;
|
||||||
|
const dividers = scroller.querySelectorAll("span.w-px");
|
||||||
|
expect(dividers.length).toBeGreaterThan(1);
|
||||||
|
for (const d of dividers) expect(d.className).toContain("shrink-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not strand the focus on the page when the bar goes with it", () => {
|
||||||
|
const enter = open();
|
||||||
|
fireEvent.click(enter);
|
||||||
|
act(() => screen.getByLabelText("player.exitMaximize").focus());
|
||||||
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
|
expect(document.activeElement).not.toBe(document.body);
|
||||||
|
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
|
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
|
||||||
@@ -1753,6 +1955,19 @@ describe("PlayerModal — clip mode", () => {
|
|||||||
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
expect(screen.queryByTestId("clip-panel")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves the focus in the panel when the view ends, whatever it came from", async () => {
|
||||||
|
open();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
act(() => screen.getByLabelText("player.markWatched").focus());
|
||||||
|
key("f");
|
||||||
|
key("c");
|
||||||
|
const panel = screen.getByTestId("clip-panel");
|
||||||
|
const inPanel = panel.querySelector<HTMLElement>("button:not([disabled])")!;
|
||||||
|
act(() => inPanel.focus());
|
||||||
|
key("f");
|
||||||
|
expect(document.activeElement).toBe(inPanel);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
|
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
|
||||||
open();
|
open();
|
||||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
@@ -1816,3 +2031,174 @@ describe("PlayerModal — clip mode", () => {
|
|||||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An embed-blocked or deleted video answers with onError, never 'ended'. Stepping BACK onto one used
|
||||||
|
// to skip forward again (the auto-advance direction), straight to the item you had just left.
|
||||||
|
describe("PlayerModal — stepping over a video that cannot play", () => {
|
||||||
|
const A = vid("A", 0);
|
||||||
|
const B = vid("B", 0);
|
||||||
|
const C = vid("C", 0);
|
||||||
|
const fail = () => act(() => playerEvents.onError?.({ data: 150 }));
|
||||||
|
const opened = (id: string) => started.filter((s) => s.videoId === id).length;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||||
|
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||||
|
video_id: id,
|
||||||
|
position_seconds: 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = async (startIndex: number) => {
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal
|
||||||
|
video={[A, B, C][startIndex]!}
|
||||||
|
queue={[A, B, C]}
|
||||||
|
startIndex={startIndex}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe([A, B, C][startIndex]!.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
it("Previous onto a dead video goes on back, not forward to where you came from", async () => {
|
||||||
|
me = { related_enabled: true, preferences: { playerAutoAdvance: "next" } } as unknown as Me;
|
||||||
|
await open(2);
|
||||||
|
prev();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fail();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a step of your own skips a dead video with auto-advance off too", async () => {
|
||||||
|
await open(2);
|
||||||
|
prev();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fail();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a video found dead is stepped over without loading it again", async () => {
|
||||||
|
await open(2);
|
||||||
|
prev();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fail();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||||
|
expect(opened("B")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Off, and saying why: the counter shows a video before this one, so a dark button alone would not
|
||||||
|
// tell (DESIGN "Unavailable with a reason stays focusable").
|
||||||
|
it("Previous is off, with its reason, when only dead videos are before you", async () => {
|
||||||
|
await open(0);
|
||||||
|
fail(); // opened on it, auto-advance off: it stays, and it is known dead now
|
||||||
|
await settle();
|
||||||
|
expect(lastStart()?.videoId).toBe("A");
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
const prevs = screen.getAllByRole("button", { name: "player.previous" });
|
||||||
|
for (const b of prevs) {
|
||||||
|
expect((b as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect(b.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
}
|
||||||
|
// Where a key's focus can read it: a browser's own title shows for a mouse alone.
|
||||||
|
act(() => prevs[0]!.focus());
|
||||||
|
expect(await screen.findByText(/player\.noPlayableBefore/)).toBeTruthy();
|
||||||
|
prev();
|
||||||
|
await settle();
|
||||||
|
expect(lastStart()?.videoId).toBe("B");
|
||||||
|
// Next has playable C ahead: on, and no reason.
|
||||||
|
for (const b of screen.getAllByRole("button", { name: "player.next" }))
|
||||||
|
expect(b.getAttribute("title")).not.toContain("player.noPlayableAfter");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Next that the queue's end turned off keeps the focus; a step back gives it a reason, and the
|
||||||
|
// button that holds the focus must be the same button afterwards.
|
||||||
|
it("keeps the focus on a control when its reason arrives", async () => {
|
||||||
|
await open(0);
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
const button = screen.getByRole("button", { name: "player.next" });
|
||||||
|
act(() => button.focus());
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
prev();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
expect(button.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
expect(document.activeElement).toBe(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Previous at the queue's start is plainly off: the counter says why", async () => {
|
||||||
|
await open(0);
|
||||||
|
for (const b of screen.getAllByRole("button", { name: "player.previous" })) {
|
||||||
|
expect((b as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(b.getAttribute("title")).not.toContain("player.noPlayableBefore");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Removing the playing video passes over the ones known dead, the way a step does, and goes back
|
||||||
|
// when nothing playable followed it.
|
||||||
|
it("removing the last video goes back over a dead one to the one that plays", async () => {
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal
|
||||||
|
video={A}
|
||||||
|
queue={[A, B, C]}
|
||||||
|
startIndex={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={vi.fn()}
|
||||||
|
onRemoveFromList={async () => true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fail();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||||
|
fireEvent.click(trash("C"));
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
expect(opened("B")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The player gives up after three in a row: it stays on the third's error, and all three are
|
||||||
|
// remembered — a later step passes over them.
|
||||||
|
it("three dead in a row stop on the third, and a step then passes over all three", async () => {
|
||||||
|
const [D, E] = [vid("D", 0), vid("E", 0)];
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal
|
||||||
|
video={A}
|
||||||
|
queue={[A, B, C, D, E]}
|
||||||
|
startIndex={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
onState={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||||
|
fail();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||||
|
fail();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("D"));
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
expect(lastStart()?.videoId).toBe("D");
|
||||||
|
prev();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||||
|
next();
|
||||||
|
await waitFor(() => expect(lastStart()?.videoId).toBe("E"));
|
||||||
|
expect([opened("B"), opened("C"), opened("D")]).toEqual([1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a video opened dead with auto-advance off stays on its error", async () => {
|
||||||
|
await open(1);
|
||||||
|
fail();
|
||||||
|
await settle();
|
||||||
|
expect(lastStart()?.videoId).toBe("B");
|
||||||
|
expect(started).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { useNavigationActions } from "../navigation/navigationContext";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { usePref } from "../../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
import { PlayerDescription, PlayerTransportControls, ReasonTooltip } from "./playerModalControls";
|
||||||
|
import { unavailable } from "../../components/ui/unavailable";
|
||||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -72,7 +73,7 @@ import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib
|
|||||||
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";
|
||||||
import { afterRemoval } from "../../lib/playerQueue";
|
import { afterRemoval, stepIndex } from "../../lib/playerQueue";
|
||||||
import {
|
import {
|
||||||
useNativeControlsYield,
|
useNativeControlsYield,
|
||||||
useYtBigView,
|
useYtBigView,
|
||||||
@@ -118,6 +119,11 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
|||||||
// a rem width, so it grows with the text size; the video takes what is left.
|
// a rem width, so it grows with the text size; the video takes what is left.
|
||||||
const WIDE_PLAYER_PX = 1280;
|
const WIDE_PLAYER_PX = 1280;
|
||||||
const SIDE_PANEL = "20rem";
|
const SIDE_PANEL = "20rem";
|
||||||
|
// The way a queue step goes: back, on, or to a random other item.
|
||||||
|
type Way = 1 | -1 | "random";
|
||||||
|
const autoWay = (a: Exclude<AutoMode, "off">): Way =>
|
||||||
|
a === "random" ? "random" : a === "next" ? 1 : -1;
|
||||||
|
|
||||||
// The side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
|
// The side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
|
||||||
const PANEL_STRIP = "2.75rem";
|
const PANEL_STRIP = "2.75rem";
|
||||||
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
|
||||||
@@ -444,7 +450,8 @@ export default function PlayerModal({
|
|||||||
// above it ends there.
|
// above it ends there.
|
||||||
const clipDockRef = useRef<HTMLDivElement>(null);
|
const clipDockRef = useRef<HTMLDivElement>(null);
|
||||||
const [clipDockH, setClipDockH] = useState(0);
|
const [clipDockH, setClipDockH] = useState(0);
|
||||||
const clipDocked = clipOpen && (maximized || isFullscreen);
|
const bigView = maximized || isFullscreen;
|
||||||
|
const clipDocked = clipOpen && bigView;
|
||||||
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
|
// Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel
|
||||||
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
|
// rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every
|
||||||
// F. So it renders once, into a host node of its own, and the host moves between the two.
|
// F. So it renders once, into a host node of its own, and the host moves between the two.
|
||||||
@@ -527,14 +534,41 @@ export default function PlayerModal({
|
|||||||
const lost = document.activeElement == null || document.activeElement === document.body;
|
const lost = document.activeElement == null || document.activeElement === document.body;
|
||||||
if (!refocusClip.current && !lost) return;
|
if (!refocusClip.current && !lost) return;
|
||||||
refocusClip.current = false;
|
refocusClip.current = false;
|
||||||
const big = maximized || isFullscreen;
|
const target = bigView ? clipDockButtonRef.current : clipButtonRef.current;
|
||||||
const target = big ? clipDockButtonRef.current : clipButtonRef.current;
|
|
||||||
if (target) target.focus();
|
if (target) target.focus();
|
||||||
else focusModal();
|
else focusModal();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clipOpen) refocusAfterClip();
|
if (!clipOpen) refocusAfterClip();
|
||||||
}, [clipOpen]);
|
}, [clipOpen]);
|
||||||
|
// The big view covers the card, so the card's part with the controls goes inert under it (see the
|
||||||
|
// markup) and the focus cannot stay there. A key's focus moves to the way out and comes back to
|
||||||
|
// the same control when the view ends. A mouse's goes to the card: on the way out, the next Space
|
||||||
|
// would leave the big view instead of pausing. Which it was is read as the focus arrives: Chrome
|
||||||
|
// calls a clicked button :focus-visible once any key is pressed, the F that opens the view too.
|
||||||
|
// When the view ends, a focus that has a place of its own stays there (the clip panel's, kept by
|
||||||
|
// its move above); one with none goes back to the card when the control refuses it (a Next that a
|
||||||
|
// step to the queue's end turned off). The bar's controls go with the bar.
|
||||||
|
const cardPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bigExitRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const focusBeforeBig = useRef<HTMLElement | null>(null);
|
||||||
|
const panelFocusByKey = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (bigView) {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!(active instanceof HTMLElement) || !cardPanelRef.current?.contains(active)) return;
|
||||||
|
const byKey = panelFocusByKey.current;
|
||||||
|
focusBeforeBig.current = byKey ? active : null;
|
||||||
|
if (byKey && bigExitRef.current) bigExitRef.current.focus();
|
||||||
|
else focusModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const back = focusBeforeBig.current;
|
||||||
|
focusBeforeBig.current = null;
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active === document.body || active === cardRef.current) back?.focus();
|
||||||
|
if (document.activeElement !== back) reclaimFocus();
|
||||||
|
}, [bigView, focusModal, reclaimFocus]);
|
||||||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
||||||
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
* panel lives under the video in the card, and in place of the docked bar when maximised; the
|
||||||
* browser's fullscreen steps back to the maximised view, which has room for it. */
|
* browser's fullscreen steps back to the maximised view, which has room for it. */
|
||||||
@@ -657,20 +691,55 @@ export default function PlayerModal({
|
|||||||
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
|
// Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
|
||||||
// queue+index from a ref so the window keydown handler (bound once) always steps from the
|
// queue+index from a ref so the window keydown handler (bound once) always steps from the
|
||||||
// current position, not a stale closure.
|
// current position, not a stale closure.
|
||||||
const navRef = useLatest({ queue, index });
|
//
|
||||||
const goPrev = () => {
|
// The videos YouTube refused to play this session (embed-blocked, removed): a step passes over
|
||||||
const { queue: q, index: i } = navRef.current;
|
// them, and Previous / Next go dark when only those are left that way. State, not a ref, because
|
||||||
if (q && i > 0) setPlayingId(q[i - 1]!.id);
|
// the buttons read it.
|
||||||
|
const [dead, setDead] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
|
const navRef = useLatest({ queue, index, dead });
|
||||||
|
// Which way the step that reached the playing item went — what an item that turns out unplayable
|
||||||
|
// carries on along. null: no step yet (the item the player opened on).
|
||||||
|
const stepRef = useRef<{ way: Way } | null>(null);
|
||||||
|
const stepTo = (id: string, way: Way) => {
|
||||||
|
stepRef.current = { way };
|
||||||
|
setPlayingId(id);
|
||||||
};
|
};
|
||||||
const goNext = () => {
|
// Step from `i` the given way over the videos known dead; whether there was anywhere to go.
|
||||||
const { queue: q, index: i } = navRef.current;
|
const moveOn = (
|
||||||
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id);
|
q: Video[],
|
||||||
|
i: number,
|
||||||
|
way: Way,
|
||||||
|
d: ReadonlySet<string>,
|
||||||
|
wrap: boolean,
|
||||||
|
): boolean => {
|
||||||
|
if (way === "random") {
|
||||||
|
const rest = q.filter((v, k) => k !== i && !d.has(v.id));
|
||||||
|
if (!rest.length) return false;
|
||||||
|
stepTo(rest[Math.floor(Math.random() * rest.length)]!.id, "random");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const j = stepIndex(q, i, way, d, wrap);
|
||||||
|
if (j < 0) return false;
|
||||||
|
stepTo(q[j]!.id, way);
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
const step = (dir: 1 | -1) => {
|
||||||
|
const { queue: q, index: i, dead: d } = navRef.current;
|
||||||
|
if (q) moveOn(q, i, dir, d, false);
|
||||||
|
};
|
||||||
|
const goPrev = () => step(-1);
|
||||||
|
const goNext = () => step(1);
|
||||||
|
const canPrev = !!queue && stepIndex(queue, index, -1, dead) >= 0;
|
||||||
|
const canNext = !!queue && stepIndex(queue, index, 1, dead) >= 0;
|
||||||
|
// Off with items still left that way: only ones YouTube refused are there, which the counter alone
|
||||||
|
// does not tell. At the queue's ends the counter does.
|
||||||
|
const prevReason = queue && index > 0 ? t("player.noPlayableBefore") : undefined;
|
||||||
|
const nextReason = queue && index < queue.length - 1 ? t("player.noPlayableAfter") : undefined;
|
||||||
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
|
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
|
||||||
// parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it
|
// parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it
|
||||||
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
|
// is the only side that knows which item is playing — its queue is frozen at mount, so the parent
|
||||||
// cannot tell. Step to the item that followed (clamped when the last one goes), and close when the
|
// cannot tell. Step to the item that followed (the one before when the last one goes), passing over
|
||||||
// list runs out.
|
// the ones known unplayable, and close when the list runs out.
|
||||||
//
|
//
|
||||||
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for the
|
// Guarded against a double press by a REF, not by disabling the button: a trigger disabled for the
|
||||||
// removal's lifetime was still disabled when focus came back to it (it once waited on a confirm) —
|
// removal's lifetime was still disabled when focus came back to it (it once waited on a confirm) —
|
||||||
@@ -687,19 +756,19 @@ export default function PlayerModal({
|
|||||||
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
|
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
|
||||||
// and auto-advance keep working while the request runs, and a cursor from before the await
|
// and auto-advance keep working while the request runs, and a cursor from before the await
|
||||||
// threw the viewer back onto a video they had already moved past.
|
// threw the viewer back onto a video they had already moved past.
|
||||||
const { queue: q, index: i } = navRef.current;
|
const { queue: q, index: i, dead: d } = navRef.current;
|
||||||
if ((q?.[i] ?? video).id !== victim.id) {
|
if ((q?.[i] ?? video).id !== victim.id) {
|
||||||
// They moved on — take the removed item out of the queue and leave playback where it is.
|
// They moved on — take the removed item out of the queue and leave playback where it is.
|
||||||
setQueue((cur) => cur?.filter((v) => v.id !== victim.id));
|
setQueue((cur) => cur?.filter((v) => v.id !== victim.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = afterRemoval(q, i, victim.id);
|
const next = afterRemoval(q, i, victim.id, d);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
onClose(); // that was the last one
|
onClose(); // that was the last one
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setQueue(next.queue);
|
setQueue(next.queue);
|
||||||
setPlayingId(next.playingId);
|
stepTo(next.playingId, next.way); // one that cannot play after all carries on the same way
|
||||||
};
|
};
|
||||||
|
|
||||||
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||||||
@@ -738,28 +807,28 @@ export default function PlayerModal({
|
|||||||
if (l === "one") return replayCurrent();
|
if (l === "one") return replayCurrent();
|
||||||
// A related video's end is not the queue's: the list beside it is where the next one is picked.
|
// A related video's end is not the queue's: the list beside it is where the next one is picked.
|
||||||
if (related) return;
|
if (related) return;
|
||||||
const { queue: q, index: i } = navRef.current;
|
const { queue: q, index: i, dead: d } = navRef.current;
|
||||||
if (!q || q.length === 0) return;
|
if (!q || q.length === 0 || a === "off") return;
|
||||||
const wrap = l === "all";
|
const wrap = l === "all";
|
||||||
if (a === "next") {
|
// Nowhere else to go (a one-item list, or every other item dead): Loop "All" repeats this one.
|
||||||
if (i < q.length - 1) setPlayingId(q[i + 1]!.id);
|
if (!moveOn(q, i, autoWay(a), d, wrap) && wrap) replayCurrent();
|
||||||
else if (wrap) {
|
};
|
||||||
if (q.length === 1) replayCurrent();
|
// The playing queue item turned out unplayable (YouTube's onError — it never reaches 'ended').
|
||||||
else setPlayingId(q[0]!.id);
|
// Remember it, then carry on the way the step that reached it went: Previous onto a dead video
|
||||||
}
|
// goes on back, not forward to the one you just left. The item the player opened on was reached
|
||||||
} else if (a === "prev") {
|
// by no step, so it moves on only with auto-advance on, in its direction. `go` false: the run of
|
||||||
if (i > 0) setPlayingId(q[i - 1]!.id);
|
// errors hit the player's cap — remembered, but playback stays on the error.
|
||||||
else if (wrap) {
|
const skipUnplayable = (go: boolean) => {
|
||||||
if (q.length === 1) replayCurrent();
|
if (related || clipOpenRef.current) return;
|
||||||
else setPlayingId(q[q.length - 1]!.id);
|
const { queue: q, index: i, dead: d } = navRef.current;
|
||||||
}
|
const gone = q?.[i]?.id;
|
||||||
} else if (a === "random" && q.length > 1) {
|
if (!q || !gone) return;
|
||||||
let r = i;
|
const now = new Set(d).add(gone);
|
||||||
while (r === i) r = Math.floor(Math.random() * q.length);
|
setDead(now);
|
||||||
setPlayingId(q[r]!.id);
|
if (!go || q.length <= 1) return;
|
||||||
} else if (a === "random" && wrap) {
|
const { autoMode: a, loopMode: l } = modeRef.current;
|
||||||
replayCurrent();
|
const way = stepRef.current?.way ?? (a === "off" ? null : autoWay(a));
|
||||||
}
|
if (way != null) moveOn(q, i, way, now, l === "all");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click
|
// Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click
|
||||||
@@ -794,6 +863,9 @@ export default function PlayerModal({
|
|||||||
// 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;
|
||||||
|
// A video cleared from the related list starts over: the position this session stored for it would
|
||||||
|
// otherwise resume a plain pick there, and the player's close would patch it back into the lists.
|
||||||
|
const clearPosition = (id: string) => void sessionPosRef.current.set(id, 0);
|
||||||
const playRelated = (v: Video, startAt: number | null) => {
|
const playRelated = (v: Video, startAt: number | null) => {
|
||||||
setRelatedOpen(false);
|
setRelatedOpen(false);
|
||||||
// The video already playing (its row stays in the list): a new player for the same id would
|
// The video already playing (its row stays in the list): a new player for the same id would
|
||||||
@@ -982,14 +1054,13 @@ export default function PlayerModal({
|
|||||||
mountRef,
|
mountRef,
|
||||||
playerRef,
|
playerRef,
|
||||||
currentIdRef,
|
currentIdRef,
|
||||||
navRef,
|
|
||||||
modeRef,
|
|
||||||
setWatched,
|
setWatched,
|
||||||
setPlayerError,
|
setPlayerError,
|
||||||
setLiveData,
|
setLiveData,
|
||||||
reapplyStored,
|
reapplyStored,
|
||||||
reclaimFocus,
|
reclaimFocus,
|
||||||
advanceOnEnd,
|
advanceOnEnd,
|
||||||
|
skipUnplayable,
|
||||||
sessionPosRef,
|
sessionPosRef,
|
||||||
qc,
|
qc,
|
||||||
});
|
});
|
||||||
@@ -1057,6 +1128,11 @@ export default function PlayerModal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
>
|
>
|
||||||
|
{/* What names the dialog: the headings go inert with the card's panel in the big view, and an
|
||||||
|
inert element names nothing. Hidden, so it is read as the name and only as that. */}
|
||||||
|
<span id={titleId} hidden>
|
||||||
|
{heading}
|
||||||
|
</span>
|
||||||
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
|
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
|
||||||
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
|
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
|
||||||
for a second copy of the same two buttons (owner). */}
|
for a second copy of the same two buttons (owner). */}
|
||||||
@@ -1108,7 +1184,7 @@ export default function PlayerModal({
|
|||||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
maximized || isFullscreen
|
bigView
|
||||||
? `absolute inset-x-0 top-0 ${clipDocked ? "" : "bottom-14"}`
|
? `absolute inset-x-0 top-0 ${clipDocked ? "" : "bottom-14"}`
|
||||||
: "relative w-full h-full"
|
: "relative w-full h-full"
|
||||||
}
|
}
|
||||||
@@ -1166,60 +1242,71 @@ export default function PlayerModal({
|
|||||||
{clipDocked && (
|
{clipDocked && (
|
||||||
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
<div ref={clipDockRef} className="absolute inset-x-0 bottom-0 z-menu" />
|
||||||
)}
|
)}
|
||||||
{(maximized || isFullscreen) && !clipDocked && (
|
{bigView && !clipDocked && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 media-marks px-3 text-sm text-white"
|
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center gap-x-3 border-t border-white/10 bg-media-chrome/95 media-marks px-3 text-sm text-white"
|
||||||
>
|
>
|
||||||
{hasQueue && (
|
{/* Glyphs only, each named by its label and tooltip. The controls scroll when a
|
||||||
<>
|
narrow window cannot hold them; the way out (fullscreen, leave the big view)
|
||||||
<PlayerTransportControls
|
stays outside that part, in sight: on an iPad it had scrolled off the right.
|
||||||
variant="docked"
|
The bar's full height and a little side room: a scroller clips what overflows
|
||||||
index={index}
|
it, the focus ring drawn outside each control included. */}
|
||||||
queueLength={queue!.length}
|
<div className="-mx-1 flex min-w-0 flex-1 items-center gap-x-3 self-stretch overflow-x-auto px-1">
|
||||||
autoMode={autoMode}
|
{hasQueue && (
|
||||||
loopMode={loopMode}
|
<>
|
||||||
onPrev={goPrev}
|
<PlayerTransportControls
|
||||||
onNext={goNext}
|
variant="docked"
|
||||||
onCycleAuto={cycleAuto}
|
index={index}
|
||||||
onCycleLoop={cycleLoop}
|
queueLength={queue!.length}
|
||||||
/>
|
canPrev={canPrev}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
canNext={canNext}
|
||||||
</>
|
prevReason={prevReason}
|
||||||
)}
|
nextReason={nextReason}
|
||||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
autoMode={autoMode}
|
||||||
|
loopMode={loopMode}
|
||||||
|
onPrev={goPrev}
|
||||||
|
onNext={goNext}
|
||||||
|
onCycleAuto={cycleAuto}
|
||||||
|
onCycleLoop={cycleLoop}
|
||||||
|
/>
|
||||||
|
<span className="h-4 w-px shrink-0 bg-white/20" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||||
portal into the fullscreen element rather than <body> (a body-portaled node is
|
portal into the fullscreen element rather than <body> (a body-portaled node is
|
||||||
painted underneath the element holding the lock, i.e. invisible). */}
|
painted underneath the element holding the lock, i.e. invisible). */}
|
||||||
{!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />}
|
{!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<DownloadButton videoId={subject.id} title={subject.title} variant="overlay" />
|
<DownloadButton
|
||||||
)}
|
videoId={subject.id}
|
||||||
{/* Clip mode here: the clip panel takes this bar's place. */}
|
title={subject.title}
|
||||||
{playerError == null && (
|
variant="overlay"
|
||||||
<IconButton
|
/>
|
||||||
ref={clipDockButtonRef}
|
)}
|
||||||
variant="overlay"
|
{/* Clip mode here: the clip panel takes this bar's place. */}
|
||||||
onClick={openClip}
|
{playerError == null && (
|
||||||
label={t("clip.open")}
|
<IconButton
|
||||||
>
|
ref={clipDockButtonRef}
|
||||||
<Scissors />
|
variant="overlay"
|
||||||
</IconButton>
|
onClick={openClip}
|
||||||
)}
|
label={t("clip.open")}
|
||||||
{!navigated && (
|
>
|
||||||
<Button
|
<Scissors />
|
||||||
variant="overlay"
|
</IconButton>
|
||||||
size="sm"
|
)}
|
||||||
pressed={watched}
|
{!navigated && (
|
||||||
onClick={() => setWatched(!watched)}
|
<IconButton
|
||||||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
variant="overlay"
|
||||||
icon={watched ? <CheckCheck /> : <Check />}
|
pressed={watched}
|
||||||
>
|
onClick={() => setWatched(!watched)}
|
||||||
<span className="hidden md:inline">
|
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
>
|
||||||
</span>
|
{watched ? <CheckCheck /> : <Check />}
|
||||||
</Button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<span className="h-4 w-px bg-white/20" />
|
</div>
|
||||||
|
<span className="h-4 w-px shrink-0 bg-white/20" />
|
||||||
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||||
the window-filling view is the biggest, and YouTube's own button opens the
|
the window-filling view is the biggest, and YouTube's own button opens the
|
||||||
system player. */}
|
system player. */}
|
||||||
@@ -1232,9 +1319,9 @@ export default function PlayerModal({
|
|||||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<Button
|
<IconButton
|
||||||
|
ref={bigExitRef}
|
||||||
variant="overlay"
|
variant="overlay"
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
exitBigView();
|
exitBigView();
|
||||||
@@ -1242,10 +1329,9 @@ export default function PlayerModal({
|
|||||||
focusModal();
|
focusModal();
|
||||||
}}
|
}}
|
||||||
label={t("player.exitMaximize")}
|
label={t("player.exitMaximize")}
|
||||||
icon={<Minimize2 />}
|
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
|
<Minimize2 />
|
||||||
</Button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||||
@@ -1304,6 +1390,11 @@ export default function PlayerModal({
|
|||||||
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
||||||
description never makes the card taller than the video. */}
|
description never makes the card taller than the video. */}
|
||||||
<div
|
<div
|
||||||
|
ref={cardPanelRef}
|
||||||
|
inert={bigView}
|
||||||
|
onFocus={(e) => {
|
||||||
|
panelFocusByKey.current = e.target.matches(":focus-visible");
|
||||||
|
}}
|
||||||
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||||||
style={wide ? { width: panelWidth } : undefined}
|
style={wide ? { width: panelWidth } : undefined}
|
||||||
>
|
>
|
||||||
@@ -1312,10 +1403,7 @@ export default function PlayerModal({
|
|||||||
data-testid="player-strip"
|
data-testid="player-strip"
|
||||||
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
|
||||||
>
|
>
|
||||||
{/* The dialog is named by this heading's id: the panel's own one is not rendered. */}
|
<h2 className="sr-only">{heading}</h2>
|
||||||
<h2 id={titleId} className="sr-only">
|
|
||||||
{heading}
|
|
||||||
</h2>
|
|
||||||
{closeButton}
|
{closeButton}
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={panelToggleRef}
|
ref={panelToggleRef}
|
||||||
@@ -1329,24 +1417,28 @@ export default function PlayerModal({
|
|||||||
{hasQueue && (
|
{hasQueue && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
<IconButton
|
<ReasonTooltip reason={!canPrev ? prevReason : undefined} side="left">
|
||||||
variant="ghost"
|
<IconButton
|
||||||
onClick={goPrev}
|
variant="ghost"
|
||||||
disabled={index === 0}
|
onClick={goPrev}
|
||||||
label={t("player.previous")}
|
{...unavailable(!canPrev, prevReason)}
|
||||||
title={`${t("player.previous")} · Shift+←`}
|
label={t("player.previous")}
|
||||||
>
|
title={`${t("player.previous")} · Shift+←`}
|
||||||
<SkipBack />
|
>
|
||||||
</IconButton>
|
<SkipBack />
|
||||||
<IconButton
|
</IconButton>
|
||||||
variant="ghost"
|
</ReasonTooltip>
|
||||||
onClick={goNext}
|
<ReasonTooltip reason={!canNext ? nextReason : undefined} side="left">
|
||||||
disabled={index >= queue!.length - 1}
|
<IconButton
|
||||||
label={t("player.next")}
|
variant="ghost"
|
||||||
title={`${t("player.next")} · Shift+→`}
|
onClick={goNext}
|
||||||
>
|
{...unavailable(!canNext, nextReason)}
|
||||||
<SkipForward />
|
label={t("player.next")}
|
||||||
</IconButton>
|
title={`${t("player.next")} · Shift+→`}
|
||||||
|
>
|
||||||
|
<SkipForward />
|
||||||
|
</IconButton>
|
||||||
|
</ReasonTooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1377,6 +1469,10 @@ export default function PlayerModal({
|
|||||||
variant="modal"
|
variant="modal"
|
||||||
index={index}
|
index={index}
|
||||||
queueLength={queue!.length}
|
queueLength={queue!.length}
|
||||||
|
canPrev={canPrev}
|
||||||
|
canNext={canNext}
|
||||||
|
prevReason={prevReason}
|
||||||
|
nextReason={nextReason}
|
||||||
autoMode={autoMode}
|
autoMode={autoMode}
|
||||||
loopMode={loopMode}
|
loopMode={loopMode}
|
||||||
onPrev={goPrev}
|
onPrev={goPrev}
|
||||||
@@ -1391,9 +1487,7 @@ export default function PlayerModal({
|
|||||||
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
|
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
|
||||||
right (wide, it heads the panel). */}
|
right (wide, it heads the panel). */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">{heading}</h2>
|
||||||
{heading}
|
|
||||||
</h2>
|
|
||||||
{!wide && (
|
{!wide && (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setDescAnchor}
|
ref={setDescAnchor}
|
||||||
@@ -1425,7 +1519,12 @@ export default function PlayerModal({
|
|||||||
maxWidth="max-w-sm"
|
maxWidth="max-w-sm"
|
||||||
initialFocus="close"
|
initialFocus="close"
|
||||||
>
|
>
|
||||||
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
|
<RelatedVideos
|
||||||
|
videoId={active.id}
|
||||||
|
onPlay={playRelated}
|
||||||
|
onWrite={onWrite}
|
||||||
|
onCleared={clearPosition}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
{(navigated || related) && (
|
{(navigated || related) && (
|
||||||
@@ -1681,6 +1780,7 @@ export default function PlayerModal({
|
|||||||
videoId={active.id}
|
videoId={active.id}
|
||||||
onPlay={playRelated}
|
onPlay={playRelated}
|
||||||
onWrite={onWrite}
|
onWrite={onWrite}
|
||||||
|
onCleared={clearPosition}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -163,6 +163,15 @@ describe("RelatedVideos", () => {
|
|||||||
await waitFor(() => expect(titles()).toEqual(["Video A"]));
|
await waitFor(() => expect(titles()).toEqual(["Video A"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears a started video's progress from its menu", async () => {
|
||||||
|
vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })]));
|
||||||
|
const reset = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never);
|
||||||
|
setup();
|
||||||
|
fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!);
|
||||||
|
fireEvent.click(menuItem("card.clearProgress"));
|
||||||
|
await waitFor(() => expect(reset).toHaveBeenCalledWith("A"));
|
||||||
|
});
|
||||||
|
|
||||||
it("reads further with the cursor the last page gave", async () => {
|
it("reads further with the cursor the last page gave", async () => {
|
||||||
const asked = vi
|
const asked = vi
|
||||||
.spyOn(api, "related")
|
.spyOn(api, "related")
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import Tooltip from "../../components/ui/Tooltip";
|
|||||||
import VideoCard from "../video/VideoCard";
|
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 { clearVideoProgress, 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). */
|
||||||
@@ -53,12 +54,15 @@ export function RelatedVideos({
|
|||||||
videoId,
|
videoId,
|
||||||
onPlay,
|
onPlay,
|
||||||
onWrite,
|
onWrite,
|
||||||
|
onCleared,
|
||||||
}: {
|
}: {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
|
||||||
onPlay: (v: Video, startAt: number | null) => void;
|
onPlay: (v: Video, startAt: number | null) => void;
|
||||||
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
|
||||||
onWrite?: (write: Promise<unknown>) => void;
|
onWrite?: (write: Promise<unknown>) => void;
|
||||||
|
/** A video's progress was cleared from its row: the host forgets where its session left it. */
|
||||||
|
onCleared?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -100,7 +104,23 @@ 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClearProgress = (id: string) => {
|
||||||
|
onCleared?.(id);
|
||||||
|
const seq = begin(id, { position_seconds: 0 });
|
||||||
|
const write = clearVideoProgress(qc, id);
|
||||||
|
onWrite?.(write);
|
||||||
|
write
|
||||||
|
.then(() => {
|
||||||
|
settle(seq);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
notifyActionError(e, msg("errors.writeFailed.videoProgress"));
|
||||||
settle(seq);
|
settle(seq);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -118,7 +138,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);
|
||||||
});
|
});
|
||||||
@@ -230,7 +250,7 @@ export function RelatedVideos({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onOpen: open }} />
|
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onClearProgress, onOpen: open }} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function mount(variant: "modal" | "docked", autoMode: AutoMode, loopMode: LoopMo
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
index={1}
|
index={1}
|
||||||
queueLength={3}
|
queueLength={3}
|
||||||
|
canPrev
|
||||||
|
canNext
|
||||||
autoMode={autoMode}
|
autoMode={autoMode}
|
||||||
loopMode={loopMode}
|
loopMode={loopMode}
|
||||||
onPrev={() => {}}
|
onPrev={() => {}}
|
||||||
@@ -58,6 +60,20 @@ describe("PlayerTransportControls", () => {
|
|||||||
expect(loop.hasAttribute("data-lit")).toBe(false);
|
expect(loop.hasAttribute("data-lit")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The big view's bar spoke its controls' names from a width up, and on an iPad the row outgrew the
|
||||||
|
// screen: its last control scrolled out of sight. A glyph in both places; the name is the label.
|
||||||
|
for (const variant of ["modal", "docked"] as const)
|
||||||
|
it(`${variant}: every control is its glyph alone`, () => {
|
||||||
|
mount(variant, "next", "all");
|
||||||
|
for (const name of [
|
||||||
|
"player.previous",
|
||||||
|
"player.next",
|
||||||
|
/^player\.autoAdvance\.label: /,
|
||||||
|
/^player\.loop\.label: /,
|
||||||
|
])
|
||||||
|
expect(button(name).textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
for (const variant of ["modal", "docked"] as const)
|
for (const variant of ["modal", "docked"] as const)
|
||||||
it(`${variant}: each mode's tooltip names it and the mode it is in`, () => {
|
it(`${variant}: each mode's tooltip names it and the mode it is in`, () => {
|
||||||
for (const loopMode of LOOP_MODES) {
|
for (const loopMode of LOOP_MODES) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||||
import type { VideoDetail } from "../../lib/api";
|
import type { VideoDetail } from "../../lib/api";
|
||||||
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
||||||
import { Button } from "../../components/ui/Button";
|
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
|
import Tooltip from "../../components/ui/Tooltip";
|
||||||
import { StateMessage } from "../../components/ui/QueryState";
|
import { StateMessage } from "../../components/ui/QueryState";
|
||||||
|
import { unavailable } from "../../components/ui/unavailable";
|
||||||
import { ClampedText } from "../../components/ui/ClampedText";
|
import { ClampedText } from "../../components/ui/ClampedText";
|
||||||
import { renderDescription } from "./descriptionLinks";
|
import { renderDescription } from "./descriptionLinks";
|
||||||
|
|
||||||
@@ -13,13 +14,32 @@ import { renderDescription } from "./descriptionLinks";
|
|||||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||||
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
||||||
|
|
||||||
|
/** Why a control is off, where a key's focus reads it too: a browser's own tooltip (`title`, left on
|
||||||
|
* the button for what it does) shows for a mouse alone. `stable`: the reason comes and goes under a
|
||||||
|
* control that may hold the focus. */
|
||||||
|
export function ReasonTooltip({
|
||||||
|
reason,
|
||||||
|
side,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
reason: string | undefined;
|
||||||
|
side?: "left";
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip hint={reason ?? ""} always={!!reason} side={side} stable>
|
||||||
|
{children}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes.
|
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes.
|
||||||
* Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal
|
* Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal
|
||||||
* glass theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view
|
* glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only
|
||||||
* docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a
|
* in both: the docked bar once spoke the names from a width up, and on an iPad its row outgrew the
|
||||||
* control does, its glyph and its tooltip are said once; only the primitive and the label's
|
* screen. What a control does, its glyph and its tooltip are said once; only the look differs.
|
||||||
* visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has
|
* Every control's tooltip carries its full name (and the shortcut, where it has one), so a glyph
|
||||||
* one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles,
|
* still says what it does and which mode it is in. The modes are cycles,
|
||||||
* not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its
|
* not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its
|
||||||
* own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). The
|
* own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). The
|
||||||
* wrapper (fragment vs. bordered row) stays at the call site. */
|
* wrapper (fragment vs. bordered row) stays at the call site. */
|
||||||
@@ -27,6 +47,10 @@ export function PlayerTransportControls({
|
|||||||
variant,
|
variant,
|
||||||
index,
|
index,
|
||||||
queueLength,
|
queueLength,
|
||||||
|
canPrev,
|
||||||
|
canNext,
|
||||||
|
prevReason,
|
||||||
|
nextReason,
|
||||||
autoMode,
|
autoMode,
|
||||||
loopMode,
|
loopMode,
|
||||||
onPrev,
|
onPrev,
|
||||||
@@ -37,6 +61,14 @@ export function PlayerTransportControls({
|
|||||||
variant: "docked" | "modal";
|
variant: "docked" | "modal";
|
||||||
index: number;
|
index: number;
|
||||||
queueLength: number;
|
queueLength: number;
|
||||||
|
/** Whether there is a playable item that way (one YouTube refused is passed over). */
|
||||||
|
canPrev: boolean;
|
||||||
|
canNext: boolean;
|
||||||
|
/** Why Previous / Next are off when items are left that way (only ones YouTube refused): the
|
||||||
|
* button stays focusable and its tooltip says so. Unset at the queue's ends, which explain
|
||||||
|
* themselves. */
|
||||||
|
prevReason?: string;
|
||||||
|
nextReason?: string;
|
||||||
autoMode: AutoMode;
|
autoMode: AutoMode;
|
||||||
loopMode: LoopMode;
|
loopMode: LoopMode;
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
@@ -46,8 +78,9 @@ export function PlayerTransportControls({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const docked = variant === "docked";
|
const docked = variant === "docked";
|
||||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
// Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines.
|
||||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`;
|
||||||
|
const divider = `h-4 w-px shrink-0 ${docked ? "bg-white/20" : "bg-border"}`;
|
||||||
const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
|
const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
|
||||||
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
|
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
|
||||||
type Control = {
|
type Control = {
|
||||||
@@ -57,11 +90,8 @@ export function PlayerTransportControls({
|
|||||||
title: string;
|
title: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
reason?: string;
|
||||||
lit?: boolean;
|
lit?: boolean;
|
||||||
/** From which width the docked bar shows the name beside the glyph. */
|
|
||||||
labelFrom: "sm" | "md";
|
|
||||||
/** The glyph after the name (Next points the way it goes). */
|
|
||||||
trailing?: boolean;
|
|
||||||
};
|
};
|
||||||
const step: Control[] = [
|
const step: Control[] = [
|
||||||
{
|
{
|
||||||
@@ -70,8 +100,8 @@ export function PlayerTransportControls({
|
|||||||
name: t("player.previous"),
|
name: t("player.previous"),
|
||||||
title: `${t("player.previous")} · Shift+←`,
|
title: `${t("player.previous")} · Shift+←`,
|
||||||
onClick: onPrev,
|
onClick: onPrev,
|
||||||
disabled: index === 0,
|
disabled: !canPrev,
|
||||||
labelFrom: "sm",
|
reason: canPrev ? undefined : prevReason,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "next",
|
key: "next",
|
||||||
@@ -79,9 +109,8 @@ export function PlayerTransportControls({
|
|||||||
name: t("player.next"),
|
name: t("player.next"),
|
||||||
title: `${t("player.next")} · Shift+→`,
|
title: `${t("player.next")} · Shift+→`,
|
||||||
onClick: onNext,
|
onClick: onNext,
|
||||||
disabled: index === queueLength - 1,
|
disabled: !canNext,
|
||||||
labelFrom: "sm",
|
reason: canNext ? undefined : nextReason,
|
||||||
trailing: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
// Persistent playback settings — cycle on click, saved to your account.
|
// Persistent playback settings — cycle on click, saved to your account.
|
||||||
@@ -94,7 +123,6 @@ export function PlayerTransportControls({
|
|||||||
title: `${autoName} · ${t("player.autoAdvance.hint")}`,
|
title: `${autoName} · ${t("player.autoAdvance.hint")}`,
|
||||||
onClick: onCycleAuto,
|
onClick: onCycleAuto,
|
||||||
lit: autoMode !== "off",
|
lit: autoMode !== "off",
|
||||||
labelFrom: "md",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "loop",
|
key: "loop",
|
||||||
@@ -103,39 +131,22 @@ export function PlayerTransportControls({
|
|||||||
title: `${loopName} · ${t("player.loop.hint")}`,
|
title: `${loopName} · ${t("player.loop.hint")}`,
|
||||||
onClick: onCycleLoop,
|
onClick: onCycleLoop,
|
||||||
lit: loopMode !== "off",
|
lit: loopMode !== "off",
|
||||||
labelFrom: "md",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const render = (c: Control) =>
|
const render = (c: Control) => (
|
||||||
docked ? (
|
<ReasonTooltip key={c.key} reason={c.reason}>
|
||||||
<Button
|
|
||||||
key={c.key}
|
|
||||||
variant="overlay"
|
|
||||||
size="sm"
|
|
||||||
icon={c.trailing ? undefined : c.glyph}
|
|
||||||
onClick={c.onClick}
|
|
||||||
disabled={c.disabled}
|
|
||||||
lit={c.lit}
|
|
||||||
label={c.name}
|
|
||||||
title={c.title}
|
|
||||||
>
|
|
||||||
<span className={c.labelFrom === "sm" ? "hidden sm:inline" : "hidden md:inline"}>
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
{c.trailing && c.glyph}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
key={c.key}
|
variant={docked ? "overlay" : undefined}
|
||||||
onClick={c.onClick}
|
onClick={c.onClick}
|
||||||
disabled={c.disabled}
|
{...unavailable(c.disabled, c.reason)}
|
||||||
lit={c.lit}
|
lit={c.lit}
|
||||||
label={c.name}
|
label={c.name}
|
||||||
title={c.title}
|
title={c.title}
|
||||||
>
|
>
|
||||||
{c.glyph}
|
{c.glyph}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
</ReasonTooltip>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{render(step[0]!)}
|
{render(step[0]!)}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
|||||||
import { LinkedPlayer } from "./LinkedPlayer";
|
import { LinkedPlayer } from "./LinkedPlayer";
|
||||||
import { api, type Video } from "../../lib/api";
|
import { api, type Video } from "../../lib/api";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
|
import { msg } from "../../lib/notifications";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||||
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
|
|||||||
const open = vi.fn();
|
const open = vi.fn();
|
||||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
|
||||||
);
|
);
|
||||||
expect(open).not.toHaveBeenCalled();
|
expect(open).not.toHaveBeenCalled();
|
||||||
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
|
// Said once: the address no longer names it, so a reload or a copied link does not say it again.
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
import { useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, type Video } from "../../lib/api";
|
import { api, type Video } from "../../lib/api";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||||
import { clearParams } from "../../lib/urlState";
|
import { clearParams } from "../../lib/urlState";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
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
|
/** 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
|
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
|
||||||
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
|
|||||||
open: (video: Video, inList: boolean) => void,
|
open: (video: Video, inList: boolean) => void,
|
||||||
scope: string | number | null = null,
|
scope: string | number | null = null,
|
||||||
): void {
|
): void {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||||
const [linkedScope] = useState(scope);
|
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
|
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
|
||||||
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
|
|||||||
});
|
});
|
||||||
const openIt = useEffectEvent(open);
|
const openIt = useEffectEvent(open);
|
||||||
const failed = useEffectEvent((err: unknown) => {
|
const failed = useEffectEvent((err: unknown) => {
|
||||||
notifyActionError(err, t("player.linkedFailed"));
|
notifyActionError(err, msg("player.linkedFailed"));
|
||||||
clearParams(...PLAYER_KEYS);
|
clearParams(...PLAYER_KEYS);
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The card shows its state and the everyday toggles (watched, save, add to playlist) and a
|
// The card shows its state and the everyday toggles (watched, save, add to playlist) and a
|
||||||
// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view
|
// "…" into the list's ONE menu; download, details, hide and clear progress live only in that menu. Every view
|
||||||
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
|
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
|
||||||
// picture, and beside the title for touch — only one is displayed per device) points both at the
|
// picture, and beside the title for touch — only one is displayed per device) points both at the
|
||||||
// same menu.
|
// same menu.
|
||||||
@@ -77,7 +77,7 @@ describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions"
|
|||||||
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
|
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
|
||||||
expect(all).toContain(want);
|
expect(all).toContain(want);
|
||||||
expect(all).toContain("card.menu.more");
|
expect(all).toContain("card.menu.more");
|
||||||
for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"])
|
for (const gone of ["downloads.button.label", "card.hide", "card.clearProgress", "card.info"])
|
||||||
expect(all).not.toContain(gone);
|
expect(all).not.toContain(gone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
|
|||||||
type More = { props: Record<string, unknown>; open: boolean };
|
type More = { props: Record<string, unknown>; open: boolean };
|
||||||
|
|
||||||
// The card keeps its state and the everyday toggles — watched, save, add to playlist — and
|
// The card keeps its state and the everyday toggles — watched, save, add to playlist — and
|
||||||
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
|
// a "…" for everything else (download, details, copy, hide, clear progress, pin), which is the list's ONE
|
||||||
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
|
||||||
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
|
||||||
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
|
// A video's ONE action list. The right-click and the card's "…" open the same menu, which
|
||||||
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
|
// holds everything the card no longer shows (download, details, copy, hide, clear progress, pin) beside the
|
||||||
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
// everyday toggles, and each row fires the same handler the card's own buttons do.
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
@@ -102,18 +102,18 @@ function setup(v: Video) {
|
|||||||
const handlers = {
|
const handlers = {
|
||||||
onState: vi.fn(),
|
onState: vi.fn(),
|
||||||
onToggleSave: vi.fn(),
|
onToggleSave: vi.fn(),
|
||||||
onResetState: vi.fn(),
|
onClearProgress: vi.fn(),
|
||||||
onOpen: vi.fn(),
|
onOpen: vi.fn(),
|
||||||
};
|
};
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
render(
|
const { unmount } = render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<Feed v={v} handlers={handlers} />
|
<Feed v={v} handlers={handlers} />
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { handlers };
|
return { handlers, unmount };
|
||||||
}
|
}
|
||||||
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
|
||||||
const more = () => screen.getByRole("button", { name: "more" });
|
const more = () => screen.getByRole("button", { name: "more" });
|
||||||
@@ -137,16 +137,29 @@ describe("a video's menu", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
|
it("offers Continue and the restart on a half-watched one, the way back and the clearing", async () => {
|
||||||
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
|
||||||
rightClick();
|
rightClick();
|
||||||
expect(labels()).toContain("card.restartTitle");
|
expect(labels()).toContain("card.restartTitle");
|
||||||
expect(labels()).toContain("card.menu.unsave");
|
expect(labels()).toContain("card.menu.unsave");
|
||||||
expect(labels()).toContain("card.resetState");
|
expect(labels()).toContain("card.clearProgress");
|
||||||
fireEvent.click(item("card.restartTitle"));
|
fireEvent.click(item("card.restartTitle"));
|
||||||
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// It clears a resume point and nothing else: with none, there is nothing for it to do.
|
||||||
|
it("offers clearing only where there is a resume point, whatever the status", () => {
|
||||||
|
for (const status of ["hidden", "watched"] as const) {
|
||||||
|
const { unmount } = setup(video({ status }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).not.toContain("card.clearProgress");
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
setup(video({ status: "hidden", position_seconds: 30 }));
|
||||||
|
rightClick();
|
||||||
|
expect(labels()).toContain("card.clearProgress");
|
||||||
|
});
|
||||||
|
|
||||||
it("each toggle fires the card's own handler", async () => {
|
it("each toggle fires the card's own handler", async () => {
|
||||||
const { handlers } = setup(video({ status: "watched" }));
|
const { handlers } = setup(video({ status: "watched" }));
|
||||||
rightClick();
|
rightClick();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function downloadRow(state: string | undefined) {
|
|||||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true),
|
() =>
|
||||||
|
useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onClearProgress: vi.fn() }, true),
|
||||||
{ wrapper },
|
{ wrapper },
|
||||||
);
|
);
|
||||||
return () => result.current.items(video, null).find((i) => i.id === "download")!;
|
return () => result.current.items(video, null).find((i) => i.id === "download")!;
|
||||||
@@ -74,6 +75,7 @@ describe("the video menu's channel-filter row", () => {
|
|||||||
{
|
{
|
||||||
onState: vi.fn(),
|
onState: vi.fn(),
|
||||||
onToggleSave: vi.fn(),
|
onToggleSave: vi.fn(),
|
||||||
|
onClearProgress: vi.fn(),
|
||||||
channelFilter: ids ? { ids, set } : undefined,
|
channelFilter: ids ? { ids, set } : undefined,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -20,25 +20,26 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api, type Video, type VideoStatus } from "../../lib/api";
|
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||||||
import { isResettable } from "../../lib/api/feed";
|
import { hasResumePoint } from "../../lib/api/feed";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { pinVideo, useComfortPin } from "../comfort/comfortPin";
|
import { pinVideo, useComfortPin } from "../comfort/comfortPin";
|
||||||
import { useCopyItems } from "../../components/ui/copyItems";
|
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
|
||||||
// and the Home shelves list the same actions in the same order. The card itself keeps only its
|
// and the Home shelves list the same actions in the same order. The card itself keeps only its
|
||||||
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
|
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
|
||||||
// download, details, copy, hide, reset, pin — lives here, and only here.
|
// download, details, copy, hide, clear progress, pin — lives here, and only here.
|
||||||
|
|
||||||
export interface VideoActionHandlers {
|
export interface VideoActionHandlers {
|
||||||
onState: (id: string, status: VideoStatus) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
/** Omitted where a surface cannot reset a video's state (Home's shelves). */
|
/** Back to fresh: its state row and resume point both go. */
|
||||||
onResetState?: (id: string) => void;
|
onClearProgress: (id: string) => void;
|
||||||
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
|
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
|
||||||
@@ -70,7 +71,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
anchor: Element | null,
|
anchor: Element | null,
|
||||||
{ pin: withPin = true }: { pin?: boolean } = {},
|
{ pin: withPin = true }: { pin?: boolean } = {},
|
||||||
): ActionMenuItem[] => {
|
): ActionMenuItem[] => {
|
||||||
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
|
const { onState, onToggleSave, onClearProgress, onOpen, channelFilter } = handlers;
|
||||||
const watched = v.status === "watched";
|
const watched = v.status === "watched";
|
||||||
const hidden = v.status === "hidden";
|
const hidden = v.status === "hidden";
|
||||||
const inProgress = v.position_seconds > 0 && !watched;
|
const inProgress = v.position_seconds > 0 && !watched;
|
||||||
@@ -188,17 +189,17 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
|||||||
separatorBefore: true,
|
separatorBefore: true,
|
||||||
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
||||||
},
|
},
|
||||||
...(onResetState && isResettable(v)
|
...(hasResumePoint(v)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "reset",
|
id: "clearProgress",
|
||||||
label: t("card.resetState"),
|
label: t("card.clearProgress"),
|
||||||
icon: <Eraser className={icon} />,
|
icon: <Eraser className={icon} />,
|
||||||
onSelect: () => onResetState(v.id),
|
onSelect: () => onClearProgress(v.id),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
|
...(withPin ? pin.item(pinVideo(v.id), raw(v.title ?? "")) : []),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const ALLOWED = new Set([
|
|||||||
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
|
// the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark
|
||||||
// together; a direct api call skips the mark.
|
// together; a direct api call skips the mark.
|
||||||
const WRITES =
|
const WRITES =
|
||||||
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
|
/\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/;
|
||||||
|
|
||||||
describe("a video's state, Watch-later and playlist-item writes", () => {
|
describe("a video's state, Watch-later and playlist-item writes", () => {
|
||||||
it("go through features/video/videoWrites, never the api directly", () => {
|
it("go through features/video/videoWrites, never the api directly", () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
addPlaylistItem,
|
addPlaylistItem,
|
||||||
movePlaylistItem,
|
movePlaylistItem,
|
||||||
removePlaylistItem,
|
removePlaylistItem,
|
||||||
resetVideoState,
|
clearVideoProgress,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
writeWatchLater,
|
writeWatchLater,
|
||||||
} from "./videoWrites";
|
} from "./videoWrites";
|
||||||
@@ -41,11 +41,11 @@ describe("videoWrites", () => {
|
|||||||
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
|
expect(marks(qc)).toEqual({ X: { status: "hidden" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a reset as new, from the start", async () => {
|
it("marks a cleared video as back at the start, its status as it was", async () => {
|
||||||
vi.spyOn(api, "clearState").mockResolvedValue({ ok: true } as never);
|
vi.spyOn(api, "clearProgress").mockResolvedValue({ ok: true } as never);
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
await resetVideoState(qc, "X");
|
await clearVideoProgress(qc, "X");
|
||||||
expect(marks(qc)).toEqual({ X: { status: "new", position_seconds: 0 } });
|
expect(marks(qc)).toEqual({ X: { position_seconds: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a Watch-later save and its removal", async () => {
|
it("marks a Watch-later save and its removal", async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user