Compare commits
39
Commits
da74b8b9bf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb4063362 | ||
|
|
b4ba9d95b1 | ||
|
|
c18a2063c8 | ||
|
|
b636ef38bc | ||
|
|
195b5f3917 | ||
|
|
1a1e9d9542 | ||
|
|
d09b5d6abb | ||
|
|
439f5cd67b | ||
|
|
d79cb528fd | ||
|
|
9b76d2f96c | ||
|
|
bf54a3ce5f | ||
|
|
efbbb23525 | ||
|
|
3b85881af4 | ||
|
|
8cf8c4139c | ||
|
|
cd773e8f33 | ||
|
|
242f00671d | ||
|
|
7f8d7cbe20 | ||
|
|
8dd2f0c015 | ||
|
|
b3402d158f | ||
|
|
c910cf2f20 | ||
|
|
245c0a2019 | ||
|
|
add7f7d167 | ||
|
|
a29afd916d | ||
|
|
e9ce989998 | ||
|
|
7299e82c22 | ||
|
|
41052eb4ea | ||
|
|
85fd9b6704 | ||
|
|
e06c0688d9 | ||
|
|
15a3603513 | ||
|
|
c430558ab3 | ||
|
|
be38d652a5 | ||
|
|
31a75f4985 | ||
|
|
f8a8e905da | ||
|
|
5e8589bbf8 | ||
|
|
ceadf69e43 | ||
|
|
4379f76747 | ||
|
|
b3be568e36 | ||
|
|
a8cbc7b338 | ||
|
|
cf8dc80428 |
@@ -7,6 +7,44 @@ 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.116.0 — 2026-11-03
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- In a playlist, tick rows with their box, Shift+click or x, then move or remove them together. An Undo puts them back at their places, and a single row's move has one too.
|
||||||
|
- Drag rows onto another list in the rail: one row, or all the selected ones, also under a sort or a filter.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removing a video from inside the player no longer loses your place in the list when the player closes.
|
||||||
|
|
||||||
|
## 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
|
## 0.114.1 — 2026-10-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+159
-65
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -412,30 +406,6 @@ def remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def place_item(db: Session, pl: Playlist, video_id: str, position: int) -> None:
|
|
||||||
"""Put a video already in the list at `position` (clamped to the end), renumbering the whole
|
|
||||||
list: positions can have gaps after removals, so "index N" is only meaningful over the
|
|
||||||
ordered rows."""
|
|
||||||
rows = queries.items(db, pl.id)
|
|
||||||
new = next(r for r in rows if r.video_id == video_id)
|
|
||||||
rows.remove(new)
|
|
||||||
rows.insert(min(position, len(rows)), new)
|
|
||||||
for pos, row in enumerate(rows):
|
|
||||||
row.position = pos
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_playlist(db: Session, pl: Playlist, video_id: str, position: int | None) -> None:
|
|
||||||
"""Add a video to one of the user's lists, at `position` when given (the list page's "Undo" of
|
|
||||||
a removal puts the row back where it was in ONE request), else at the end. A video already in
|
|
||||||
the list stays where it is."""
|
|
||||||
added = add_item(db, pl, video_id, mark_dirty=False)
|
|
||||||
if added and position is not None:
|
|
||||||
place_item(db, pl, video_id, position)
|
|
||||||
recompute_dirty(db, pl)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
|
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
|
||||||
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
|
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
|
||||||
reads `dirty` under it)."""
|
reads `dirty` under it)."""
|
||||||
@@ -453,39 +423,163 @@ def delete_playlist(db: Session, pl: Playlist) -> None:
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def move_item(db: Session, src: Playlist, dst: Playlist, video_id: str) -> None:
|
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> list[str]:
|
||||||
"""Move one video from `src` into `dst`, in the caller's one transaction.
|
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply
|
||||||
|
not among them. Says which went: an Undo must not bring back one this removal never took."""
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
|
wanted = set(video_ids)
|
||||||
|
rows = [it for it in queries.items(db, pl.id) if it.video_id in wanted]
|
||||||
|
for row in rows:
|
||||||
|
db.delete(row)
|
||||||
|
if rows:
|
||||||
|
recompute_dirty(db, pl)
|
||||||
|
touch(pl)
|
||||||
|
db.flush()
|
||||||
|
return [row.video_id for row in rows]
|
||||||
|
|
||||||
The client-side shape — add to the target, then remove from the source — leaves the video in
|
|
||||||
BOTH lists whenever the second call fails. Idempotent: a video already in the target is simply
|
def _insert_at(
|
||||||
dropped from the source (moving into a list that has it is a removal, not a duplicate). Raises
|
db: Session,
|
||||||
`MoveRefused` for the same list twice, or a video a concurrent edit already took out."""
|
pl: Playlist,
|
||||||
|
placed: list[tuple[int, str]],
|
||||||
|
leaving: dict[str, PlaylistItem] | None = None,
|
||||||
|
staying_out: frozenset[str] = frozenset(),
|
||||||
|
) -> int:
|
||||||
|
"""Add videos at their indices (ascending, so each index is where the row stood when the rows
|
||||||
|
before it were back) and renumber the list 0..n-1, as positions can have gaps. A video already in
|
||||||
|
the list stays where it is, and one the catalog has lost or in `staying_out` is left out, the
|
||||||
|
ones after it closing up. A video with a row in `leaving` brings that row along, for the reason
|
||||||
|
`move_items` re-parents. Says how many were added; the caller holds the playlist's lock."""
|
||||||
|
leaving = leaving or {}
|
||||||
|
rows = queries.items(db, pl.id)
|
||||||
|
present = {r.video_id for r in rows}
|
||||||
|
known = queries.known_video_ids(db, [vid for _, vid in placed])
|
||||||
|
added = 0
|
||||||
|
left_out = 0
|
||||||
|
for at, vid in sorted(placed, key=lambda p: p[0]):
|
||||||
|
if vid in present or vid not in known or vid in staying_out:
|
||||||
|
left_out += 1
|
||||||
|
continue
|
||||||
|
row = leaving.pop(vid, None)
|
||||||
|
if row is None:
|
||||||
|
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.playlist_id = pl.id
|
||||||
|
rows.insert(min(at - left_out, len(rows)), row)
|
||||||
|
present.add(vid)
|
||||||
|
added += 1
|
||||||
|
for pos, row in enumerate(rows):
|
||||||
|
row.position = pos
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: list[int]) -> int:
|
||||||
|
"""Put removed videos back at their places, in ONE request: an append plus a whole-list reorder
|
||||||
|
leaves them at the end whenever the second call fails. Says how many came back."""
|
||||||
|
lock_playlists(db, pl.id)
|
||||||
|
added = _insert_at(db, pl, list(zip(positions, video_ids, strict=True)))
|
||||||
|
if added:
|
||||||
|
recompute_dirty(db, pl)
|
||||||
|
touch(pl)
|
||||||
|
db.flush()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
class ListGone(Exception):
|
||||||
|
"""A list of a two-list write was deleted between the route's read and the lock."""
|
||||||
|
|
||||||
|
def __init__(self, *, source: bool):
|
||||||
|
super().__init__("source" if source else "target")
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_both(db: Session, src: Playlist, dst: Playlist) -> None:
|
||||||
|
"""Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise. A list
|
||||||
|
already gone is said here: a write into it would answer with its foreign key's 500."""
|
||||||
|
found = lock_playlists(db, src.id, dst.id)
|
||||||
|
if src.id not in found:
|
||||||
|
raise ListGone(source=True)
|
||||||
|
if dst.id not in found:
|
||||||
|
raise ListGone(source=False)
|
||||||
|
|
||||||
|
|
||||||
|
def move_items(
|
||||||
|
db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
"""Move these videos from `src` into `dst`, in the caller's one transaction, in the source's
|
||||||
|
order whatever order they were picked in. Returns the ones that landed in the target and the ones
|
||||||
|
it already held, which only left the source: the Undo needs to tell them apart. A video a
|
||||||
|
concurrent edit already took out of the source is in neither. Raises `MoveRefused` for the same
|
||||||
|
list twice."""
|
||||||
if src.id == dst.id:
|
if src.id == dst.id:
|
||||||
raise MoveRefused("same_playlist")
|
raise MoveRefused("same_playlist")
|
||||||
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
|
_lock_both(db, src, dst)
|
||||||
lock_playlists(db, src.id, dst.id)
|
wanted = set(video_ids)
|
||||||
row = queries.item(db, src.id, video_id)
|
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
|
||||||
if row is None:
|
held = queries.in_playlist(db, dst.id, [r.video_id for r in rows])
|
||||||
raise MoveRefused("not_in_playlist")
|
position = next_item_position(db, dst.id)
|
||||||
dst_changed = False
|
moved: list[str] = []
|
||||||
if queries.item(db, dst.id, video_id) is None:
|
already_there: list[str] = []
|
||||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
for row in rows:
|
||||||
# survives the move, and there is no window where the video belongs to neither list. The
|
if row.video_id in held:
|
||||||
# check above holds: both lists are locked, and an insert into the target (another tab's
|
already_there.append(row.video_id)
|
||||||
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
|
db.delete(row)
|
||||||
row.playlist_id = dst.id
|
else:
|
||||||
row.position = next_item_position(db, dst.id)
|
moved.append(row.video_id)
|
||||||
dst_changed = True
|
# Re-parented rather than deleted and inserted: `added_at` ("when I saved this") survives
|
||||||
if not dst_changed:
|
# the move, and there is no window where the video belongs to neither list. `held` stays
|
||||||
db.delete(row)
|
# true: both lists are locked, and an insert into the target (another tab's add, a
|
||||||
# The source always changed; the target only when the row landed in it. Touching a target
|
# YouTube merge) waits for the lock, as its foreign key needs the target row.
|
||||||
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
|
row.playlist_id = dst.id
|
||||||
recompute_dirty(db, src)
|
row.position = position
|
||||||
touch(src)
|
position += 1
|
||||||
if dst_changed:
|
if rows:
|
||||||
|
recompute_dirty(db, src)
|
||||||
|
touch(src)
|
||||||
|
# Only when a row landed in it: touching a target that already had the video promoted an untouched
|
||||||
|
# list in the rail's "recently changed" sort.
|
||||||
|
if moved:
|
||||||
recompute_dirty(db, dst)
|
recompute_dirty(db, dst)
|
||||||
touch(dst)
|
touch(dst)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
return moved, already_there
|
||||||
|
|
||||||
|
|
||||||
|
def undo_move(
|
||||||
|
db: Session,
|
||||||
|
src: Playlist,
|
||||||
|
dst: Playlist,
|
||||||
|
moved: list[str],
|
||||||
|
already_there: list[str],
|
||||||
|
positions: list[int],
|
||||||
|
) -> int:
|
||||||
|
"""Take a move back in one transaction: the `moved` videos leave `dst` and return to their places
|
||||||
|
in `src`, keeping their rows, as the move did. The `already_there` ones return too and stay in
|
||||||
|
`dst`, which held them before. A moved video `dst` no longer holds has gone on from there, and
|
||||||
|
bringing it back would leave it in lists the reader never put it in, so it stays out, as a
|
||||||
|
move skips a video a concurrent edit took out. `positions` pairs with `moved` + `already_there`.
|
||||||
|
Says how many came back. Raises `MoveRefused` for the same list twice, as a move does."""
|
||||||
|
if src.id == dst.id:
|
||||||
|
raise MoveRefused("same_playlist")
|
||||||
|
_lock_both(db, src, dst)
|
||||||
|
wanted = set(moved)
|
||||||
|
rows = {it.video_id: it for it in queries.items(db, dst.id) if it.video_id in wanted}
|
||||||
|
changed = bool(rows)
|
||||||
|
placed = list(zip(positions, [*moved, *already_there], strict=True))
|
||||||
|
back = _insert_at(db, src, placed, leaving=rows, staying_out=frozenset(wanted - rows.keys()))
|
||||||
|
# What `src` holds again by now only leaves `dst`.
|
||||||
|
for row in rows.values():
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
if changed:
|
||||||
|
recompute_dirty(db, dst)
|
||||||
|
touch(dst)
|
||||||
|
if back:
|
||||||
|
recompute_dirty(db, src)
|
||||||
|
touch(src)
|
||||||
|
db.flush()
|
||||||
|
return back
|
||||||
|
|
||||||
|
|
||||||
def reorder_items(db: Session, pl: Playlist, order: list[str]) -> int:
|
def reorder_items(db: Session, pl: Playlist, order: list[str]) -> int:
|
||||||
|
|||||||
+19
-9
@@ -7,23 +7,33 @@ whatever sentence the route happened to hand-write. `refused` is the generic fam
|
|||||||
(`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")
|
||||||
|
|||||||
+148
-40
@@ -3,10 +3,10 @@ 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, model_validator
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
@@ -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})
|
||||||
@@ -371,24 +386,17 @@ def delete_playlist(
|
|||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
|
|
||||||
|
|
||||||
class AddItemIn(VideoRefIn):
|
|
||||||
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
|
|
||||||
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
|
|
||||||
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
|
|
||||||
position: int | None = Field(default=None, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/items")
|
@router.post("/{playlist_id}/items")
|
||||||
def add_item(
|
def add_item(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
body: AddItemIn,
|
body: VideoRefIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
video_id = body.video_id
|
video_id = body.video_id
|
||||||
_video_or_404(db, video_id)
|
_video_or_404(db, video_id)
|
||||||
service.add_to_playlist(db, pl, video_id, body.position)
|
service.add_item(db, pl, video_id, mark_dirty=True)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
@@ -400,58 +408,158 @@ def remove_item(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
service.remove_item(db, pl, video_id, mark_dirty=True)
|
service.remove_item(db, pl, video_id, mark_dirty=True)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
class MoveItemIn(BaseModel):
|
class VideoIdsIn(BaseModel):
|
||||||
|
# Required: with a default, a misnamed key answered 200 having acted on no video.
|
||||||
|
video_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _paired(video_count: int, positions: list[int]) -> None:
|
||||||
|
"""One place per video, none before the start and none twice: both undo bodies come back from
|
||||||
|
the client, and the insert's arithmetic counts on places that only rise."""
|
||||||
|
if len(positions) != video_count:
|
||||||
|
raise ValueError("positions must pair one-to-one with the videos")
|
||||||
|
if any(at < 0 for at in positions):
|
||||||
|
raise ValueError("positions cannot be negative")
|
||||||
|
if len(set(positions)) != len(positions):
|
||||||
|
raise ValueError("positions cannot repeat")
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreItemsIn(VideoIdsIn):
|
||||||
|
# Where each video goes back: an index over the ordered rows, one per video.
|
||||||
|
positions: list[int] = []
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_position_per_video(self):
|
||||||
|
_paired(len(self.video_ids), self.positions)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MoveItemsIn(VideoIdsIn):
|
||||||
target_playlist_id: int
|
target_playlist_id: int
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{playlist_id}/items/{video_id}/move")
|
class UndoMoveIn(BaseModel):
|
||||||
def move_item(
|
from_playlist_id: int
|
||||||
|
# Required, as `video_ids` is. An empty one is still a real body: the target held them all.
|
||||||
|
moved: list[str]
|
||||||
|
already_there: list[str] = []
|
||||||
|
# One per video of `moved` + `already_there`, in that order.
|
||||||
|
positions: list[int]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_position_per_video(self):
|
||||||
|
_paired(len(self.moved) + len(self.already_there), self.positions)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def _list_gone(e: service.ListGone) -> HTTPException:
|
||||||
|
"""Each list of a two-list write under the reason the route's own read gives it."""
|
||||||
|
return not_found("playlist") if e.source else not_found("move_target")
|
||||||
|
|
||||||
|
|
||||||
|
def _move_refused(e: service.MoveRefused) -> HTTPException:
|
||||||
|
return HTTPException(status_code=400, detail={"code": "playlist_move", "reason": e.reason})
|
||||||
|
|
||||||
|
|
||||||
|
def _other_list(db: Session, user: User, playlist_id: int) -> "Playlist":
|
||||||
|
"""The other list of a move. Its own reason: "this playlist doesn't exist any more" about a list
|
||||||
|
that is gone was read as the one on screen, which is still there."""
|
||||||
|
other = queries.own_playlist(db, user, playlist_id)
|
||||||
|
if other is None:
|
||||||
|
raise not_found("move_target")
|
||||||
|
return other
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{playlist_id}/items/remove-bulk")
|
||||||
|
def remove_items(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
video_id: str,
|
body: VideoIdsIn,
|
||||||
body: MoveItemIn,
|
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Move one video from a playlist into another of the caller's playlists, atomically.
|
"""Remove many videos from a playlist at once. Returns the ones that went."""
|
||||||
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
|
removed = service.remove_items(db, pl, body.video_ids)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": pl.id, "removed": removed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{playlist_id}/items/restore")
|
||||||
|
def restore_items(
|
||||||
|
playlist_id: int,
|
||||||
|
body: RestoreItemsIn,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The "Undo" of a removal: the videos back at their places. Returns how many came back."""
|
||||||
|
pl = _own_playlist(db, user, playlist_id, lock=True)
|
||||||
|
added = service.restore_items(db, pl, body.video_ids, body.positions)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": pl.id, "added": added}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{playlist_id}/items/move-bulk")
|
||||||
|
def move_items(
|
||||||
|
playlist_id: int,
|
||||||
|
body: MoveItemsIn,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Move videos into another of the caller's playlists, atomically.
|
||||||
|
|
||||||
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
|
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
|
||||||
from the source — leaves the video in BOTH lists whenever the second call fails, which is
|
from the source — leaves a video in BOTH lists whenever the second call fails. A video the
|
||||||
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
|
target already has only leaves the source, and one a concurrent edit (another tab, a YouTube
|
||||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
re-pull) already took out is skipped. Says which landed there and which it already held, for
|
||||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
the "Undo"."""
|
||||||
src = _own_playlist(db, user, playlist_id)
|
src = _own_playlist(db, user, playlist_id)
|
||||||
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
|
dst = _other_list(db, user, body.target_playlist_id)
|
||||||
# the list on screen — which is still there.
|
|
||||||
dst = queries.own_playlist(db, user, body.target_playlist_id)
|
|
||||||
if dst is None:
|
|
||||||
raise not_found("move_target")
|
|
||||||
try:
|
try:
|
||||||
service.move_item(db, src, dst, video_id)
|
moved, already_there = service.move_items(db, src, dst, body.video_ids)
|
||||||
except service.MoveRefused as e:
|
except service.MoveRefused as e:
|
||||||
# A same-list move is a bad request; a video a concurrent edit (another tab, a YouTube
|
raise _move_refused(e) from None
|
||||||
# re-pull) already took out is gone.
|
except service.ListGone as e:
|
||||||
status = 400 if e.reason == "same_playlist" else 404
|
raise _list_gone(e) from None
|
||||||
raise HTTPException(
|
|
||||||
status_code=status, detail={"code": "playlist_move", "reason": e.reason}
|
|
||||||
) from None
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"from_playlist_id": src.id,
|
||||||
|
"to_playlist_id": dst.id,
|
||||||
|
"moved": moved,
|
||||||
|
"already_there": already_there,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ReorderItemsIn(BaseModel):
|
@router.post("/{playlist_id}/items/undo-move")
|
||||||
video_ids: list[str] = []
|
def undo_move(
|
||||||
|
playlist_id: int,
|
||||||
|
body: UndoMoveIn,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The "Undo" of a move, back into this playlist in one transaction. Returns how many came back."""
|
||||||
|
src = _own_playlist(db, user, playlist_id)
|
||||||
|
dst = _other_list(db, user, body.from_playlist_id)
|
||||||
|
try:
|
||||||
|
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
|
||||||
|
except service.MoveRefused as e:
|
||||||
|
raise _move_refused(e) from None
|
||||||
|
except service.ListGone as e:
|
||||||
|
raise _list_gone(e) from None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "playlist_id": src.id, "added": added}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{playlist_id}/order")
|
@router.put("/{playlist_id}/order")
|
||||||
def reorder_items(
|
def reorder_items(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
body: ReorderItemsIn,
|
body: VideoIdsIn,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ def playlist_remove_item(
|
|||||||
|
|
||||||
|
|
||||||
class PlexItemKeysIn(BaseModel):
|
class PlexItemKeysIn(BaseModel):
|
||||||
item_rating_keys: list[str] = []
|
# Required: with a default, a misnamed key answered 200 having acted on no item, and a reorder
|
||||||
|
# took it for an empty order.
|
||||||
|
item_rating_keys: list[str]
|
||||||
|
|
||||||
|
|
||||||
class PlexItemAddBulkIn(PlexItemKeysIn):
|
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||||
|
|||||||
@@ -314,7 +314,8 @@ def search_youtube(
|
|||||||
|
|
||||||
|
|
||||||
class ClearSearchIn(BaseModel):
|
class ClearSearchIn(BaseModel):
|
||||||
video_ids: list[str] = []
|
# Required: a default read a misnamed key as an empty list, a 200 that cleared nothing.
|
||||||
|
video_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clear")
|
@router.post("/clear")
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Adding a video AT a position: the list page's "Undo" of a removal.
|
"""Putting a video back AT a position: the list page's "Undo" of a removal.
|
||||||
|
|
||||||
The row has to come back where it was in ONE request. The two-call shape (append, then send the
|
The row has to come back where it was in ONE request. The two-call shape (append, then send the
|
||||||
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
|
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
|
||||||
@@ -38,14 +38,17 @@ def _ids(db, pl_id):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _add(client, pl_id, body):
|
def _restore(client, pl_id, video_id, position):
|
||||||
return client.post(f"/api/playlists/{pl_id}/items", json=body)
|
return client.post(
|
||||||
|
f"/api/playlists/{pl_id}/items/restore",
|
||||||
|
json={"video_ids": [video_id], "positions": [position]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
|
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c"])
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
|
r = _restore(as_user(user), pl.id, "vX", 1)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
||||||
|
|
||||||
@@ -54,36 +57,37 @@ def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
|
|||||||
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
|
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
||||||
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
|
_restore(as_user(user), pl.id, "vX", 2)
|
||||||
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b"])
|
pl = _seed(db, user, ["a", "b"])
|
||||||
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
|
_restore(as_user(user), pl.id, "vX", 40)
|
||||||
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||||
|
|
||||||
|
|
||||||
def test_no_position_still_appends(db, make_user, as_user):
|
def test_an_add_appends(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b"])
|
pl = _seed(db, user, ["a", "b"])
|
||||||
_add(as_user(user), pl.id, {"video_id": "vX"})
|
r = as_user(user).post(f"/api/playlists/{pl.id}/items", json={"video_id": "vX"})
|
||||||
|
assert r.status_code == 200
|
||||||
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
||||||
# The add is idempotent; a position must not turn it into a reorder.
|
# A restore is idempotent; a position must not turn it into a reorder.
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c"])
|
pl = _seed(db, user, ["a", "b", "c"])
|
||||||
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
|
_restore(as_user(user), pl.id, "c", 0)
|
||||||
assert _ids(db, pl.id) == ["a", "b", "c"]
|
assert _ids(db, pl.id) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_negative_position_is_refused(db, make_user, as_user):
|
def test_a_negative_position_is_refused(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a"])
|
pl = _seed(db, user, ["a"])
|
||||||
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
|
r = _restore(as_user(user), pl.id, "vX", -1)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
assert _ids(db, pl.id) == ["a"]
|
assert _ids(db, pl.id) == ["a"]
|
||||||
|
|
||||||
@@ -101,6 +105,6 @@ def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user,
|
|||||||
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
|
assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(Playlist, pl.id).dirty is True
|
assert db.get(Playlist, pl.id).dirty is True
|
||||||
_add(client, pl.id, {"video_id": "b", "position": 1})
|
_restore(client, pl.id, "b", 1)
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert db.get(Playlist, pl.id).dirty is False
|
assert db.get(Playlist, pl.id).dirty is False
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
"""Many videos at once: remove, move, and the Undo of each. Every test reads both lists after the call,
|
||||||
|
as `test_playlist_move` does: a half-applied move is the failure these routes exist to rule out."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.catalog.models import Channel, Video
|
||||||
|
from app.reader import queries, service
|
||||||
|
from app.reader.models import Playlist, PlaylistItem
|
||||||
|
from app.sync.playlists import current_fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_videos(db, ids):
|
||||||
|
db.add(Channel(id="UCtest", title="Test"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for v in ids:
|
||||||
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist(db, user, name, video_ids, **kw):
|
||||||
|
pl = Playlist(user_id=user.id, name=name, **kw)
|
||||||
|
db.add(pl)
|
||||||
|
db.flush()
|
||||||
|
for i, vid in enumerate(video_ids):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
|
||||||
|
db.commit()
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(db, pl_id):
|
||||||
|
db.expire_all()
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem.video_id)
|
||||||
|
.where(PlaylistItem.playlist_id == pl_id)
|
||||||
|
.order_by(PlaylistItem.position)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _positions(db, pl_id):
|
||||||
|
db.expire_all()
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem.position)
|
||||||
|
.where(PlaylistItem.playlist_id == pl_id)
|
||||||
|
.order_by(PlaylistItem.position)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_bulk_takes_out_the_named_videos_and_counts_what_was_there(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
pl = _playlist(db, user, "L", ["a", "b", "c", "d"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["b", "d", "gone"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"ok": True, "playlist_id": pl.id, "removed": ["b", "d"]}
|
||||||
|
assert _ids(db, pl.id) == ["a", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_bulk_of_nothing_leaves_the_list_untouched(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
pl = _playlist(db, user, "L", ["a"])
|
||||||
|
stamp = datetime.now(timezone.utc) - timedelta(days=3)
|
||||||
|
pl.updated_at = stamp
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["gone"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["removed"] == []
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).updated_at == stamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_puts_the_videos_back_at_their_places(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
pl = _playlist(db, user, "L", ["b", "d"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/restore",
|
||||||
|
json={"video_ids": ["c", "a"], "positions": [2, 0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json() == {"ok": True, "playlist_id": pl.id, "added": 2}
|
||||||
|
assert _ids(db, pl.id) == ["a", "b", "c", "d"]
|
||||||
|
assert _positions(db, pl.id) == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_leaves_a_video_already_back_where_it_is_and_skips_one_the_catalog_lost(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, "L", ["b", "a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/restore",
|
||||||
|
json={"video_ids": ["a", "lost"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 0
|
||||||
|
assert _ids(db, pl.id) == ["b", "a"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"body",
|
||||||
|
[
|
||||||
|
{"video_ids": ["a"], "positions": []},
|
||||||
|
{"video_ids": ["a"], "positions": [0, 1]},
|
||||||
|
{"video_ids": ["a"], "positions": [-1]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_restore_refuses_positions_that_do_not_pair(db, make_user, as_user, body):
|
||||||
|
user = make_user()
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
|
||||||
|
assert as_user(user).post(f"/api/playlists/{pl.id}/items/restore", json=body).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_bulk_lands_in_the_sources_order_whatever_order_they_were_picked_in(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "x"])
|
||||||
|
src = _playlist(db, user, "Source", ["a", "b", "c"])
|
||||||
|
dst = _playlist(db, user, "Target", ["x"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["c", "a"], "target_playlist_id": dst.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {
|
||||||
|
"ok": True,
|
||||||
|
"from_playlist_id": src.id,
|
||||||
|
"to_playlist_id": dst.id,
|
||||||
|
"moved": ["a", "c"],
|
||||||
|
"already_there": [],
|
||||||
|
}
|
||||||
|
assert _ids(db, src.id) == ["b"]
|
||||||
|
assert _ids(db, dst.id) == ["x", "a", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_bulk_says_which_the_target_already_held_and_skips_one_already_gone(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
src = _playlist(db, user, "Source", ["a", "b"])
|
||||||
|
dst = _playlist(db, user, "Target", ["b"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a", "b", "c"], "target_playlist_id": dst.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["moved"] == ["a"]
|
||||||
|
assert r.json()["already_there"] == ["b"]
|
||||||
|
assert _ids(db, src.id) == []
|
||||||
|
assert _ids(db, dst.id) == ["b", "a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_bulk_keeps_when_each_video_was_saved(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"])
|
||||||
|
dst = _playlist(db, user, "Target", [])
|
||||||
|
saved = datetime(2020, 1, 2, tzinfo=timezone.utc)
|
||||||
|
row = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == src.id))
|
||||||
|
row.added_at = saved
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a"], "target_playlist_id": dst.id},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
moved = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == dst.id))
|
||||||
|
assert moved.added_at == saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_target_that_gained_nothing_does_not_rise_in_the_recently_changed_sort(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
stamp = datetime.now(timezone.utc) - timedelta(days=3)
|
||||||
|
dst.updated_at = stamp
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a"], "target_playlist_id": dst.id},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, dst.id).updated_at == stamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_bulk_into_the_same_list_is_refused(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
pl = _playlist(db, user, "L", ["a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a"], "target_playlist_id": pl.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
|
||||||
|
assert _ids(db, pl.id) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_bulk_marks_a_linked_list_on_both_sides_as_unsynced(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"], yt_playlist_id="PLs")
|
||||||
|
dst = _playlist(db, user, "Target", [], yt_playlist_id="PLd")
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a"], "target_playlist_id": dst.id},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, src.id).dirty is True
|
||||||
|
assert db.get(Playlist, dst.id).dirty is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_restores_both_lists_as_they_stood(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
src = _playlist(db, user, "Source", ["a", "b", "c", "d"])
|
||||||
|
dst = _playlist(db, user, "Target", ["c"])
|
||||||
|
client = as_user(user)
|
||||||
|
|
||||||
|
moved = client.post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a", "c"], "target_playlist_id": dst.id},
|
||||||
|
).json()
|
||||||
|
assert _ids(db, src.id) == ["b", "d"]
|
||||||
|
assert _ids(db, dst.id) == ["c", "a"]
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={
|
||||||
|
"from_playlist_id": dst.id,
|
||||||
|
"moved": moved["moved"],
|
||||||
|
"already_there": moved["already_there"],
|
||||||
|
"positions": [0, 2],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json() == {"ok": True, "playlist_id": src.id, "added": 2}
|
||||||
|
assert _ids(db, src.id) == ["a", "b", "c", "d"]
|
||||||
|
# The one the target held before the move stays there.
|
||||||
|
assert _ids(db, dst.id) == ["c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_keeps_when_each_video_was_saved(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
saved = datetime(2020, 1, 2, tzinfo=timezone.utc)
|
||||||
|
row = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == dst.id))
|
||||||
|
row.added_at = saved
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
db.expire_all()
|
||||||
|
back = db.scalar(select(PlaylistItem).where(PlaylistItem.playlist_id == src.id))
|
||||||
|
assert back.added_at == saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_leaves_a_video_that_moved_on_where_it_is(db, make_user, as_user):
|
||||||
|
"""A to B, then B to C, then the first Undo: the video is not in the list the Undo takes it from,
|
||||||
|
and coming back would leave it in two lists the reader never put it in."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", ["b"])
|
||||||
|
third = _playlist(db, user, "Third", ["a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a", "b"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, src.id) == ["b"]
|
||||||
|
assert _ids(db, dst.id) == []
|
||||||
|
assert _ids(db, third.id) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undo_closes_up_the_places_of_a_video_that_stayed_out(db, make_user, as_user):
|
||||||
|
"""Each index counts the rows put back before it, so one that stays out moves the rest up."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c", "d"])
|
||||||
|
src = _playlist(db, user, "Source", ["c", "d"])
|
||||||
|
dst = _playlist(db, user, "Target", ["b"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a", "b"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, src.id) == ["b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_restore_closes_up_the_places_of_a_video_the_catalog_lost(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["b", "c", "d"])
|
||||||
|
pl = _playlist(db, user, "L", ["c", "d"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/restore",
|
||||||
|
json={"video_ids": ["lost", "b"], "positions": [0, 1]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, pl.id) == ["b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_takes_a_video_out_of_the_target_when_the_source_has_it_again(
|
||||||
|
db, make_user, as_user
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 0
|
||||||
|
assert _ids(db, src.id) == ["a"]
|
||||||
|
assert _ids(db, dst.id) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "body", "gone", "reason"),
|
||||||
|
[
|
||||||
|
("move-bulk", {"video_ids": ["a"], "target_playlist_id": "dst"}, "dst", "move_target"),
|
||||||
|
("move-bulk", {"video_ids": ["a"], "target_playlist_id": "dst"}, "src", "playlist"),
|
||||||
|
(
|
||||||
|
"undo-move",
|
||||||
|
{"from_playlist_id": "dst", "moved": ["a"], "positions": [0]},
|
||||||
|
"src",
|
||||||
|
"playlist",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"undo-move",
|
||||||
|
{"from_playlist_id": "dst", "moved": ["a"], "positions": [0]},
|
||||||
|
"dst",
|
||||||
|
"move_target",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_list_deleted_before_the_lock_is_not_found(
|
||||||
|
db, make_user, as_user, monkeypatch, path, body, gone, reason
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", ["a"])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
ids = {"src": src.id, "dst": dst.id}
|
||||||
|
body = {k: (dst.id if v == "dst" else v) for k, v in body.items()}
|
||||||
|
# Another tab's delete committed between the route's read and this lock.
|
||||||
|
monkeypatch.setattr(service, "lock_playlists", lambda _db, *got: set(got) - {ids[gone]})
|
||||||
|
|
||||||
|
r = as_user(user).post(f"/api/playlists/{src.id}/items/{path}", json=body)
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert reason in str(r.json()["detail"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "path", "body"),
|
||||||
|
[
|
||||||
|
("post", "items", {"video_id": "a"}),
|
||||||
|
("delete", "items/a", None),
|
||||||
|
("post", "items/remove-bulk", {"video_ids": ["a"]}),
|
||||||
|
("post", "items/restore", {"video_ids": ["a"], "positions": [0]}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_one_list_write_finds_its_list_under_the_lock(
|
||||||
|
db, make_user, as_user, monkeypatch, method, path, body
|
||||||
|
):
|
||||||
|
"""Read with its lock, so a delete that won the row answers 404 rather than a foreign key's 500."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
real = queries.own_playlist
|
||||||
|
|
||||||
|
def locked_read_finds_nothing(db_, user_, playlist_id, *, lock=False):
|
||||||
|
return None if lock else real(db_, user_, playlist_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(queries, "own_playlist", locked_read_finds_nothing)
|
||||||
|
kw = {"json": body} if body is not None else {}
|
||||||
|
|
||||||
|
r = getattr(as_user(user), method)(f"/api/playlists/{pl.id}/{path}", **kw)
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "body"),
|
||||||
|
[
|
||||||
|
("items/remove-bulk", {"ids": ["a"]}),
|
||||||
|
("items/move-bulk", {"ids": ["a"], "target_playlist_id": 1}),
|
||||||
|
("items/undo-move", {"ids": ["a"], "from_playlist_id": 1}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_body_without_its_videos_is_refused_rather_than_acting_on_none(
|
||||||
|
db, make_user, as_user, path, body
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
|
||||||
|
assert as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undo_of_only_videos_the_target_held_names_no_moved_ones(db, make_user, as_user):
|
||||||
|
"""An empty `moved` is a real body: every video of the move was already in the target."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": [], "already_there": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.json()["added"] == 1
|
||||||
|
assert _ids(db, src.id) == ["a"]
|
||||||
|
assert _ids(db, dst.id) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undo_within_one_list_is_refused_and_keeps_its_videos(db, make_user, as_user):
|
||||||
|
"""A move refuses the same list twice, so no Undo of one exists; a row must not go for asking."""
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, "L", ["a", "b"])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{pl.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": pl.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
|
||||||
|
assert _ids(db, pl.id) == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "key", "extra"),
|
||||||
|
[
|
||||||
|
("items/restore", "video_ids", {}),
|
||||||
|
("items/undo-move", "moved", {"from_playlist_id": None}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_two_videos_cannot_ask_for_one_place(db, make_user, as_user, path, key, extra):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b", "c"])
|
||||||
|
pl = _playlist(db, user, "L", ["c"])
|
||||||
|
other = _playlist(db, user, "Other", ["a", "b"])
|
||||||
|
body = {key: ["a", "b"], "positions": [0, 0]}
|
||||||
|
body.update({k: other.id for k in extra})
|
||||||
|
|
||||||
|
r = as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body)
|
||||||
|
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert _ids(db, pl.id) == ["c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reorder_without_its_videos_is_refused(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
pl = _playlist(db, user, "L", [])
|
||||||
|
|
||||||
|
assert as_user(user).put(f"/api/playlists/{pl.id}/order", json={"ids": []}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def _synced(db, pl):
|
||||||
|
pl.synced_fingerprint = current_fingerprint(db, pl)
|
||||||
|
pl.dirty = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bulk_remove_and_its_undo_leave_a_linked_list_as_it_was(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
pl = _playlist(db, user, "L", ["a", "b"], yt_playlist_id="PLx")
|
||||||
|
_synced(db, pl)
|
||||||
|
client = as_user(user)
|
||||||
|
|
||||||
|
r = client.post(f"/api/playlists/{pl.id}/items/remove-bulk", json={"video_ids": ["a"]})
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).dirty is True
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/playlists/{pl.id}/items/restore", json={"video_ids": ["a"], "positions": [0]}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, pl.id).dirty is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_move_and_its_undo_leave_both_linked_lists_as_they_were(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a", "b"])
|
||||||
|
src = _playlist(db, user, "Source", ["a", "b"], yt_playlist_id="PLs")
|
||||||
|
dst = _playlist(db, user, "Target", ["b"], yt_playlist_id="PLd")
|
||||||
|
_synced(db, src)
|
||||||
|
_synced(db, dst)
|
||||||
|
client = as_user(user)
|
||||||
|
|
||||||
|
moved = client.post(
|
||||||
|
f"/api/playlists/{src.id}/items/move-bulk",
|
||||||
|
json={"video_ids": ["a"], "target_playlist_id": dst.id},
|
||||||
|
).json()
|
||||||
|
assert moved["moved"] == ["a"]
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, src.id).dirty is True
|
||||||
|
assert db.get(Playlist, dst.id).dirty is True
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "positions": [0]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
db.expire_all()
|
||||||
|
assert db.get(Playlist, src.id).dirty is False
|
||||||
|
assert db.get(Playlist, dst.id).dirty is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_is_one_transaction(db, make_user, as_user, monkeypatch):
|
||||||
|
user = make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", ["a"])
|
||||||
|
|
||||||
|
def boom(*a, **kw):
|
||||||
|
raise RuntimeError("second step failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_insert_at", boom)
|
||||||
|
client = as_user(user)
|
||||||
|
body = {"from_playlist_id": dst.id, "moved": ["a"], "already_there": [], "positions": [0]}
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
_ = client.post(f"/api/playlists/{src.id}/items/undo-move", json=body)
|
||||||
|
|
||||||
|
db.rollback()
|
||||||
|
assert _ids(db, dst.id) == ["a"]
|
||||||
|
assert _ids(db, src.id) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_undo_move_refuses_positions_that_do_not_pair(db, make_user, as_user):
|
||||||
|
user = make_user()
|
||||||
|
src = _playlist(db, user, "Source", [])
|
||||||
|
dst = _playlist(db, user, "Target", [])
|
||||||
|
|
||||||
|
r = as_user(user).post(
|
||||||
|
f"/api/playlists/{src.id}/items/undo-move",
|
||||||
|
json={"from_playlist_id": dst.id, "moved": ["a"], "already_there": ["b"], "positions": [0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "body"),
|
||||||
|
[
|
||||||
|
("remove-bulk", {"video_ids": ["a"]}),
|
||||||
|
("restore", {"video_ids": ["a"], "positions": [0]}),
|
||||||
|
("move-bulk", {"video_ids": ["a"], "target_playlist_id": 0}),
|
||||||
|
("undo-move", {"from_playlist_id": 0, "moved": ["a"], "positions": [0]}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_another_accounts_list_is_not_found(db, make_user, as_user, path, body):
|
||||||
|
owner, other = make_user(), make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
theirs = _playlist(db, owner, "Theirs", ["a"])
|
||||||
|
mine = _playlist(db, other, "Mine", [])
|
||||||
|
body = {
|
||||||
|
k: (mine.id if k in ("target_playlist_id", "from_playlist_id") else v)
|
||||||
|
for k, v in body.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
r = as_user(other).post(f"/api/playlists/{theirs.id}/items/{path}", json=body)
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert _ids(db, theirs.id) == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "key"), [("move-bulk", "target_playlist_id"), ("undo-move", "from_playlist_id")]
|
||||||
|
)
|
||||||
|
def test_the_other_list_of_a_move_must_be_the_callers_too(db, make_user, as_user, path, key):
|
||||||
|
owner, other = make_user(), make_user()
|
||||||
|
_seed_videos(db, ["a"])
|
||||||
|
theirs = _playlist(db, owner, "Theirs", ["a"])
|
||||||
|
mine = _playlist(db, other, "Mine", ["a"])
|
||||||
|
body = {"video_ids": ["a"], "moved": ["a"], "positions": [0], key: theirs.id}
|
||||||
|
|
||||||
|
r = as_user(other).post(f"/api/playlists/{mine.id}/items/{path}", json=body)
|
||||||
|
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert "move_target" in str(r.json()["detail"])
|
||||||
|
assert _ids(db, theirs.id) == ["a"]
|
||||||
|
assert _ids(db, mine.id) == ["a"]
|
||||||
@@ -51,7 +51,8 @@ def _ids(db, pl_id):
|
|||||||
|
|
||||||
def _move(client, src, video_id, dst):
|
def _move(client, src, video_id, dst):
|
||||||
return client.post(
|
return client.post(
|
||||||
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
|
f"/api/playlists/{src}/items/move-bulk",
|
||||||
|
json={"video_ids": [video_id], "target_playlist_id": dst},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +68,8 @@ def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"from_playlist_id": src.id,
|
"from_playlist_id": src.id,
|
||||||
"to_playlist_id": dst.id,
|
"to_playlist_id": dst.id,
|
||||||
"video_id": "v1",
|
"moved": ["v1"],
|
||||||
|
"already_there": [],
|
||||||
}
|
}
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
assert _ids(db, src.id) == ["v2"]
|
assert _ids(db, src.id) == ["v2"]
|
||||||
@@ -116,8 +118,9 @@ def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user)
|
|||||||
dst = _playlist(db, user, "Target", [])
|
dst = _playlist(db, user, "Target", [])
|
||||||
|
|
||||||
r = _move(as_user(user), src.id, "v1", dst.id)
|
r = _move(as_user(user), src.id, "v1", dst.id)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 200
|
||||||
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
|
assert (r.json()["moved"], r.json()["already_there"]) == ([], [])
|
||||||
|
assert _ids(db, dst.id) == []
|
||||||
|
|
||||||
|
|
||||||
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
|
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
|
||||||
|
|||||||
@@ -102,16 +102,24 @@ PLEX = PlexPlaylist.__table__.name
|
|||||||
"call",
|
"call",
|
||||||
[
|
[
|
||||||
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
|
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
|
||||||
lambda c, pid, other: c.post(
|
|
||||||
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
|
||||||
),
|
|
||||||
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
|
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
|
||||||
lambda c, pid, other: c.post(
|
lambda c, pid, other: c.post(
|
||||||
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
f"/api/playlists/{pid}/items/move-bulk",
|
||||||
|
json={"video_ids": ["v1"], "target_playlist_id": other},
|
||||||
),
|
),
|
||||||
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/remove-bulk", json={"video_ids": ["v1"]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/restore", json={"video_ids": ["v2"], "positions": [0]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/undo-move",
|
||||||
|
json={"from_playlist_id": other, "moved": ["v2"], "positions": [0]},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
ids=["add", "add-at", "watch-later", "move", "reorder"],
|
ids=["add", "watch-later", "move", "reorder", "remove-bulk", "restore", "undo-move"],
|
||||||
)
|
)
|
||||||
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
@@ -168,18 +176,35 @@ PLEX_ITEMS = PlexPlaylistItem.__table__.name
|
|||||||
"call",
|
"call",
|
||||||
[
|
[
|
||||||
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
|
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
|
||||||
lambda c, pid, other: c.post(
|
|
||||||
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
|
||||||
),
|
|
||||||
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
|
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
|
||||||
lambda c, pid, other: c.post(
|
lambda c, pid, other: c.post(
|
||||||
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
f"/api/playlists/{pid}/items/move-bulk",
|
||||||
|
json={"video_ids": ["v1"], "target_playlist_id": other},
|
||||||
),
|
),
|
||||||
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||||
# the ORM cascade deletes the items before the list row
|
# the ORM cascade deletes the items before the list row
|
||||||
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
|
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/remove-bulk", json={"video_ids": ["v1"]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/restore", json={"video_ids": ["v2"], "positions": [0]}
|
||||||
|
),
|
||||||
|
lambda c, pid, other: c.post(
|
||||||
|
f"/api/playlists/{pid}/items/undo-move",
|
||||||
|
json={"from_playlist_id": other, "moved": ["v2"], "positions": [0]},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
"add-present",
|
||||||
|
"remove",
|
||||||
|
"move",
|
||||||
|
"reorder",
|
||||||
|
"delete",
|
||||||
|
"remove-bulk",
|
||||||
|
"restore",
|
||||||
|
"undo-move",
|
||||||
],
|
],
|
||||||
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
|
|
||||||
)
|
)
|
||||||
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
||||||
db, make_user, as_user, statements, call
|
db, make_user, as_user, statements, call
|
||||||
@@ -193,14 +218,23 @@ def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
|||||||
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||||
|
|
||||||
|
|
||||||
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
|
@pytest.mark.parametrize(
|
||||||
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
|
("path", "body"),
|
||||||
|
[
|
||||||
|
("move-bulk", lambda other: {"video_ids": ["v1"], "target_playlist_id": other}),
|
||||||
|
("undo-move", lambda other: {"from_playlist_id": other, "moved": ["v2"], "positions": [0]}),
|
||||||
|
],
|
||||||
|
ids=["move", "undo"],
|
||||||
|
)
|
||||||
|
def test_a_move_locks_both_lists_in_one_ordered_statement(
|
||||||
|
db, make_user, as_user, statements, path, body
|
||||||
|
):
|
||||||
|
"""Two moves A→B and B→A each locking "its" list first would lock crosswise, and an Undo runs the
|
||||||
|
other way round from a move by its nature."""
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pid, other = _yt(db, user)
|
pid, other = _yt(db, user)
|
||||||
statements.clear()
|
statements.clear()
|
||||||
r = as_user(user).post(
|
r = as_user(user).post(f"/api/playlists/{pid}/items/{path}", json=body(other))
|
||||||
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
lock = _locked_before_items(statements, YT, YT_ITEMS)
|
lock = _locked_before_items(statements, YT, YT_ITEMS)
|
||||||
assert lock and "ORDER BY" in lock, lock
|
assert lock and "ORDER BY" in lock, lock
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ rows, since positions have gaps after a removal. One request: an append plus a s
|
|||||||
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
|
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +103,22 @@ def test_positions_must_pair_with_the_keys(db, make_user, as_user):
|
|||||||
assert _order(db, pl.id)[0] == ["a"]
|
assert _order(db, pl.id)[0] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "path"),
|
||||||
|
[("post", "items/bulk"), ("post", "items/remove-bulk"), ("put", "order")],
|
||||||
|
)
|
||||||
|
def test_a_body_without_its_keys_is_refused_rather_than_acting_on_none(
|
||||||
|
db, make_user, as_user, method, path
|
||||||
|
):
|
||||||
|
user = make_user()
|
||||||
|
pl = _seed(db, user, ["a"], extra=())
|
||||||
|
|
||||||
|
r = getattr(as_user(user), method)(f"/api/plex/playlists/{pl.id}/{path}", json={"keys": ["a"]})
|
||||||
|
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert _order(db, pl.id)[0] == ["a"]
|
||||||
|
|
||||||
|
|
||||||
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
|
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
|
||||||
user = make_user()
|
user = make_user()
|
||||||
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
|
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
|
||||||
|
|||||||
@@ -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 -------------------------------------------------------------------------------
|
||||||
@@ -213,9 +210,8 @@ def test_an_add_is_idempotent_and_lands_where_asked(db, make_user):
|
|||||||
pl = service.create_playlist(db, user, "L")
|
pl = service.create_playlist(db, user, "L")
|
||||||
assert service.add_item(db, pl, "a", mark_dirty=False) is True
|
assert service.add_item(db, pl, "a", mark_dirty=False) is True
|
||||||
assert service.add_item(db, pl, "a", mark_dirty=False) is False
|
assert service.add_item(db, pl, "a", mark_dirty=False) is False
|
||||||
service.add_to_playlist(db, pl, "b", None)
|
assert service.add_item(db, pl, "b", mark_dirty=True) is True
|
||||||
service.add_to_playlist(db, pl, "c", 0)
|
assert service.restore_items(db, pl, ["c", "a"], [0, 2]) == 1 # "a" is there: it stays put
|
||||||
service.add_to_playlist(db, pl, "a", 2) # already there: stays put
|
|
||||||
assert _uncommitted(db)
|
assert _uncommitted(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert _ids(db, pl) == ["c", "a", "b"]
|
assert _ids(db, pl) == ["c", "a", "b"]
|
||||||
@@ -238,14 +234,13 @@ def test_a_move_never_leaves_a_video_in_two_lists(db, make_user):
|
|||||||
service.add_item(db, dst, "b", mark_dirty=False)
|
service.add_item(db, dst, "b", mark_dirty=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
with pytest.raises(service.MoveRefused, match="same_playlist"):
|
with pytest.raises(service.MoveRefused, match="same_playlist"):
|
||||||
service.move_item(db, src, src, "a")
|
service.move_items(db, src, src, ["a"])
|
||||||
service.move_item(db, src, dst, "a")
|
assert service.move_items(db, src, dst, ["a"]) == (["a"], [])
|
||||||
service.move_item(db, src, dst, "b") # already in the target: dropped from the source
|
# already in the target: dropped from the source
|
||||||
|
assert service.move_items(db, src, dst, ["b"]) == ([], ["b"])
|
||||||
db.commit()
|
db.commit()
|
||||||
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
|
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
|
||||||
with pytest.raises(service.MoveRefused, match="not_in_playlist"):
|
assert service.move_items(db, src, dst, ["a"]) == ([], [])
|
||||||
service.move_item(db, src, dst, "a")
|
|
||||||
db.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):
|
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):
|
||||||
|
|||||||
@@ -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 ------------------------------------------------------------------------------
|
||||||
@@ -308,6 +310,14 @@ def test_clearing_leaves_a_video_the_caller_never_found(db, make_user, as_user):
|
|||||||
assert r.json() == {"ok": True, "removed": 0}
|
assert r.json() == {"ok": True, "removed": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_refuses_a_body_without_its_list(db, make_user, as_user):
|
||||||
|
# A misnamed key read as an empty list: 200, and nothing cleared.
|
||||||
|
user = make_user()
|
||||||
|
r = as_user(user).post("/api/search/clear", json={"ids": ["s1"]})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert as_user(user).post("/api/search/clear", json={"video_ids": []}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
# --- Plex show and season marks ---------------------------------------------------------------
|
# --- Plex show and season marks ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'))"},
|
||||||
|
|||||||
+34
-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
|
||||||
@@ -536,6 +536,26 @@ of a full-width panel and nothing on the right.
|
|||||||
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
|
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
|
||||||
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
|
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
|
||||||
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
|
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
|
||||||
|
- **Selecting rows:** a list whose rows take bulk actions shows a box first in every row, always, and one
|
||||||
|
over the column for the rows shown. Shift+click selects a range, `x` toggles the row under the keyboard,
|
||||||
|
Esc clears. A selected row wears the accent tint as well as its tick. An action covers the selected rows
|
||||||
|
shown; a row a filter hides stays selected and comes back ticked; another list starts over. The row's own
|
||||||
|
menu still acts on that row alone. While anything is selected the toolbar's second row is the action band:
|
||||||
|
the count (and how many a filter hides), the last move's target as one click, Move to, Remove, Clear
|
||||||
|
selection. An action clears the selection, undoes by its toast and, when it fails, ticks its rows again.
|
||||||
|
An Undo brings back what is still there to bring: a video the move put in the target and the target no
|
||||||
|
longer holds stays where it went, and a warning says how many came back. One the target held already only
|
||||||
|
left the source, so it returns there.
|
||||||
|
Delete takes the selection when there is one, else the row under the keyboard, with no confirm. With every
|
||||||
|
tick hidden by a filter it removes nothing and says so. An Esc during a drag puts the drag down, and only that.
|
||||||
|
Below `xl` the band's buttons are glyphs named in their tooltips, and a filter's hidden count is "+N" with
|
||||||
|
its sentence in the tooltip. The row the band replaces keeps its height, invisible, so a tick moves no row.
|
||||||
|
On a phone the count is its number (the words read out, and in the tooltip) and the undo pair stays out.
|
||||||
|
A row's grip also carries it onto a list in the rail, under a sort or a filter too (the grip then says
|
||||||
|
so, and no key lifts it); a selected row carries every selected one, named on the drag image below the
|
||||||
|
pointer. The target row lights up (`ring-accent`); the open list is none; an unpinned rail peeks by the
|
||||||
|
drag's own hover and scrolls at its edges. Dropped inside the list, one row takes its new place and many
|
||||||
|
stay. A key never leaves the list and moves its own row: the band and the row's menu are the other way.
|
||||||
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
|
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
|
||||||
[WCAG 2.3.3]
|
[WCAG 2.3.3]
|
||||||
|
|
||||||
@@ -829,3 +849,16 @@ 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 | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
|
||||||
|
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
|
||||||
|
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
|
||||||
|
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
|
||||||
|
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
|
||||||
|
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
|
||||||
|
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
|
||||||
|
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
|
||||||
|
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
|
||||||
|
|||||||
+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
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -108,8 +108,8 @@ project checks needs a row.
|
|||||||
|
|
||||||
| Scope | Narrowed | Why | Measured |
|
| Scope | Narrowed | Why | Measured |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 9 specs: 6 × 3 engines, 3 on chromium only (the row below) |
|
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 12 specs: 9 × 3 engines, 3 on chromium only (the row below) |
|
||||||
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 9 specs |
|
| e2e | `a11y.spec.ts` `member.spec.ts` `devices.spec.ts` run on chromium only (`CHROMIUM_ONLY`, the webkit and ipad projects' `testIgnore`) | What they hold does not change with the engine: the axe scan reads the DOM and the accessibility tree, which the engines build alike; the member spec checks which pages and requests a role gets; the devices spec opens its own desktop and iPad contexts. Run three times they would triple their time and catch nothing more. What an engine does change (layout, events) the other specs hold on all three. | 3 of 12 specs |
|
||||||
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
|
||||||
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
|
||||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { test, expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { openApp } from "./helpers";
|
||||||
|
|
||||||
|
// A video dragged onto a list in the rail, with a real pointer: the rail and the page are separate
|
||||||
|
// React trees, and jsdom lays out nothing to drag across. The browser projects share one account, so
|
||||||
|
// each test holds lists of its own, named for it, and takes them away after.
|
||||||
|
|
||||||
|
interface Listed {
|
||||||
|
id: number;
|
||||||
|
items: { id: string; title: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const made: number[] = [];
|
||||||
|
|
||||||
|
async function newList(page: Page, name: string, videos: string[] = []): Promise<number> {
|
||||||
|
const res = await page.request.post("/api/playlists", { data: { name } });
|
||||||
|
const { id } = (await res.json()) as { id: number };
|
||||||
|
made.push(id);
|
||||||
|
for (const video_id of videos) {
|
||||||
|
const added = await page.request.post(`/api/playlists/${String(id)}/items`, {
|
||||||
|
data: { video_id },
|
||||||
|
});
|
||||||
|
expect(added.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = async (page: Page, id: number) =>
|
||||||
|
((await (await page.request.get(`/api/playlists/${String(id)}`)).json()) as Listed).items.map(
|
||||||
|
(v) => v.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function setUp(page: Page, name: string, project: string) {
|
||||||
|
await pinOnTouch(page, project);
|
||||||
|
const feed = (await (await page.request.get("/api/feed?limit=3")).json()) as {
|
||||||
|
items: { id: string; title: string }[];
|
||||||
|
};
|
||||||
|
expect(feed.items.length).toBe(3);
|
||||||
|
const videos = feed.items;
|
||||||
|
const from = await newList(
|
||||||
|
page,
|
||||||
|
`${name} from`,
|
||||||
|
videos.map((v) => v.id),
|
||||||
|
);
|
||||||
|
const to = await newList(page, `${name} to`);
|
||||||
|
await openApp(page, `/?page=playlists&pl=${String(from)}`);
|
||||||
|
await expect(page.getByRole("heading", { name: `${name} from` })).toBeVisible();
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(3);
|
||||||
|
return { videos, from, to, target: page.locator(`[data-playlist-drop="${String(to)}"]`) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const centre = async (el: Locator) => {
|
||||||
|
const box = await el.boundingBox();
|
||||||
|
if (!box) throw new Error("nothing to point at");
|
||||||
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pick the row up by its grip and carry it to a point, the button still held. */
|
||||||
|
async function carry(page: Page, grip: Locator, to: { x: number; y: number }) {
|
||||||
|
const from = await centre(grip);
|
||||||
|
await page.mouse.move(from.x, from.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(from.x + 8, from.y + 8, { steps: 3 });
|
||||||
|
await page.mouse.move(to.x, to.y, { steps: 12 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same onto the rail. An unpinned one is a tab until the drag's own hover peeks it open. */
|
||||||
|
async function carryToRail(page: Page, grip: Locator, target: Locator) {
|
||||||
|
if (await target.isVisible()) {
|
||||||
|
await carry(page, grip, await centre(target));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tab = page.getByRole("button", { name: "Pin Playlists open" });
|
||||||
|
await carry(page, grip, await centre(tab));
|
||||||
|
await expect(target).toBeVisible();
|
||||||
|
await page.mouse.move(...(Object.values(await centre(target)) as [number, number]), { steps: 6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What dnd-kit tells a screen reader: said once the drag has taken the step in, so a drop after it
|
||||||
|
* is the one a person makes, not one that beats the row to its new place. */
|
||||||
|
const said = (page: Page, text: RegExp) =>
|
||||||
|
expect(page.locator('[id^="DndLiveRegion"]').filter({ hasText: text })).toBeAttached();
|
||||||
|
|
||||||
|
/** A coarse pointer's rail never peeks, a drawer instead: on the tablet it is pinned, for these. */
|
||||||
|
async function pinOnTouch(page: Page, project: string) {
|
||||||
|
if (project !== "ipad") return;
|
||||||
|
await page.route("**/api/me", async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const me = (await res.json()) as { preferences?: Record<string, unknown> };
|
||||||
|
await route.fulfill({
|
||||||
|
response: res,
|
||||||
|
json: { ...me, preferences: { ...me.preferences, playlistsCollapsed: false } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("acting on the selected rows", { tag: "@playlists" }, () => {
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const box = (page: Page, title: string) =>
|
||||||
|
page.getByRole("checkbox", { name: `Select “${title}”` });
|
||||||
|
|
||||||
|
test("a Shift range, the band's Remove, and its Undo back to the places", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from } = await setUp(page, `band ${info.project.name}`, info.project.name);
|
||||||
|
await box(page, videos[0]!.title).click();
|
||||||
|
await box(page, videos[1]!.title).click({ modifiers: ["Shift"] });
|
||||||
|
await expect(box(page, videos[1]!.title)).toBeChecked();
|
||||||
|
await expect(box(page, videos[2]!.title)).not.toBeChecked();
|
||||||
|
await page.getByRole("button", { name: "Remove", exact: true }).click();
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
|
||||||
|
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("x ticks the walked row, Esc clears, and Delete takes the selection and goes on", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from } = await setUp(page, `keys ${info.project.name}`, info.project.name);
|
||||||
|
const firstRow = page.locator("tbody tr").first();
|
||||||
|
await page.keyboard.press("j");
|
||||||
|
await expect(firstRow).toBeFocused();
|
||||||
|
await page.keyboard.press("x");
|
||||||
|
await expect(box(page, videos[0]!.title)).toBeChecked();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(box(page, videos[0]!.title)).not.toBeChecked();
|
||||||
|
await page.keyboard.press("x");
|
||||||
|
await page.keyboard.press("j");
|
||||||
|
await page.keyboard.press("x");
|
||||||
|
await page.keyboard.press("Delete");
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual([videos[2]!.id]);
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||||
|
await expect(firstRow).toBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("dragging onto a list", { tag: "@playlists" }, () => {
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await page.mouse.up();
|
||||||
|
for (const id of made.splice(0)) await page.request.delete(`/api/playlists/${String(id)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a row leaves for the list it is dropped on, lit up on the way, and Undo brings it back", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from, to, target } = await setUp(
|
||||||
|
page,
|
||||||
|
`drag one ${info.project.name}`,
|
||||||
|
info.project.name,
|
||||||
|
);
|
||||||
|
const grip = page.getByRole("button", { name: `Reorder “${videos[1]!.title}”`, exact: false });
|
||||||
|
await carryToRail(page, grip, target);
|
||||||
|
await expect(target).toHaveClass(/ring-2/);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(target).not.toHaveClass(/ring-2/);
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(2);
|
||||||
|
// The grip that held the focus has left: the keyboard keeps its place in the list.
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => document.activeElement?.closest("tbody") != null))
|
||||||
|
.toBe(true);
|
||||||
|
await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]);
|
||||||
|
expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]);
|
||||||
|
await page.getByRole("button", { name: "Undo", exact: true }).last().click();
|
||||||
|
await expect.poll(() => contents(page, from)).toEqual(videos.map((v) => v.id));
|
||||||
|
expect(await contents(page, to)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a selected row carries the selected ones, named on the drag image", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from, to, target } = await setUp(
|
||||||
|
page,
|
||||||
|
`drag many ${info.project.name}`,
|
||||||
|
info.project.name,
|
||||||
|
);
|
||||||
|
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
|
||||||
|
await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click();
|
||||||
|
const grip = page.getByRole("button", { name: `Reorder “${videos[2]!.title}”`, exact: false });
|
||||||
|
await carryToRail(page, grip, target);
|
||||||
|
const image = page.getByText("2 videos", { exact: true });
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
// Above every layer, by the tooltip's token: the overlay wrapping it takes the level.
|
||||||
|
expect(await image.evaluate((el) => getComputedStyle(el.parentElement!).zIndex)).toBe("100");
|
||||||
|
// Below the pointer, so it leaves the list's name to read.
|
||||||
|
const { y } = await centre(target);
|
||||||
|
expect((await image.boundingBox())!.y).toBeGreaterThan(y);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => contents(page, to)).toEqual([videos[0]!.id, videos[2]!.id]);
|
||||||
|
expect(await contents(page, from)).toEqual([videos[1]!.id]);
|
||||||
|
await expect(page.getByRole("checkbox", { name: "Select all" })).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("many rows dropped inside the list stay as they were", async ({ page }, info) => {
|
||||||
|
const { videos, from } = await setUp(
|
||||||
|
page,
|
||||||
|
`drag inside ${info.project.name}`,
|
||||||
|
info.project.name,
|
||||||
|
);
|
||||||
|
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
|
||||||
|
await page.getByRole("checkbox", { name: videos[1]!.title, exact: false }).click();
|
||||||
|
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
|
||||||
|
await carry(page, grip, await centre(page.locator("tbody tr").nth(2)));
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(3);
|
||||||
|
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one row dropped inside the list takes the new place", async ({ page }, info) => {
|
||||||
|
const { videos, from } = await setUp(
|
||||||
|
page,
|
||||||
|
`drag order ${info.project.name}`,
|
||||||
|
info.project.name,
|
||||||
|
);
|
||||||
|
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
|
||||||
|
await carry(page, grip, await centre(page.locator("tbody tr").nth(2)));
|
||||||
|
await said(page, /position 3/);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect
|
||||||
|
.poll(() => contents(page, from))
|
||||||
|
.toEqual([videos[1]!.id, videos[2]!.id, videos[0]!.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Escape puts the drag down, with nothing moved and nothing lit", async ({ page }, info) => {
|
||||||
|
const { videos, from, to, target } = await setUp(
|
||||||
|
page,
|
||||||
|
`drag cancel ${info.project.name}`,
|
||||||
|
info.project.name,
|
||||||
|
);
|
||||||
|
// A tick on another row: the Esc is the drag's, and leaves the selection alone.
|
||||||
|
const ticked = page.getByRole("checkbox", { name: `Select “${videos[2]!.title}”` });
|
||||||
|
await ticked.click();
|
||||||
|
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
|
||||||
|
await carryToRail(page, grip, target);
|
||||||
|
await expect(target).toHaveClass(/ring-2/);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(target).not.toHaveClass(/ring-2/);
|
||||||
|
await page.mouse.up();
|
||||||
|
expect(await contents(page, from)).toEqual(videos.map((v) => v.id));
|
||||||
|
expect(await contents(page, to)).toEqual([]);
|
||||||
|
await expect(ticked).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key still reorders a row, alone", async ({ page }, info) => {
|
||||||
|
const { videos, from } = await setUp(page, `drag key ${info.project.name}`, info.project.name);
|
||||||
|
await page.getByRole("checkbox", { name: videos[0]!.title, exact: false }).click();
|
||||||
|
await page.getByRole("checkbox", { name: videos[2]!.title, exact: false }).click();
|
||||||
|
await page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false }).focus();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await said(page, /picked up/i);
|
||||||
|
// A person's pause: dnd-kit drops an arrow pressed before it has measured the lifted row, which
|
||||||
|
// nothing on the page announces (11 of 31 runs without it, none of 31 with it).
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await said(page, /position 2/);
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await expect
|
||||||
|
.poll(() => contents(page, from))
|
||||||
|
.toEqual([videos[1]!.id, videos[0]!.id, videos[2]!.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("under a filter a row still goes to another list, and none is reordered", async ({
|
||||||
|
page,
|
||||||
|
}, info) => {
|
||||||
|
const { videos, from, to, target } = await setUp(
|
||||||
|
page,
|
||||||
|
`drag filter ${info.project.name}`,
|
||||||
|
info.project.name,
|
||||||
|
);
|
||||||
|
await page.getByRole("columnheader", { name: "Title" }).getByRole("button").last().click();
|
||||||
|
await page.getByPlaceholder("Title").fill(videos[1]!.title);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||||
|
const grip = page.getByRole("button", {
|
||||||
|
name: `Drag “${videos[1]!.title}” onto a list`,
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
await carryToRail(page, grip, target);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => contents(page, to)).toEqual([videos[1]!.id]);
|
||||||
|
expect(await contents(page, from)).toEqual([videos[0]!.id, videos[2]!.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the rail scrolls while the drag holds at its edge", async ({ page }, info) => {
|
||||||
|
const name = `drag scroll ${info.project.name}`;
|
||||||
|
for (let i = 0; i < 12; i++) await newList(page, `${name} ${String(i)}`);
|
||||||
|
await page.setViewportSize({ width: 1280, height: 520 });
|
||||||
|
const { videos } = await setUp(page, name, info.project.name);
|
||||||
|
const grip = page.getByRole("button", { name: `Reorder “${videos[0]!.title}”`, exact: false });
|
||||||
|
const area = page.locator("[data-playlist-drop-area]");
|
||||||
|
await carryToRail(page, grip, area.locator("[data-playlist-drop]").first());
|
||||||
|
const scroller = () =>
|
||||||
|
area.evaluate((el) => {
|
||||||
|
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||||
|
const { overflowY } = getComputedStyle(p);
|
||||||
|
if ((overflowY === "auto" || overflowY === "scroll") && p.scrollHeight > p.clientHeight) {
|
||||||
|
const { left, right, bottom } = p.getBoundingClientRect();
|
||||||
|
return { top: p.scrollTop, x: (left + right) / 2, bottom };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { top: -1, x: 0, bottom: 0 };
|
||||||
|
});
|
||||||
|
const before = await scroller();
|
||||||
|
expect(before.top).toBeGreaterThanOrEqual(0);
|
||||||
|
await page.mouse.move(before.x, before.bottom - 8, { steps: 4 });
|
||||||
|
await expect.poll(async () => (await scroller()).top).toBeGreaterThan(before.top);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
|
import type { InputHTMLAttributes, ReactNode, RefObject } from "react";
|
||||||
|
|
||||||
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
|
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
|
||||||
// with the chosen colour (the accent; the media amber on the player chrome) — keyboard, screen
|
// with the chosen colour (the accent; the media amber on the player chrome) — keyboard, screen
|
||||||
@@ -10,18 +10,30 @@ import type { InputHTMLAttributes, ReactNode, Ref } from "react";
|
|||||||
|
|
||||||
type Native = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange">;
|
type Native = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange">;
|
||||||
|
|
||||||
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`. */
|
/** The bare checkbox: inside your own `<label>`, or with an `aria-label`. Prefer `CheckRow`.
|
||||||
|
* `onChange` also says whether Shift was held, for a range. `mixed` is the third state, some of what
|
||||||
|
* it covers: the DOM has no attribute for it, only the property. */
|
||||||
export function Checkbox({
|
export function Checkbox({
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
ref,
|
ref,
|
||||||
|
mixed = false,
|
||||||
...rest
|
...rest
|
||||||
}: Native & { onChange?: (checked: boolean) => void; ref?: Ref<HTMLInputElement> }) {
|
}: Native & {
|
||||||
|
onChange?: (checked: boolean, shift: boolean) => void;
|
||||||
|
ref?: RefObject<HTMLInputElement | null>;
|
||||||
|
mixed?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={(el) => {
|
||||||
|
if (ref) ref.current = el;
|
||||||
|
if (el) el.indeterminate = mixed;
|
||||||
|
}}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={(e) => onChange?.(e.target.checked)}
|
onChange={(e) =>
|
||||||
|
onChange?.(e.target.checked, e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey)
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"size-4 shrink-0 cursor-pointer accent-chosen disabled:cursor-not-allowed",
|
"size-4 shrink-0 cursor-pointer accent-chosen disabled:cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
@@ -54,7 +66,7 @@ export function CheckRow({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox checked={checked} onChange={onChange} disabled={disabled} />
|
<Checkbox checked={checked} onChange={(on) => onChange(on)} disabled={disabled} />
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, type ReactNode } from "react";
|
import { useRef, type ReactNode } from "react";
|
||||||
import { useListNav } from "../../lib/listNav";
|
import { useListNav, type NavSelection } from "../../lib/listNav";
|
||||||
|
|
||||||
// A plain list of cards or rows the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for
|
// A plain list of cards or rows the keyboard can walk — `j`/`k`, the arrows, Enter (lib/listNav) — for
|
||||||
// the lists that mount every item (the Plex title, season and episode grids, the download lists, a
|
// the lists that mount every item (the Plex title, season and episode grids, the download lists, a
|
||||||
@@ -11,16 +11,19 @@ export default function NavGrid({
|
|||||||
as: Tag = "div",
|
as: Tag = "div",
|
||||||
className,
|
className,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
selection,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
as?: "div" | "tbody";
|
as?: "div" | "tbody";
|
||||||
className?: string;
|
className?: string;
|
||||||
/** The Delete key takes the focused item out (lib/listNav): its index among the items. */
|
/** The Delete key takes the focused item out (lib/listNav): its index among the items. */
|
||||||
onDelete?: (index: number) => void;
|
onDelete?: (index: number) => void;
|
||||||
|
/** `x` selects the focused item, Esc clears (lib/listNav). */
|
||||||
|
selection?: NavSelection;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement & HTMLTableSectionElement>(null);
|
const ref = useRef<HTMLDivElement & HTMLTableSectionElement>(null);
|
||||||
useListNav(ref, { enabled: true, onDelete });
|
useListNav(ref, { enabled: true, onDelete, selection });
|
||||||
return (
|
return (
|
||||||
<Tag ref={ref} className={className}>
|
<Tag ref={ref} className={className}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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`, `RowSelectBox` (a table row's selecting box, Shift says a range), `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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Checkbox } from "./NativeControls";
|
||||||
|
|
||||||
|
/** A table row's selecting box, and the same box over the column for all the rows. The whole cell is
|
||||||
|
* the target, 44 on touch: the label starts at the cell's edge, past its padding, so it ends where
|
||||||
|
* the next cell's control starts instead of lying under it. `onToggle` says whether Shift was held. */
|
||||||
|
export function RowSelectBox({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
mixed,
|
||||||
|
title,
|
||||||
|
onToggle,
|
||||||
|
ref,
|
||||||
|
}: {
|
||||||
|
ref?: RefObject<HTMLInputElement | null>;
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
mixed?: boolean;
|
||||||
|
title?: string;
|
||||||
|
onToggle: (shift: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
title={title}
|
||||||
|
// Shift+press would otherwise extend the page's text selection over the rows between.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.shiftKey) e.preventDefault();
|
||||||
|
}}
|
||||||
|
className="-ml-2 grid min-h-6 w-9 cursor-pointer place-items-center touch-target"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
ref={ref}
|
||||||
|
aria-label={label}
|
||||||
|
checked={checked}
|
||||||
|
mixed={mixed}
|
||||||
|
onChange={(_, shift) => onToggle(shift)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -232,7 +232,9 @@ export function ColumnHead<T>({
|
|||||||
two of them per playlist table, whose grip and actions headers are empty. */}
|
two of them per playlist table, whose grip and actions headers are empty. */}
|
||||||
{/* min-w-6 as well as min-h-6: a short header ("Ki", Hungarian "Who") was a 12px
|
{/* min-w-6 as well as min-h-6: a short header ("Ki", Hungarian "Who") was a 12px
|
||||||
target beside its funnel, under the 24px floor. */}
|
target beside its funnel, under the 24px floor. */}
|
||||||
{col.sortable ? (
|
{col.headerCell ? (
|
||||||
|
col.headerCell
|
||||||
|
) : col.sortable ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onClick={() => onToggleSort(col.key)}
|
onClick={() => onToggleSort(col.key)}
|
||||||
className="inline-flex min-h-6 min-w-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
|
className="inline-flex min-h-6 min-w-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,46 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.116.0",
|
||||||
|
"date": "2026-11-03",
|
||||||
|
"new": [
|
||||||
|
"In a playlist, tick rows with their box, Shift+click or x, then move or remove them together. An Undo puts them back at their places, and a single row's move has one too.",
|
||||||
|
"Drag rows onto another list in the rail: one row, or all the selected ones, also under a sort or a filter."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Removing a video from inside the player no longer loses your place in the list when the player closes."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.114.1",
|
||||||
"date": "2026-10-30",
|
"date": "2026-10-30",
|
||||||
|
|||||||
@@ -1,4 +1,46 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.116.0",
|
||||||
|
"date": "2026-11-03",
|
||||||
|
"new": [
|
||||||
|
"A listákban a sorok kijelölhetők a jelölőnégyzetükkel, Shift+kattintással vagy az x billentyűvel, és együtt áthelyezhetők vagy eltávolíthatók. A Visszavonás a helyükre teszi őket, és egyetlen sor áthelyezésénél is ott van.",
|
||||||
|
"A sorok ráhúzhatók a sáv egy másik listájára: egy sor vagy az összes kijelölt, rendezés és szűrés mellett is."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Ha a lejátszóból távolítasz el egy videót, a lejátszó bezárása után megmarad a helyed a listában."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.114.1",
|
||||||
"date": "2026-10-30",
|
"date": "2026-10-30",
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,7 @@ 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";
|
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,
|
||||||
@@ -54,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();
|
||||||
@@ -106,6 +109,22 @@ export function RelatedVideos({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onToggleSave = (id: string, saved: boolean) => {
|
const onToggleSave = (id: string, saved: boolean) => {
|
||||||
const seq = begin(id, { saved });
|
const seq = begin(id, { saved });
|
||||||
const write = writeWatchLater(qc, id, saved);
|
const write = writeWatchLater(qc, id, saved);
|
||||||
@@ -231,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]!)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,7 +20,7 @@ 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";
|
||||||
@@ -33,13 +33,13 @@ import { raw } from "../../lib/notifications";
|
|||||||
// 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
|
||||||
@@ -71,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;
|
||||||
@@ -189,13 +189,13 @@ 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),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -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|removeManyFromPlaylist|restoreToPlaylist|moveManyToPlaylist|undoPlaylistMove)\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", () => {
|
||||||
@@ -38,7 +38,8 @@ describe("a video's state, Watch-later and playlist-item writes", () => {
|
|||||||
it("catches a write named without being called on the spot, and a playlist item's", () => {
|
it("catches a write named without being called on the spot, and a playlist item's", () => {
|
||||||
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
|
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
|
||||||
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
|
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
|
||||||
expect(WRITES.test("api.moveToPlaylist(a, id, b)")).toBe(true);
|
expect(WRITES.test("api.moveManyToPlaylist(a, ids, b)")).toBe(true);
|
||||||
|
expect(WRITES.test("api.undoPlaylistMove(a, b, m, t, p)")).toBe(true);
|
||||||
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
|
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import { api } from "../../lib/api";
|
|||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
addPlaylistItem,
|
addPlaylistItem,
|
||||||
movePlaylistItem,
|
movePlaylistItems,
|
||||||
|
removePlaylistItems,
|
||||||
|
restorePlaylistItems,
|
||||||
|
undoPlaylistMove,
|
||||||
removePlaylistItem,
|
removePlaylistItem,
|
||||||
resetVideoState,
|
clearVideoProgress,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
writeWatchLater,
|
writeWatchLater,
|
||||||
} from "./videoWrites";
|
} from "./videoWrites";
|
||||||
@@ -41,11 +44,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 () => {
|
||||||
@@ -77,12 +80,47 @@ describe("videoWrites", () => {
|
|||||||
expect(marks(qc)).toBeUndefined();
|
expect(marks(qc)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a move into and out of the Watch later list", async () => {
|
it("marks a move into and out of the Watch later list, by what the server moved", async () => {
|
||||||
vi.spyOn(api, "moveToPlaylist").mockResolvedValue({ ok: true } as never);
|
vi.spyOn(api, "moveManyToPlaylist").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
moved: ["X"],
|
||||||
|
already_there: ["Y"],
|
||||||
|
} as never);
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
await movePlaylistItem(qc, MINE, "X", WATCH_LATER);
|
await movePlaylistItems(qc, MINE, ["X", "Y", "gone"], WATCH_LATER);
|
||||||
|
expect(marks(qc)).toEqual({ X: { saved: true }, Y: { saved: true } });
|
||||||
|
await movePlaylistItems(qc, WATCH_LATER, ["X", "Y", "gone"], MINE);
|
||||||
|
expect(marks(qc)).toEqual({ X: { saved: false }, Y: { saved: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a bulk removal from Watch later, and its restore", async () => {
|
||||||
|
vi.spyOn(api, "removeManyFromPlaylist").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
removed: ["X"],
|
||||||
|
} as never);
|
||||||
|
vi.spyOn(api, "restoreToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||||
|
const qc = new QueryClient();
|
||||||
|
await removePlaylistItems(qc, WATCH_LATER, ["X", "gone"]);
|
||||||
|
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||||
|
await restorePlaylistItems(qc, WATCH_LATER, ["X"], [0]);
|
||||||
expect(marks(qc)).toEqual({ X: { saved: true } });
|
expect(marks(qc)).toEqual({ X: { saved: true } });
|
||||||
await movePlaylistItem(qc, WATCH_LATER, "X", MINE);
|
});
|
||||||
|
|
||||||
|
it("takes a move into Watch later back: only what landed there loses the mark", async () => {
|
||||||
|
vi.spyOn(api, "undoPlaylistMove").mockResolvedValue({ ok: true } as never);
|
||||||
|
const qc = new QueryClient();
|
||||||
|
await undoPlaylistMove(qc, MINE, WATCH_LATER, {
|
||||||
|
moved: ["X"],
|
||||||
|
already_there: ["Y"],
|
||||||
|
positions: [0, 1],
|
||||||
|
});
|
||||||
|
expect(api.undoPlaylistMove).toHaveBeenCalledWith(
|
||||||
|
MINE.id,
|
||||||
|
WATCH_LATER.id,
|
||||||
|
["X"],
|
||||||
|
["Y"],
|
||||||
|
[0, 1],
|
||||||
|
);
|
||||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { markVideo } from "../../lib/progressCache";
|
|||||||
// once it has the change — into the lists that are never refetched (`markVideo`: every related
|
// once it has the change — into the lists that are never refetched (`markVideo`: every related
|
||||||
// list, every live search). These are the ONE way to make those writes, and
|
// list, every live search). These are the ONE way to make those writes, and
|
||||||
// `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the
|
// `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the
|
||||||
// Notification Center's undo, the feed's reset, the feed's and Home's Watch-later toggles) left a
|
// Notification Center's undo, the feed's clear, the feed's and Home's Watch-later toggles) left a
|
||||||
// related list hiding a video the user had un-hidden, or showing it unsaved after a save. A
|
// related list hiding a video the user had un-hidden, or showing it unsaved after a save. A
|
||||||
// playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked
|
// playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked
|
||||||
// in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each
|
// in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each
|
||||||
@@ -20,10 +20,10 @@ export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Back to pristine: the state row and its resume position both go. */
|
/** Forget where the user left it: the resume point goes, the status stays. */
|
||||||
export function resetVideoState(qc: QueryClient, id: string) {
|
export function clearVideoProgress(qc: QueryClient, id: string) {
|
||||||
return api.clearState(id).then((r) => {
|
return api.clearProgress(id).then((r) => {
|
||||||
markVideo(qc, id, { status: "new", position_seconds: 0 });
|
markVideo(qc, id, { position_seconds: 0 });
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,10 +43,8 @@ const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boo
|
|||||||
if (list.kind === "watch_later") markVideo(qc, id, { saved });
|
if (list.kind === "watch_later") markVideo(qc, id, { saved });
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
|
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
||||||
* removal took it from. */
|
return api.addToPlaylist(list.id, id).then((r) => {
|
||||||
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
|
|
||||||
return api.addToPlaylist(list.id, id, position).then((r) => {
|
|
||||||
markIfWatchLater(qc, list, id, true);
|
markIfWatchLater(qc, list, id, true);
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
@@ -59,11 +57,52 @@ export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move a video from one playlist to another, in one request (see `api.moveToPlaylist`). */
|
/** Take videos out of a playlist, in one request. Marks what the server says went. */
|
||||||
export function movePlaylistItem(qc: QueryClient, from: ListRef, id: string, to: ListRef) {
|
export function removePlaylistItems(qc: QueryClient, list: ListRef, ids: string[]) {
|
||||||
return api.moveToPlaylist(from.id, id, to.id).then((r) => {
|
return api.removeManyFromPlaylist(list.id, ids).then((r) => {
|
||||||
markIfWatchLater(qc, from, id, false);
|
for (const id of r.removed) markIfWatchLater(qc, list, id, false);
|
||||||
markIfWatchLater(qc, to, id, true);
|
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Put removed videos back at their places, in one request. */
|
||||||
|
export function restorePlaylistItems(
|
||||||
|
qc: QueryClient,
|
||||||
|
list: ListRef,
|
||||||
|
ids: string[],
|
||||||
|
positions: number[],
|
||||||
|
) {
|
||||||
|
return api.restoreToPlaylist(list.id, ids, positions).then((r) => {
|
||||||
|
for (const id of ids) markIfWatchLater(qc, list, id, true);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move videos from one playlist to another, in one request (see `api.moveManyToPlaylist`). Marks
|
||||||
|
* what the server says it moved: a video a concurrent edit already took out is in neither answer. */
|
||||||
|
export function movePlaylistItems(qc: QueryClient, from: ListRef, ids: string[], to: ListRef) {
|
||||||
|
return api.moveManyToPlaylist(from.id, ids, to.id).then((r) => {
|
||||||
|
for (const id of [...r.moved, ...r.already_there]) {
|
||||||
|
markIfWatchLater(qc, from, id, false);
|
||||||
|
markIfWatchLater(qc, to, id, true);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take a move back: every video returns to `from`, and only the ones that landed in `to` leave it;
|
||||||
|
* it held the others before the move. */
|
||||||
|
export function undoPlaylistMove(
|
||||||
|
qc: QueryClient,
|
||||||
|
from: ListRef,
|
||||||
|
to: ListRef,
|
||||||
|
back: { moved: string[]; already_there: string[]; positions: number[] },
|
||||||
|
) {
|
||||||
|
return api
|
||||||
|
.undoPlaylistMove(from.id, to.id, back.moved, back.already_there, back.positions)
|
||||||
|
.then((r) => {
|
||||||
|
for (const id of back.moved) markIfWatchLater(qc, to, id, false);
|
||||||
|
for (const id of [...back.moved, ...back.already_there]) markIfWatchLater(qc, from, id, true);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { RUN_STATUSES } from "../lib/activityUrlState";
|
import { RUN_STATUSES } from "../lib/activityUrlState";
|
||||||
|
import {
|
||||||
|
REFUSAL_BY_REASON,
|
||||||
|
REFUSAL_FAMILIES,
|
||||||
|
REFUSAL_FIXED,
|
||||||
|
REFUSAL_UNSHOWN,
|
||||||
|
} from "../lib/api/refusalCodes";
|
||||||
import { vocabKey } from "../lib/vocab";
|
import { vocabKey } from "../lib/vocab";
|
||||||
|
|
||||||
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
|
||||||
@@ -21,6 +29,25 @@ import { vocabKey } from "../lib/vocab";
|
|||||||
const backend = (file: string) =>
|
const backend = (file: string) =>
|
||||||
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
|
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
|
||||||
|
|
||||||
|
// Every refusal the backend can answer, read from its syntax tree by `backend/tests/refusal_sites.py`
|
||||||
|
// (stdlib `ast`, on the host's Python, as the ruff lane is): each code with every reason it can be sent
|
||||||
|
// with — a reason in a variable followed to its `Literal` type, its helper's every call, its map — and
|
||||||
|
// what it could not follow, which fails below. It also hands back every `Literal` alias it read.
|
||||||
|
type RefusalScan = {
|
||||||
|
codes: Record<string, { reasons: string[]; unread: string[]; reasonless: string[] }>;
|
||||||
|
unreadCodes: string[];
|
||||||
|
errors: string[];
|
||||||
|
notRefusals: string[];
|
||||||
|
excluded: Record<string, number>;
|
||||||
|
aliases: Record<string, Record<string, string[]>>;
|
||||||
|
};
|
||||||
|
const scan = JSON.parse(
|
||||||
|
execFileSync("python", ["-I", "backend/tests/refusal_sites.py"], {
|
||||||
|
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||||
|
encoding: "utf-8",
|
||||||
|
}),
|
||||||
|
) as RefusalScan;
|
||||||
|
|
||||||
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
|
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
|
||||||
string,
|
string,
|
||||||
{ default: Record<string, unknown> }
|
{ default: Record<string, unknown> }
|
||||||
@@ -48,12 +75,11 @@ const keysOf = (lang: string): Set<string> => {
|
|||||||
const specs = [
|
const specs = [
|
||||||
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
|
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
|
||||||
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
|
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
|
||||||
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …` → `<Alias> = Literal[…]`.
|
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …`, the alias's values from the scan.
|
||||||
const configPy = backend("config.py");
|
const configPy = backend("config.py");
|
||||||
const choicesOf = (attr: string): string[] => {
|
const choicesOf = (attr: string): string[] => {
|
||||||
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
|
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
|
||||||
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1];
|
return (alias && scan.aliases["config.py"]?.[alias]) || [];
|
||||||
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
|
|
||||||
};
|
};
|
||||||
const choiceIds = specs
|
const choiceIds = specs
|
||||||
.filter((s) => s.type === "choice")
|
.filter((s) => s.type === "choice")
|
||||||
@@ -84,20 +110,10 @@ const runEnum = (name: string) =>
|
|||||||
const runStatuses = runEnum("RunStatus");
|
const runStatuses = runEnum("RunStatus");
|
||||||
const runTriggers = runEnum("RunTrigger");
|
const runTriggers = runEnum("RunTrigger");
|
||||||
const quotaActions = enumValues("quota.py");
|
const quotaActions = enumValues("quota.py");
|
||||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
// The admin actions' refusals → `errors.admin.<reason>`: the codes recorded under that prefix.
|
||||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
const adminCodes = Object.entries(REFUSAL_FAMILIES)
|
||||||
// ruff wraps over several lines still matches.
|
.filter(([, f]) => f.prefix === "errors.admin")
|
||||||
const adminCodes = [
|
.map(([code]) => code);
|
||||||
...(
|
|
||||||
readFileSync(new URL("../lib/api/refusalText.ts", import.meta.url), "utf-8").match(
|
|
||||||
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
|
|
||||||
)?.[1] ?? ""
|
|
||||||
).matchAll(/"(\w+)"/g),
|
|
||||||
].map((m) => m[1]!);
|
|
||||||
const adminRefusals = [
|
|
||||||
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
|
|
||||||
].filter((m) => adminCodes.includes(m[1]!));
|
|
||||||
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
|
|
||||||
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
|
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
|
||||||
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
|
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
|
||||||
// rather than splitting on dots.
|
// rather than splitting on dots.
|
||||||
@@ -118,102 +134,46 @@ const failCategories = [
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every
|
// Every backend source, by its path under app/.
|
||||||
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
const appPaths = (dir = ""): string[] =>
|
||||||
const routeFiles = (dir = "routes"): string[] =>
|
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
|
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
}).flatMap((e) =>
|
}).flatMap((e) =>
|
||||||
e.isDirectory()
|
e.isDirectory()
|
||||||
? routeFiles(`${dir}/${e.name}`)
|
? e.name === "__pycache__"
|
||||||
|
? []
|
||||||
|
: appPaths(`${dir}${e.name}/`)
|
||||||
: e.name.endsWith(".py")
|
: e.name.endsWith(".py")
|
||||||
? [`${dir}/${e.name}`]
|
? [`${dir}${e.name}`]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
const routeSources = (): string[] => routeFiles().map(backend);
|
|
||||||
// The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler
|
// The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler
|
||||||
// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` /
|
// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` /
|
||||||
// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal
|
// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal
|
||||||
// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered.
|
// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered.
|
||||||
const appFiles = (dir: string): string[] =>
|
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
|
||||||
withFileTypes: true,
|
|
||||||
}).flatMap((e) =>
|
|
||||||
e.isDirectory()
|
|
||||||
? e.name === "routes" || e.name === "__pycache__"
|
|
||||||
? []
|
|
||||||
: appFiles(`${dir}${e.name}/`)
|
|
||||||
: e.name.endsWith(".py") && `${dir}${e.name}` !== "main.py"
|
|
||||||
? [`${dir}${e.name}`]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const skipWords = [
|
const skipWords = [
|
||||||
...new Set(
|
...new Set(
|
||||||
appFiles("").flatMap((f) =>
|
appPaths()
|
||||||
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
|
.filter((f) => !f.startsWith("routes/") && f !== "main.py")
|
||||||
),
|
.flatMap((f) =>
|
||||||
),
|
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
|
||||||
];
|
|
||||||
// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid
|
|
||||||
// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable.
|
|
||||||
const opaqueWhats = routeFiles()
|
|
||||||
.filter((f) => !f.endsWith("/_common.py"))
|
|
||||||
.flatMap((f) =>
|
|
||||||
[...backend(f).matchAll(/not_found\((?!"|\))[^)]*\)|what=(?!")\S+/g)].map(
|
|
||||||
(m) => `${f}: ${m[0]}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const goneWhats = [
|
|
||||||
...new Set(
|
|
||||||
routeSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
|
|
||||||
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
|
|
||||||
// `setting_refused` picks from at runtime, and every `FolderError` reason.
|
|
||||||
const appSources = (dir = ""): string[] =>
|
|
||||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
|
||||||
withFileTypes: true,
|
|
||||||
}).flatMap((e) =>
|
|
||||||
e.isDirectory()
|
|
||||||
? appSources(`${dir}${e.name}/`)
|
|
||||||
: e.name.endsWith(".py")
|
|
||||||
? [backend(`${dir}${e.name}`)]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const refusedReasons = [
|
|
||||||
...new Set([
|
|
||||||
...appSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
|
|
||||||
),
|
|
||||||
// A folder change the rules refuse travels as `FolderError("<reason>")` and is answered with
|
|
||||||
// `refused(409, e.reason)`, which the pattern above cannot read.
|
|
||||||
...appSources().flatMap((src) =>
|
|
||||||
[...src.matchAll(/FolderError\("(\w+)"\)/g)].map((m) => m[1]!),
|
|
||||||
),
|
|
||||||
...[
|
|
||||||
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
|
|
||||||
/:\s*"(\w+)"/g,
|
|
||||||
),
|
),
|
||||||
].map((m) => m[1]!),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
|
|
||||||
const youtubeReasons = [
|
|
||||||
...(
|
|
||||||
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
|
|
||||||
).matchAll(/"(\w+)"/g),
|
|
||||||
].map((m) => m[1]!);
|
|
||||||
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
|
|
||||||
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
|
|
||||||
const clipReasons = [
|
|
||||||
...new Set(
|
|
||||||
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const backendCodes = Object.keys(scan.codes);
|
||||||
|
const recordedCodes = [
|
||||||
|
...Object.keys(REFUSAL_FAMILIES),
|
||||||
|
...Object.keys(REFUSAL_BY_REASON),
|
||||||
|
...Object.keys(REFUSAL_FIXED),
|
||||||
|
...Object.keys(REFUSAL_UNSHOWN),
|
||||||
|
];
|
||||||
|
// The codes whose reason picks the sentence, and what each can carry as its reason.
|
||||||
|
const reasoned = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
|
||||||
|
const reasonsOf: Record<string, string[]> = Object.fromEntries(
|
||||||
|
reasoned.map((code) => [code, scan.codes[code]?.reasons ?? []]),
|
||||||
|
);
|
||||||
|
|
||||||
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
|
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
|
||||||
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
|
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
|
||||||
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
|
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
|
||||||
@@ -275,10 +235,11 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
|
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
|
||||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
||||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
expect(reasonsOf.not_found).toEqual(
|
||||||
expect(opaqueWhats).toEqual([]);
|
expect.arrayContaining(["playlist", "download", "job", "user", "debug_session"]),
|
||||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
);
|
||||||
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
expect(reasonsOf.youtube).toEqual(["gone", "quota", "reconnect", "unavailable"]);
|
||||||
|
expect(reasonsOf.clip).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
||||||
expect(crewJobs).toEqual(
|
expect(crewJobs).toEqual(
|
||||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||||
);
|
);
|
||||||
@@ -294,11 +255,46 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
"no service user",
|
"no service user",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(refusedReasons).toEqual(
|
expect(reasonsOf.refused).toEqual(
|
||||||
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
expect.arrayContaining([
|
||||||
|
"weak_password",
|
||||||
|
"setting_too_low",
|
||||||
|
"plex_write_failed",
|
||||||
|
"file_folder_gone",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
// Every code the frontend localizes is raised somewhere the scan can see.
|
});
|
||||||
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
|
|
||||||
|
it("every refusal code the backend answers is said by exactly one record, and no record names another", () => {
|
||||||
|
expect(backendCodes).toEqual(expect.arrayContaining(["refused", "playlist", "too_large"]));
|
||||||
|
expect(backendCodes.filter((c) => recordedCodes.filter((r) => r === c).length !== 1)).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every refusal code and reason is one the scan can follow", () => {
|
||||||
|
// The modules set aside as not refusals still use the key.
|
||||||
|
expect(Object.keys(scan.excluded).sort()).toEqual(scan.notRefusals);
|
||||||
|
expect(scan.unreadCodes).toEqual([]);
|
||||||
|
expect(scan.errors).toEqual([]);
|
||||||
|
// A reason it could not follow is a sentence nobody is asked for, unless no screen says that code.
|
||||||
|
expect(
|
||||||
|
Object.entries(scan.codes)
|
||||||
|
.filter(([code]) => !(code in REFUSAL_UNSHOWN))
|
||||||
|
.flatMap(([, c]) => c.unread),
|
||||||
|
).toEqual([]);
|
||||||
|
// A code whose reason picks the sentence, sent without one, says only its fallback.
|
||||||
|
expect(reasoned.flatMap((code) => scan.codes[code]?.reasonless ?? [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every code whose reason picks the sentence is read with a reason (a scan that matches nothing is green over nothing)", () => {
|
||||||
|
expect(Object.entries(reasonsOf).filter(([, rs]) => rs.length === 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a code whose reasons share sentences names every reason it is sent with, and no other", () => {
|
||||||
|
for (const [code, { reasons }] of Object.entries(REFUSAL_BY_REASON))
|
||||||
|
expect(Object.keys(reasons).sort(), code).toEqual([...reasonsOf[code]!].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the run list's status chips are the server's run statuses", () => {
|
it("the run list's status chips are the server's run statuses", () => {
|
||||||
@@ -418,33 +414,6 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => {
|
|
||||||
expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]);
|
|
||||||
expect(
|
|
||||||
[...idsUnder(keys, "errors.notFound")].filter(
|
|
||||||
(w) => w !== "generic" && !goneWhats.includes(w),
|
|
||||||
),
|
|
||||||
).toEqual([]);
|
|
||||||
expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]);
|
|
||||||
expect(
|
|
||||||
[...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
|
|
||||||
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
|
|
||||||
expect(
|
|
||||||
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${lang}: every reason a clip is refused, and none it cannot be`, () => {
|
|
||||||
expect(missing(clipReasons.map((r) => `clip.errors.${r}`))).toEqual([]);
|
|
||||||
expect([...idsUnder(keys, "clip.errors")].filter((r) => !clipReasons.includes(r))).toEqual(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
|
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
|
||||||
for (const [group, words] of Object.entries(VOCAB)) {
|
for (const [group, words] of Object.entries(VOCAB)) {
|
||||||
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
|
||||||
@@ -456,11 +425,49 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`${lang}: every admin refusal reason`, () => {
|
it(`${lang}: every refusal the backend can answer has its sentence`, () => {
|
||||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
const wanted = [
|
||||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
...Object.entries(REFUSAL_FAMILIES).flatMap(([code, f]) => [
|
||||||
[],
|
...reasonsOf[code]!.map((r) => `${f.prefix}.${r}${"suffix" in f ? f.suffix : ""}`),
|
||||||
);
|
f.fallback,
|
||||||
|
]),
|
||||||
|
...Object.values(REFUSAL_BY_REASON).flatMap((f) => [
|
||||||
|
...Object.values(f.reasons),
|
||||||
|
f.fallback,
|
||||||
|
]),
|
||||||
|
...Object.values(REFUSAL_FIXED),
|
||||||
|
"errors.generic",
|
||||||
|
];
|
||||||
|
expect(missing([...new Set(wanted)])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
|
||||||
|
// By prefix: the admin actions share `errors.admin`. A suffixed family shares its prefix with more
|
||||||
|
// than its refusals — the sign-in screens' `gate`, `addressLabel` — so only an id with the
|
||||||
|
// refusal's own key counts as a reason there.
|
||||||
|
const byPrefix = new Map<
|
||||||
|
string,
|
||||||
|
{ suffix: string; reasons: Set<string>; spare: Set<string> }
|
||||||
|
>();
|
||||||
|
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
|
||||||
|
const p = byPrefix.get(f.prefix) ?? {
|
||||||
|
suffix: "suffix" in f ? f.suffix : "",
|
||||||
|
reasons: new Set<string>(),
|
||||||
|
spare: new Set<string>(),
|
||||||
|
};
|
||||||
|
for (const r of reasonsOf[code]!) p.reasons.add(r);
|
||||||
|
if (f.fallback.startsWith(`${f.prefix}.`))
|
||||||
|
p.spare.add(f.fallback.slice(f.prefix.length + 1));
|
||||||
|
byPrefix.set(f.prefix, p);
|
||||||
|
}
|
||||||
|
for (const [prefix, { suffix, reasons, spare }] of byPrefix)
|
||||||
|
expect(
|
||||||
|
[...idsUnder(keys, prefix)].filter(
|
||||||
|
(r) =>
|
||||||
|
(!suffix || keys.has(`${prefix}.${r}${suffix}`)) && !spare.has(r) && !reasons.has(r),
|
||||||
|
),
|
||||||
|
prefix,
|
||||||
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"saveForLater": "Save for later",
|
"saveForLater": "Save for later",
|
||||||
"unhide": "Unhide",
|
"unhide": "Unhide",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"resetState": "Reset — clear watch progress/status",
|
"clearProgress": "Clear watch progress",
|
||||||
"thisChannel": "This channel",
|
"thisChannel": "This channel",
|
||||||
"continueTitle": "Continue where you left off",
|
"continueTitle": "Continue where you left off",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"stored": "Stored",
|
"stored": "Stored",
|
||||||
"subs": "Subscribers",
|
"subs": "Subscribers",
|
||||||
"lastUpload": "Last upload",
|
"lastUpload": "Last upload",
|
||||||
|
"added": "Added",
|
||||||
"length": "Length",
|
"length": "Length",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
|
|||||||
@@ -219,6 +219,7 @@
|
|||||||
"edit": {
|
"edit": {
|
||||||
"empty_edit": "Choose a trim range or crop area first",
|
"empty_edit": "Choose a trim range or crop area first",
|
||||||
"source_not_ready": "The source download isn't ready to edit",
|
"source_not_ready": "The source download isn't ready to edit",
|
||||||
|
"not_media": "Only video and audio can be edited",
|
||||||
"generic": "Couldn't create that edit"
|
"generic": "Couldn't create that edit"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
"prefs": "Couldn't save your settings",
|
"prefs": "Couldn't save your settings",
|
||||||
"deleteAccount": "Couldn't delete your account",
|
"deleteAccount": "Couldn't delete your account",
|
||||||
"videoState": "Couldn't change the video's state",
|
"videoState": "Couldn't change the video's state",
|
||||||
"videoReset": "Couldn't reset the video",
|
"videoProgress": "Couldn't clear the video's watch progress",
|
||||||
"watchLaterAdd": "Couldn't add it to Watch later",
|
"watchLaterAdd": "Couldn't add it to Watch later",
|
||||||
"watchLaterRemove": "Couldn't remove it from Watch later",
|
"watchLaterRemove": "Couldn't remove it from Watch later",
|
||||||
"clearSearch": "Couldn't clear the search results",
|
"clearSearch": "Couldn't clear the search results",
|
||||||
|
|||||||
@@ -34,8 +34,10 @@
|
|||||||
"search": "Jump to the search box",
|
"search": "Jump to the search box",
|
||||||
"listStep": "Next / previous item",
|
"listStep": "Next / previous item",
|
||||||
"gridMove": "Move around the list",
|
"gridMove": "Move around the list",
|
||||||
"open": "Open the selected item",
|
"open": "Open the current item",
|
||||||
"listRemove": "Remove the selected item from the list",
|
"listRemove": "Remove from the list",
|
||||||
|
"listSelect": "Select or deselect the current item",
|
||||||
|
"listClearSelection": "Clear the selection",
|
||||||
"cap": {
|
"cap": {
|
||||||
"space": "Space",
|
"space": "Space",
|
||||||
"menu": "Menu"
|
"menu": "Menu"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
|
"noPlayableBefore": "No video before this one can play here",
|
||||||
|
"noPlayableAfter": "No video after this one can play here",
|
||||||
"autoAdvance": {
|
"autoAdvance": {
|
||||||
"label": "Auto",
|
"label": "Auto",
|
||||||
"hint": "When a video ends, what plays next. Click to cycle Off, Next, Previous, Random. Saved to your account.",
|
"hint": "When a video ends, what plays next. Click to cycle Off, Next, Previous, Random. Saved to your account.",
|
||||||
@@ -45,7 +47,6 @@
|
|||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"views": "{{formattedCount}} views",
|
"views": "{{formattedCount}} views",
|
||||||
"stream": "stream",
|
"stream": "stream",
|
||||||
"watched": "Watched",
|
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"watchedUnmark": "Watched — click to unmark",
|
"watchedUnmark": "Watched — click to unmark",
|
||||||
"jumpToTime": "Jump to this time",
|
"jumpToTime": "Jump to this time",
|
||||||
|
|||||||
@@ -94,7 +94,6 @@
|
|||||||
"sync_all_busy": "Your playlists are already syncing with YouTube — try again when it finishes"
|
"sync_all_busy": "Your playlists are already syncing with YouTube — try again when it finishes"
|
||||||
},
|
},
|
||||||
"moveErrors": {
|
"moveErrors": {
|
||||||
"not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else",
|
|
||||||
"same_playlist": "That video is already in this playlist"
|
"same_playlist": "That video is already in this playlist"
|
||||||
},
|
},
|
||||||
"railMenu": "Playlist actions",
|
"railMenu": "Playlist actions",
|
||||||
@@ -102,6 +101,45 @@
|
|||||||
"removeItem": "Remove",
|
"removeItem": "Remove",
|
||||||
"removeItemOf": "Remove “{{title}}” from the playlist",
|
"removeItemOf": "Remove “{{title}}” from the playlist",
|
||||||
"dragHandleOf": "Reorder “{{title}}”",
|
"dragHandleOf": "Reorder “{{title}}”",
|
||||||
|
"selectRow": "Select “{{title}}”",
|
||||||
|
"selectAllShown": "Select all the rows shown",
|
||||||
|
"selectedCount_one": "{{count}} selected",
|
||||||
|
"selectedCount_other": "{{count}} selected",
|
||||||
|
"selectedHidden_one": "{{count}} more hidden by the filter",
|
||||||
|
"selectedHidden_other": "{{count}} more hidden by the filter",
|
||||||
|
"selectedHiddenShort": "+{{n}}",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"moveSelected": "Move to",
|
||||||
|
"removeSelected": "Remove",
|
||||||
|
"movedManyToast_one": "{{count}} video moved to “{{name}}”",
|
||||||
|
"movedManyToast_other": "{{count}} videos moved to “{{name}}”",
|
||||||
|
"removedManyToast_one": "{{count}} video removed from the playlist",
|
||||||
|
"removedManyToast_other": "{{count}} videos removed from the playlist",
|
||||||
|
"moveNoneLeft_one": "Nothing moved: it isn’t in this playlist any more",
|
||||||
|
"moveNoneLeft_other": "Nothing moved: they aren’t in this playlist any more",
|
||||||
|
"removeNoneLeft_one": "Nothing removed: it isn’t in this playlist any more",
|
||||||
|
"removeNoneLeft_other": "Nothing removed: they aren’t in this playlist any more",
|
||||||
|
"selectionCleared": "Selection cleared",
|
||||||
|
"removeAllHidden_one": "The selected row is hidden by the filter",
|
||||||
|
"removeAllHidden_other": "The selected rows are hidden by the filter",
|
||||||
|
"undoNoneBack_one": "Nothing came back: that video isn’t where it was any more",
|
||||||
|
"undoNoneBack_other": "Nothing came back: those videos aren’t where they were any more",
|
||||||
|
"undoSomeBack_one": "Only {{count}} video came back: the rest aren’t where they were any more",
|
||||||
|
"undoSomeBack_other": "Only {{count}} videos came back: the rest aren’t where they were any more",
|
||||||
|
"dragCancelledMany_one": "Move cancelled; {{count}} video stays where it was",
|
||||||
|
"dragCancelledMany_other": "Move cancelled; {{count}} videos stay where they were",
|
||||||
|
"moveTargetsFailed": "Your playlists couldn’t be loaded",
|
||||||
|
"moveTargetsLoading": "Loading your playlists…",
|
||||||
|
"moveManyFailed": "Couldn't move the videos",
|
||||||
|
"removeManyFailed": "Couldn't remove the videos",
|
||||||
|
"undoMoveFailed": "Couldn't move the video back",
|
||||||
|
"undoMoveManyFailed": "Couldn't move the videos back",
|
||||||
|
"undoRemoveManyFailed": "Couldn't put the videos back",
|
||||||
|
"dragHandleToList": "Drag “{{title}}” onto a list",
|
||||||
|
"dragPickedMany_one": "{{count}} video picked up",
|
||||||
|
"dragPickedMany_other": "{{count}} videos picked up",
|
||||||
|
"dragOverList": "Over “{{name}}”",
|
||||||
|
"dragDroppedOnList": "Dropped on “{{name}}”",
|
||||||
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
|
"dragBlockedSort": "Reordering by hand is off while a column sort or grouping is applied.",
|
||||||
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
|
"dragBlockedFilter": "Reordering by hand is off while a filter is applied.",
|
||||||
"clearView": "Reset view",
|
"clearView": "Reset view",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"saveForLater": "Mentés későbbre",
|
"saveForLater": "Mentés későbbre",
|
||||||
"unhide": "Megjelenítés",
|
"unhide": "Megjelenítés",
|
||||||
"hide": "Elrejtés",
|
"hide": "Elrejtés",
|
||||||
"resetState": "Alaphelyzet — nézési előzmény/státusz törlése",
|
"clearProgress": "Megtekintés visszaállítása",
|
||||||
"thisChannel": "Ez a csatorna",
|
"thisChannel": "Ez a csatorna",
|
||||||
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
||||||
"continue": "Folytatás",
|
"continue": "Folytatás",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"stored": "Tárolt",
|
"stored": "Tárolt",
|
||||||
"subs": "Feliratkozók",
|
"subs": "Feliratkozók",
|
||||||
"lastUpload": "Utolsó feltöltés",
|
"lastUpload": "Utolsó feltöltés",
|
||||||
|
"added": "Hozzáadva",
|
||||||
"length": "Hossz",
|
"length": "Hossz",
|
||||||
"tags": "Címkék",
|
"tags": "Címkék",
|
||||||
"actions": "Műveletek"
|
"actions": "Műveletek"
|
||||||
|
|||||||
@@ -219,6 +219,7 @@
|
|||||||
"edit": {
|
"edit": {
|
||||||
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
|
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
|
||||||
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
|
"source_not_ready": "A forrásletöltés még nem szerkeszthető",
|
||||||
|
"not_media": "Csak videó és hang szerkeszthető",
|
||||||
"generic": "Nem sikerült létrehozni a vágást"
|
"generic": "Nem sikerült létrehozni a vágást"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
"prefs": "Nem sikerült menteni a beállításaidat",
|
"prefs": "Nem sikerült menteni a beállításaidat",
|
||||||
"deleteAccount": "Nem sikerült törölni a fiókodat",
|
"deleteAccount": "Nem sikerült törölni a fiókodat",
|
||||||
"videoState": "Nem sikerült módosítani a videó állapotát",
|
"videoState": "Nem sikerült módosítani a videó állapotát",
|
||||||
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
|
"videoProgress": "Nem sikerült visszaállítani a videó megtekintését",
|
||||||
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
|
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
|
||||||
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
|
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
|
||||||
"clearSearch": "Nem sikerült törölni a keresési találatokat",
|
"clearSearch": "Nem sikerült törölni a keresési találatokat",
|
||||||
|
|||||||
@@ -34,8 +34,10 @@
|
|||||||
"search": "Ugrás a keresőbe",
|
"search": "Ugrás a keresőbe",
|
||||||
"listStep": "Következő / előző elem",
|
"listStep": "Következő / előző elem",
|
||||||
"gridMove": "Mozgás a listában",
|
"gridMove": "Mozgás a listában",
|
||||||
"open": "A kijelölt elem megnyitása",
|
"open": "Az aktuális elem megnyitása",
|
||||||
"listRemove": "A kijelölt elem eltávolítása a listából",
|
"listRemove": "Eltávolítás a listából",
|
||||||
|
"listSelect": "Az aktuális elem kijelölése vagy a jelölés levétele",
|
||||||
|
"listClearSelection": "A kijelölés törlése",
|
||||||
"cap": {
|
"cap": {
|
||||||
"space": "Szóköz",
|
"space": "Szóköz",
|
||||||
"menu": "Menü"
|
"menu": "Menü"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
"previous": "Előző",
|
"previous": "Előző",
|
||||||
"next": "Következő",
|
"next": "Következő",
|
||||||
|
"noPlayableBefore": "Előtte nincs itt lejátszható videó",
|
||||||
|
"noPlayableAfter": "Utána nincs itt lejátszható videó",
|
||||||
"autoAdvance": {
|
"autoAdvance": {
|
||||||
"label": "Automatikus",
|
"label": "Automatikus",
|
||||||
"hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.",
|
"hint": "A videó végén mi jöjjön. Kattintásra vált: Ki, Következő, Előző, Véletlen. A fiókodhoz mentve.",
|
||||||
@@ -45,7 +47,6 @@
|
|||||||
"channel": "Csatorna",
|
"channel": "Csatorna",
|
||||||
"views": "{{formattedCount}} megtekintés",
|
"views": "{{formattedCount}} megtekintés",
|
||||||
"stream": "élő adás",
|
"stream": "élő adás",
|
||||||
"watched": "Megtekintve",
|
|
||||||
"markWatched": "Megtekintettnek jelöl",
|
"markWatched": "Megtekintettnek jelöl",
|
||||||
"watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához",
|
"watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához",
|
||||||
"jumpToTime": "Ugrás erre az időpontra",
|
"jumpToTime": "Ugrás erre az időpontra",
|
||||||
|
|||||||
@@ -94,7 +94,6 @@
|
|||||||
"sync_all_busy": "A listáid épp szinkronizálnak a YouTube-bal — próbáld újra, ha végzett"
|
"sync_all_busy": "A listáid épp szinkronizálnak a YouTube-bal — próbáld újra, ha végzett"
|
||||||
},
|
},
|
||||||
"moveErrors": {
|
"moveErrors": {
|
||||||
"not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott",
|
|
||||||
"same_playlist": "Ez a videó már ebben a listában van"
|
"same_playlist": "Ez a videó már ebben a listában van"
|
||||||
},
|
},
|
||||||
"railMenu": "Lejátszási lista műveletei",
|
"railMenu": "Lejátszási lista műveletei",
|
||||||
@@ -102,6 +101,45 @@
|
|||||||
"removeItem": "Eltávolítás",
|
"removeItem": "Eltávolítás",
|
||||||
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
"removeItemOf": "„{{title}}” eltávolítása a listáról",
|
||||||
"dragHandleOf": "„{{title}}” átrendezése",
|
"dragHandleOf": "„{{title}}” átrendezése",
|
||||||
|
"selectRow": "„{{title}}” kijelölése",
|
||||||
|
"selectAllShown": "Az összes látható sor kijelölése",
|
||||||
|
"selectedCount_one": "{{count}} kijelölve",
|
||||||
|
"selectedCount_other": "{{count}} kijelölve",
|
||||||
|
"selectedHidden_one": "további {{count}} a szűrő mögött",
|
||||||
|
"selectedHidden_other": "további {{count}} a szűrő mögött",
|
||||||
|
"selectedHiddenShort": "+{{n}}",
|
||||||
|
"clearSelection": "Kijelölés törlése",
|
||||||
|
"moveSelected": "Áthelyezés ide",
|
||||||
|
"removeSelected": "Eltávolítás",
|
||||||
|
"movedManyToast_one": "{{count}} videó áthelyezve ide: „{{name}}”",
|
||||||
|
"movedManyToast_other": "{{count}} videó áthelyezve ide: „{{name}}”",
|
||||||
|
"removedManyToast_one": "{{count}} videó eltávolítva a listáról",
|
||||||
|
"removedManyToast_other": "{{count}} videó eltávolítva a listáról",
|
||||||
|
"moveNoneLeft_one": "Nem történt áthelyezés: ez már nincs ebben a listában",
|
||||||
|
"moveNoneLeft_other": "Nem történt áthelyezés: ezek már nincsenek ebben a listában",
|
||||||
|
"removeNoneLeft_one": "Nem történt eltávolítás: ez már nincs ebben a listában",
|
||||||
|
"removeNoneLeft_other": "Nem történt eltávolítás: ezek már nincsenek ebben a listában",
|
||||||
|
"selectionCleared": "Kijelölés törölve",
|
||||||
|
"removeAllHidden_one": "A kijelölt sort a szűrő elrejti",
|
||||||
|
"removeAllHidden_other": "A kijelölt sorokat a szűrő elrejti",
|
||||||
|
"undoNoneBack_one": "Semmi nem került vissza: ez a videó már nincs ott, ahol volt",
|
||||||
|
"undoNoneBack_other": "Semmi nem került vissza: ezek a videók már nincsenek ott, ahol voltak",
|
||||||
|
"undoSomeBack_one": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
|
||||||
|
"undoSomeBack_other": "Csak {{count}} videó került vissza: a többi már nincs ott, ahol volt",
|
||||||
|
"dragCancelledMany_one": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
|
||||||
|
"dragCancelledMany_other": "Áthelyezés megszakítva; {{count}} videó a helyén marad",
|
||||||
|
"moveTargetsFailed": "Nem sikerült betölteni a listáidat",
|
||||||
|
"moveTargetsLoading": "A listáid betöltése…",
|
||||||
|
"moveManyFailed": "Nem sikerült áthelyezni a videókat",
|
||||||
|
"removeManyFailed": "Nem sikerült eltávolítani a videókat",
|
||||||
|
"undoMoveFailed": "Nem sikerült visszahelyezni a videót",
|
||||||
|
"undoMoveManyFailed": "Nem sikerült visszahelyezni a videókat",
|
||||||
|
"undoRemoveManyFailed": "Nem sikerült visszatenni a videókat",
|
||||||
|
"dragHandleToList": "„{{title}}” húzása egy listára",
|
||||||
|
"dragPickedMany_one": "{{count}} videó felvéve",
|
||||||
|
"dragPickedMany_other": "{{count}} videó felvéve",
|
||||||
|
"dragOverList": "A(z) „{{name}}” lista fölött",
|
||||||
|
"dragDroppedOnList": "Letéve a(z) „{{name}}” listára",
|
||||||
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
|
"dragBlockedSort": "A kézi átrendezés ki van kapcsolva, amíg oszloprendezés vagy csoportosítás van érvényben.",
|
||||||
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
|
"dragBlockedFilter": "A kézi átrendezés ki van kapcsolva, amíg szűrő van érvényben.",
|
||||||
"clearView": "Nézet visszaállítása",
|
"clearView": "Nézet visszaállítása",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { REFUSAL_FAMILIES, type RefusalFamily } from "../lib/api/refusalCodes";
|
||||||
|
|
||||||
// Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either
|
// Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either
|
||||||
// one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary,
|
// one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary,
|
||||||
// the old login page, a pager the DataTable replaced), each a string a translator would keep in step
|
// the old login page, a pager the DataTable replaced), each a string a translator would keep in step
|
||||||
@@ -49,9 +51,14 @@ const code = sources(SRC).join("\n");
|
|||||||
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
|
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
|
||||||
// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
|
// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
|
||||||
// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
|
// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
|
||||||
|
// A refusal's sentence is built from its record (`lib/api/refusalCodes`) rather than a template the
|
||||||
|
// scan could read, so each family's prefix is a shape of its own.
|
||||||
const shapes = [
|
const shapes = [
|
||||||
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
|
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
|
||||||
...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
|
...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
|
||||||
|
...Object.values(REFUSAL_FAMILIES).map(
|
||||||
|
(f: RefusalFamily) => `${f.prefix}.\${reason}${f.suffix ?? ""}`,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
.filter((s) => s.includes("${"))
|
.filter((s) => s.includes("${"))
|
||||||
.map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
|
.map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export interface ManagedChannel {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
stored_videos: number;
|
stored_videos: number;
|
||||||
last_video_at: string | null;
|
last_video_at: string | null;
|
||||||
|
/** When the follow reached this list: the subscription import that first saw it, or a
|
||||||
|
* subscribe made here. */
|
||||||
|
added_at: string;
|
||||||
total_duration_seconds: number;
|
total_duration_seconds: number;
|
||||||
priority: number;
|
priority: number;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export interface Video {
|
|||||||
watch_url: string;
|
watch_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a video has anything its reset would clear: pristine = never opened, the default status
|
/** Whether a video has a resume point, the one thing "Clear watch progress" clears. Its status is no
|
||||||
* and no resume position. The card's reset button and the card menu's reset both ask this. */
|
* part of it: a hidden video stays hidden, a watched one watched. */
|
||||||
export function isResettable(v: Pick<Video, "status" | "position_seconds">): boolean {
|
export function hasResumePoint(v: Pick<Video, "position_seconds">): boolean {
|
||||||
return v.status !== "new" || v.position_seconds > 0;
|
return v.position_seconds > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
|
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
|
||||||
@@ -290,8 +290,8 @@ export const feedApi = {
|
|||||||
{ method: "POST", body: JSON.stringify({ status }) },
|
{ method: "POST", body: JSON.stringify({ status }) },
|
||||||
{ idempotent: true },
|
{ idempotent: true },
|
||||||
),
|
),
|
||||||
clearState: (id: string) =>
|
clearProgress: (id: string) =>
|
||||||
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
|
req(`/api/videos/${id}/progress`, { method: "DELETE" }, { idempotent: true }),
|
||||||
// Returns the position the server actually STORED — which is not always what we sent: a
|
// Returns the position the server actually STORED — which is not always what we sent: a
|
||||||
// trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers
|
// trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers
|
||||||
// that mirror the position into a cache must write this answer, never their own reading, so the
|
// that mirror the position into a cache must write this answer, never their own reading, so the
|
||||||
|
|||||||
@@ -68,25 +68,65 @@ export const playlistsApi = {
|
|||||||
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
||||||
deletePlaylist: (id: number, onYoutube = false) =>
|
deletePlaylist: (id: number, onYoutube = false) =>
|
||||||
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
||||||
// `position` (an index in the stored order) puts it back where a removal took it from — the
|
addToPlaylist: (id: number, videoId: string) =>
|
||||||
// list page's Undo; omitted, it is appended.
|
|
||||||
addToPlaylist: (id: number, videoId: string, position?: number) =>
|
|
||||||
req(`/api/playlists/${id}/items`, {
|
req(`/api/playlists/${id}/items`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ video_id: videoId, ...(position != null && { position }) }),
|
body: JSON.stringify({ video_id: videoId }),
|
||||||
}),
|
}),
|
||||||
removeFromPlaylist: (id: number, videoId: string) =>
|
removeFromPlaylist: (id: number, videoId: string) =>
|
||||||
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||||
// One request on purpose: add-then-remove from the client leaves the video in BOTH lists when
|
// One request on purpose: add-then-remove from the client leaves a video in BOTH lists when the
|
||||||
// the second call fails. The server does it in one transaction.
|
// second call fails. The server does it in one transaction, and says which landed in the target
|
||||||
moveToPlaylist: (
|
// and which it already held: the Undo needs to tell them apart.
|
||||||
|
moveManyToPlaylist: (
|
||||||
id: number,
|
id: number,
|
||||||
videoId: string,
|
videoIds: string[],
|
||||||
targetPlaylistId: number,
|
targetPlaylistId: number,
|
||||||
): Promise<{ ok: true; from_playlist_id: number; to_playlist_id: number; video_id: string }> =>
|
): Promise<{
|
||||||
req(`/api/playlists/${id}/items/${videoId}/move`, {
|
ok: true;
|
||||||
|
from_playlist_id: number;
|
||||||
|
to_playlist_id: number;
|
||||||
|
moved: string[];
|
||||||
|
already_there: string[];
|
||||||
|
}> =>
|
||||||
|
req(`/api/playlists/${id}/items/move-bulk`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ target_playlist_id: targetPlaylistId }),
|
body: JSON.stringify({ video_ids: videoIds, target_playlist_id: targetPlaylistId }),
|
||||||
|
}),
|
||||||
|
// The Undo of a move, back into `id`. `positions` pairs with `moved` + `alreadyThere`.
|
||||||
|
undoPlaylistMove: (
|
||||||
|
id: number,
|
||||||
|
fromPlaylistId: number,
|
||||||
|
moved: string[],
|
||||||
|
alreadyThere: string[],
|
||||||
|
positions: number[],
|
||||||
|
): Promise<{ ok: true; playlist_id: number; added: number }> =>
|
||||||
|
req(`/api/playlists/${id}/items/undo-move`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
from_playlist_id: fromPlaylistId,
|
||||||
|
moved,
|
||||||
|
already_there: alreadyThere,
|
||||||
|
positions,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
removeManyFromPlaylist: (
|
||||||
|
id: number,
|
||||||
|
videoIds: string[],
|
||||||
|
): Promise<{ ok: true; playlist_id: number; removed: string[] }> =>
|
||||||
|
req(`/api/playlists/${id}/items/remove-bulk`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ video_ids: videoIds }),
|
||||||
|
}),
|
||||||
|
// The Undo of a removal, in one request; an index in the stored order per video.
|
||||||
|
restoreToPlaylist: (
|
||||||
|
id: number,
|
||||||
|
videoIds: string[],
|
||||||
|
positions: number[],
|
||||||
|
): Promise<{ ok: true; playlist_id: number; added: number }> =>
|
||||||
|
req(`/api/playlists/${id}/items/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ video_ids: videoIds, positions }),
|
||||||
}),
|
}),
|
||||||
reorderPlaylist: (id: number, videoIds: string[]) =>
|
reorderPlaylist: (id: number, videoIds: string[]) =>
|
||||||
req(`/api/playlists/${id}/order`, {
|
req(`/api/playlists/${id}/order`, {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Which sentence answers each server refusal (`{ code, reason, … }`), as data: `localizeDetail` says a
|
||||||
|
// refusal through these records, and `i18n/backendIds.contract.test.ts` reads every code and reason the
|
||||||
|
// backend can send and requires each to land here with its sentence in both languages. A refusal that
|
||||||
|
// fell through to the generic "couldn't carry out that action" used to stay unnoticed — the language
|
||||||
|
// parity test is green when EN and HU miss the same key.
|
||||||
|
//
|
||||||
|
// No imports: the contract test reads this module in a plain node run, without i18n.
|
||||||
|
|
||||||
|
/** A code whose reasons each name their own sentence: `<prefix>.<reason><suffix>`. */
|
||||||
|
export type RefusalFamily = { prefix: string; suffix?: string; fallback: string };
|
||||||
|
|
||||||
|
export const REFUSAL_FAMILIES = {
|
||||||
|
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and the
|
||||||
|
// rest of the detail its values.
|
||||||
|
refused: { prefix: "errors.refused", fallback: "errors.generic" },
|
||||||
|
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it — wait
|
||||||
|
// for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||||||
|
youtube: { prefix: "errors.youtube", fallback: "errors.youtube.unavailable" },
|
||||||
|
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's row
|
||||||
|
// 404s the same way); `reason` names WHAT is gone.
|
||||||
|
not_found: { prefix: "errors.notFound", fallback: "errors.notFound.generic" },
|
||||||
|
// A clip the server will not cut (downloads/clip): the range, the frame, the source; a cap comes
|
||||||
|
// with the refusal (`limit_s`).
|
||||||
|
clip: { prefix: "clip.errors", fallback: "clip.failed" },
|
||||||
|
// An admin action on an account or an invite: an address no longer approved, a link or invitation
|
||||||
|
// with nothing to go to or no mail to go by. The toast's title names the button; this is the WHY,
|
||||||
|
// which used to be the generic "try again" no retry could fix. One namespace for the three codes.
|
||||||
|
activate: { prefix: "errors.admin", fallback: "errors.generic" },
|
||||||
|
resend: { prefix: "errors.admin", fallback: "errors.generic" },
|
||||||
|
invite_send: { prefix: "errors.admin", fallback: "errors.generic" },
|
||||||
|
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||||||
|
playlist: { prefix: "playlists.errors", fallback: "errors.generic" },
|
||||||
|
// The one-step "move to another list": both reasons mean the list moved under you, named apart.
|
||||||
|
playlist_move: { prefix: "playlists.moveErrors", fallback: "playlists.moveFailed" },
|
||||||
|
edit: { prefix: "downloads.errors.edit", fallback: "downloads.errors.edit.generic" },
|
||||||
|
cookies: { prefix: "downloads.errors.cookies", fallback: "downloads.errors.cookies.generic" },
|
||||||
|
// A download URL the server refuses to fetch. The reasons are deliberately distinct — "we could not
|
||||||
|
// look that host up" is a different thing to tell someone than "we will not fetch from that address".
|
||||||
|
url_blocked: {
|
||||||
|
prefix: "downloads.errors.urlBlocked",
|
||||||
|
fallback: "downloads.errors.urlBlocked.generic",
|
||||||
|
},
|
||||||
|
// Password sign-in refusals, in one line. The sign-in card renders the account-shaped ones as a full
|
||||||
|
// panel off `detailData`; this is the form every other consumer (and the card's fallback) gets.
|
||||||
|
login: {
|
||||||
|
prefix: "welcome.auth.blocked",
|
||||||
|
suffix: ".short",
|
||||||
|
fallback: "welcome.auth.genericError",
|
||||||
|
},
|
||||||
|
} satisfies Record<string, RefusalFamily>;
|
||||||
|
|
||||||
|
/** A code whose few reasons share sentences: each reason the backend sends is named here. */
|
||||||
|
export const REFUSAL_BY_REASON = {
|
||||||
|
quota: {
|
||||||
|
reasons: {
|
||||||
|
max_bytes: "downloads.errors.quota.max_bytes",
|
||||||
|
max_jobs: "downloads.errors.quota.max_jobs",
|
||||||
|
},
|
||||||
|
fallback: "downloads.errors.quota.generic",
|
||||||
|
},
|
||||||
|
// The set-password link. Its failure is one a user meets by simply clicking an old link, so it may
|
||||||
|
// not answer in hardcoded English under a bilingual UI.
|
||||||
|
reset: {
|
||||||
|
reasons: { weak_password: "welcome.auth.weakPassword", invalid: "welcome.auth.deadLink.short" },
|
||||||
|
fallback: "welcome.auth.deadLink.short",
|
||||||
|
},
|
||||||
|
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
||||||
|
collection: {
|
||||||
|
reasons: { name_taken: "plex.collection.nameTaken" },
|
||||||
|
fallback: "plex.collection.editFailed",
|
||||||
|
},
|
||||||
|
} satisfies Record<string, { reasons: Record<string, string>; fallback: string }>;
|
||||||
|
|
||||||
|
/** A code with one sentence, whatever else it carries. */
|
||||||
|
export const REFUSAL_FIXED = {
|
||||||
|
// The email confirmation link — like `reset`, met by clicking an old link.
|
||||||
|
verify: "welcome.auth.verifyInvalid",
|
||||||
|
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
|
||||||
|
upload_too_large: "downloads.errors.upload.tooLarge",
|
||||||
|
upload_empty: "downloads.errors.upload.empty",
|
||||||
|
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||||
|
// number. On a DEFAULT install it is what an over-cap upload gets, because the ceiling and the
|
||||||
|
// route's own cap are the same figure and the edge answers first — the numbered `upload_too_large`
|
||||||
|
// answers only once an admin has LOWERED the knob (bodylimit.ROUTE_MAX_BODY_BYTES).
|
||||||
|
// A BACKSTOP, not the plan: the middleware answers without draining, so whether the browser reads
|
||||||
|
// this at all depends on the client having finished sending (readable up to 200 MiB over loopback,
|
||||||
|
// a bare `onerror` through a dev proxy). That is why the upload dialog checks the file against
|
||||||
|
// `upload_max_bytes` BEFORE sending. The sentence stays PAYLOAD-NEUTRAL: a JSON form gets it too
|
||||||
|
// (`debug_sessions` refuses an over-size draft with the same code), and advice about a file told the
|
||||||
|
// issue composer's user to pick a smaller one — of nothing.
|
||||||
|
too_large: "errors.tooLarge",
|
||||||
|
// A manual sync trigger on cooldown: a pace limit rather than an error, and the copy says so.
|
||||||
|
sync_cooldown: "errors.syncCooldown",
|
||||||
|
} satisfies Record<string, string>;
|
||||||
|
|
||||||
|
/** Codes no screen prints, so they read as the generic failure — each with why nobody reads it. */
|
||||||
|
export const REFUSAL_UNSHOWN = {
|
||||||
|
subtitle: "answers a <track> src; the player polls the track's status and never reads this body",
|
||||||
|
json_required: "answers the client log beacon, which the UI always sends as JSON",
|
||||||
|
diagnostics_off: "answers the client log beacon; the client stops streaming on it, silently",
|
||||||
|
rate_limited: "answers the client log beacon over its rate; dropped lines are not reported",
|
||||||
|
bad_cursor: "a list cursor the UI itself wrote; only a hand-edited request gets this",
|
||||||
|
invalid_input: "a NUL byte in a request, which no form of the UI can send",
|
||||||
|
} satisfies Record<string, string>;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A server refusal said through the records (`refusalCodes`), in both languages, by the real i18n —
|
||||||
|
// one case per record shape, so moving the dispatch into data changed what no reader sees.
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import i18n from "../../i18n";
|
||||||
|
import { formatBytes } from "../format";
|
||||||
|
import { localizeDetail } from "./refusalText";
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
|
||||||
|
const say = async (d: unknown) => {
|
||||||
|
await i18n.changeLanguage(lng);
|
||||||
|
return localizeDetail(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("names an edit refused for a file that is not video or audio", async () => {
|
||||||
|
const said = await say({ code: "edit", reason: "not_media" });
|
||||||
|
expect(said).toBe(i18n.t("downloads.errors.edit.not_media"));
|
||||||
|
expect(said).not.toBe(i18n.t("downloads.errors.edit.generic"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a family's reason, and its own fallback for a reason it does not know", async () => {
|
||||||
|
expect(await say({ code: "playlist_move", reason: "same_playlist" })).toBe(
|
||||||
|
i18n.t("playlists.moveErrors.same_playlist"),
|
||||||
|
);
|
||||||
|
expect(await say({ code: "playlist_move", reason: "nope" })).toBe(
|
||||||
|
i18n.t("playlists.moveFailed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a sign-in refusal's one-line form", async () => {
|
||||||
|
expect(await say({ code: "login", reason: "suspended" })).toBe(
|
||||||
|
i18n.t("welcome.auth.blocked.suspended.short"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a refusal's numbers into its sentence", async () => {
|
||||||
|
expect(await say({ code: "quota", reason: "max_jobs", limit: 7 })).toContain("7");
|
||||||
|
expect(await say({ code: "reset", reason: "weak_password", min: 12 })).toContain("12");
|
||||||
|
expect(await say({ code: "refused", reason: "view_name_taken", name: "Esti" })).toContain(
|
||||||
|
"Esti",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a download cap in GB and an upload cap in bytes, in the reader's number format", async () => {
|
||||||
|
expect(await say({ code: "quota", reason: "max_bytes", limit: 1.5 * 2 ** 30 })).toContain(
|
||||||
|
lng === "hu" ? "1,5" : "1.5",
|
||||||
|
);
|
||||||
|
const upload = await say({ code: "upload_too_large", limit: 5_242_880 });
|
||||||
|
expect(upload).toContain(formatBytes(5_242_880, lng));
|
||||||
|
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
|
||||||
|
expect(await say({ code: "quota", reason: "nope" })).toBe(
|
||||||
|
i18n.t("downloads.errors.quota.generic"),
|
||||||
|
);
|
||||||
|
expect(await say({ code: "collection", reason: "nope" })).toBe(
|
||||||
|
i18n.t("plex.collection.editFailed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
|
||||||
|
expect(await say({ code: "reset", reason: "invalid" })).toBe(
|
||||||
|
i18n.t("welcome.auth.deadLink.short"),
|
||||||
|
);
|
||||||
|
expect(await say({ code: "sync_cooldown" })).toBe(i18n.t("errors.syncCooldown"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a code no screen shows, or one it does not know, as the generic failure", async () => {
|
||||||
|
expect(await say({ code: "bad_cursor" })).toBe(i18n.t("errors.generic"));
|
||||||
|
expect(await say({ code: "never_sent" })).toBe(i18n.t("errors.generic"));
|
||||||
|
expect(await say({ code: "constructor" })).toBe(i18n.t("errors.generic"));
|
||||||
|
expect(await say(null)).toBe(i18n.t("errors.generic"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,15 +3,52 @@
|
|||||||
// moment, and the notice store cannot import the request core that imports it.
|
// moment, and the notice store cannot import the request core that imports it.
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { formatBytes, formatDecimal } from "../format";
|
import { formatBytes, formatDecimal } from "../format";
|
||||||
|
import {
|
||||||
|
REFUSAL_BY_REASON,
|
||||||
|
REFUSAL_FAMILIES,
|
||||||
|
REFUSAL_FIXED,
|
||||||
|
type RefusalFamily,
|
||||||
|
} from "./refusalCodes";
|
||||||
|
|
||||||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
|
type Detail = {
|
||||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
code?: string;
|
||||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
reason?: string;
|
||||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
limit?: number;
|
||||||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
limit_s?: number;
|
||||||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
min?: number;
|
||||||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
|
} | null;
|
||||||
|
|
||||||
|
// Which sentence answers which code lives in `refusalCodes` — the records the backendIds contract holds
|
||||||
|
// against every code and reason the backend can send. A Map, so a code is looked up as data only.
|
||||||
|
const families = new Map<string, RefusalFamily>(Object.entries(REFUSAL_FAMILIES));
|
||||||
|
const byReason = new Map(
|
||||||
|
Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [
|
||||||
|
code,
|
||||||
|
{ reasons: new Map<string, string>(Object.entries(f.reasons)), fallback: f.fallback },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
|
||||||
|
|
||||||
|
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
|
||||||
|
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
|
||||||
|
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
|
||||||
|
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
|
||||||
|
const lang = i18n.language;
|
||||||
|
if (o.code === "refused") return { ...o };
|
||||||
|
if (o.code === "clip")
|
||||||
|
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
|
||||||
|
if (o.code === "quota")
|
||||||
|
return o.reason === "max_bytes"
|
||||||
|
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
|
||||||
|
: { limit: o.limit };
|
||||||
|
if (o.code === "reset") return { min: o.limit ?? o.min };
|
||||||
|
if (o.code === "upload_too_large") return { size: formatBytes(o.limit, lang) };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. A reason this
|
||||||
|
// build does not know reads as its code's fallback sentence rather than a raw key, and a code it does
|
||||||
|
// not know (or one no screen shows, `REFUSAL_UNSHOWN`) as the generic failure.
|
||||||
export function localizeDetail(d: unknown): string {
|
export function localizeDetail(d: unknown): string {
|
||||||
const t = i18n.t.bind(i18n);
|
const t = i18n.t.bind(i18n);
|
||||||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||||||
@@ -19,102 +56,18 @@ export function localizeDetail(d: unknown): string {
|
|||||||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||||||
// "check your input" message rather than leaking them into a translated sentence.
|
// "check your input" message rather than leaking them into a translated sentence.
|
||||||
if (Array.isArray(d)) return t("errors.validation");
|
if (Array.isArray(d)) return t("errors.validation");
|
||||||
const o = d as
|
const o = d as Detail | undefined;
|
||||||
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
|
if (!o?.code) return t("errors.generic");
|
||||||
| null
|
const family = families.get(o.code);
|
||||||
| undefined;
|
if (family)
|
||||||
if (o?.code === "quota") {
|
return t(`${family.prefix}.${o.reason}${family.suffix ?? ""}`, {
|
||||||
if (o.reason === "max_bytes") {
|
...valuesOf(o),
|
||||||
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
|
defaultValue: t(family.fallback),
|
||||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
|
||||||
}
|
|
||||||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
|
||||||
return t("downloads.errors.quota.generic");
|
|
||||||
}
|
|
||||||
if (o?.code === "edit")
|
|
||||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
|
||||||
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
|
|
||||||
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
|
|
||||||
if (o?.code === "clip")
|
|
||||||
return t(`clip.errors.${o.reason}`, {
|
|
||||||
defaultValue: t("clip.failed"),
|
|
||||||
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
|
|
||||||
});
|
});
|
||||||
if (o?.code === "cookies")
|
const shared = byReason.get(o.code);
|
||||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
if (shared) {
|
||||||
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
|
return t(shared.reasons.get(o.reason ?? "") ?? shared.fallback, valuesOf(o));
|
||||||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
}
|
||||||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
const one = fixed.get(o.code);
|
||||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
return one ? t(one, valuesOf(o)) : t("errors.generic");
|
||||||
if (o?.code === "login")
|
|
||||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
|
||||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
|
||||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
|
||||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
|
||||||
if (o?.code === "reset")
|
|
||||||
return o.reason === "weak_password"
|
|
||||||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
|
||||||
: t("welcome.auth.deadLink.short");
|
|
||||||
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
|
|
||||||
if (o?.code === "upload_too_large")
|
|
||||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
|
|
||||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
|
||||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
|
||||||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
|
|
||||||
// because the ceiling and the route's own cap are the same figure and the edge answers first —
|
|
||||||
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
|
|
||||||
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
|
|
||||||
// the generic "couldn't carry out that action", the one message no retry can fix.
|
|
||||||
//
|
|
||||||
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
|
|
||||||
// to read this at all depends on the client having finished sending: measured readable up to
|
|
||||||
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
|
|
||||||
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
|
|
||||||
// happens when that check could not run.
|
|
||||||
//
|
|
||||||
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
|
|
||||||
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
|
|
||||||
// about a file told the issue composer's user to pick a smaller one — of nothing. The
|
|
||||||
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
|
|
||||||
if (o?.code === "too_large") return t("errors.tooLarge");
|
|
||||||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
|
||||||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
|
||||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
|
||||||
if (o?.code === "url_blocked")
|
|
||||||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
|
||||||
// The one-step "move to another list". Both reasons mean the same thing to a reader —
|
|
||||||
// the list moved under you — but they are different enough to be worth naming separately, and an
|
|
||||||
// unknown reason falls back to the plain failure rather than printing a key.
|
|
||||||
if (o?.code === "playlist_move")
|
|
||||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
|
||||||
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
|
||||||
if (o?.code === "collection")
|
|
||||||
return o.reason === "name_taken"
|
|
||||||
? t("plex.collection.nameTaken")
|
|
||||||
: t("plex.collection.editFailed");
|
|
||||||
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
|
|
||||||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
|
||||||
if (o?.code === "youtube")
|
|
||||||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
|
||||||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
|
||||||
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
|
||||||
// reads as the generic failure rather than a key.
|
|
||||||
if (o?.code === "refused")
|
|
||||||
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
|
||||||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
|
||||||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
|
||||||
// An admin action on an account or an invite that the server refused: activating an address that
|
|
||||||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
|
||||||
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
|
|
||||||
// "try again", which no retry could fix.
|
|
||||||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
|
||||||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
|
||||||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
|
||||||
// generically rather than printing a key.
|
|
||||||
if (o?.code === "not_found")
|
|
||||||
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
|
|
||||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
|
||||||
// limit, and the copy says so.
|
|
||||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
|
||||||
return t("errors.generic");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ describe("isTypingTarget", () => {
|
|||||||
it("is false on ordinary content", () => {
|
it("is false on ordinary content", () => {
|
||||||
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
|
expect(isTypingTarget(press({ key: "a" }))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("is false on a box, where no letter types: the page's keys go on working after a tick", () => {
|
||||||
|
for (const type of ["checkbox", "radio"]) {
|
||||||
|
const el = document.createElement("input");
|
||||||
|
el.type = type;
|
||||||
|
document.body.append(el);
|
||||||
|
expect(isTypingTarget(press({ key: "a" }, el))).toBe(false);
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays true on a slider, whose arrows are its own", () => {
|
||||||
|
const el = document.createElement("input");
|
||||||
|
el.type = "range";
|
||||||
|
document.body.append(el);
|
||||||
|
expect(isTypingTarget(press({ key: "ArrowLeft" }, el))).toBe(true);
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isActivationTarget", () => {
|
describe("isActivationTarget", () => {
|
||||||
@@ -101,6 +119,11 @@ describe("isActivationTarget", () => {
|
|||||||
document.body.append(btn);
|
document.body.append(btn);
|
||||||
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
|
expect(isActivationTarget(press({ key: " " }, btn))).toBe(true);
|
||||||
btn.remove();
|
btn.remove();
|
||||||
|
const box = document.createElement("input");
|
||||||
|
box.type = "checkbox";
|
||||||
|
document.body.append(box);
|
||||||
|
expect(isActivationTarget(press({ key: " " }, box))).toBe(true);
|
||||||
|
box.remove();
|
||||||
expect(isActivationTarget(press({ key: " " }))).toBe(false);
|
expect(isActivationTarget(press({ key: " " }))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type KeyScope =
|
|||||||
| "search"
|
| "search"
|
||||||
| "list"
|
| "list"
|
||||||
| "listDelete"
|
| "listDelete"
|
||||||
|
| "listSelect"
|
||||||
| "undo"
|
| "undo"
|
||||||
| "contextMenu"
|
| "contextMenu"
|
||||||
| "ytPlayer"
|
| "ytPlayer"
|
||||||
@@ -46,6 +47,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
|
|||||||
"search",
|
"search",
|
||||||
"list",
|
"list",
|
||||||
"listDelete",
|
"listDelete",
|
||||||
|
"listSelect",
|
||||||
]);
|
]);
|
||||||
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
|
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
|
||||||
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
||||||
@@ -138,6 +140,15 @@ export const SHORTCUTS = [
|
|||||||
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
|
// Its own scope beside `list`: only a list that can take an item out (a playlist's rows) registers
|
||||||
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
|
// it, so the sheet does not promise Delete on the feed or a Plex grid, where nothing answers it.
|
||||||
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
|
{ id: "remove", scope: "listDelete", keys: ["Delete"], labelKey: "keys.listRemove" },
|
||||||
|
// …and only a list whose rows can be selected registers these. Escape stays quiet in a field: the
|
||||||
|
// field's own Escape comes first, and a selection is not what the reader is in while typing.
|
||||||
|
{ id: "select", scope: "listSelect", keys: ["x"], labelKey: "keys.listSelect" },
|
||||||
|
{
|
||||||
|
id: "clearSelection",
|
||||||
|
scope: "listSelect",
|
||||||
|
keys: ["Escape"],
|
||||||
|
labelKey: "keys.listClearSelection",
|
||||||
|
},
|
||||||
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
||||||
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
||||||
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
||||||
@@ -264,15 +275,22 @@ export function isTypingTarget(e: KeyboardEvent): boolean {
|
|||||||
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const tag = (el.tagName || "").toLowerCase();
|
const tag = (el.tagName || "").toLowerCase();
|
||||||
|
// A box takes no letters, and a click on a table row's box leaves the focus on it: `j` went dead
|
||||||
|
// there. A slider stays a field, as its arrows are its own.
|
||||||
|
if (isBox(el)) return false;
|
||||||
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
|
return tag === "input" || tag === "textarea" || tag === "select" || !!el.isContentEditable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBox(el: HTMLElement | null): boolean {
|
||||||
|
return el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio");
|
||||||
|
}
|
||||||
|
|
||||||
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
|
/** True while focus is on something Space/Enter activates natively. Both players defer Space to a
|
||||||
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
|
* focused button ("Skip intro") rather than toggling playback; this is that shared rule. */
|
||||||
export function isActivationTarget(e: KeyboardEvent): boolean {
|
export function isActivationTarget(e: KeyboardEvent): boolean {
|
||||||
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
const el = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null);
|
||||||
const tag = (el?.tagName || "").toLowerCase();
|
const tag = (el?.tagName || "").toLowerCase();
|
||||||
return tag === "button" || tag === "a";
|
return tag === "button" || tag === "a" || isBox(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
|
/** One printable character that is neither a letter nor a digit (`[`, `]`, `,`). */
|
||||||
@@ -462,7 +480,7 @@ export function useActiveScopes(): KeyScope[] {
|
|||||||
// registers `page` in its own effect, which React runs after every child's — so by mount order
|
// registers `page` in its own effect, which React runs after every child's — so by mount order
|
||||||
// the module jumps would head the sheet, above the list keys of the page you are looking at.
|
// the module jumps would head the sheet, above the list keys of the page you are looking at.
|
||||||
const surfaces = live.filter((s) => !isPageLevelScope(s));
|
const surfaces = live.filter((s) => !isPageLevelScope(s));
|
||||||
const pageLevel = (["list", "listDelete", "search", "page"] as const).filter((s) =>
|
const pageLevel = (["list", "listSelect", "listDelete", "search", "page"] as const).filter((s) =>
|
||||||
live.includes(s),
|
live.includes(s),
|
||||||
);
|
);
|
||||||
return [...surfaces, ...pageLevel, "global"];
|
return [...surfaces, ...pageLevel, "global"];
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user