release: 0.84.0 (bug/hunt-2026-09)
This commit is contained in:
@@ -7,6 +7,40 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.84.0 — 2026-09-12
|
||||
|
||||
A bug-fix release, from a list you wrote while using Siftlode. Two of these were destroying your own data: a YouTube video rewound itself when you reopened it, and clearing a resume point on a half-watched Plex item told Plex nothing. Subtitles now appear in seconds instead of minutes, the Plex player's bitrate is a real measurement rather than a guess, and a channel you turn full history on for fills in front of you instead of looking frozen.
|
||||
|
||||
### Features
|
||||
|
||||
- Subtitles arrive in seconds. Picking an embedded subtitle track used to take about two and a half minutes on a large film, and switching from Hungarian to English cost the same again — because the cost was never the conversion, it was reading the whole file. It now reads a window around where you are, shows that first, and finishes the rest behind it; every text track rides the SAME read, so switching tracks is instant. On a 17 GB film: 154 s before, 8 s for the window now. Nothing blocks while it works, there is a “Preparing subtitles” pill instead of a frozen player, and a track that can never be shown finally says so.
|
||||
- The Plex player's info panel tells you the truth. Bitrate is measured from the bytes actually arriving, not copied off the manifest — and it shows a dash until there is something to measure rather than a confident wrong number. It also lists the audio and the subtitle track you are on, which it never did.
|
||||
- A new admin switch: loudness normalisation for Plex playback (Configuration → Plex). Off by default, because it makes a film markedly louder and narrows its dynamic range — that is a taste call, not a fix. On a 5.1 film it moved the measured loudness from −35.2 to −17.2 LUFS, and it does not touch A/V sync.
|
||||
- Collections can be sorted. The collection page had no sort control at all and always ordered by release year — it now carries the same Sort control as the library grid, with the same options, and your choice survives a reload without re-sorting the library behind it. A collection opened from a shared link also shows its real name instead of a raw id.
|
||||
- Remove a video from a playlist without leaving the player, and the player steps to whatever followed instead of closing. Above the move-to submenu there is now a one-click “move to the list you last moved to”.
|
||||
- Live YouTube search results can be sorted — by views, date or length, over the results already on screen, so sorting rearranges what you are looking at instead of running a different search.
|
||||
- The channel name in the player is a real link again: middle-click and Ctrl-click open it in a new tab, the way the YouTube icon beside it always did, while a plain click still takes the in-app route.
|
||||
|
||||
### Fixes
|
||||
|
||||
- A YouTube video no longer rewinds when you reopen it. Closing at 13:50 and coming back put you at 12:31 — the checkpoint reached the server but not the page's own cache, so the stale position won. It now goes forward: 811 → 816 → 825 s across reopens.
|
||||
- “Clear resume point” works on a half-watched Plex item. The push to Plex was skipped for exactly the case you would use it on, so Plex kept the old offset and handed it straight back on the next import. Verified against the real server, including a full 44,885-item re-import.
|
||||
- Turning full history on for a channel fills the page in front of you. The grid and the item count stayed frozen for the best part of a minute while the header counted up, because the request that starts the backfill returns before anything is stored. Measured: the count now moves 54 → 139 within about twelve seconds, no reload.
|
||||
- Starting or seeking a Plex film is about eight seconds faster. Every session start and every seek waited out a dead eight-second timer for a file the transcoder never writes — on a film with more than one audio track, which is most of them. A multi-audio session now starts in 0.9 s cold and 0.7 s on a seek.
|
||||
- A Plex session the server no longer has now recovers or says so, instead of spinning forever. A stream that kept being lost could restart itself indefinitely behind a spinner that never became an error.
|
||||
- A Plex stream request for something that cannot exist answers immediately rather than holding the server for 20–30 seconds. Enough of those at once could stall the whole app.
|
||||
- The Plex player no longer counts one viewing twice, and a failed “watched” mark no longer half-happens: it used to leave the item looking watched in the app while the server disagreed, or quietly mark it watched anyway a few seconds after telling you it had failed.
|
||||
- Dragging a Plex playlist into a new order sticks. Leaving and coming straight back showed the old order, and the next drag saved that.
|
||||
- A refused action says WHY, in the language you are reading. Several places showed a generic “couldn't do that” — or nothing at all — where the server had explained itself, including the Plex “Add to playlist” dialog, the collection editor and the upload dialog. The demo account gets its reason back too.
|
||||
- Dialogs behave for the keyboard. Opening one puts the cursor where you would expect (the jump-to box opens on its search field, not its close button), closing one hands the focus back to what you opened it from, and a dialog that closes underneath another one no longer steals the focus out of the one still on screen.
|
||||
- The regular micro-stutter in the Plex player is NOT Siftlode's, and this release does not pretend to fix it. Measured per frame: the player delivers every source frame with nothing dropped; the hold comes from a 23.976 fps film on a 120.0148 Hz display, and it reproduces identically outside Siftlode. Setting the display to 119.88 Hz removes it for every application, not just this one.
|
||||
|
||||
### Internal
|
||||
|
||||
- The round started from sixteen items written from real use, not from an audit. Eleven were fixed, one was measured and closed without a code change (the display cadence above), two are parked for a reproducible case, and one — playlist multi-select and bulk actions — was scoped out to its own sprint. The measurements are the point: item 8's “35 s” turned out to be one file's number, and the real law is bytes-ffmpeg-must-read divided by ~140 MB/s, which is why the fix is a window rather than a faster conversion.
|
||||
- Seven deep-review rounds followed, each reviewing the previous round's own fix commits: 32 → 24 → 43 → 31 → 22 → 26 → 18 raised. Round 7 was run TARGETED rather than full — four reviewers on the six surfaces round 6 had changed, and adversarial verification spent only where a wrong fix could re-break the previous round's — which is seven agents instead of about thirty-six for two HIGH findings. Every regression test added in a fix round was mutation-checked: reverted, run, and confirmed red for the defect it is named after. Thirteen of round 7's mutations were, and two were instructive — one guard no test could drive, and one round-6 test whose mock modelled a server that accepts a write and then serves the pre-write state.
|
||||
- What the eight-lane gate structurally cannot catch, and what this round taught about it: every real defect of the last three rounds was a wrong belief about an external system, not broken logic. ffmpeg substitutes the VARIANT before the sequence in its segment filenames, so a guard written the other way round was inert on every multi-audio file and a warm-up waited eight seconds for a name that is never written. A reclaim budget's window was anchored at the first failure rather than the last, so any failure cadence between one and two minutes reset it forever — invisible to two tests that spaced their cases at zero and ten minutes. jsdom performs no default Tab, so a focus test can be green with no focus trap installed at all. The conclusion carried forward is to measure the boundary when writing the code, not when reviewing it: one ffmpeg run costs pennies, a review round costs a session.
|
||||
|
||||
## 0.83.0 — 2026-09-09
|
||||
|
||||
Siftlode now opens on a page that answers one question: what should I watch right now. You say how much time you have — ten minutes, half an hour, a whole evening — and it gives you one suggestion, from YouTube or from Plex, with a button that picks up where you left off. Under it sits a shelf of the things you always come back to, which you fill yourself.
|
||||
|
||||
+5
-1
@@ -341,7 +341,11 @@ def require_human(user: User = Depends(current_user)) -> User:
|
||||
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
||||
where there's no scope check to lean on."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
# Structured, because `req` leaves 403 to the caller and `notifyActionError` only shows a
|
||||
# STRUCTURED detail — a plain string here reached the demo user as their caller's generic
|
||||
# "couldn't do that", with no hint that the demo account is the reason (round 6). Affordances
|
||||
# this gate sits behind are not all UI-gated on `is_demo` (the comfort shelf is not).
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -271,6 +271,11 @@ class Settings(BaseSettings):
|
||||
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
|
||||
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
|
||||
# encodes at source resolution.
|
||||
# Loudness normalisation of the downmixed stereo audio on the HLS path (EBU R128 `loudnorm`).
|
||||
# OFF by default: it makes a film ~11 dB louder and narrows its dynamic range, which is a taste
|
||||
# call, not a fix. Only affects files that go through remux/transcode — a direct-played file is
|
||||
# served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for.
|
||||
plex_audio_loudnorm: bool = False
|
||||
plex_transcode_preset: str = "veryfast"
|
||||
plex_transcode_crf: int = 20
|
||||
plex_transcode_max_height: int = 1080
|
||||
|
||||
+64
-4
@@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
# When started via uvicorn --log-config the "siftlode" logger is already configured
|
||||
# (see log_config.json). This block is a timestamped fallback for other entrypoints
|
||||
@@ -21,12 +24,14 @@ log = logging.getLogger("siftlode.app")
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.exc import DataError
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app import state
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.plex import stream as plex_stream
|
||||
from app.plex import subtitles as plex_subtitles
|
||||
from app.routes import (
|
||||
admin,
|
||||
audit as audit_routes,
|
||||
@@ -77,6 +82,7 @@ async def lifespan(app: FastAPI):
|
||||
# alone doesn't buy that; not waiting does. The sweep only touches directories no live session
|
||||
# owns, so racing it against early playback is safe.
|
||||
sweep = asyncio.create_task(_sweep_hls_orphans())
|
||||
reaper = asyncio.create_task(_reap_hls_sessions())
|
||||
start_scheduler()
|
||||
try:
|
||||
yield
|
||||
@@ -86,10 +92,36 @@ async def lifespan(app: FastAPI):
|
||||
# pending ("Task was destroyed but it is pending!" — noise that reads like a bug during
|
||||
# triage). Cancelling does NOT interrupt the rmtree thread itself; this only makes our own
|
||||
# bookkeeping honest.
|
||||
sweep.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await sweep
|
||||
for task in (sweep, reaper):
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
shutdown_scheduler()
|
||||
# Kills a running subtitle extraction: its worker would otherwise hold interpreter exit for
|
||||
# the rest of the read (see plex_subtitles.shutdown).
|
||||
plex_subtitles.shutdown()
|
||||
|
||||
|
||||
# How often idle HLS sessions are reaped (see plex/stream.py `reap_idle`).
|
||||
_HLS_REAP_EVERY_S = 60.0
|
||||
|
||||
|
||||
async def _reap_hls_sessions() -> None:
|
||||
"""The periodic reaper plex/stream.py's docstring promises. `reap_idle` used to run only inside
|
||||
the NEXT session request, so after the last playback of the day an abandoned session — its
|
||||
ffmpeg still working through the film, its directory a whole segmented copy of it — lived until
|
||||
somebody pressed play again or the process restarted.
|
||||
|
||||
A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open
|
||||
player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer
|
||||
invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead
|
||||
held one whole segmented copy of a film per item on the download volume (round 5)."""
|
||||
while True:
|
||||
await asyncio.sleep(_HLS_REAP_EVERY_S)
|
||||
try:
|
||||
await asyncio.to_thread(plex_stream.reap_idle)
|
||||
except Exception:
|
||||
log.exception("HLS session reap failed")
|
||||
|
||||
|
||||
async def _sweep_hls_orphans() -> None:
|
||||
@@ -108,6 +140,34 @@ async def _sweep_hls_orphans() -> None:
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""FastAPI's own 422, in its own shape — except that it can be ENCODED. The JSON parser accepts
|
||||
`NaN`/`Infinity`, a float field refuses them, and the refusal echoes the value back: the default
|
||||
handler then failed to serialise its own error (JSON has no NaN) and the request became a 500
|
||||
with a traceback. A non-finite value is echoed as its name instead."""
|
||||
finite = {float: lambda x: x if math.isfinite(x) else str(x)}
|
||||
return JSONResponse(
|
||||
status_code=422, content={"detail": jsonable_encoder(exc.errors(), custom_encoder=finite)}
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(DataError)
|
||||
async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse:
|
||||
"""A NUL byte in a string the request sent is a 422, on every route at once. Postgres cannot hold
|
||||
NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or
|
||||
an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection
|
||||
search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not
|
||||
hold: round 3 guarded the by-rating_key lookup and missed the rest. Any other DataError is left
|
||||
alone — re-raised, it stays the 500 it always was."""
|
||||
if "NUL (0x00)" not in str(exc.orig):
|
||||
raise exc
|
||||
return JSONResponse(
|
||||
status_code=422, content={"detail": {"code": "invalid_input", "reason": "nul"}}
|
||||
)
|
||||
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
|
||||
@@ -24,6 +24,11 @@ from app.db import Base
|
||||
from app.utils import canonical_email
|
||||
|
||||
|
||||
# The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint
|
||||
# routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500.
|
||||
POSITION_MAX_S = 2**31 - 1
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
||||
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
||||
@@ -423,6 +428,7 @@ class VideoState(Base, UpdatedAtMixin):
|
||||
# status: a partially-watched video keeps status "new" but a non-zero position, so
|
||||
# it can render a progress bar and match the "in progress" feed filter. 0 = not
|
||||
# started / finished (trivially-early and near-finished positions are not stored).
|
||||
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
|
||||
position_seconds: Mapped[int] = mapped_column(
|
||||
Integer, default=0, server_default="0", nullable=False
|
||||
)
|
||||
|
||||
@@ -197,7 +197,12 @@ class PlexClient:
|
||||
)
|
||||
|
||||
def unscrobble(self, rating_key: str) -> None:
|
||||
"""Mark the item *unwatched* on the token's Plex account (clears viewCount)."""
|
||||
"""Mark the item *unwatched* on the token's Plex account.
|
||||
|
||||
Clears the view count AND the resume point (`viewOffset`) — measured against the live server
|
||||
on 2026-09-09, from both a watched+offset state and an offset-only one. This one call is the
|
||||
whole un-mark; `/:/timeline?time=0` and `/:/progress?time=0` were both tried there and do
|
||||
NOT clear an offset, so do not reach for them."""
|
||||
self._write(
|
||||
"GET",
|
||||
f"/:/unscrobble?identifier={self._LIBRARY_IDENTIFIER}"
|
||||
|
||||
@@ -11,5 +11,11 @@ _M = TypeVar("_M")
|
||||
def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None:
|
||||
"""The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex
|
||||
tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call
|
||||
sites keep their `Model | None` static type."""
|
||||
sites keep their `Model | None` static type.
|
||||
|
||||
A key with a NUL in it (`%00` in a path segment) names no row — a 404, like any unknown key.
|
||||
Postgres refuses NUL in text, so asking would be a DataError; the app-wide handler turns the
|
||||
places that DO ask (lists and filters compared to rating_key) into a 422 (see main.py)."""
|
||||
if "\x00" in str(rating_key):
|
||||
return None
|
||||
return db.execute(select(model).where(model.rating_key == str(rating_key))).scalars().first()
|
||||
|
||||
@@ -14,6 +14,22 @@ from app.models import PlexItem, PlexLibrary
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
|
||||
# The ONE Plex sort vocabulary, shared by every surface that offers the Sort control (the browse grid
|
||||
# and the collection info page) and mirrored by the frontend's `plexSort.ts`. It lives here rather than
|
||||
# inside a router because the two surfaces order DIFFERENT things — browse orders a union subquery in
|
||||
# SQL, a collection orders its already-loaded member list in Python — and a key that one of them cannot
|
||||
# honour would put an option in the UI that silently does nothing. Tests pin both implementations to
|
||||
# this tuple, so adding a key here fails the gate until every surface can order by it.
|
||||
PLEX_SORT_KEYS: tuple[str, ...] = (
|
||||
"added",
|
||||
"lastwatched",
|
||||
"release",
|
||||
"year",
|
||||
"rating",
|
||||
"duration",
|
||||
"title",
|
||||
)
|
||||
|
||||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||||
#
|
||||
# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
|
||||
|
||||
+206
-16
@@ -21,10 +21,12 @@ CPU-only prod host; only P3's full transcode is CPU-heavy.
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
@@ -55,6 +57,10 @@ _SEG_SECONDS = 6
|
||||
# worse than the old zero-buffer behaviour).
|
||||
_WARMUP_SEGMENTS = 3
|
||||
_WARMUP_SEG_TIMEOUT_S = 8.0
|
||||
# The HLS variant ffmpeg gives the VIDEO rendition of a multi-audio session. `-var_stream_map` names
|
||||
# each audio rendition (`name:a0`, `a1`, …) but leaves the video one unnamed, so ffmpeg falls back to
|
||||
# its index for that one. See `segment_name` for why this is a constant and not a comment.
|
||||
_VIDEO_VARIANT = "0"
|
||||
_SESSION_IDLE_S = 600 # reap a session with no access for this long
|
||||
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session);
|
||||
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names
|
||||
@@ -94,11 +100,19 @@ class HlsSession:
|
||||
directory: Path,
|
||||
proc: subprocess.Popen,
|
||||
start_s: float,
|
||||
entry: str,
|
||||
variants: "Sequence[str]",
|
||||
transcode: bool = False,
|
||||
hw: bool = False,
|
||||
tonemap: bool = False,
|
||||
):
|
||||
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed
|
||||
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the
|
||||
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key,
|
||||
# was answered 200 with the NEW session's twelfth segment, produced from a different offset
|
||||
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
|
||||
# (deep-review round 5). A request naming a session that is no longer registered is a 404,
|
||||
# which the player recovers from by restarting at the playhead.
|
||||
self.id = secrets.token_urlsafe(9)
|
||||
self.key = key
|
||||
self.dir = directory
|
||||
self.proc = proc
|
||||
@@ -113,7 +127,13 @@ class HlsSession:
|
||||
# subtitle cue-shift both key off the true content position — no drift. Provisional = X until
|
||||
# the first segment is probed.
|
||||
self.media_start_s = start_s
|
||||
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
|
||||
# The rendition names this session's ffmpeg was told to write — () for a single-audio
|
||||
# session. Held on the session because it is what `artifact_may_arrive` needs to tell a name
|
||||
# this session CAN produce from one belonging to the other argv branch (see it).
|
||||
self.variants = tuple(variants)
|
||||
# The playlist filename hls.js should load: the master of a multi-audio session, else the
|
||||
# single index. Derived, not passed, so it cannot disagree with the argv.
|
||||
self.entry = _MASTER_PL if self.variants else _INDEX_PL
|
||||
self.last_access = time.time()
|
||||
|
||||
|
||||
@@ -271,6 +291,30 @@ def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
|
||||
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
|
||||
|
||||
|
||||
# Audio is ALWAYS re-encoded on the HLS path: the source is typically AC3/DTS 5.1, which no browser
|
||||
# plays, so it is downmixed to stereo AAC. `-ac 2` uses ffmpeg's standard downmix matrix; nothing is
|
||||
# normalised, which is why a film mastered for a cinema arrives with whispered dialogue and deafening
|
||||
# action (bug hunt 2026-09, item 14).
|
||||
_AUDIO_ENC = ["-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
# Optional loudness normalisation (admin knob `plex_audio_loudnorm`, default OFF). EBU R128 single-pass
|
||||
# `loudnorm` — two-pass is impossible here, since a seek-restart session must start emitting segments
|
||||
# immediately and cannot analyse the file first.
|
||||
#
|
||||
# The downmix is FORCED FIRST (`aformat=channel_layouts=stereo`) rather than left to `-ac 2`: measuring
|
||||
# 5.1 and then folding it down would normalise something other than what the viewer hears. `loudnorm`
|
||||
# runs its own resampler at 192 kHz internally, so the chain resamples back to 48 kHz for the AAC
|
||||
# encoder. Measured on a real 5.1 AC3 film (Westworld S1E6, 60 s from 15:00): I -27.6 → -16.6 LUFS,
|
||||
# LRA 13.3 → 9.8 LU — the quiet/loud gap narrows, and everything gets ~11 dB louder, so expect to turn
|
||||
# the volume down after switching this on. The filter's 3 s lookahead does NOT desync A/V: measured
|
||||
# under this pipeline's own `-copyts`, the audio start PTS is identical with and without it.
|
||||
_LOUDNORM_AF = "aformat=channel_layouts=stereo,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000"
|
||||
|
||||
|
||||
def _aenc_opts(loudnorm: bool) -> list[str]:
|
||||
"""Audio encoder args for one output, with the normalisation filter when the admin enabled it."""
|
||||
return [*_AUDIO_ENC, *(["-af", _LOUDNORM_AF] if loudnorm else [])]
|
||||
|
||||
|
||||
def _venc_opts(
|
||||
transcode: bool,
|
||||
hw: bool,
|
||||
@@ -446,6 +490,7 @@ def _ffmpeg_cmd(
|
||||
preset: str = "veryfast",
|
||||
crf: int = 20,
|
||||
max_height: int = 0,
|
||||
loudnorm: bool = False,
|
||||
) -> list[str]:
|
||||
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
|
||||
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
|
||||
@@ -469,11 +514,15 @@ def _ffmpeg_cmd(
|
||||
if audio_langs:
|
||||
# MULTI-RENDITION: map EVERY audio track as an alternate HLS rendition in one group, so hls.js
|
||||
# switches audio CLIENT-SIDE on the same timeline (no session restart, no drift) — the premium
|
||||
# audio-switch path. Produces master.m3u8 + stream_%v.m3u8 (v0=video, v1..=audio) + seg_%v_%d.ts.
|
||||
# audio-switch path. Produces master.m3u8 + one stream_%v.m3u8 and seg_%v_*.ts per variant,
|
||||
# where %v is the video rendition's index ("0") and each audio rendition's `name:` (a0, a1,
|
||||
# …). Those names are spelled by `segment_name` / `variant_playlist_name` below, which the
|
||||
# guard and the warm-up read back — see them for the measurement behind the ordering.
|
||||
args += ["-map", "0:v:0"]
|
||||
for i in range(len(audio_langs)):
|
||||
args += ["-map", f"{ai}:a:{i}?"]
|
||||
args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
# One `-af` covers EVERY audio output of this file, so each rendition is normalised.
|
||||
args += ["-sn", *venc, *_aenc_opts(loudnorm)]
|
||||
var = ["v:0,agroup:aud"]
|
||||
for i, lang in enumerate(audio_langs):
|
||||
entry = f"a:{i},agroup:aud,name:a{i}"
|
||||
@@ -482,23 +531,25 @@ def _ffmpeg_cmd(
|
||||
if i == 0:
|
||||
entry += ",default:yes"
|
||||
var.append(entry)
|
||||
# The name patterns come from the vocabulary functions, with ffmpeg's own placeholders passed
|
||||
# through them — so the producer and the parser cannot spell the layout differently (round 7).
|
||||
args += _HLS_TAIL + [
|
||||
"-master_pl_name",
|
||||
"master.m3u8",
|
||||
_MASTER_PL,
|
||||
"-var_stream_map",
|
||||
" ".join(var),
|
||||
"-hls_segment_filename",
|
||||
str(out_dir / "seg_%v_%d.ts"),
|
||||
str(out_dir / "stream_%v.m3u8"),
|
||||
str(out_dir / segment_name("%d", "%v")),
|
||||
str(out_dir / variant_playlist_name("%v")),
|
||||
]
|
||||
else:
|
||||
ao = audio_ord if audio_ord is not None else 0
|
||||
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
|
||||
args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||
args += [*venc, *_aenc_opts(loudnorm)]
|
||||
args += _HLS_TAIL + [
|
||||
"-hls_segment_filename",
|
||||
str(out_dir / "seg_%d.ts"),
|
||||
str(out_dir / "index.m3u8"),
|
||||
str(out_dir / segment_name("%d")),
|
||||
str(out_dir / _INDEX_PL),
|
||||
]
|
||||
return args
|
||||
|
||||
@@ -579,16 +630,26 @@ def start_session(
|
||||
src = paths.local_media_path(db, item.file_path)
|
||||
if src is None:
|
||||
return None
|
||||
# Read once, here: the knob has to be identical for the directory name and the ffmpeg argv, and a
|
||||
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
|
||||
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
|
||||
key = item.rating_key
|
||||
start_s = max(0.0, float(start_s))
|
||||
audio_langs = _probe_audio_langs(src) if multi else []
|
||||
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
|
||||
tag = "multi" if is_multi else str(audio_ord)
|
||||
# The rendition names the argv below will produce — one source for the argv, the warm-up's
|
||||
# segment names, the entry playlist and the artifact guard.
|
||||
variants = session_variants(audio_langs if is_multi else None)
|
||||
# Loudness normalisation changes the AUDIO THE SEGMENTS CONTAIN, so it belongs in the directory
|
||||
# name beside the other parameters that do (offset, audio track, A/V offset): a session left over
|
||||
# from before the admin flipped the knob must not be mistaken for one that honours it. Appended to
|
||||
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
|
||||
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
|
||||
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
|
||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
|
||||
|
||||
def _seg(i: int) -> Path:
|
||||
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
|
||||
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
|
||||
return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
|
||||
|
||||
def _spawn(
|
||||
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
|
||||
@@ -632,6 +693,7 @@ def start_session(
|
||||
preset=preset,
|
||||
crf=crf,
|
||||
max_height=max_height,
|
||||
loudnorm=loudnorm,
|
||||
),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -642,7 +704,7 @@ def start_session(
|
||||
directory,
|
||||
proc,
|
||||
start_s,
|
||||
"master.m3u8" if is_multi else "index.m3u8",
|
||||
variants,
|
||||
transcode=transcode,
|
||||
hw=hw,
|
||||
tonemap=tonemap,
|
||||
@@ -714,6 +776,101 @@ def current_session(key: str) -> HlsSession | None:
|
||||
return s
|
||||
|
||||
|
||||
# The names ffmpeg writes into a session directory, in ONE place — and "one place" means the argv
|
||||
# BUILDS its patterns from these same functions (`segment_name("%d", "%v")`), not that a comment
|
||||
# says so. Getting this wrong is invisible in both directions: a name we failed to anticipate used
|
||||
# to fall back to "wait and see" (round 6's HIGH), and now that the rule is positive it falls back
|
||||
# to a 404 instead — so the producer and the parser have to be literally the same spelling.
|
||||
#
|
||||
# MEASURED (ffmpeg 7.1.5 in the API container — the binary that actually writes these files — and
|
||||
# re-checked on 8.1.1): `-hls_segment_filename seg_%v_%d.ts` substitutes `%v` with the VARIANT and
|
||||
# `%d` with the SEQUENCE — variant FIRST. A dual-audio session writes seg_0_0.ts… (video, the
|
||||
# unnamed variant) plus seg_a0_0.ts… / seg_a1_0.ts… (the `name:aN` audio renditions), and the
|
||||
# playlists beside them are master.m3u8 + stream_0/a0/a1.m3u8. Single-audio uses `seg_%d.ts` +
|
||||
# index.m3u8, with no variant field at all.
|
||||
#
|
||||
# The two argv branches are DISJOINT vocabularies, which is why the guard below is keyed to the
|
||||
# SESSION and not to HLS in general: a single-audio session can never write `master.m3u8`,
|
||||
# `stream_*.m3u8` or any `seg_<variant>_<n>.ts`, and a multi-audio one can never write `index.m3u8`
|
||||
# or `seg_<n>.ts`. Accepting the union meant a hopeless name of the other shape was waited out for
|
||||
# the full playlist/segment timeout — the threadpool parking this guard exists to end (round 7).
|
||||
_MASTER_PL = "master.m3u8"
|
||||
_INDEX_PL = "index.m3u8"
|
||||
_SEG_NAME_RE = re.compile(r"^seg_(?:([0-9a-z]{1,4})_)?(\d{1,9})\.ts$")
|
||||
|
||||
|
||||
def session_variants(audio_langs: "Sequence[str | None] | None") -> tuple[str, ...]:
|
||||
"""The `-var_stream_map` rendition names a session will write, or () for a single-audio one.
|
||||
|
||||
The video rendition is left unnamed in the map, so ffmpeg falls back to its index
|
||||
(`_VIDEO_VARIANT`); each audio rendition is named `aN` in track order."""
|
||||
if not audio_langs:
|
||||
return ()
|
||||
return (_VIDEO_VARIANT, *(f"a{i}" for i in range(len(audio_langs))))
|
||||
|
||||
|
||||
def segment_name(seq: "int | str", variant: "str | None" = None) -> str:
|
||||
"""The file ffmpeg writes for the `seq`-th segment of `variant`.
|
||||
|
||||
`variant=None` is a single-audio session, whose argv carries no `%v` field at all. `seq` and
|
||||
`variant` are typed loosely because `_ffmpeg_cmd` builds its own `-hls_segment_filename` pattern
|
||||
through this function, passing ffmpeg's `"%d"` / `"%v"` placeholders — that is what keeps the
|
||||
producer and the parser from drifting, rather than a comment asking the next editor not to."""
|
||||
return f"seg_{variant}_{seq}.ts" if variant else f"seg_{seq}.ts"
|
||||
|
||||
|
||||
def variant_playlist_name(variant: str) -> str:
|
||||
"""The sub-playlist ffmpeg writes for one rendition of a multi-audio session."""
|
||||
return f"stream_{variant}.m3u8"
|
||||
|
||||
|
||||
def playlist_names(variants: "Sequence[str]") -> frozenset[str]:
|
||||
"""Every playlist name this session's argv can write — the master plus one per rendition, or
|
||||
just `index.m3u8` for a single-audio session."""
|
||||
if not variants:
|
||||
return frozenset({_INDEX_PL})
|
||||
return frozenset({_MASTER_PL, *(variant_playlist_name(v) for v in variants)})
|
||||
|
||||
|
||||
def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
|
||||
"""Whether waiting for `filename` can possibly end in a file OF THIS SESSION.
|
||||
|
||||
Positive rule, not a blocklist: a session produces segments and playlists and NOTHING else, so
|
||||
any other name is refused at once. ffmpeg also produces segments strictly in order, so a segment
|
||||
whose PREDECESSOR does not exist yet is not the next one — it is beyond the generated region,
|
||||
and no amount of waiting will conjure it. Playlists are the one thing that must still be waited
|
||||
for: they are rewritten continuously and have no ordering to read.
|
||||
|
||||
Keyed to the SESSION's own vocabulary, not to HLS's: the two argv branches are disjoint, so a
|
||||
name belonging to the other shape is as hopeless here as a random string. Reading those as "an
|
||||
HLS name, so wait and see" left the whole class reachable — measured through the real route
|
||||
against a live single-audio session, `master.m3u8` and `stream_q7z.m3u8` were 404s after 30.0 s
|
||||
and `seg_a0_0.ts` / `seg_zzzz_0.ts` after 20.1 s each (deep-review round 7).
|
||||
|
||||
Saying no at once matters because the route that waits is a SYNC one: it holds a thread off the
|
||||
~40-slot pool, and a pooled DB connection with it, for the whole timeout. An ordinary seek past
|
||||
the produced region was enough to park one for 20 s, and forty concurrent requests for a name
|
||||
that will never exist parked the whole API (deep-review round 5). Round 5's guard read only
|
||||
single-audio segment names, so both halves of that were still fully reachable: every multi-audio
|
||||
name fell through, and so did any `.m3u8`/`.vtt` name at all."""
|
||||
if filename in playlist_names(s.variants):
|
||||
return True
|
||||
m = _SEG_NAME_RE.match(filename)
|
||||
if m is None:
|
||||
return False
|
||||
variant, seq = m.group(1), int(m.group(2))
|
||||
# The variant field must be one THIS session writes: present and known for a multi-audio
|
||||
# session, absent for a single-audio one.
|
||||
if variant not in (s.variants or (None,)):
|
||||
return False
|
||||
if seq == 0:
|
||||
return True
|
||||
try:
|
||||
return (s.dir / segment_name(seq - 1, variant)).exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool:
|
||||
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
|
||||
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
|
||||
@@ -785,14 +942,47 @@ def sweep_orphans(older_than: float | None = None) -> int:
|
||||
return dropped
|
||||
|
||||
|
||||
def reap_idle() -> int:
|
||||
# How long a session whose ffmpeg has FINISHED may go unfetched before a new session request reaps
|
||||
# it (see reap_idle).
|
||||
_FINISHED_IDLE_S = 30
|
||||
|
||||
|
||||
def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
|
||||
"""Kill sessions nobody has said they still want for `_SESSION_IDLE_S` — or, once their ffmpeg
|
||||
has finished, for `finished_idle_s`.
|
||||
|
||||
A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so
|
||||
ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist,
|
||||
and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that
|
||||
killed a one-minute pause, and playback died ~30 s after resuming (deep-review round 4).
|
||||
|
||||
What makes the short rule safe again is that fetching is no longer the only signal: a player
|
||||
parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a
|
||||
viewer who is merely paused says so far more often than this rule asks (round 5). The heartbeat
|
||||
stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both
|
||||
states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who
|
||||
scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing": round 6
|
||||
found that claim in two comments and not in the code. Round 4's blanket
|
||||
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
|
||||
one a whole segmented copy of the film on a volume shared with the download center."""
|
||||
now = time.time()
|
||||
dropped = 0
|
||||
with _lock:
|
||||
for key, s in list(_sessions.items()):
|
||||
done = s.proc.poll() is not None
|
||||
if now - s.last_access > _SESSION_IDLE_S or (done and now - s.last_access > 30):
|
||||
idle = now - s.last_access
|
||||
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
|
||||
_kill(s)
|
||||
_sessions.pop(key, None)
|
||||
dropped += 1
|
||||
return dropped
|
||||
|
||||
|
||||
def touch_session(key: str) -> None:
|
||||
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move
|
||||
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
|
||||
viewer's session alive was to keep EVERY abandoned one just as long."""
|
||||
with _lock:
|
||||
s = _sessions.get(key)
|
||||
if s is not None:
|
||||
s.last_access = time.time()
|
||||
|
||||
+730
-14
@@ -1,15 +1,57 @@
|
||||
"""WebVTT conversion, HLS-clock time-shift, and on-disk cache for Plex subtitle streams.
|
||||
"""WebVTT conversion, HLS-clock time-shift, and the on-disk cache + background extractor for Plex
|
||||
subtitle streams.
|
||||
|
||||
Text subtitles (embedded or external) are turned into WebVTT so the browser <track> can render them;
|
||||
bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared
|
||||
Plex on-disk cache root (``cache.CACHE_DIR``).
|
||||
|
||||
WHY THERE IS A BACKGROUND EXTRACTOR HERE — measured on prod, 2026-09-10, against a 17.06 GB 1080p
|
||||
MKV with two embedded SubRip tracks on the 81 TB array:
|
||||
|
||||
=========================================================== ========
|
||||
full pass, ONE track (what this module used to do) 154 s
|
||||
full pass, BOTH text tracks in a SINGLE ffmpeg run 111 s
|
||||
`-c:s copy` instead of converting to WebVTT 143 s
|
||||
from 1:00:00 to the end of the file 66 s
|
||||
a 630 s WINDOW at 1:00:00, both tracks 8 s
|
||||
=========================================================== ========
|
||||
|
||||
The conversion is not the cost (dropping it saved 7%): ffmpeg has to stream every byte of the
|
||||
container between its seek point and its stop point, and the array delivers ~140 MB/s — so the time
|
||||
is (bytes read) / 140 MB/s, and the ONLY lever is reading less. Hence:
|
||||
|
||||
* extract a WINDOW around the playhead first — the viewer sees subtitles in seconds — then complete
|
||||
the whole track in the background so every later seek is instant and the next session is free;
|
||||
* extract EVERY embedded text track in ONE pass, because the read is shared: picking Hungarian used
|
||||
to cost a full read and then switching to English cost another one (the "hektikus" report);
|
||||
* never do any of it inside a request (it froze the <track> with no feedback AND held an API worker
|
||||
thread for the whole read).
|
||||
|
||||
`-copyts` CANNOT be used for a window: it makes ffmpeg ignore `-t`/`-to` (measured — a [0, 600 s]
|
||||
window still took the full 145 s and emitted the whole track), so windows are extracted on a
|
||||
zero-based clock and shifted back to absolute time here with `shift_vtt`.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import PlexItem
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex.cache import CACHE_DIR
|
||||
from app.plex.client import PlexError
|
||||
from app.plex.client import PlexClient, PlexError
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
|
||||
BITMAP_SUBS = {
|
||||
@@ -31,6 +73,9 @@ def srt_to_vtt(text: str) -> str:
|
||||
|
||||
|
||||
def _vtt_ts_to_s(ts: str) -> float:
|
||||
# Also reads ffmpeg's spelling of a NEGATIVE time (a windowed pass, see `_ffmpeg_extract`): its
|
||||
# webvtt muxer splits the milliseconds with C's truncating division, so every field carries the
|
||||
# sign — -5.005 s is `00:-5.-05`, -65.3 s is `-1:-5.-300` — and summing the signed fields is exact.
|
||||
parts = ts.split(":")
|
||||
h = "0" if len(parts) == 2 else parts[0]
|
||||
m, rest = parts[-2], parts[-1]
|
||||
@@ -39,16 +84,12 @@ def _vtt_ts_to_s(ts: str) -> float:
|
||||
|
||||
|
||||
def _vtt_s_to_ts(x: float) -> str:
|
||||
x = max(0.0, x)
|
||||
h = int(x // 3600)
|
||||
x -= h * 3600
|
||||
m = int(x // 60)
|
||||
x -= m * 60
|
||||
s = int(x)
|
||||
ms = int(round((x - s) * 1000))
|
||||
if ms >= 1000:
|
||||
s += 1
|
||||
ms -= 1000
|
||||
# Rounded to whole milliseconds FIRST, then split: rounding the fraction last carried a rounded-up
|
||||
# millisecond into the seconds and no further, so 59.9996 came out as `00:00:60.000` — a timestamp
|
||||
# the WebVTT parser rejects, silently dropping that cue.
|
||||
h, rest = divmod(int(round(max(0.0, x) * 1000)), 3_600_000)
|
||||
m, rest = divmod(rest, 60_000)
|
||||
s, ms = divmod(rest, 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||
|
||||
|
||||
@@ -109,7 +150,7 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
||||
"webvtt",
|
||||
"pipe:1",
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True)
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S)
|
||||
if r.returncode != 0 or not r.stdout:
|
||||
raise PlexError(
|
||||
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
|
||||
@@ -125,11 +166,686 @@ def cache_read(key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def cache_write(key: str, vtt: str) -> None:
|
||||
def cache_write(key: str, vtt: str) -> bool:
|
||||
"""Atomically replace the cached VTT; False when it could not be written (`publish` must then
|
||||
not claim the region it was carrying)."""
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CACHE_DIR / f"{key}.vtt.tmp"
|
||||
tmp.write_text(vtt, encoding="utf-8")
|
||||
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# --- cache identity -----------------------------------------------------------------------------
|
||||
|
||||
# The cached VTT holds ABSOLUTE cue times (per rating_key + subtitle ordinal); the per-session shift
|
||||
# is applied on the way out, so a resume/seek to any offset reuses the same file. Beside it, a small
|
||||
# JSON sidecar records WHICH absolute-time ranges the file actually covers — a windowed extraction
|
||||
# publishes a usable but incomplete track, and "incomplete" must never be mistaken for "all there is".
|
||||
_FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can't hold infinity)
|
||||
|
||||
|
||||
def cache_key(rating_key: str, sub_ord: int) -> str:
|
||||
return f"sub_{rating_key}_{sub_ord}"
|
||||
|
||||
|
||||
def _vtt_path(key: str) -> Path:
|
||||
return CACHE_DIR / f"{key}.vtt"
|
||||
|
||||
|
||||
def _meta_path(key: str) -> Path:
|
||||
return CACHE_DIR / f"{key}.json"
|
||||
|
||||
|
||||
def read_meta(key: str) -> tuple[list[list[float]], bool]:
|
||||
"""Covered absolute-time ranges + whether the track is fully extracted. A VTT with no sidecar
|
||||
(written by an older build, which only ever cached complete tracks) counts as complete."""
|
||||
try:
|
||||
raw = json.loads(_meta_path(key).read_text(encoding="utf-8"))
|
||||
return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
|
||||
|
||||
|
||||
def _exists(p: Path) -> bool:
|
||||
"""`Path.exists` that answers False for a path the OS refuses outright: pathlib swallows only
|
||||
ENOENT-like errors, so an over-long name (ENAMETOOLONG, from an absurd rating_key or ordinal)
|
||||
escaped as a 500 from the status route."""
|
||||
try:
|
||||
return p.exists()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _write_meta(key: str, ranges: list[list[float]], complete: bool) -> None:
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CACHE_DIR / f"{key}.json.tmp"
|
||||
tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8")
|
||||
tmp.replace(_meta_path(key))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def generation(key: str) -> int:
|
||||
"""Monotonic id of the cached file's current content. It goes into the <track> URL so that when a
|
||||
window is superseded by a wider one the browser refetches instead of keeping the short VTT."""
|
||||
try:
|
||||
return _vtt_path(key).stat().st_mtime_ns
|
||||
except (OSError, ValueError): # ValueError: a NUL in the key — `stat` refuses the path outright
|
||||
return 0
|
||||
|
||||
|
||||
def merge_ranges(ranges: list[list[float]]) -> list[list[float]]:
|
||||
"""Sort + coalesce touching/overlapping covered ranges."""
|
||||
out: list[list[float]] = []
|
||||
for a, b in sorted((r for r in ranges if r[1] > r[0]), key=lambda r: r[0]):
|
||||
if out and a <= out[-1][1]:
|
||||
out[-1][1] = max(out[-1][1], b)
|
||||
else:
|
||||
out.append([a, b])
|
||||
return out
|
||||
|
||||
|
||||
def covers(ranges: list[list[float]], at: float) -> bool:
|
||||
return any(a <= at <= b for a, b in ranges)
|
||||
|
||||
|
||||
def merge_vtt(*sources: str) -> str:
|
||||
"""Union of the cue blocks of several absolute-time VTTs, ordered by start time.
|
||||
|
||||
Two extraction passes over the same file emit identical cues for the region they share, so they
|
||||
are de-duplicated — but NOT on the raw timing text. The two passes do not spell a timestamp the
|
||||
same way: a window comes back through `shift_vtt`, which always writes `HH:MM:SS.mmm`, while a
|
||||
full pass is ffmpeg's own output, and its webvtt muxer drops the hour below one hour
|
||||
(`10:00.123`). Keyed on the text, every cue of a window in the first hour survived twice and the
|
||||
viewer saw each line stacked on itself. So each timing line is parsed and re-emitted in one
|
||||
canonical form, and the key is that canonical timing PLUS the cue text: two different cues that
|
||||
happen to share a time (two speakers, a sign over dialogue) are both kept."""
|
||||
seen: set[str] = set()
|
||||
cues: list[tuple[float, str]] = []
|
||||
for src in sources:
|
||||
for block in re.split(r"\n[ \t]*\n", src or ""):
|
||||
canon = _canonical_cue(block.strip())
|
||||
if canon is None:
|
||||
continue # header / NOTE / STYLE — the merged file gets its own single header
|
||||
start, key, text = canon
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cues.append((start, text))
|
||||
cues.sort(key=lambda c: c[0])
|
||||
return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n"
|
||||
|
||||
|
||||
def _canonical_cue(block: str) -> tuple[float, str, str] | None:
|
||||
"""(start seconds, identity, the block with its timing line canonicalised) — or None for a block
|
||||
that is not a cue. The identity leaves out anything before the timing line (an SRT-style cue
|
||||
number), which is a label, not content."""
|
||||
lines = block.split("\n")
|
||||
for i, ln in enumerate(lines):
|
||||
if "-->" not in ln:
|
||||
continue
|
||||
left, _, right = ln.partition("-->")
|
||||
rparts = right.strip().split(None, 1)
|
||||
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
|
||||
try:
|
||||
st = _vtt_ts_to_s(left.strip())
|
||||
en = _vtt_ts_to_s(rparts[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}"
|
||||
return st, "\n".join(lines[i:]), "\n".join(lines)
|
||||
return None
|
||||
|
||||
|
||||
def _last_cue_end(vtt: str) -> float:
|
||||
"""Where the last cue of a VTT ends — how far a pass that died early provably got."""
|
||||
end = 0.0
|
||||
for ln in vtt.split("\n"):
|
||||
if "-->" not in ln:
|
||||
continue
|
||||
try:
|
||||
end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0]))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return end
|
||||
|
||||
|
||||
def publish(key: str, vtt: str, covered: tuple[float, float], complete: bool) -> bool:
|
||||
"""Merge a freshly extracted region into the cached track and widen its covered ranges. False
|
||||
when the region did not land (the cache is unwritable, or the VTT there cannot be read) — the
|
||||
caller must then record a FAILED pass: returning quietly let the next poll schedule the same read
|
||||
again, a whole-container pass every time, while the player said "preparing" for good.
|
||||
|
||||
A read-merge-write, so it runs under `_PUBLISH_LOCK`: two jobs publish into the same keys (a
|
||||
window and the full pass, and every embedded track of the file rides each of them), and without
|
||||
the lock the later writer drops whatever the earlier one merged — permanently, once `complete` is
|
||||
set. It is also the only writer of the cache files, which is what keeps their fixed `.tmp`
|
||||
staging names safe."""
|
||||
with _PUBLISH_LOCK:
|
||||
old = cache_read(key)
|
||||
if old is None and _vtt_path(key).exists():
|
||||
# There, but unreadable (`cache_read` swallows the OSError). Merging into "" would replace
|
||||
# a whole track with this one region while its sidecar still claimed all of it.
|
||||
log.warning("subtitle cache %s unreadable; not publishing into it", key)
|
||||
return False
|
||||
old_ranges, was_complete = read_meta(key)
|
||||
if old is None:
|
||||
# A sidecar with no VTT behind it claims cues that are not there, so it is not merged
|
||||
# into. And a VTT with no sidecar reads as a COMPLETE track (see read_meta — how older
|
||||
# builds cached), while a publish is two renames: so before a key's first VTT lands, its
|
||||
# sidecar is made to claim nothing. Dying between the two then leaves a window that is
|
||||
# extracted again, never one taken for the whole track for good.
|
||||
old_ranges, was_complete = [], False
|
||||
_write_meta(key, [], False)
|
||||
if not cache_write(key, merge_vtt(old or "", vtt)):
|
||||
log.warning("subtitle cache %s could not be written", key)
|
||||
return False # the region never landed, so the sidecar must not claim it
|
||||
_write_meta(key, merge_ranges(old_ranges + [list(covered)]), complete or was_complete)
|
||||
return True
|
||||
|
||||
|
||||
# --- background extraction ----------------------------------------------------------------------
|
||||
|
||||
# How much of the track to extract before the viewer sees anything. WINDOW_PRE_S starts a little
|
||||
# before the playhead so a cue that is already on screen is not missed; WINDOW_LEN_S is the span
|
||||
# published as the first, provisional VTT (~4 s of work on the measured file). It only has to hold
|
||||
# until the full pass lands behind it — 111 s for that file — so it is deliberately much longer than
|
||||
# any realistic full pass.
|
||||
WINDOW_PRE_S = 30.0
|
||||
WINDOW_LEN_S = 300.0
|
||||
# …but it does not always land: a full pass can be backed off (FULL_RETRY_S) or simply slower than
|
||||
# the window lasts (a file past ~40 GB, or several passes sharing the array). A window was only asked
|
||||
# for once the playhead had LEFT coverage, so every edge unmounted the track for a poll plus an
|
||||
# extraction. Within this distance of a covered stretch's end, the next window is read ahead.
|
||||
LOOKAHEAD_S = 60.0
|
||||
# Ceiling on one ffmpeg run so a wedged extraction can't hold a pool thread forever (the old
|
||||
# in-request extraction had no timeout at all).
|
||||
EXTRACT_TIMEOUT_S = 900.0
|
||||
# How long a timed-out ffmpeg gets, after a TERM, to close its outputs before it is killed.
|
||||
_TERM_GRACE_S = 10.0
|
||||
# A failed track (or window) is remembered briefly so a 1 s status poll doesn't respawn ffmpeg every
|
||||
# second, and forgotten again soon after so fixing the file (or Plex coming back) doesn't need a
|
||||
# restart.
|
||||
FAIL_TTL_S = 60.0
|
||||
# …except a FULL pass that failed. That is a read of the whole container (minutes), a failure that
|
||||
# reproduces does so every time, and the viewer is typically inside what it did publish — so the
|
||||
# short memo re-armed a whole-file read every minute for as long as they watched. Windows around the
|
||||
# playhead still run meanwhile; only the full pass waits this out.
|
||||
FULL_RETRY_S = 3600.0
|
||||
# The furthest position the status poll accepts. No film is this long, and an absurd one used to
|
||||
# reach ffmpeg's `-ss` (which rejects anything past its int64 microseconds).
|
||||
MAX_POSITION_S = 100_000.0
|
||||
# Windows and full passes run on SEPARATE pools, so a window never waits behind a full pass. They
|
||||
# shared one FIFO pool of three whose comment promised "room for a window" that nothing enforced:
|
||||
# stepping through three episodes with subtitles on left three full passes (minutes each) holding every
|
||||
# worker, and a seek window on the third queued behind them. Two of each: bounded, so a burst can't
|
||||
# thrash the array.
|
||||
_WIN_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-win")
|
||||
_FULL_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-full")
|
||||
# Bounds on what may wait for those pools — separate too, so held full-pass slots (minutes each)
|
||||
# cannot refuse a window (seconds). The queues are unbounded and every job is a read of the library:
|
||||
# a viewer scrubbing through a film queued a window per stop (the one where they stopped ran LAST),
|
||||
# and a script could queue thousands. Past a bound nothing is queued — the next poll asks again, by
|
||||
# which time something has finished.
|
||||
_MAX_WINDOWS_INFLIGHT = 8
|
||||
_MAX_FULL_INFLIGHT = 6
|
||||
_MAX_TRACK_WINDOWS = 2
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
# Queued or running jobs → the absolute span a window job reads (None for a full pass).
|
||||
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
|
||||
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
|
||||
_FAILURES: dict[str, tuple[str, float]] = {}
|
||||
# rating_key → when its full pass last failed (see FULL_RETRY_S).
|
||||
_FULL_FAILED: dict[str, float] = {}
|
||||
# cache key → the spans windows failed to read, with when. A failed window says nothing about the rest
|
||||
# of the track — the full pass may be about to deliver it — so it is remembered as a SPAN: it must be
|
||||
# neither respawned every poll nor taken as a failure of positions it never tried. (It used to set the
|
||||
# track-level memo, which told every viewer at any uncovered position "failed" — a final answer that
|
||||
# ends the player's poll — including one planted by a request for an absurd position.)
|
||||
_WIN_FAILED: dict[str, list[tuple[float, float, float]]] = {}
|
||||
_PUBLISH_LOCK = threading.Lock()
|
||||
# The ffmpeg children currently running, so a shutdown can kill them (see `shutdown`), and whether it
|
||||
# has: a worker that reaches its Popen after the kill sweep must not start a read.
|
||||
_PROCS: set[subprocess.Popen] = set()
|
||||
_STOPPING = False
|
||||
|
||||
|
||||
class SubtitleUnavailable(Exception):
|
||||
"""A track that cannot be served as text, carrying the stable code the route maps to a status."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
# code -> HTTP status. An extraction/upstream failure is 422 with a structured detail (502 is
|
||||
# reserved for the raw byte proxies); the rest are genuine "no such thing" / "not text".
|
||||
UNAVAILABLE_STATUS = {
|
||||
"no_item": 404,
|
||||
"no_track": 404,
|
||||
"no_file": 404,
|
||||
"not_fetchable": 404,
|
||||
"bitmap": 415,
|
||||
"extract_failed": 422,
|
||||
"plex_error": 422,
|
||||
}
|
||||
# The codes that are a FINAL answer about the track: it can never be served as text, so the player
|
||||
# stops asking. The others (`plex_error`, `extract_failed`) are about now or about one stretch — a
|
||||
# memo that lapses, a window that failed where the rest is fine — and answered as `unavailable`: said,
|
||||
# but asked again. Given as `failed`, one Plex hiccup ended the subtitles for the rest of the session,
|
||||
# at cached positions too, and after the full pass had completed behind it.
|
||||
FINAL_CODES = frozenset({"no_item", "no_track", "no_file", "not_fetchable", "bitmap"})
|
||||
|
||||
|
||||
def reset_state() -> None:
|
||||
"""Drop the in-flight/failure registries (tests; nothing in the app needs it)."""
|
||||
global _STOPPING
|
||||
with _LOCK:
|
||||
_INFLIGHT.clear()
|
||||
_FAILURES.clear()
|
||||
_FULL_FAILED.clear()
|
||||
_WIN_FAILED.clear()
|
||||
_STOPPING = False
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Stop extracting, for the app's shutdown. The pool's workers are non-daemon threads and Python
|
||||
joins them at interpreter exit — so a worker still inside a full pass (minutes on a big file, up
|
||||
to EXTRACT_TIMEOUT_S) held the process open after uvicorn had stopped, until the container runtime
|
||||
SIGKILLed it. Queued jobs are dropped and running ffmpeg children are killed, which returns their
|
||||
workers at once — and a worker still BEFORE its ffmpeg (the Plex lookup, the DB) finds the flag
|
||||
and never starts one. Nothing that matters is lost: an interrupted publish cannot pass for a whole
|
||||
track (see `publish`), and an interrupted track is simply extracted again on the next poll."""
|
||||
global _STOPPING
|
||||
with _LOCK:
|
||||
_STOPPING = True
|
||||
procs = list(_PROCS)
|
||||
for pool in (_WIN_POOL, _FULL_POOL):
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
for p in procs:
|
||||
p.kill()
|
||||
|
||||
|
||||
def _sweep_memos() -> None:
|
||||
"""(Caller holds _LOCK.) Drop every lapsed failure memo. They were only ever pruned when the SAME
|
||||
key was read again, and the status route takes any rating_key/ordinal — so a script polling fresh
|
||||
keys grew the API process without bound. Swept on every write, they hold at most a TTL's worth."""
|
||||
now = time.monotonic()
|
||||
for k in [k for k, (_, t) in _FAILURES.items() if now - t >= FAIL_TTL_S]:
|
||||
del _FAILURES[k]
|
||||
for k in [k for k, t in _FULL_FAILED.items() if now - t >= FULL_RETRY_S]:
|
||||
del _FULL_FAILED[k]
|
||||
for k in list(_WIN_FAILED):
|
||||
_live_window_failures(k)
|
||||
|
||||
|
||||
def _fail(key: str, code: str) -> None:
|
||||
with _LOCK:
|
||||
_sweep_memos()
|
||||
_FAILURES[key] = (code, time.monotonic())
|
||||
|
||||
|
||||
def _recent_failure(key: str) -> str | None:
|
||||
with _LOCK:
|
||||
hit = _FAILURES.get(key)
|
||||
if hit and time.monotonic() - hit[1] < FAIL_TTL_S:
|
||||
return hit[0]
|
||||
_FAILURES.pop(key, None)
|
||||
return None
|
||||
|
||||
|
||||
def _full_failed(rating_key: str) -> bool:
|
||||
with _LOCK:
|
||||
when = _FULL_FAILED.get(rating_key)
|
||||
if when is not None and time.monotonic() - when < FULL_RETRY_S:
|
||||
return True
|
||||
_FULL_FAILED.pop(rating_key, None)
|
||||
return False
|
||||
|
||||
|
||||
def _live_window_failures(key: str) -> list[tuple[float, float, float]]:
|
||||
"""(Caller holds _LOCK.) The key's failed-window spans that are still remembered."""
|
||||
now = time.monotonic()
|
||||
live = [f for f in _WIN_FAILED.get(key, []) if now - f[2] < FAIL_TTL_S]
|
||||
if live:
|
||||
_WIN_FAILED[key] = live
|
||||
else:
|
||||
_WIN_FAILED.pop(key, None)
|
||||
return live
|
||||
|
||||
|
||||
def _window_fail(key: str, span: tuple[float, float]) -> None:
|
||||
with _LOCK:
|
||||
_sweep_memos()
|
||||
_WIN_FAILED[key] = [*_live_window_failures(key), (span[0], span[1], time.monotonic())]
|
||||
|
||||
|
||||
def _window_failed(key: str, at: float) -> bool:
|
||||
with _LOCK:
|
||||
return any(a <= at <= b for a, b, _ in _live_window_failures(key))
|
||||
|
||||
|
||||
def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
|
||||
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
|
||||
|
||||
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
|
||||
= nothing to show for ``at`` yet · ``unavailable`` = nothing for ``at`` and nothing being read
|
||||
for it right now (``code`` says why), ask again · ``failed`` = the track can never be shown
|
||||
(``code`` says why) — the only final answer. ``gen`` changes whenever the cached file grows, so
|
||||
the client can refetch a window that has been superseded; 0 = nothing is cached at all."""
|
||||
key = cache_key(rating_key, sub_ord)
|
||||
ranges, complete = read_meta(key)
|
||||
have = cache_read(key) is not None
|
||||
covered = have and covers(ranges, at)
|
||||
gen = generation(key)
|
||||
if complete and have:
|
||||
return {"state": "ready", "code": None, "gen": gen}
|
||||
if not have and _exists(_vtt_path(key)):
|
||||
# On disk but unreadable (permissions, a bad volume): nothing can serve it, and no extraction
|
||||
# can help — `publish` refuses to merge into it, and a job whose sidecar covers the position
|
||||
# skips as already done — so scheduling only kept the player on "preparing" for good. `gen` 0:
|
||||
# there is nothing a <track> could fetch.
|
||||
return {"state": "unavailable", "code": "cache_unreadable", "gen": 0}
|
||||
code = _recent_failure(key)
|
||||
if code and not covered:
|
||||
return {
|
||||
"state": "failed" if code in FINAL_CODES else "unavailable",
|
||||
"code": code,
|
||||
"gen": gen,
|
||||
}
|
||||
# Nothing left that could cover the position for now: the full pass failed, AND so did a window
|
||||
# here. Still not final — the memos lapse, and the rest of the track may be right there.
|
||||
full_failed = _full_failed(rating_key)
|
||||
window_failed = not covered and _window_failed(key, at)
|
||||
if full_failed and window_failed:
|
||||
return {"state": "unavailable", "code": "extract_failed", "gen": gen}
|
||||
if not code:
|
||||
probe, want_window = at, not covered and not window_failed
|
||||
if covered:
|
||||
# Read ahead of an edge instead of waiting to fall off it (see LOOKAHEAD_S). The probe is
|
||||
# just PAST the edge: `covers` is inclusive, so the edge itself reads as already done.
|
||||
edge = max(b for a, b in ranges if a <= at <= b)
|
||||
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
|
||||
probe = edge + 1.0
|
||||
want_window = not _window_failed(key, probe)
|
||||
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed)
|
||||
return {"state": "partial" if covered else "preparing", "code": None, "gen": gen}
|
||||
|
||||
|
||||
def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]:
|
||||
"""What the <track> fetch can serve RIGHT NOW, scheduling nothing: the cached VTT (whatever it
|
||||
covers), else the recent failure that explains why there is none.
|
||||
|
||||
Scheduling belongs to `ensure` — the status poll, which knows where the viewer is. The fetch does
|
||||
not: when it scheduled too, it judged readiness at position 0, so a viewer who resumed an hour in
|
||||
got an unrequested window over the head of the file (plus a remount when it landed), and with a
|
||||
failure memo alive it was refused a window that WAS on disk while the poll called it showable."""
|
||||
key = cache_key(rating_key, sub_ord)
|
||||
hit = cache_read(key)
|
||||
return (hit, None) if hit is not None else (None, _recent_failure(key))
|
||||
|
||||
|
||||
def _schedule(
|
||||
rating_key: str, sub_ord: int, at: float, want_window: bool, want_full: bool = True
|
||||
) -> None:
|
||||
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one
|
||||
full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already
|
||||
queued or running instead of starting another ffmpeg.
|
||||
|
||||
A window is joined when one of this track's in-flight windows will COVER the position asked about
|
||||
— decided by what it reads, not by an identity derived from the position. The poll sends the live
|
||||
playhead, so a position-keyed window was a new job every second; bucketing the position instead
|
||||
still queued a second read when the playhead crossed a bucket edge, and joined a backward seek to
|
||||
a window that could not cover it. Nor may it be one window per track: a window cut around the
|
||||
wrong spot (the first poll after a resume can still report 0) made the right one wait for it. The
|
||||
full pass is per ITEM, because one read fans out to every embedded text track of the file."""
|
||||
jobs: list[tuple[tuple, float | None]] = []
|
||||
with _LOCK:
|
||||
if _STOPPING:
|
||||
return
|
||||
n_windows = sum(1 for span in _INFLIGHT.values() if span is not None)
|
||||
n_full = len(_INFLIGHT) - n_windows
|
||||
if want_window and n_windows < _MAX_WINDOWS_INFLIGHT:
|
||||
spans = [
|
||||
span
|
||||
for ident, span in _INFLIGHT.items()
|
||||
if span is not None and ident[1:3] == (rating_key, sub_ord)
|
||||
]
|
||||
if not any(a <= at <= b for a, b in spans) and len(spans) < _MAX_TRACK_WINDOWS:
|
||||
# Rounded to what ffmpeg is told (`-ss %.3f`): the cues come back on ffmpeg's zero,
|
||||
# and shifting them by an unrounded start put them up to half a millisecond off —
|
||||
# enough to spell a timestamp differently from the full pass and double the cue.
|
||||
w_start = float(f"{max(0.0, at - WINDOW_PRE_S):.3f}")
|
||||
ident = ("win", rating_key, sub_ord, w_start)
|
||||
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
|
||||
jobs.append((ident, w_start))
|
||||
if want_full and n_full < _MAX_FULL_INFLIGHT and ("full", rating_key) not in _INFLIGHT:
|
||||
_INFLIGHT[("full", rating_key)] = None
|
||||
jobs.append((("full", rating_key), None))
|
||||
for ident, window_start in jobs:
|
||||
pool = _FULL_POOL if window_start is None else _WIN_POOL
|
||||
pool.submit(_run, ident, rating_key, sub_ord, window_start, at)
|
||||
|
||||
|
||||
def _already_done(key: str, window_start: float | None, at: float) -> bool:
|
||||
"""A queued job looks again before it reads anything. While it waited, another job may have
|
||||
covered it: every embedded track rides each pass over its file, so a window for one language also
|
||||
publishes the others."""
|
||||
if not _exists(_vtt_path(key)):
|
||||
return False
|
||||
ranges, complete = read_meta(key)
|
||||
return complete or (window_start is not None and covers(ranges, at))
|
||||
|
||||
|
||||
def _run(
|
||||
ident: tuple, rating_key: str, sub_ord: int, window_start: float | None, at: float
|
||||
) -> None:
|
||||
key = cache_key(rating_key, sub_ord)
|
||||
try:
|
||||
if _STOPPING or _already_done(key, window_start, at):
|
||||
return
|
||||
_extract_and_publish(rating_key, sub_ord, window_start)
|
||||
except SubtitleUnavailable as e:
|
||||
if e.code == "extract_failed":
|
||||
_pass_failed(rating_key, [key], window_start)
|
||||
else:
|
||||
_fail(key, e.code)
|
||||
except PlexError as e:
|
||||
log.warning("subtitle extraction failed for %s/%s: %s", rating_key, sub_ord, e)
|
||||
_fail(key, "plex_error")
|
||||
except (
|
||||
Exception
|
||||
): # a worker thread has nobody to raise to — record it, let the next poll see it
|
||||
log.exception("subtitle extraction crashed for %s/%s", rating_key, sub_ord)
|
||||
_pass_failed(rating_key, [key], window_start)
|
||||
finally:
|
||||
with _LOCK:
|
||||
_INFLIGHT.pop(ident, None)
|
||||
|
||||
|
||||
def _pass_failed(
|
||||
rating_key: str,
|
||||
keys: list[str],
|
||||
window_start: float | None,
|
||||
span: tuple[float, float] | None = None,
|
||||
) -> None:
|
||||
"""Record an ffmpeg pass that failed: a full pass backs off the whole item (FULL_RETRY_S); a
|
||||
window marks only the stretch it failed to read, for the tracks it was reading."""
|
||||
if window_start is None:
|
||||
with _LOCK:
|
||||
_sweep_memos()
|
||||
_FULL_FAILED[rating_key] = time.monotonic()
|
||||
return
|
||||
for key in keys:
|
||||
_window_fail(key, span or (window_start, window_start + WINDOW_PRE_S + WINDOW_LEN_S))
|
||||
|
||||
|
||||
def _text_streams(db: Session, rating_key: str) -> tuple[PlexItem, list[dict]]:
|
||||
"""The item plus its subtitle streams, in the ordinal order the API and the UI both use."""
|
||||
it = by_rating_key(db, PlexItem, rating_key)
|
||||
if it is None:
|
||||
raise SubtitleUnavailable("no_item")
|
||||
with PlexClient(db) as plex:
|
||||
part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[0].get("Part") or [{}]
|
||||
return it, [s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3]
|
||||
|
||||
|
||||
def _is_bitmap(stream: dict) -> bool:
|
||||
return (stream.get("codec") or stream.get("format") or "").lower() in BITMAP_SUBS
|
||||
|
||||
|
||||
def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | None) -> None:
|
||||
"""One extraction job. A sidecar track is fetched whole from Plex (cheap, no ffmpeg); an embedded
|
||||
one is extracted together with EVERY other embedded text track in the file, because they all ride
|
||||
on the same single read — the whole point of the measurement at the top of this module."""
|
||||
with SessionLocal() as db:
|
||||
it, subs = _text_streams(db, rating_key)
|
||||
if sub_ord < 0 or sub_ord >= len(subs):
|
||||
raise SubtitleUnavailable("no_track")
|
||||
want = subs[sub_ord]
|
||||
if _is_bitmap(want):
|
||||
raise SubtitleUnavailable("bitmap")
|
||||
if want.get("key"):
|
||||
# An external sidecar is one small fetch from Plex, never a read of the media file, so a
|
||||
# window job fetches the whole thing too. It must not be left to the full job: that
|
||||
# identity is per ITEM (the embedded fan-out shares it), so a sidecar picked while an
|
||||
# embedded track's full pass was running sat behind that pass for minutes.
|
||||
vtt = _sidecar_vtt(db, want)
|
||||
if not publish(cache_key(rating_key, sub_ord), vtt, (0.0, _FULL_END_S), True):
|
||||
raise SubtitleUnavailable("extract_failed")
|
||||
return
|
||||
if want.get("index") is None:
|
||||
raise SubtitleUnavailable("not_fetchable")
|
||||
src = plex_paths.local_media_path(db, it.file_path)
|
||||
if src is None:
|
||||
raise SubtitleUnavailable("no_file")
|
||||
# Ordinal among the file's EMBEDDED subtitle streams — what `-map 0:s:N` counts. Sidecars sit
|
||||
# in the same list but not in the file, so they must not shift the mapping.
|
||||
targets = {
|
||||
sum(1 for x in subs[:i] if x.get("index") is not None): i
|
||||
for i, s in enumerate(subs)
|
||||
if s.get("index") is not None and not _is_bitmap(s)
|
||||
}
|
||||
start = window_start or 0.0
|
||||
length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None
|
||||
end = _FULL_END_S if length is None else start + length
|
||||
texts, clean = _ffmpeg_extract(str(src), sorted(targets), window_start, length)
|
||||
for emb_ord, vtt in texts.items():
|
||||
key = cache_key(rating_key, targets[emb_ord])
|
||||
# A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more
|
||||
# than its last cue, never call it complete, and remember the failure: published as complete,
|
||||
# the track read "ready" for good while its subtitles stopped wherever ffmpeg did (the cache
|
||||
# has no eviction, so nothing would ever have extracted it again).
|
||||
covered_end = end if clean else start + _last_cue_end(vtt)
|
||||
landed = publish(
|
||||
key,
|
||||
shift_vtt(vtt, -start), # zero-based window clock → absolute cue times
|
||||
(start, covered_end),
|
||||
clean and length is None,
|
||||
)
|
||||
if not landed:
|
||||
# A read that could not be kept is a failed read (see publish) — backed off like one.
|
||||
_pass_failed(rating_key, [key], window_start, (start, end))
|
||||
elif not clean:
|
||||
_pass_failed(rating_key, [key], window_start, (covered_end, end))
|
||||
|
||||
|
||||
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
||||
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
||||
with PlexClient(db) as plex:
|
||||
raw = plex.raw_get(stream["key"])
|
||||
codec = (stream.get("codec") or stream.get("format") or "").lower()
|
||||
if codec in ("srt", "subrip", ""):
|
||||
return srt_to_vtt(raw.decode("utf-8", "replace"))
|
||||
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
|
||||
fh.write(raw)
|
||||
tmp = fh.name
|
||||
try:
|
||||
return ffmpeg_to_vtt(tmp, None)
|
||||
finally:
|
||||
Path(tmp).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _ffmpeg_extract(
|
||||
src: str, emb_ords: list[int], start: float | None, length: float | None
|
||||
) -> tuple[dict[int, str], bool]:
|
||||
"""Extract several embedded subtitle streams to WebVTT in ONE ffmpeg run (= one read of the file).
|
||||
Returns the texts and whether ffmpeg exited cleanly — a non-zero exit can still leave partial
|
||||
output, which is worth publishing but must never be taken for the whole track.
|
||||
|
||||
`start`/`length` bound the read to a window. Without `-copyts` the cues come out on a zero-based
|
||||
clock (the caller shifts them back) — which is exactly why `-copyts` is absent: with it ffmpeg
|
||||
ignores `-t` and reads the whole container anyway (measured)."""
|
||||
if not emb_ords:
|
||||
return {}, True
|
||||
with tempfile.TemporaryDirectory(prefix="plexsub-") as tmpdir:
|
||||
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||
if start:
|
||||
cmd += ["-ss", f"{start:.3f}"]
|
||||
cmd += ["-i", src]
|
||||
outs = {o: Path(tmpdir) / f"{o}.vtt" for o in emb_ords}
|
||||
for emb_ord, out in outs.items():
|
||||
cmd += ["-map", f"0:s:{emb_ord}"]
|
||||
if length is not None:
|
||||
cmd += ["-t", f"{length:.3f}"]
|
||||
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
|
||||
# at `start` comes out at a negative time — and by default the muxer then shifts the
|
||||
# WHOLE output so that cue sits at 0: every cue of the window late by the distance to
|
||||
# that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic
|
||||
# one), and doubled for good once the full pass merged in beside it. Kept negative, the
|
||||
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
|
||||
cmd += ["-avoid_negative_ts", "disabled"]
|
||||
cmd += ["-f", "webvtt", "-y", str(out)]
|
||||
# Popen rather than run(): the child is registered so `shutdown` can kill it — and one that
|
||||
# would start after the shutdown's kill sweep never does, or is killed right here.
|
||||
with _LOCK:
|
||||
if _STOPPING:
|
||||
raise SubtitleUnavailable("extract_failed")
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
|
||||
)
|
||||
with _LOCK:
|
||||
_PROCS.add(proc)
|
||||
if _STOPPING:
|
||||
proc.kill()
|
||||
timed_out = False
|
||||
try:
|
||||
try:
|
||||
_, err = proc.communicate(timeout=EXTRACT_TIMEOUT_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
# A pass that ran out of time is a pass that died partway: what it reached is kept and
|
||||
# published as partial (the caller's unclean path), like any other non-zero exit. It
|
||||
# used to be killed and its outputs deleted unread — and with the full-pass backoff, the
|
||||
# same doomed read came back every hour. TERM first: ffmpeg closes its outputs on it,
|
||||
# whereas a KILL leaves only what was already flushed in whole blocks (measured: none).
|
||||
timed_out = True
|
||||
proc.terminate()
|
||||
try:
|
||||
_, err = proc.communicate(timeout=_TERM_GRACE_S)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
_, err = proc.communicate()
|
||||
finally:
|
||||
with _LOCK:
|
||||
_PROCS.discard(proc)
|
||||
got = {
|
||||
o: p.read_text(encoding="utf-8", errors="replace")
|
||||
for o, p in outs.items()
|
||||
if p.exists()
|
||||
}
|
||||
if not got:
|
||||
raise SubtitleUnavailable("extract_failed")
|
||||
clean = proc.returncode == 0 and not timed_out
|
||||
if not clean:
|
||||
log.warning(
|
||||
"ffmpeg subtitle pass %s: %s",
|
||||
"timed out" if timed_out else f"exited {proc.returncode}",
|
||||
(err or b"")[:200],
|
||||
)
|
||||
return got, clean
|
||||
|
||||
@@ -17,12 +17,20 @@ from app.models import User
|
||||
M = TypeVar("M", bound=Base)
|
||||
|
||||
|
||||
def get_or_404(db: Session, model: type[M], id_: Any, *, detail: str = "Not found") -> M:
|
||||
def not_found(what: str = "generic") -> HTTPException:
|
||||
"""The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's
|
||||
language (`errors.notFound.<what>`, generic fallback). This is the 404 a user actually meets: the
|
||||
thing was deleted in another tab or on another device. It used to be a bare English string
|
||||
("Unknown view", "Not found"), which the Hungarian UI toasted verbatim."""
|
||||
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
|
||||
|
||||
|
||||
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
|
||||
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
||||
idiom repeated across routes."""
|
||||
obj = db.get(model, id_)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
raise not_found(what)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -33,13 +41,13 @@ def owned_or_404(
|
||||
user: User,
|
||||
*,
|
||||
owner_attr: str = "user_id",
|
||||
detail: str = "Not found",
|
||||
what: str = "generic",
|
||||
) -> M:
|
||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||
Generalizes the near-identical per-route ownership helpers (e.g. plex's
|
||||
`_own_playlist_or_404`, the saved-views owner check)."""
|
||||
obj = get_or_404(db, model, id_, detail=detail)
|
||||
obj = get_or_404(db, model, id_, what=what)
|
||||
if getattr(obj, owner_attr) != user.id:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
raise not_found(what)
|
||||
return obj
|
||||
|
||||
+10
-16
@@ -173,7 +173,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
||||
|
||||
|
||||
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
|
||||
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
|
||||
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
||||
@@ -291,7 +291,7 @@ def send_invitation(
|
||||
|
||||
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
|
||||
undecided, and inviting a denied address would contradict the decision."""
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||
if inv.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -364,7 +364,7 @@ def delete_invite(
|
||||
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
||||
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
|
||||
inv = get_or_404(db, Invite, invite_id, what="invite")
|
||||
if _accounts_for(db, [inv.email]):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -434,9 +434,7 @@ def set_user_role(
|
||||
role = body.role
|
||||
if role not in ("user", "admin"):
|
||||
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
||||
if target.id == admin.id:
|
||||
@@ -491,9 +489,7 @@ def set_user_suspended(
|
||||
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
||||
sign-in)."""
|
||||
suspended = body.suspended
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
||||
if target.id == admin.id:
|
||||
@@ -537,9 +533,7 @@ def admin_delete_user(
|
||||
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
|
||||
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
|
||||
not the demo account, not yourself (use Settings → Account), and not the last admin."""
|
||||
target = db.get(User, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
|
||||
if target.id == admin.id:
|
||||
@@ -632,7 +626,7 @@ def user_access(
|
||||
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
|
||||
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
|
||||
per row on every load to answer a question nobody asked yet."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
return _serialize_user(target) | {
|
||||
"is_allowed": is_allowed(db, target.email),
|
||||
"can_activate": can_activate(db, target),
|
||||
@@ -657,7 +651,7 @@ def create_set_password_link(
|
||||
an instance with no mail — narrowing it to one of those would only make the admin's real case the
|
||||
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
|
||||
change its password is handing them everyone else's session."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
|
||||
@@ -693,7 +687,7 @@ def activate_user(
|
||||
This endpoint changes nothing about what an unauthenticated caller can cause — the admin could
|
||||
already do exactly this by re-entering the address in the whitelist box (`add_invite` →
|
||||
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo:
|
||||
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
|
||||
if target.is_active:
|
||||
@@ -732,7 +726,7 @@ def resend_user_verification(
|
||||
|
||||
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
|
||||
of the public endpoint is exactly what this exists to escape."""
|
||||
target = get_or_404(db, User, user_id, detail="No such user")
|
||||
target = get_or_404(db, User, user_id, what="user")
|
||||
if target.is_demo or target.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
|
||||
@@ -1246,7 +1246,7 @@ def logout_others(
|
||||
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
|
||||
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
|
||||
# session at a newer epoch than the DB (see set_password).
|
||||
bump_session_epoch(user)
|
||||
@@ -1293,7 +1293,7 @@ def set_password(
|
||||
account) needs no current password — the session already proves identity. Changing an
|
||||
existing one requires the current password, so a hijacked session can't silently rotate it."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
raise HTTPException(status_code=403, detail={"code": "demo"})
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.models import (
|
||||
User,
|
||||
Video,
|
||||
)
|
||||
from app.routes._common import owned_or_404
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404
|
||||
from app.sync.explore import explore_ingest_page
|
||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
@@ -287,9 +287,7 @@ def channel_detail(
|
||||
needed — a stub, or a channel last enriched before those fields existed (published_at is a
|
||||
reliable sentinel: every real channel has a creation date). The enrich uses the API key
|
||||
(1 unit, cached after), so it's skipped for the quota-less demo account."""
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||
if channel.published_at is None and not user.is_demo:
|
||||
try:
|
||||
with (
|
||||
@@ -366,9 +364,7 @@ def explore_channel(
|
||||
it, then ingests one page of its uploads (newest-first) marked via_explore + enriched.
|
||||
Returns the next page token so the page can "load more". Guarded against the shared
|
||||
backfill quota reserve; blocked for the demo account (it spends shared quota)."""
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||
if _is_blocked(db, user, channel_id):
|
||||
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
||||
if quota.backfill_reserve_reached():
|
||||
@@ -426,7 +422,7 @@ def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if sub is None:
|
||||
raise HTTPException(status_code=404, detail="Not subscribed to this channel")
|
||||
raise not_found("subscription")
|
||||
return sub
|
||||
|
||||
|
||||
@@ -512,9 +508,7 @@ def reset_backfill(
|
||||
state (a "reset" trigger). Clears the channel's backfill markers, opts it back into deep
|
||||
backfill, and re-runs its recent pull immediately; the deep scheduler re-pages the full
|
||||
back-catalog on its next run. Idempotent — videos upsert by id, so nothing duplicates."""
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||
channel.backfill_done = False
|
||||
channel.backfill_cursor = None
|
||||
channel.recent_synced_at = None
|
||||
@@ -544,9 +538,7 @@ def subscribe(
|
||||
local subscription with YouTube's returned resource id and enriches the channel's metadata
|
||||
if it's still a stub, so the new row renders properly in the manager. It does NOT pull the
|
||||
channel's videos — the scheduler picks those up on its next run, like any subscribed channel."""
|
||||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||
existing = db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user.id, Subscription.channel_id == channel_id
|
||||
@@ -663,8 +655,7 @@ def block_channel(
|
||||
"""Block a channel for this user: its videos won't appear in their live search (and won't be
|
||||
ingested for them) and are hidden from their feed / explore. Idempotent. The now-hidden
|
||||
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
|
||||
if db.get(Channel, channel_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
get_or_404(db, Channel, channel_id, what="channel")
|
||||
if not _is_blocked(db, user, channel_id):
|
||||
db.add(BlockedChannel(user_id=user.id, channel_id=channel_id))
|
||||
# Stop any active exploration of it so it can be cleaned up.
|
||||
@@ -706,7 +697,7 @@ def attach_tag(
|
||||
_user_subscription(db, user, channel_id)
|
||||
# Only the user's own tags may be attached as personal links (owned_or_404 → 404 if the tag
|
||||
# is missing OR belongs to someone else, without leaking which).
|
||||
owned_or_404(db, Tag, body.tag_id, user, detail="Unknown tag")
|
||||
owned_or_404(db, Tag, body.tag_id, user, what="tag")
|
||||
exists = db.execute(
|
||||
select(ChannelTag).where(
|
||||
ChannelTag.channel_id == channel_id,
|
||||
|
||||
@@ -168,7 +168,7 @@ def remove_pin(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pin = owned_or_404(db, ComfortPin, pin_id, user)
|
||||
pin = owned_or_404(db, ComfortPin, pin_id, user, what="pin")
|
||||
db.delete(pin)
|
||||
db.commit()
|
||||
return {"deleted": pin_id}
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.models import (
|
||||
MediaAsset,
|
||||
User,
|
||||
)
|
||||
from app.routes._common import get_or_404, not_found
|
||||
from app.security import hash_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
from app.utils import iso
|
||||
@@ -801,7 +802,7 @@ def share_download(
|
||||
# Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so
|
||||
# sharing can't be used to probe which addresses exist or to reach a suspended account.
|
||||
if not is_messageable_user(recipient):
|
||||
raise HTTPException(status_code=404, detail="No user with that email.")
|
||||
raise not_found("recipient")
|
||||
if recipient.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
||||
exists = db.execute(
|
||||
@@ -1091,8 +1092,7 @@ def admin_set_quota(
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if db.get(User, user_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown user")
|
||||
get_or_404(db, User, user_id, what="user")
|
||||
cur = quota.resolve(db, user_id)
|
||||
row = db.get(DownloadQuota, user_id)
|
||||
if row is None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -22,12 +22,14 @@ from app.feedquery import (
|
||||
sort_keys,
|
||||
)
|
||||
from app.models import (
|
||||
POSITION_MAX_S,
|
||||
ChannelTag,
|
||||
Tag,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.routes._common import get_or_404
|
||||
from app.sync.videos import parse_iso8601_duration
|
||||
from app.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
@@ -234,8 +236,7 @@ def set_video_state(
|
||||
status = body.status
|
||||
if status not in VALID_STATES:
|
||||
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}")
|
||||
if db.get(Video, video_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -303,11 +304,16 @@ def clear_video_state(
|
||||
|
||||
|
||||
class VideoProgressIn(BaseModel):
|
||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||
# one into a 500.
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
|
||||
# Accept a float and truncate (below) — the player may post a raw currentTime (a float), which
|
||||
# the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the
|
||||
# 10s progress hot path, losing the resume position; `float` keeps the old tolerance.
|
||||
position_seconds: float = 0
|
||||
duration_seconds: float = 0
|
||||
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
|
||||
position_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
duration_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
|
||||
|
||||
@router.post("/videos/{video_id}/progress")
|
||||
@@ -326,8 +332,7 @@ def set_video_progress(
|
||||
if position < 0:
|
||||
position = 0
|
||||
|
||||
if db.get(Video, video_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
|
||||
# Decide whether this position is worth keeping (else clear it).
|
||||
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
|
||||
|
||||
@@ -159,8 +159,13 @@ def delete_my_account(
|
||||
which all cascade on the users row. Quota-audit events are kept but anonymised (FK SET NULL).
|
||||
The shared demo account can't be deleted, and the last remaining admin can't delete itself
|
||||
(that would lock everyone out of the admin surfaces)."""
|
||||
# Structured, like the other three demo refusals: `req` leaves 403 to the caller and
|
||||
# `notifyActionError` discards a plain-string detail in favour of the caller's translated
|
||||
# fallback, so a hand-written English sentence here reaches a Hungarian UI as a generic
|
||||
# "couldn't do that" — or, on this affordance, as nothing at all. `reason` distinguishes it
|
||||
# from the other sites' bare `{code: "demo"}` so the UI can say what specifically is refused.
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
|
||||
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
|
||||
if user.role == "admin" and count_admins(db) <= 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.sync.playlists import (
|
||||
sync_user_playlists,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes._common import get_or_404, owned_or_404
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
@@ -48,10 +49,13 @@ def _check_pull_cooldown(user: User) -> None:
|
||||
|
||||
|
||||
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
pl = db.get(Playlist, playlist_id)
|
||||
if pl is None or pl.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown playlist")
|
||||
return pl
|
||||
return owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
|
||||
|
||||
def _refused(status: int, reason: str) -> HTTPException:
|
||||
"""A playlist action the server refuses, as a reason the UI translates
|
||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
||||
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
|
||||
|
||||
|
||||
def _touch(pl: Playlist) -> None:
|
||||
@@ -256,7 +260,7 @@ def create_playlist(
|
||||
) -> dict:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
raise _refused(400, "name_required")
|
||||
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
@@ -288,8 +292,7 @@ def add_watch_later(
|
||||
) -> dict:
|
||||
"""Bookmark shortcut: add a video to the built-in Watch later playlist."""
|
||||
video_id = body.video_id
|
||||
if db.get(Video, video_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
pl = _watch_later(db, user)
|
||||
_add_item(db, pl, video_id, mark_dirty=False)
|
||||
return {"saved": True, "playlist_id": pl.id}
|
||||
@@ -317,10 +320,7 @@ def sync_youtube(
|
||||
"""Pull the user's YouTube playlists into local mirrors now (read-only direction).
|
||||
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
||||
if not has_read_scope(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Connect YouTube (read access) to sync your playlists.",
|
||||
)
|
||||
raise _refused(403, "youtube_read_scope")
|
||||
_check_pull_cooldown(user)
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return sync_user_playlists(db, user)
|
||||
@@ -337,18 +337,16 @@ def revert_youtube(
|
||||
once the in-session undo history is gone."""
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
if not pl.yt_playlist_id:
|
||||
raise HTTPException(status_code=400, detail="This playlist isn't linked to YouTube.")
|
||||
raise _refused(400, "not_on_youtube")
|
||||
if not has_read_scope(user):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
|
||||
)
|
||||
raise _refused(403, "youtube_read_scope")
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return repull_playlist(db, user, pl)
|
||||
except YouTubeError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail="Couldn't refresh from YouTube.")
|
||||
raise _refused(422, "youtube_failed")
|
||||
|
||||
|
||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
@@ -356,9 +354,7 @@ def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
Watch later and read-only YouTube mirrors are never pushable."""
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
if pl.kind == "watch_later":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="The Watch later list can't be sent to YouTube."
|
||||
)
|
||||
raise _refused(400, "watch_later_not_pushable")
|
||||
return pl
|
||||
|
||||
|
||||
@@ -376,7 +372,7 @@ def push_plan(
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
plan = plan_merge(db, user, pl)
|
||||
except YouTubeError:
|
||||
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
||||
raise _refused(422, "youtube_failed")
|
||||
plan["remaining_today"] = quota.remaining_today(db)
|
||||
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||
return plan
|
||||
@@ -406,14 +402,11 @@ def push(
|
||||
)
|
||||
plan = plan_merge(db, user, pl, live=live)
|
||||
if plan["units_estimate"] > quota.remaining_today(db):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Not enough YouTube quota left today for this sync.",
|
||||
)
|
||||
raise _refused(429, "quota_exceeded")
|
||||
return merge_playlist(db, user, pl, live=live)
|
||||
except YouTubeError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
||||
raise _refused(422, "youtube_failed")
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
@@ -458,7 +451,7 @@ def rename_playlist(
|
||||
if "name" in body.model_fields_set:
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||
raise _refused(400, "name_required")
|
||||
if name != pl.name:
|
||||
pl.name = name
|
||||
recompute_dirty(db, pl)
|
||||
@@ -493,18 +486,18 @@ def delete_playlist(
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
if pl.kind == "watch_later":
|
||||
raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.")
|
||||
raise _refused(400, "watch_later_undeletable")
|
||||
if on_youtube:
|
||||
if not pl.yt_playlist_id:
|
||||
raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.")
|
||||
raise _refused(400, "not_on_youtube")
|
||||
if not has_write_scope(user):
|
||||
raise HTTPException(status_code=403, detail="Enable YouTube editing in Settings first.")
|
||||
raise _refused(403, "youtube_editing_off")
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt.delete_playlist(pl.yt_playlist_id)
|
||||
except YouTubeError:
|
||||
raise HTTPException(status_code=422, detail="YouTube delete failed.")
|
||||
raise _refused(422, "youtube_failed")
|
||||
db.delete(pl) # items cascade
|
||||
db.commit()
|
||||
return {"deleted": playlist_id}
|
||||
@@ -519,8 +512,7 @@ def add_item(
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
video_id = body.video_id
|
||||
if db.get(Video, video_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
_add_item(db, pl, video_id, mark_dirty=True)
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
@@ -557,7 +549,9 @@ def move_item(
|
||||
case. Idempotent: if the video is already in the target, the source row is simply dropped
|
||||
(moving into a list that already has it is a removal, not a duplicate)."""
|
||||
src = _own_playlist(db, user, playlist_id)
|
||||
dst = _own_playlist(db, user, body.target_playlist_id)
|
||||
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
|
||||
# the list on screen — which is still there.
|
||||
dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
|
||||
if src.id == dst.id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||
|
||||
@@ -54,6 +54,22 @@ from app.utils import to_tsquery_str
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def sort_column(u, sort: str):
|
||||
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
|
||||
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
|
||||
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
|
||||
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback."""
|
||||
return {
|
||||
"added": u.c.added_at,
|
||||
"title": func.lower(u.c.title),
|
||||
"year": u.c.year,
|
||||
"rating": u.c.rating,
|
||||
"duration": u.c.duration_s,
|
||||
"release": u.c.rel,
|
||||
"lastwatched": u.c.last_watched,
|
||||
}.get(sort, u.c.added_at)
|
||||
|
||||
|
||||
@router.get("/libraries")
|
||||
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay
|
||||
@@ -221,14 +237,7 @@ def unified_library(
|
||||
if tsq is not None:
|
||||
order = [u.c.rank.desc()]
|
||||
else:
|
||||
col = {
|
||||
"title": func.lower(u.c.title),
|
||||
"year": u.c.year,
|
||||
"rating": u.c.rating,
|
||||
"duration": u.c.duration_s,
|
||||
"release": u.c.rel,
|
||||
"lastwatched": u.c.last_watched,
|
||||
}.get(sort, u.c.added_at)
|
||||
col = sort_column(u, sort)
|
||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||
order.append(u.c.rk.desc())
|
||||
rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
@@ -12,6 +16,7 @@ from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
@@ -83,13 +88,20 @@ def _collection_card(c: PlexCollection) -> dict:
|
||||
def collections(
|
||||
library: str | None = None,
|
||||
q: str | None = None,
|
||||
keys: str | None = None,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a
|
||||
`library` plex_key → that one library's collections (used by the collection editor). WITHOUT one →
|
||||
the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
|
||||
library selected)."""
|
||||
library selected).
|
||||
|
||||
`keys` (CSV of rating_keys) narrows to just those. It exists because a Plex link carries the
|
||||
collection FILTER as bare rating_keys — nothing in the URL says what "17045" is called — so the
|
||||
sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them
|
||||
through this endpoint costs one small row each; pulling the whole list to find two titles is
|
||||
~200 KB."""
|
||||
if library is not None:
|
||||
lib = (
|
||||
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library)))
|
||||
@@ -113,6 +125,13 @@ def collections(
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
|
||||
if keys is not None:
|
||||
wanted = [k for k in (keys or "").split(",") if k]
|
||||
# An explicit but empty `keys=` asks for nothing — answer nothing, rather than falling through
|
||||
# to "every collection" (which is what an unguarded empty IN () would have to become).
|
||||
if not wanted:
|
||||
return {"collections": []}
|
||||
query = query.where(PlexCollection.rating_key.in_(wanted))
|
||||
rows = (
|
||||
db.execute(
|
||||
query.order_by(
|
||||
@@ -149,14 +168,69 @@ def collections(
|
||||
}
|
||||
|
||||
|
||||
class Member(NamedTuple):
|
||||
"""One collection member flattened to everything the Sort control can order by. The page's two
|
||||
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
|
||||
ordering is a pure list sort over these, which is also what makes it unit-testable."""
|
||||
|
||||
kind: str # "movie" | "show"
|
||||
obj: Any # PlexItem | PlexShow, carried through to the card serializer
|
||||
title: str # lower-cased; never NULL (the column is not nullable)
|
||||
year: int | None
|
||||
rating: float | None
|
||||
added: datetime | None
|
||||
release: date | None
|
||||
duration: int | None # movies only — a show has no single runtime
|
||||
last_watched: datetime | None
|
||||
|
||||
|
||||
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
|
||||
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
|
||||
# that silently does nothing here.
|
||||
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
|
||||
"added": lambda m: m.added,
|
||||
"lastwatched": lambda m: m.last_watched,
|
||||
"release": lambda m: m.release,
|
||||
"year": lambda m: m.year,
|
||||
"rating": lambda m: m.rating,
|
||||
"duration": lambda m: m.duration,
|
||||
"title": lambda m: m.title,
|
||||
}
|
||||
|
||||
# What the page ordered by before it had a control, and still its default: a film series wants to be
|
||||
# read in release order, not by whenever Plex happened to ingest it.
|
||||
DEFAULT_MEMBER_SORT = "year"
|
||||
|
||||
|
||||
def sort_members(members: list[Member], sort: str, sort_dir: str) -> list[Member]:
|
||||
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
|
||||
|
||||
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
|
||||
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
|
||||
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
|
||||
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
|
||||
it. An unknown key falls back to the page's default instead of returning an arbitrary order."""
|
||||
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
|
||||
ordered = sorted(members, key=lambda m: m.title) # stable secondary, applied first
|
||||
have = [m for m in ordered if key(m) is not None]
|
||||
missing = [m for m in ordered if key(m) is None]
|
||||
have.sort(key=key, reverse=sort_dir != "asc")
|
||||
return have + missing
|
||||
|
||||
|
||||
@router.get("/collections/{rating_key}")
|
||||
def collection_detail(
|
||||
rating_key: str,
|
||||
sort: str = DEFAULT_MEMBER_SORT,
|
||||
sort_dir: str = "asc",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One collection's hero data + its members (movies + shows), ordered by release year then title —
|
||||
the Collection info page. Read-only here; edits go through the admin mutation endpoints below."""
|
||||
"""One collection's hero data + its members (movies + shows) in the requested order — the
|
||||
Collection info page. The sort vocabulary is the browse grid's (`PLEX_SORT_KEYS`) so the same Sort
|
||||
control means the same thing on both surfaces; the default is release year ascending, which is
|
||||
what this page did before it had a control. Read-only here; edits go through the admin mutation
|
||||
endpoints below."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
@@ -179,19 +253,63 @@ def collection_detail(
|
||||
.all()
|
||||
)
|
||||
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
|
||||
sstate = rollup.state_map(db, user.id, [s.id for s in shows])
|
||||
# Merge the two type-homogeneous lists into ONE member list ordered by (year, title).
|
||||
tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [
|
||||
(s.year, (s.title or "").lower(), "show", s) for s in shows
|
||||
# ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` —
|
||||
# the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The
|
||||
# cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on
|
||||
# a show collection while the control still offered it.
|
||||
show_rows = (
|
||||
db.execute(
|
||||
rollup.agg_subquery(db, user.id, include_resumed=True).where(
|
||||
PlexItem.show_id.in_([s.id for s in shows])
|
||||
)
|
||||
).all()
|
||||
if shows
|
||||
else []
|
||||
)
|
||||
sstate = {r.show_id: rollup.show_status(r.total, r.watched, r.inprog) for r in show_rows}
|
||||
sresumed = {r.show_id: r.resumed_at for r in show_rows}
|
||||
|
||||
def _movie_last_watched(st: PlexState | None) -> datetime | None:
|
||||
# Same definition as the browse grid's: when it was last resumed, else when it was marked
|
||||
# watched (coalesce(progress_updated_at, watched_at)).
|
||||
return (st.progress_updated_at or st.watched_at) if st else None
|
||||
|
||||
# Merge the two type-homogeneous lists into ONE member list, then order it.
|
||||
members = [
|
||||
Member(
|
||||
kind="movie",
|
||||
obj=m,
|
||||
title=(m.title or "").lower(),
|
||||
year=m.year,
|
||||
rating=m.rating,
|
||||
added=m.added_at,
|
||||
release=m.originally_available_at,
|
||||
duration=m.duration_s,
|
||||
last_watched=_movie_last_watched(mstate.get(m.id)),
|
||||
)
|
||||
for m in movies
|
||||
] + [
|
||||
Member(
|
||||
kind="show",
|
||||
obj=sh,
|
||||
title=(sh.title or "").lower(),
|
||||
year=sh.year,
|
||||
rating=sh.rating,
|
||||
added=sh.added_at,
|
||||
release=sh.originally_available_at,
|
||||
duration=None,
|
||||
last_watched=sresumed.get(sh.id),
|
||||
)
|
||||
for sh in shows
|
||||
]
|
||||
tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1]))
|
||||
members = sort_members(members, sort, sort_dir)
|
||||
items = [
|
||||
movie_card(o, mstate.get(o.id))
|
||||
if kind == "movie"
|
||||
else show_card(o, sstate.get(o.id, "new"))
|
||||
for _, _, kind, o in tagged
|
||||
movie_card(m.obj, mstate.get(m.obj.id))
|
||||
if m.kind == "movie"
|
||||
else show_card(m.obj, sstate.get(m.obj.id, "new"))
|
||||
for m in members
|
||||
]
|
||||
years = [y for y, _, _, _ in tagged if y is not None]
|
||||
years = [m.year for m in members if m.year is not None]
|
||||
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
|
||||
return {
|
||||
**_collection_card(col),
|
||||
@@ -229,7 +347,9 @@ def _reject_duplicate_name(
|
||||
.first()
|
||||
)
|
||||
if dup is not None and dup.rating_key != exclude_rk:
|
||||
raise HTTPException(status_code=409, detail="A collection with this name already exists")
|
||||
# Structured, so the bilingual UI says it in the reader's language (it was toasted in
|
||||
# Hungarian beside an English error dialog saying the same thing).
|
||||
raise HTTPException(status_code=409, detail={"code": "collection", "reason": "name_taken"})
|
||||
|
||||
|
||||
def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Plex playback: image/subtitle proxies, HLS streaming, and watch-state writes."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -12,17 +10,20 @@ from fastapi import (
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Path,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, resolved_user_id
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import (
|
||||
POSITION_MAX_S,
|
||||
PlexItem,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
@@ -89,62 +90,70 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||
return imgcache.response(data, ct)
|
||||
|
||||
|
||||
# Both subtitle routes authenticate with the full `current_user`, not the cookie-only
|
||||
# `_require_session` the image proxies use. That lighter gate exists for bursts of low-sensitivity
|
||||
# poster fetches and skips the is_active / is_suspended / session-epoch checks — acceptable for a
|
||||
# poster, not for a route that starts reads of the media library: a suspended or "log out
|
||||
# everywhere"-revoked cookie kept scheduling them. They are not bursty (one poll a second, one fetch
|
||||
# per track), so the DB user-load costs nothing that matters.
|
||||
#
|
||||
# Both path parameters are bounded, the same way on both routes: they become a cache FILE name
|
||||
# (`sub_<rating_key>_<ord>`), and an absurd one — a 250-digit key or ordinal — outgrew the OS's name
|
||||
# limit and turned the status poll into a 500. A Plex rating_key is a short number; the bound only
|
||||
# has to be generous.
|
||||
_RatingKey = Path(max_length=32)
|
||||
_SubOrd = Path(ge=0, le=9999)
|
||||
|
||||
|
||||
@router.get("/subtitle/{rating_key}/{sub_ord}/status")
|
||||
def subtitle_status(
|
||||
rating_key: str = _RatingKey,
|
||||
sub_ord: int = _SubOrd,
|
||||
user: User = Depends(current_user),
|
||||
# Bounded both ways: a non-finite position used to reach the scheduler's arithmetic and 500, and
|
||||
# an absurd one reached ffmpeg's `-ss`, which refused it.
|
||||
at: float = Query(0.0, ge=0.0, le=subtitles.MAX_POSITION_S, allow_inf_nan=False),
|
||||
) -> dict:
|
||||
"""Is this subtitle track showable yet — and if not, get it moving. Extraction runs in the
|
||||
background (see plex/subtitles.py for the measurements that forced it there), so the player polls
|
||||
this while it waits instead of hanging on a <track> that will not load for a minute or more.
|
||||
|
||||
`at` is the viewer's absolute position: the extractor windows around it, so what gets read first
|
||||
is the part actually about to be watched."""
|
||||
return subtitles.ensure(rating_key, sub_ord, at)
|
||||
|
||||
|
||||
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
||||
def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.0) -> Response:
|
||||
def subtitle(
|
||||
rating_key: str = _RatingKey,
|
||||
sub_ord: int = _SubOrd,
|
||||
user: User = Depends(current_user),
|
||||
# Finite, like every float this router takes: `-inf` crashed the shift (a 500), `nan` stacked
|
||||
# every cue at 0. It can legitimately be negative (a subtitle nudged later than the session start).
|
||||
offset: float = Query(0.0, allow_inf_nan=False),
|
||||
) -> Response:
|
||||
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
|
||||
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
|
||||
from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file)
|
||||
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached."""
|
||||
_require_session(request)
|
||||
# Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way
|
||||
# out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion).
|
||||
cache_key = f"sub_{rating_key}_{sub_ord}"
|
||||
hit = subtitles.cache_read(cache_key)
|
||||
if hit is not None:
|
||||
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
it = item_or_404(db, rating_key)
|
||||
with PlexClient(db) as plex:
|
||||
part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[0].get(
|
||||
"Part"
|
||||
) or [{}]
|
||||
subs = [s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3]
|
||||
if sub_ord < 0 or sub_ord >= len(subs):
|
||||
raise HTTPException(status_code=404, detail="No such subtitle")
|
||||
s = subs[sub_ord]
|
||||
codec = (s.get("codec") or s.get("format") or "").lower()
|
||||
if codec in subtitles.BITMAP_SUBS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Image-based subtitle can't be shown as text",
|
||||
)
|
||||
if s.get("key"): # external sidecar → fetch from Plex, convert to VTT
|
||||
raw = plex.raw_get(s["key"])
|
||||
if codec in ("srt", "subrip", ""):
|
||||
vtt = subtitles.srt_to_vtt(raw.decode("utf-8", "replace"))
|
||||
else:
|
||||
fd, tmp = tempfile.mkstemp(suffix="." + (codec or "sub"))
|
||||
try:
|
||||
os.write(fd, raw)
|
||||
os.close(fd)
|
||||
vtt = subtitles.ffmpeg_to_vtt(tmp, None)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
elif s.get("index") is not None: # embedded → extract from the local file
|
||||
src = plex_paths.local_media_path(db, it.file_path)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
emb_ord = sum(1 for x in subs[:sub_ord] if x.get("index") is not None)
|
||||
vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Subtitle not fetchable")
|
||||
except PlexNotConfigured as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PlexError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
||||
subtitles.cache_write(cache_key, vtt)
|
||||
return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt")
|
||||
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached.
|
||||
|
||||
A pure READ: it serves whatever is cached and schedules nothing — the status poll owns that,
|
||||
because only it knows where the viewer is (see `subtitles.cached_track`). The player mounts the
|
||||
<track> only once /status says there is something to show, so in practice this is only ever hit
|
||||
warm; on a cache eviction race the 202 sends the player back to polling."""
|
||||
# The cached VTT holds ABSOLUTE cue times (per rk+ord); the per-session cue-shift is applied on
|
||||
# the way out, so a resume/seek to any offset reuses the same track (no cache-per-offset explosion).
|
||||
hit, code = subtitles.cached_track(rating_key, sub_ord)
|
||||
if hit is None and code:
|
||||
raise HTTPException(
|
||||
status_code=subtitles.UNAVAILABLE_STATUS.get(code, 422),
|
||||
detail={"code": "subtitle", "reason": code},
|
||||
)
|
||||
if hit is None:
|
||||
return Response(
|
||||
status_code=202, media_type="application/json", content='{"state":"preparing"}'
|
||||
)
|
||||
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
|
||||
|
||||
|
||||
# Resume/watched thresholds. _PROGRESS_MIN_S matches the feed's PROGRESS_MIN_SECONDS (5), but
|
||||
@@ -287,10 +296,15 @@ def _push_watch(
|
||||
|
||||
|
||||
class ItemProgressIn(BaseModel):
|
||||
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
|
||||
# one into a 500.
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
|
||||
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
|
||||
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
|
||||
position_seconds: float = 0
|
||||
duration_seconds: float = 0
|
||||
# Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500.
|
||||
position_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
duration_seconds: float = Field(0, le=POSITION_MAX_S)
|
||||
final: bool = False
|
||||
|
||||
|
||||
@@ -309,6 +323,14 @@ def item_progress(
|
||||
`_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
|
||||
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
|
||||
it = item_or_404(db, rating_key)
|
||||
# A player parked mid-item — playing or PAUSED — checkpoints every 10 s, so this is the signal
|
||||
# that such a viewer still wants their HLS session: the one thing `last_access` could not see,
|
||||
# since it moved only on a file fetch and a paused player fetches nothing (see
|
||||
# stream.reap_idle). It is NOT every open player: `usePlexProgress.flush` sends nothing once the
|
||||
# finish latch is up, or before the first position is known. Those are the states in which
|
||||
# reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets
|
||||
# the same 404 → reclaim the player already handles.
|
||||
plex_stream.touch_session(str(rating_key))
|
||||
position = max(0, int(body.position_seconds))
|
||||
duration = int(body.duration_seconds)
|
||||
final = body.final
|
||||
@@ -406,10 +428,20 @@ def item_state(
|
||||
)
|
||||
prev_status = st.status if st is not None else "new"
|
||||
if status == "new":
|
||||
# Does Plex hold anything for this item that the un-mark has to clear? Two things qualify: a
|
||||
# view count, and a resume point. An IN-PROGRESS item carries status "new" WITH a position —
|
||||
# that is how the importer maps a bare `viewOffset` (`_plex_watch_to_state`) — so keying the
|
||||
# push on the status alone silently skipped the single case "Clear resume point" exists for:
|
||||
# the un-mark never reached Plex, the offset stayed, and the next pull imported it straight
|
||||
# back (Protector, cleared twice by the user and back at 92s both times). `hidden` is
|
||||
# Siftlode-only and still pushes nothing, exactly as this endpoint's docstring says.
|
||||
mirrored_on_plex = st is not None and (
|
||||
st.status == "watched" or (st.position_seconds or 0) > 0
|
||||
)
|
||||
if st is not None:
|
||||
db.delete(st)
|
||||
db.commit()
|
||||
if prev_status != "new": # something → unwatched: mirror the un-mark to Plex
|
||||
if mirrored_on_plex:
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
|
||||
return {"status": "new"}
|
||||
if st is None:
|
||||
@@ -524,9 +556,10 @@ def season_state(
|
||||
@router.post("/stream/{rating_key}/session")
|
||||
def stream_session(
|
||||
rating_key: str,
|
||||
start: float = 0.0,
|
||||
# Finite: `start=inf` reached `int()` in the session builder and came back as a 500.
|
||||
start: float = Query(0.0, allow_inf_nan=False),
|
||||
audio: int | None = None,
|
||||
aoff: float = 0.0,
|
||||
aoff: float = Query(0.0, allow_inf_nan=False),
|
||||
multi: bool = False,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -536,7 +569,6 @@ def stream_session(
|
||||
HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264).
|
||||
Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio
|
||||
can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
|
||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||
it = item_or_404(db, rating_key)
|
||||
transcode = it.playable == "transcode"
|
||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
||||
@@ -560,7 +592,9 @@ def stream_session(
|
||||
return {
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||
# The session id is part of the path (not a query param) so the playlist's RELATIVE segment
|
||||
# names resolve back into the same session — see `stream_hls_file` for why it has to.
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}",
|
||||
"transcode": s.transcode,
|
||||
"hw": s.hw,
|
||||
"tonemap": s.tonemap,
|
||||
@@ -592,45 +626,82 @@ def stream_file(
|
||||
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
||||
|
||||
|
||||
# What a session directory can contain, and nothing else. No `.vtt`: ffmpeg runs with `-sn` and
|
||||
# subtitles are independent tracks served by GET /subtitle, so a WebVTT name here could only ever be
|
||||
# a name that will not arrive — and accepting it cost a 20 s parked worker per request.
|
||||
_HLS_CT = {
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".ts": "video/mp2t",
|
||||
".vtt": "text/vtt",
|
||||
}
|
||||
# A real artifact name is ~20 characters. The bound is what keeps an absurd one out of the parser:
|
||||
# `int()` on an unbounded digit run raises ValueError past 4300 digits (CPython's
|
||||
# `sys.get_int_max_str_digits`), which reached the client as a 500 — the same shape of bug the
|
||||
# subtitle routes were bounded for (`_RatingKey`, above).
|
||||
_HLS_NAME_MAX = 128
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/hls/{filename}")
|
||||
@router.get("/stream/{rating_key}/hls/{session_id}/{filename}")
|
||||
def stream_hls_file(
|
||||
rating_key: str,
|
||||
session_id: str,
|
||||
filename: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Serve a file (playlist / segment / subtitle) from the current HLS session directory. The
|
||||
playlist references its segments/subtitles by relative name, all resolving back here."""
|
||||
"""Serve a file (playlist / segment / subtitle) from ONE HLS session's directory. The playlist
|
||||
references its segments/subtitles by relative name, all resolving back here.
|
||||
|
||||
The session id in the path is what makes "back here" mean the same session. Sessions are keyed by
|
||||
rating_key alone and a seek-restart or a second viewer of the same item replaces the registered
|
||||
one, while the DIRECTORY is per (key, offset, tag) — so resolving by rating_key handed the old
|
||||
viewer the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
|
||||
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a
|
||||
session that is no longer the registered one is a 404, and the player reclaims at the playhead.
|
||||
|
||||
That reclaim is also why a request with no live session is no longer auto-started here: the
|
||||
auto-start could only ever begin at 0, silently throwing away the viewer's position."""
|
||||
# Filename must be a plain hls artifact — no path separators, only the expected extensions.
|
||||
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
|
||||
if ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename:
|
||||
if (
|
||||
len(filename) > _HLS_NAME_MAX
|
||||
or ext not in _HLS_CT
|
||||
or "/" in filename
|
||||
or "\\" in filename
|
||||
or ".." in filename
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
s = plex_stream.current_session(str(rating_key))
|
||||
if s is None:
|
||||
# No session yet (e.g. reaped): the ENTRY playlist may auto-start one at 0 — master.m3u8 for a
|
||||
# multi-audio item, index.m3u8 for a single-audio remux item.
|
||||
it = item_or_404(db, rating_key)
|
||||
if filename == "master.m3u8":
|
||||
s = plex_stream.start_session(db, it, 0.0, multi=True)
|
||||
elif filename == "index.m3u8" and it.playable == "remux":
|
||||
s = plex_stream.start_session(db, it, 0.0)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="No active playback session")
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
if s is None or s.id != session_id:
|
||||
raise HTTPException(status_code=404, detail="No active playback session")
|
||||
path = s.dir / filename
|
||||
timeout = 30 if ext == ".m3u8" else 20
|
||||
if not plex_stream.wait_for(path, timeout):
|
||||
# Only a name THIS session can actually produce is worth waiting for: its own playlists, and a
|
||||
# segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them
|
||||
# in order, so if it is not there this one is not next). The session is passed, not just its
|
||||
# directory, because the single- and multi-audio argv branches spell their names differently and
|
||||
# neither can produce the other's. Answering now instead of waiting it out is what keeps a seek
|
||||
# past the produced region — or a deliberate flood of hopeless names — from parking threadpool
|
||||
# workers and their DB connections (see `artifact_may_arrive`).
|
||||
if not plex_stream.artifact_may_arrive(s, filename):
|
||||
raise HTTPException(status_code=404, detail="Not available yet")
|
||||
timeout = 30 if ext == ".m3u8" else 20
|
||||
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
|
||||
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
|
||||
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
|
||||
# past the produced region was enough to do it (round 5).
|
||||
if not plex_stream.wait_for(path, timeout, proc=s.proc):
|
||||
raise HTTPException(status_code=404, detail="Not available yet")
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except OSError:
|
||||
# The session was torn down between the wait and the read — `_kill` rmtrees the whole
|
||||
# directory, and nothing holds `_lock` across those two steps. It has to present as the same
|
||||
# 404 a gone session does: the player's recovery is keyed on the STATUS (`code === 404`), so
|
||||
# a 500 here skipped the reclaim and ended playback with "couldn't play" instead of
|
||||
# restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which
|
||||
# kills and rmtrees the old one — before destroying the old hls instance, so the outgoing
|
||||
# instance's in-flight segment request races the rmtree on every seek-restart.
|
||||
raise HTTPException(status_code=404, detail="No active playback session") from None
|
||||
return Response(
|
||||
content=path.read_bytes(),
|
||||
content=content,
|
||||
media_type=_HLS_CT[ext],
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist:
|
||||
return owned_or_404(db, PlexPlaylist, pid, user, detail="Playlist not found")
|
||||
return owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
|
||||
|
||||
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
||||
|
||||
@@ -91,7 +91,7 @@ def create_view(
|
||||
|
||||
|
||||
def _own_view(db: Session, user: User, view_id: int) -> SavedView:
|
||||
return owned_or_404(db, SavedView, view_id, user, detail="Unknown view")
|
||||
return owned_or_404(db, SavedView, view_id, user, what="view")
|
||||
|
||||
|
||||
class UpdateViewIn(BaseModel):
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.scheduler import (
|
||||
trigger_job,
|
||||
)
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.routes._common import not_found
|
||||
|
||||
router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||
|
||||
@@ -41,7 +42,7 @@ def set_job_interval(
|
||||
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
|
||||
to the live scheduler immediately."""
|
||||
if job_id not in JOB_INTERVALS:
|
||||
raise HTTPException(status_code=404, detail="Unknown job")
|
||||
raise not_found("job")
|
||||
minutes = body.interval_minutes
|
||||
if not (MIN_INTERVAL <= minutes <= MAX_INTERVAL):
|
||||
raise HTTPException(
|
||||
@@ -73,7 +74,7 @@ def run_job_now(
|
||||
try:
|
||||
result = trigger_job(job_id, actor_id=user.id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Unknown job")
|
||||
raise not_found("job")
|
||||
if result == "already_running":
|
||||
raise HTTPException(status_code=409, detail="Job is already running")
|
||||
return {"id": job_id, "started": True}
|
||||
|
||||
@@ -80,7 +80,7 @@ def create_tag(
|
||||
|
||||
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
|
||||
# System tags (user_id NULL) are not user-editable → owned_or_404 404s them too.
|
||||
return owned_or_404(db, Tag, tag_id, user, detail="Unknown tag")
|
||||
return owned_or_404(db, Tag, tag_id, user, what="tag")
|
||||
|
||||
|
||||
class TagUpdateIn(BaseModel):
|
||||
|
||||
@@ -167,6 +167,8 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec(
|
||||
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
|
||||
),
|
||||
# Audio loudness normalisation on the HLS path (remux + transcode alike, not direct play).
|
||||
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"),
|
||||
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
|
||||
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""The shared demo account's refusal says WHY, in the reader's language (deep-review round 6).
|
||||
|
||||
`require_human` answered `403 "Not available in the demo account."` — a plain string. `req` leaves
|
||||
403 to the caller, and round 5 taught `notifyActionError` to discard a plain-string detail in favour
|
||||
of the caller's already-translated fallback, so from that commit on the demo user got a generic
|
||||
"couldn't do that" and no hint that the demo account was the reason. Not every affordance behind
|
||||
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
|
||||
|
||||
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
|
||||
Pinned at all FOUR sites: the shared dependency, the two auth routes that hand-roll the same check,
|
||||
and the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
|
||||
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
|
||||
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
|
||||
|
||||
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
|
||||
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
|
||||
the code the way round 6's did. Needs the DB.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as_user):
|
||||
demo = make_user(email="demo@example.com", is_demo=True)
|
||||
client = as_user(demo)
|
||||
for method, url, body in [
|
||||
# `require_human` itself, through an affordance the UI does NOT gate on is_demo.
|
||||
("post", "/api/discover/pins", {"kind": "video", "ref": "v1"}),
|
||||
# The three routes that repeat the check inline rather than depending on the gate.
|
||||
("post", "/auth/logout-others", None),
|
||||
("post", "/auth/set-password", {"password": "a long enough password"}),
|
||||
("delete", "/api/me/account", None),
|
||||
]:
|
||||
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
|
||||
assert r.status_code == 403, (url, r.text)
|
||||
detail = r.json()["detail"]
|
||||
# Every one of them is a dict whose `code` the frontend can localize; the delete route adds
|
||||
# a `reason` so its refusal can be worded specifically.
|
||||
assert isinstance(detail, dict), (url, detail)
|
||||
assert detail["code"] == "demo", (url, detail)
|
||||
|
||||
|
||||
def test_every_is_demo_refusal_is_on_the_list():
|
||||
"""A fifth site must not be able to appear unnoticed.
|
||||
|
||||
Round 6 converted three of four `is_demo` 403s and wrote a docstring saying it had converted
|
||||
them all; the fourth kept answering a bare English string for another round because nothing
|
||||
counted them. So count them here: every `is_demo`-guarded 403 in `app/` has to raise a
|
||||
structured detail, and the number of them has to match what this file exercises."""
|
||||
app_dir = Path(__file__).resolve().parents[1] / "app"
|
||||
sites = []
|
||||
for path in sorted(app_dir.rglob("*.py")):
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
# `if user.is_demo:` / `if u.is_demo:` — the hand-rolled gate, not the ORM filters
|
||||
# (`User.is_demo.is_(False)`) and not the column definition.
|
||||
if not re.search(r"^\s*if\s+\w+\.is_demo\s*:", line):
|
||||
continue
|
||||
block = "\n".join(lines[i : i + 6])
|
||||
if "403" not in block:
|
||||
continue
|
||||
sites.append(f"{path.relative_to(app_dir)}:{i + 1}")
|
||||
assert 'detail={"code": "demo"' in block, (
|
||||
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
|
||||
f"unstructured detail — the frontend cannot localize it:\n{block}"
|
||||
)
|
||||
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete. A new one is welcome,
|
||||
# but it has to be added to the loop above in the same commit.
|
||||
assert len(sites) == 4, sites
|
||||
|
||||
|
||||
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
|
||||
"""The other half: the structured detail must not come from having broken the gate open the
|
||||
other way. A non-demo account gets past `require_human` and fails (or succeeds) on its own
|
||||
merits — anything but a 403 demo refusal."""
|
||||
client = as_user(make_user(email="real@example.com"))
|
||||
r = client.post("/auth/logout-others")
|
||||
assert r.status_code == 200, r.text
|
||||
assert not isinstance(r.json().get("detail"), dict)
|
||||
|
||||
|
||||
def test_the_gate_reads_the_flag_and_not_the_email(db):
|
||||
"""`is_demo` is a column, not a convention about the address — a real account that happens to
|
||||
be called demo@ must not be refused."""
|
||||
from app.auth import require_human
|
||||
|
||||
assert require_human(User(email="demo@example.com", is_demo=False)) is not None
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Idle HLS sessions are reaped on a timer, and a viewer who is merely PAUSED keeps theirs.
|
||||
|
||||
plex/stream.py promises "a periodic reaper kills idle ones", but `reap_idle` ran only inside
|
||||
POST /session: after the last playback, an abandoned session (its ffmpeg still working through the
|
||||
film, its directory a whole segmented copy of it) lived until somebody pressed play again
|
||||
(deep-review round 4).
|
||||
|
||||
And it must not take a PAUSED viewer's session: a remux finishes long before the film does, and a
|
||||
paused player with a full buffer fetches nothing, so "finished and unfetched for 30 s" does not mean
|
||||
"abandoned". Round 4 bought that safety by keeping EVERY finished session for the full ten-minute
|
||||
idle window — one segmented copy of a film per item, on the volume the download center shares. Round
|
||||
5 replaced the blanket retention with the signal that already existed: an open player checkpoints its
|
||||
position every 10 s whether or not it is playing, and that route says "still here".
|
||||
|
||||
Both call sites are pinned here, because the bug each time was that only one of them moved: the
|
||||
periodic pass, and the fact that starting a session no longer sweeps OTHER viewers' sessions at all.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as app_main
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream
|
||||
|
||||
|
||||
class _Configured:
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def test_the_periodic_pass_is_the_one_that_runs_and_it_uses_the_short_rule(monkeypatch):
|
||||
async def _no_sweep():
|
||||
return None
|
||||
|
||||
reaped: list[int] = []
|
||||
monkeypatch.setattr(app_main, "SessionLocal", _Configured)
|
||||
monkeypatch.setattr(app_main.state, "is_configured", lambda db: True)
|
||||
monkeypatch.setattr(app_main, "_sweep_hls_orphans", _no_sweep)
|
||||
monkeypatch.setattr(app_main, "start_scheduler", lambda: None)
|
||||
monkeypatch.setattr(app_main, "shutdown_scheduler", lambda: None)
|
||||
monkeypatch.setattr(app_main.plex_subtitles, "shutdown", lambda: None)
|
||||
monkeypatch.setattr(app_main, "_HLS_REAP_EVERY_S", 0.01)
|
||||
# `reap_idle`, i.e. the SHORT finished rule: a finished session nobody has said they still want
|
||||
# is a whole copy of a film sitting on the download volume.
|
||||
monkeypatch.setattr(app_main.plex_stream, "reap_idle", lambda: reaped.append(1) or 0)
|
||||
|
||||
async def _cycle():
|
||||
async with app_main.lifespan(app):
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
asyncio.run(_cycle())
|
||||
assert len(reaped) >= 2, "no periodic reap ran while the app was up"
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self, finished: bool):
|
||||
self.finished = finished
|
||||
self.killed = False
|
||||
|
||||
def poll(self):
|
||||
return 0 if self.finished else None
|
||||
|
||||
def terminate(self):
|
||||
self.killed = True
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, tmp_path, name: str, finished: bool, idle_s: float):
|
||||
self.dir = tmp_path / name
|
||||
self.dir.mkdir()
|
||||
self.proc = _Proc(finished)
|
||||
self.last_access = time.time() - idle_s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(monkeypatch):
|
||||
live: dict = {}
|
||||
monkeypatch.setattr(stream, "_sessions", live)
|
||||
return live
|
||||
|
||||
|
||||
def test_the_periodic_pass_reaps_a_finished_session_nobody_has_claimed(tmp_path, sessions):
|
||||
sessions["12345"] = _Session(tmp_path, "done", finished=True, idle_s=45)
|
||||
assert stream.reap_idle() == 1
|
||||
assert sessions == {}
|
||||
|
||||
|
||||
def test_the_periodic_pass_still_reaps_what_nobody_has_touched_for_the_idle_limit(
|
||||
tmp_path, sessions
|
||||
):
|
||||
sessions["1"] = _Session(
|
||||
tmp_path, "gone_done", finished=True, idle_s=stream._SESSION_IDLE_S + 5
|
||||
)
|
||||
sessions["2"] = _Session(
|
||||
tmp_path, "gone_live", finished=False, idle_s=stream._SESSION_IDLE_S + 5
|
||||
)
|
||||
assert stream.reap_idle() == 2
|
||||
assert sessions == {}
|
||||
|
||||
|
||||
def test_touching_a_session_keeps_it_through_the_short_rule(tmp_path, sessions):
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["12345"] = s
|
||||
stream.touch_session("12345")
|
||||
assert stream.reap_idle() == 0
|
||||
assert "12345" in sessions and s.dir.exists() and not s.proc.killed
|
||||
|
||||
|
||||
# --- the two call sites, driven through the real routes (DB lane) -------------------------------
|
||||
|
||||
|
||||
def _library_item(db, rating_key: str, *, playable: str = "remux"):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
it = PlexItem(
|
||||
rating_key=rating_key,
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="A Movie",
|
||||
file_path="/media/a.mkv",
|
||||
playable=playable,
|
||||
)
|
||||
db.add(it)
|
||||
db.commit()
|
||||
return it
|
||||
|
||||
|
||||
def _client(db, user):
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_a_paused_viewers_checkpoint_keeps_their_session_alive(db, monkeypatch, tmp_path, sessions):
|
||||
"""The whole reason the short rule is safe again: a paused player fetches nothing, so
|
||||
`last_access` never moved on its own — but it does keep checkpointing its position."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
it = _library_item(db, "paused1")
|
||||
user = User(email="paused@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
s = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["paused1"] = s
|
||||
try:
|
||||
resp = _client(db, user).post(
|
||||
f"/api/plex/item/{it.rating_key}/progress",
|
||||
json={"position_seconds": 600, "duration_seconds": 3000, "final": False},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert stream.reap_idle() == 0, "the checkpoint did not say the viewer is still there"
|
||||
assert "paused1" in sessions and not s.proc.killed
|
||||
|
||||
|
||||
def test_starting_a_session_leaves_another_viewers_paused_one_alone(
|
||||
db, monkeypatch, tmp_path, sessions
|
||||
):
|
||||
"""The opportunistic sweep this route used to run was not scoped to the key being started: it
|
||||
walked the WHOLE registry with the 30 s finished rule, so pressing play on any item killed a
|
||||
paused viewer's session — the very case round 4 set out to protect (found in round 5)."""
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
monkeypatch.setattr(plex_paths, "local_media_path", lambda db_, fp: "/local/a.mkv")
|
||||
started = _library_item(db, "starting")
|
||||
user = User(email="starter@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
paused = _Session(tmp_path, "paused", finished=True, idle_s=90)
|
||||
sessions["someone-else"] = paused
|
||||
|
||||
def fake_start(db_, item, start, audio=None, aoff=0.0, multi=False, transcode=False):
|
||||
return SimpleNamespace(
|
||||
id="sid",
|
||||
media_start_s=start,
|
||||
entry="index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=False,
|
||||
tonemap=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(stream, "start_session", fake_start)
|
||||
try:
|
||||
resp = _client(db, user).post(f"/api/plex/stream/{started.rating_key}/session")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "someone-else" in sessions and not paused.proc.killed
|
||||
@@ -0,0 +1,148 @@
|
||||
"""A row that is gone answers with a reason the bilingual UI can translate (deep-review round 3).
|
||||
|
||||
The 404 a user actually meets is the one for something deleted in another tab or on another device.
|
||||
`owned_or_404` / `get_or_404` sent it as a bare English string ("Unknown view", "Not found"), and the
|
||||
Hungarian UI toasted it verbatim. The structured-404 fix had gone into one ad-hoc helper instead of
|
||||
the shared one, so every other owner check kept the English. Needs the DB.
|
||||
"""
|
||||
|
||||
from app.auth import require_write_scope
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
|
||||
def test_a_gone_view_tag_or_pin_says_what_is_gone(db, make_user, as_user):
|
||||
client = as_user(make_user())
|
||||
for method, url, what in [
|
||||
("patch", "/api/saved-views/999999", "view"),
|
||||
("delete", "/api/saved-views/999999", "view"),
|
||||
("delete", "/api/tags/999999", "tag"),
|
||||
("delete", "/api/discover/pins/999999", "pin"),
|
||||
]:
|
||||
r = getattr(client, method)(url, **({"json": {"name": "x"}} if method == "patch" else {}))
|
||||
assert r.status_code == 404, (url, r.text)
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": what}, url
|
||||
|
||||
|
||||
def test_a_gone_channel_video_or_user_says_what_is_gone(db, make_user, as_user):
|
||||
"""The siblings that still hand-wrote `db.get(...) is None → 404 "Unknown …"` (deep-review
|
||||
round 4): the same gone row, but the Hungarian UI showed the English."""
|
||||
admin = make_user(role="admin")
|
||||
client = as_user(admin)
|
||||
for method, url, body, what in [
|
||||
("post", "/api/channels/UCgone000000000000000000/block", None, "channel"),
|
||||
("post", "/api/videos/gone0000000/state", {"status": "watched"}, "video"),
|
||||
(
|
||||
"post",
|
||||
"/api/videos/gone0000000/progress",
|
||||
{"position_seconds": 60, "duration_seconds": 600},
|
||||
"video",
|
||||
),
|
||||
("put", "/api/admin/downloads/quota/999999", {"max_bytes": 1}, "user"),
|
||||
]:
|
||||
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
|
||||
assert r.status_code == 404, (url, r.text)
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": what}, url
|
||||
|
||||
|
||||
def test_every_gone_channel_route_says_it_the_same_way(db, make_user, as_user, override_dep):
|
||||
"""The round-4 sweep converted ONE of the five `db.get(Channel, …) is None -> 404 "Unknown
|
||||
channel"` sites (block), and shipped the HU/EN string with it — so the Hungarian UI said it in
|
||||
Hungarian on the Block button and in English on the four beside it (deep-review round 5). The
|
||||
asymmetric fix is the smell this repo bans, so all five are pinned together.
|
||||
|
||||
Literally all five: round 6 found this loop issuing three requests under a docstring claiming
|
||||
five, with explore silently absent and subscribe excused as "covered by the tags route" — which
|
||||
reaches `_user_subscription`, a different site answering `reason: subscription`. Reverting
|
||||
either of those two to its bare English string left the whole lane green."""
|
||||
admin = make_user(role="admin")
|
||||
client = as_user(admin)
|
||||
# Subscribe's own gate (the optional YouTube write scope) sits in FRONT of the lookup, so a
|
||||
# plain account 403s before reaching it. The gate is not what is under test here.
|
||||
override_dep(require_write_scope, lambda: admin)
|
||||
gone = "UCgone000000000000000000"
|
||||
for method, url in [
|
||||
("get", f"/api/channels/{gone}"),
|
||||
("post", f"/api/channels/{gone}/explore"),
|
||||
("post", f"/api/channels/{gone}/reset-backfill"),
|
||||
("post", f"/api/channels/{gone}/subscribe"),
|
||||
("post", f"/api/channels/{gone}/block"),
|
||||
]:
|
||||
r = getattr(client, method)(url)
|
||||
assert r.status_code == 404, (url, r.text)
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "channel"}, url
|
||||
|
||||
|
||||
def test_a_gone_subscription_recipient_or_job_says_what_is_gone(db, make_user, as_user):
|
||||
"""The three bare-English 404s the same sweep left behind, which `notifyActionError` printed
|
||||
verbatim into an otherwise Hungarian UI: "Not subscribed to this channel", "No user with that
|
||||
email.", "Unknown job".
|
||||
|
||||
All three, with a request each. Round 6 found this test naming three and issuing two: the
|
||||
recipient site had no test in the whole suite (nor did the `errors.notFound.recipient` keys
|
||||
added beside it), and `run_job_now` rode on its PATCH sibling's coverage."""
|
||||
admin = make_user(role="admin")
|
||||
client = as_user(admin)
|
||||
r = client.post("/api/channels/UCgone000000000000000000/tags", json={"tag_id": 1})
|
||||
assert r.status_code == 404, r.text
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "subscription"}
|
||||
for method, url in [
|
||||
("patch", "/api/admin/scheduler/jobs/no_such_job"),
|
||||
("post", "/api/admin/scheduler/jobs/no_such_job/run"),
|
||||
]:
|
||||
kw = {"json": {"interval_minutes": 30}} if method == "patch" else {}
|
||||
r = getattr(client, method)(url, **kw)
|
||||
assert r.status_code == 404, (url, r.text)
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "job"}, url
|
||||
# Sharing needs a finished download of one's own to share; the recipient lookup is the next check.
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref="share-404", format_sig="sig", status="ready", ref_count=1
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=admin.id,
|
||||
asset_id=asset.id,
|
||||
source_kind="url",
|
||||
source_ref="share-404",
|
||||
status="done",
|
||||
queue_pos=1,
|
||||
profile_snapshot={},
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
r = client.post(f"/api/downloads/{job.id}/share", json={"email": "nobody@example.com"})
|
||||
assert r.status_code == 404, r.text
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "recipient"}
|
||||
|
||||
|
||||
def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user):
|
||||
"""Postgres cannot hold NUL in text, so the driver refuses the query outright: a DataError, i.e.
|
||||
a 500 with a traceback, wherever a `%00` or an escaped NUL reached a comparison (deep-review
|
||||
round 4 — round 3 had guarded only the by-rating_key lookup). One app-wide handler now answers
|
||||
all of them."""
|
||||
client = as_user(make_user())
|
||||
r = client.get("/api/plex/playlists?contains=%00")
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "invalid_input", "reason": "nul"}
|
||||
assert client.get("/api/plex/playlists?contains_group=1,%00").status_code == 422
|
||||
|
||||
|
||||
def test_any_other_data_error_is_still_a_500():
|
||||
"""The other half of the app-wide handler, and the half a refactor can delete in silence: it is
|
||||
registered for EVERY DataError on every route and decides on a substring of the driver's message,
|
||||
so only a NUL may become a 422. Without this, simplifying it to an unconditional 422 — it reads
|
||||
like dead defensive code — would answer a numeric overflow or an over-long ingested title with
|
||||
"your input was bad", losing the traceback that names the real fault (deep-review round 5)."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import DataError
|
||||
|
||||
from app.main import _nul_in_text
|
||||
|
||||
overflow = DataError("SELECT 1", {}, Exception("numeric field overflow"))
|
||||
with pytest.raises(DataError):
|
||||
asyncio.run(_nul_in_text(None, overflow)) # re-raised: it stays the 500 it always was
|
||||
|
||||
nul = DataError("SELECT 1", {}, Exception("invalid byte sequence: NUL (0x00)"))
|
||||
assert asyncio.run(_nul_in_text(None, nul)).status_code == 422
|
||||
@@ -128,6 +128,60 @@ def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as
|
||||
assert _ids(db, theirs.id) == []
|
||||
|
||||
|
||||
def test_a_list_that_is_not_yours_answers_with_a_reason_the_ui_can_translate(
|
||||
db, make_user, as_user
|
||||
):
|
||||
# The one 404 a user meets in practice (the list went away in another tab). It used to be a bare
|
||||
# English "Unknown playlist", which the Hungarian UI then showed verbatim.
|
||||
user, other = make_user(), make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
theirs = _playlist(db, other, "Theirs", ["v1"])
|
||||
|
||||
r = as_user(user).delete(f"/api/playlists/{theirs.id}/items/v1")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "playlist"}
|
||||
db.expire_all()
|
||||
assert _ids(db, theirs.id) == ["v1"]
|
||||
|
||||
|
||||
def test_a_move_into_a_list_that_is_gone_names_the_target_not_the_list_on_screen(
|
||||
db, make_user, as_user
|
||||
):
|
||||
# Both lists 404'd with the same reason, which the UI reads as "THIS playlist doesn't exist any
|
||||
# more" — said about the source the reader is looking at, which is still there.
|
||||
user = make_user()
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Mine", ["v1"])
|
||||
gone = _playlist(db, user, "Gone", [])
|
||||
gone_id = gone.id
|
||||
db.delete(gone)
|
||||
db.commit()
|
||||
|
||||
r = _move(as_user(user), src.id, "v1", gone_id)
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"] == {"code": "not_found", "reason": "move_target"}
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == ["v1"]
|
||||
|
||||
|
||||
def test_a_refused_playlist_action_answers_with_a_reason_the_ui_can_translate(
|
||||
db, make_user, as_user
|
||||
):
|
||||
# The rest of the router's refusals were bare English too ("The Watch later list can't be
|
||||
# deleted.", "YouTube delete failed.", ...), shown verbatim under the Hungarian UI.
|
||||
user = make_user()
|
||||
client = as_user(user)
|
||||
wl = client.post("/api/playlists/watch-later", json={"video_id": "nope"})
|
||||
assert wl.status_code == 404
|
||||
assert wl.json()["detail"] == {"code": "not_found", "reason": "video"}
|
||||
_seed_videos(db, ["v1"])
|
||||
saved = client.post("/api/playlists/watch-later", json={"video_id": "v1"})
|
||||
wl_id = saved.json()["playlist_id"]
|
||||
r = client.delete(f"/api/playlists/{wl_id}")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "watch_later_undeletable"}
|
||||
|
||||
|
||||
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
|
||||
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
|
||||
Marking only the source is the asymmetric fix this repo keeps re-learning."""
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
""" "Clear resume point" has to reach Plex — the case the status-only guard skipped.
|
||||
|
||||
`POST /api/plex/item/{rk}/state {"status": "new"}` backs the *Clear resume point* / *Mark unwatched*
|
||||
buttons. It deletes the local row and mirrors the un-mark to Plex — but it used to decide whether to
|
||||
push from the PREVIOUS STATUS alone (`prev_status != "new"`), and an in-progress item's row is
|
||||
`status="new"` carrying a position: that is how the importer maps a bare `viewOffset`
|
||||
(`_plex_watch_to_state`). So for the one case the button exists for, nothing was ever sent to Plex,
|
||||
Plex kept the offset, and the next pull imported it straight back — the user cleared *Protector*
|
||||
twice and it returned to Continue watching at 92s both times (2026-09-09).
|
||||
|
||||
One `unscrobble` clears the view count AND the offset (measured against the live server), so the
|
||||
push itself needed no change; what needed fixing is WHEN it is scheduled.
|
||||
|
||||
DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexState, User
|
||||
from app.plex import watch_sync as plex_watch
|
||||
|
||||
|
||||
def _seed(db, *, status: str, position: int):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
it = PlexItem(
|
||||
rating_key="98614", library_id=lib.id, kind="movie", title="Protector", duration_s=5495
|
||||
)
|
||||
db.add(it)
|
||||
user = User(email="clear@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(PlexState(user_id=user.id, item_id=it.id, status=status, position_seconds=position))
|
||||
db.commit()
|
||||
return it, user
|
||||
|
||||
|
||||
def _clear(db, user, it, monkeypatch) -> list[tuple]:
|
||||
"""Drive the endpoint with the Plex link satisfied and the background push recorded."""
|
||||
pushes: list[tuple] = []
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # pass the instance-setup request gate
|
||||
monkeypatch.setattr(plex_watch, "link_for_push", lambda db_, uid: SimpleNamespace(id=1))
|
||||
monkeypatch.setattr(
|
||||
plex_watch,
|
||||
"push_state_to_plex",
|
||||
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
return pushes
|
||||
|
||||
|
||||
def test_clearing_an_in_progress_item_tells_plex(db, monkeypatch):
|
||||
# The regression: status "new" + a position IS the in-progress shape, and it must push.
|
||||
it, user = _seed(db, status="new", position=92)
|
||||
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")]
|
||||
assert db.query(PlexState).count() == 0
|
||||
|
||||
|
||||
def test_clearing_a_watched_item_still_tells_plex(db, monkeypatch):
|
||||
# The case the old guard did cover — kept, so the fix cannot narrow into a new hole.
|
||||
it, user = _seed(db, status="watched", position=0)
|
||||
assert _clear(db, user, it, monkeypatch) == [("98614", "unwatched")]
|
||||
|
||||
|
||||
def test_unhiding_touches_plex_not_at_all(db, monkeypatch):
|
||||
# `hidden` is a Siftlode-only concept "with no Plex analogue, so it never touches Plex" — the
|
||||
# endpoint's own docstring. The old status-only guard pushed here anyway, un-marking an item on
|
||||
# Plex that the user had merely hidden.
|
||||
it, user = _seed(db, status="hidden", position=0)
|
||||
assert _clear(db, user, it, monkeypatch) == []
|
||||
@@ -0,0 +1,291 @@
|
||||
"""The Collection info page's member ordering (`GET /api/plex/collections/{rk}?sort=&sort_dir=`) and
|
||||
the `keys=` lookup that gives the sidebar token / filter chip a title instead of a raw rating_key.
|
||||
|
||||
Reported from daily use: "Collectionben nem működik a rendezés". The browse grid sorts fine — the
|
||||
collection page had no control at all and a hardcoded year-ascending order. Adding the control means
|
||||
the SAME vocabulary must actually work here, hence the pin against `PLEX_SORT_KEYS`.
|
||||
|
||||
Pure-logic tests run everywhere; the endpoint tests use the `db` fixture (DB lane).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import literal_column
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
from app.plex.query import PLEX_SORT_KEYS
|
||||
from app.routes.plex.browse import sort_column
|
||||
from app.routes.plex.collections import (
|
||||
DEFAULT_MEMBER_SORT,
|
||||
MEMBER_SORT_KEYS,
|
||||
Member,
|
||||
sort_members,
|
||||
)
|
||||
|
||||
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _m(title: str, **over) -> Member:
|
||||
base = dict(
|
||||
kind="movie",
|
||||
obj=None,
|
||||
title=title,
|
||||
year=None,
|
||||
rating=None,
|
||||
added=None,
|
||||
release=None,
|
||||
duration=None,
|
||||
last_watched=None,
|
||||
)
|
||||
base.update(over)
|
||||
return Member(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _titles(members: list[Member]) -> list[str]:
|
||||
return [m.title for m in members]
|
||||
|
||||
|
||||
# --- the vocabulary is ONE vocabulary ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_sort_key_is_orderable_on_a_collection():
|
||||
"""The collection page shows the same Sort control as the browse grid, so a key it cannot order
|
||||
would be an option that silently does nothing."""
|
||||
assert set(MEMBER_SORT_KEYS) == set(PLEX_SORT_KEYS)
|
||||
|
||||
|
||||
def test_every_sort_key_is_orderable_on_the_browse_grid():
|
||||
"""The other half of the same pin: browse falls back to `added_at` for an unknown key, so a key
|
||||
missing from its map would order by date while the control claimed otherwise."""
|
||||
|
||||
class _Cols:
|
||||
title = literal_column("title")
|
||||
year = literal_column("year")
|
||||
rating = literal_column("rating")
|
||||
duration_s = literal_column("duration_s")
|
||||
rel = literal_column("rel")
|
||||
last_watched = literal_column("last_watched")
|
||||
added_at = literal_column("added_at")
|
||||
|
||||
class _U:
|
||||
c = _Cols()
|
||||
|
||||
u = _U()
|
||||
fallback = u.c.added_at
|
||||
unhandled = [k for k in PLEX_SORT_KEYS if k != "added" and sort_column(u, k) is fallback]
|
||||
assert unhandled == []
|
||||
# …and the fallback itself is still what an unknown key gets.
|
||||
assert sort_column(u, "no-such-key") is fallback
|
||||
|
||||
|
||||
# --- ordering behaviour -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_orders_by_the_requested_key_in_both_directions():
|
||||
members = [_m("b", year=2006), _m("c", year=2017), _m("a", year=2003)]
|
||||
assert _titles(sort_members(members, "year", "asc")) == ["a", "b", "c"]
|
||||
assert _titles(sort_members(members, "year", "desc")) == ["c", "b", "a"]
|
||||
|
||||
|
||||
def test_members_without_a_value_sink_to_the_end_in_both_directions():
|
||||
"""A movie with no rating is not "the worst rated" — nullslast, like the browse grid."""
|
||||
members = [_m("rated-low", rating=6.5), _m("unrated"), _m("rated-high", rating=8.1)]
|
||||
assert _titles(sort_members(members, "rating", "desc")) == [
|
||||
"rated-high",
|
||||
"rated-low",
|
||||
"unrated",
|
||||
]
|
||||
assert _titles(sort_members(members, "rating", "asc")) == ["rated-low", "rated-high", "unrated"]
|
||||
|
||||
|
||||
def test_ties_stay_title_ascending_when_the_direction_flips():
|
||||
"""Flipping the direction must reverse the RANKING, not the alphabet inside a tie."""
|
||||
members = [_m("zulu", year=2003), _m("alpha", year=2003), _m("mike", year=2017)]
|
||||
assert _titles(sort_members(members, "year", "asc")) == ["alpha", "zulu", "mike"]
|
||||
assert _titles(sort_members(members, "year", "desc")) == ["mike", "alpha", "zulu"]
|
||||
|
||||
|
||||
def test_an_unknown_key_falls_back_to_the_pages_default():
|
||||
members = [_m("b", year=2006), _m("a", year=2017)]
|
||||
assert _titles(sort_members(members, "no-such-key", "asc")) == _titles(
|
||||
sort_members(members, DEFAULT_MEMBER_SORT, "asc")
|
||||
)
|
||||
|
||||
|
||||
def test_a_show_has_no_duration_and_therefore_sinks_on_that_key():
|
||||
members = [
|
||||
_m("short movie", duration=3600),
|
||||
_m("a show", kind="show", duration=None),
|
||||
_m("long movie", duration=9000),
|
||||
]
|
||||
assert _titles(sort_members(members, "duration", "desc")) == [
|
||||
"long movie",
|
||||
"short movie",
|
||||
"a show",
|
||||
]
|
||||
|
||||
|
||||
# --- the endpoint (DB lane) -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed(db):
|
||||
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([lib_m, lib_s])
|
||||
db.flush()
|
||||
|
||||
col = PlexCollection(rating_key="col1", library_id=lib_m.id, title="Franchise", child_count=3)
|
||||
other = PlexCollection(rating_key="col2", library_id=lib_m.id, title="Elsewhere", child_count=1)
|
||||
db.add_all([col, other])
|
||||
db.flush()
|
||||
|
||||
# Titles deliberately alphabetical-opposite to the years, so a year sort can't accidentally pass
|
||||
# by falling through to the title tiebreak.
|
||||
old = PlexItem(
|
||||
rating_key="mo_old",
|
||||
library_id=lib_m.id,
|
||||
kind="movie",
|
||||
title="Zulu",
|
||||
year=2003,
|
||||
rating=8.1,
|
||||
duration_s=8000,
|
||||
added_at=NOW - timedelta(days=1),
|
||||
originally_available_at=date(2003, 7, 9),
|
||||
collection_keys=["col1"],
|
||||
)
|
||||
mid = PlexItem(
|
||||
rating_key="mo_mid",
|
||||
library_id=lib_m.id,
|
||||
kind="movie",
|
||||
title="Mike",
|
||||
year=2006,
|
||||
rating=7.4,
|
||||
duration_s=9000,
|
||||
added_at=NOW - timedelta(days=3),
|
||||
originally_available_at=date(2006, 7, 7),
|
||||
collection_keys=["col1"],
|
||||
)
|
||||
new = PlexItem(
|
||||
rating_key="mo_new",
|
||||
library_id=lib_m.id,
|
||||
kind="movie",
|
||||
title="Alpha",
|
||||
year=2017,
|
||||
rating=6.5,
|
||||
duration_s=7000,
|
||||
added_at=NOW,
|
||||
originally_available_at=date(2017, 5, 26),
|
||||
collection_keys=["col1"],
|
||||
)
|
||||
# A show member with a resumed episode: `lastwatched` must work on this leg too, not just movies.
|
||||
show = PlexShow(
|
||||
rating_key="sh1",
|
||||
library_id=lib_s.id,
|
||||
title="Series",
|
||||
year=2010,
|
||||
child_count=1,
|
||||
collection_keys=["col1"],
|
||||
)
|
||||
db.add_all([old, mid, new, show])
|
||||
db.flush()
|
||||
ep = PlexItem(
|
||||
rating_key="sh1_e1",
|
||||
library_id=lib_s.id,
|
||||
kind="episode",
|
||||
title="E1",
|
||||
show_id=show.id,
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
)
|
||||
db.add(ep)
|
||||
db.flush()
|
||||
|
||||
user = User(email="colsort@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
# The show's episode is the most recently touched thing in the collection…
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=ep.id,
|
||||
status="new",
|
||||
position_seconds=60,
|
||||
progress_updated_at=NOW,
|
||||
),
|
||||
# …and one movie was watched earlier.
|
||||
PlexState(
|
||||
user_id=user.id,
|
||||
item_id=old.id,
|
||||
status="watched",
|
||||
watched_at=NOW - timedelta(days=2),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _order(r) -> list[str]:
|
||||
return [i["title"] for i in r.json()["items"]]
|
||||
|
||||
|
||||
def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch):
|
||||
"""The order the page had before it grew a control — a franchise reads in release order."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1")
|
||||
assert r.status_code == 200
|
||||
assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"]
|
||||
|
||||
|
||||
def test_collection_detail_honours_sort_and_direction(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=year&sort_dir=desc")
|
||||
assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"]
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=title&sort_dir=asc")
|
||||
assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"]
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=rating&sort_dir=desc")
|
||||
# The show has no rating, so it sinks — it is not "rated 0".
|
||||
assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"]
|
||||
|
||||
|
||||
def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch):
|
||||
"""The show leg needs the rollup's `resumed_at`; with only a status map this key would order
|
||||
every show as never-watched."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=lastwatched&sort_dir=desc")
|
||||
# Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc.
|
||||
assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"]
|
||||
|
||||
|
||||
def test_collections_list_resolves_titles_by_key(db, monkeypatch):
|
||||
"""What a shared Plex link needs: the URL carries bare rating_keys, so the sidebar token and the
|
||||
filter chip have nothing to show but `17045` until these are resolved."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=col1")
|
||||
assert r.status_code == 200
|
||||
assert [c["title"] for c in r.json()["collections"]] == ["Franchise"]
|
||||
|
||||
|
||||
def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch):
|
||||
"""`keys=` with nothing in it must not fall through to "every collection" — that would be a
|
||||
200 KB answer to a question about zero ids."""
|
||||
user = _seed(db)
|
||||
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["collections"] == []
|
||||
@@ -155,6 +155,7 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch):
|
||||
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
assert r.json()["detail"] == {"code": "collection", "reason": "name_taken"}
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""An HLS artifact request names the session it belongs to, and gets nothing else.
|
||||
|
||||
Sessions are keyed by rating_key ALONE, while a session's directory is per (key, offset, tag). So a
|
||||
seek-restart — or a second viewer of the same item — replaces the registered session, and the old
|
||||
viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW
|
||||
session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a
|
||||
timeline the client is not on. Wrong content with a success status is the one failure a player cannot
|
||||
notice, and the reclaim-on-404 added in round 4 could never fire on it (deep-review round 5).
|
||||
|
||||
No `db` fixture: the route reads nothing from the database (its own `Depends(get_db)` went with the
|
||||
removed auto-start, and `current_user` is overridden wholesale), so these must not sit in the DB
|
||||
lane — there they SKIP on a machine with no Postgres while pytest prints green (round 6).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from app.plex import stream as plex_stream
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self, running: bool = False):
|
||||
self.running = running
|
||||
|
||||
def poll(self):
|
||||
return None if self.running else 0
|
||||
|
||||
def terminate(self):
|
||||
pass
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, session_id: str, directory, running: bool = False, audio_langs=None):
|
||||
self.id = session_id
|
||||
self.dir = directory
|
||||
self.proc = _Proc(running)
|
||||
self.last_access = time.time()
|
||||
# The real session derives both of these from the argv it spawned; the guard reads
|
||||
# `variants` to tell a name this session can produce from one only the OTHER argv branch
|
||||
# writes, so a stand-in without it would test a shape that cannot exist.
|
||||
self.variants = plex_stream.session_variants(audio_langs)
|
||||
self.entry = "master.m3u8" if self.variants else "index.m3u8"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(monkeypatch):
|
||||
live: dict = {}
|
||||
monkeypatch.setattr(plex_stream, "_sessions", live)
|
||||
return live
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
user = User(email="hls@example.com")
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
yield TestClient(app)
|
||||
finally:
|
||||
app.dependency_overrides.pop(current_user, None)
|
||||
|
||||
|
||||
def test_a_segment_is_served_from_the_session_the_url_names(client, tmp_path, sessions):
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
|
||||
(d / "seg_12.ts").write_bytes(b"mine")
|
||||
sessions["m1"] = _Session("sid-a", d)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.content == b"mine"
|
||||
|
||||
|
||||
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
|
||||
client, tmp_path, sessions
|
||||
):
|
||||
"""Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's
|
||||
next segment must fail honestly — the player reclaims at the playhead — not arrive as B's."""
|
||||
theirs = tmp_path / "m1_1500_0_+0.00"
|
||||
theirs.mkdir()
|
||||
(theirs / "seg_11.ts").write_bytes(b"")
|
||||
(theirs / "seg_12.ts").write_bytes(b"theirs")
|
||||
sessions["m1"] = _Session("sid-b", theirs)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
assert r.status_code == 404, r.text
|
||||
assert b"theirs" not in r.content
|
||||
|
||||
|
||||
def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
|
||||
client, monkeypatch, sessions
|
||||
):
|
||||
"""The entry playlist used to auto-start a session when none was registered — always at 0.0,
|
||||
silently throwing away the viewer's position. A 404 sends the player through its own reclaim,
|
||||
which restarts at the playhead."""
|
||||
started: list[float] = []
|
||||
monkeypatch.setattr(
|
||||
plex_stream,
|
||||
"start_session",
|
||||
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0),
|
||||
)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
|
||||
assert r.status_code == 404, r.text
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_a_segment_that_will_never_arrive_fails_at_once(client, tmp_path, sessions):
|
||||
"""`stream_hls_file` is a SYNC route: it runs on the ~40-slot threadpool and holds a pooled DB
|
||||
connection while it waits. It used to poll out the whole 20 s for a name that could never appear
|
||||
— an ordinary seek past the produced region was enough to park a worker and a connection, and
|
||||
forty hopeless names parked the API (deep-review round 5). ffmpeg writes segments in order, so a
|
||||
segment whose predecessor is absent is not the next one, and that is knowable at once — even
|
||||
while ffmpeg is still running, which is the case `proc` alone cannot shorten."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
|
||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
||||
started = time.monotonic()
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
|
||||
elapsed = time.monotonic() - started
|
||||
assert r.status_code == 404, r.text
|
||||
# The wait is 20 s; anything in that neighbourhood means the producer's exit was not consulted.
|
||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a segment that can never arrive"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"seg_0_9000.ts", # the VIDEO rendition of a multi-audio session: variant 0, segment 9000
|
||||
"seg_a0_9000.ts", # the first audio rendition: `name:a0` from -var_stream_map
|
||||
],
|
||||
)
|
||||
def test_a_multi_audio_segment_beyond_the_region_fails_at_once_too(
|
||||
client, tmp_path, sessions, name
|
||||
):
|
||||
"""The round-5 guard read `seg_<seq>_<variant>.ts`, but ffmpeg substitutes `%v` FIRST — so the
|
||||
real names are `seg_<variant>_<seq>.ts` and every multi-audio request fell through to the full
|
||||
20 s wait: the video rendition because its variant "0" parsed as segment 0, the audio renditions
|
||||
(`a0`, `a1`) because they did not parse at all. Dual-audio is the common shape in this library,
|
||||
so the guard was inert exactly where it was needed (round 6)."""
|
||||
d = tmp_path / "m1_0_multi_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_0_0.ts").write_bytes(b"x")
|
||||
(d / "seg_a0_0.ts").write_bytes(b"x")
|
||||
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
|
||||
started = time.monotonic()
|
||||
r = client.get(f"/api/plex/stream/m1/hls/sid-a/{name}")
|
||||
elapsed = time.monotonic() - started
|
||||
assert r.status_code == 404, r.text
|
||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s for {name}"
|
||||
|
||||
|
||||
def test_a_multi_audio_segment_whose_predecessor_exists_is_served(client, tmp_path, sessions):
|
||||
"""The other half of the same rule: reading the names correctly must not start refusing real
|
||||
ones. `seg_a0_1.ts` follows `seg_a0_0.ts` — the previous SEGMENT of that rendition, not the
|
||||
previous variant."""
|
||||
d = tmp_path / "m1_0_multi_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_a0_0.ts").write_bytes(b"x")
|
||||
(d / "seg_a0_1.ts").write_bytes(b"audio")
|
||||
sessions["m1"] = _Session("sid-a", d, running=True, audio_langs=["hu", "en"])
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_a0_1.ts")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.content == b"audio"
|
||||
|
||||
|
||||
def test_a_name_the_session_cannot_produce_fails_at_once(client, tmp_path, sessions):
|
||||
"""The guard was a blocklist: anything that was not a single-audio segment name was waved
|
||||
through to the timeout, so `GET …/anything.m3u8` still parked a worker for the full 30 s — and
|
||||
the route comment claimed a flood of hopeless names was what it prevented. A session produces
|
||||
its playlists and its segments and nothing else, so any other name is answerable now."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
||||
started = time.monotonic()
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/nope.m3u8")
|
||||
elapsed = time.monotonic() - started
|
||||
assert r.status_code == 404, r.text
|
||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a playlist this session never writes"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("digits", [100, 5000])
|
||||
def test_an_absurd_segment_index_is_a_404_not_a_500(client, tmp_path, sessions, digits):
|
||||
"""`int()` on an unbounded digit run raises ValueError past 4300 digits, and only OSError was
|
||||
caught — so a long enough name reached the client as a 500 with a traceback. Same bug class the
|
||||
subtitle routes were bounded for; the answer is the same 404 any other unproducible name gets."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", d, running=True)
|
||||
r = client.get(f"/api/plex/stream/m1/hls/sid-a/seg_{'9' * digits}.ts")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_a_session_torn_down_during_the_read_is_a_404_not_a_500(
|
||||
client, monkeypatch, tmp_path, sessions
|
||||
):
|
||||
"""`_kill` rmtrees the whole directory and nothing holds `_lock` across the wait and the read,
|
||||
so the file can vanish in between — on every seek-restart, since `loadSession` awaits the new
|
||||
session before destroying the old hls instance. The player's recovery is keyed on the 404
|
||||
STATUS, so a 500 here skipped the reclaim and ended playback with "couldn't play"."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_11.ts").write_bytes(b"")
|
||||
sessions["m1"] = _Session("sid-a", d)
|
||||
# The file existed when the wait checked, and is gone by the time the route reads it.
|
||||
monkeypatch.setattr(plex_stream, "wait_for", lambda *a, **k: True)
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_a_dead_producer_is_not_waited_out(client, tmp_path, sessions):
|
||||
"""The route passes `proc` to `wait_for`. Without it a session whose ffmpeg has already exited
|
||||
— a finished remux, a VAAPI init refusal — polls the whole 20 s for a segment that will never
|
||||
be written, holding a threadpool worker and its pooled DB connection. The predecessor IS on
|
||||
disk here, so `artifact_may_arrive` says yes and only `proc` can end the wait."""
|
||||
d = tmp_path / "m1_0_0_+0.00"
|
||||
d.mkdir()
|
||||
(d / "seg_5.ts").write_bytes(b"x")
|
||||
sessions["m1"] = _Session("sid-a", d, running=False)
|
||||
started = time.monotonic()
|
||||
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_6.ts")
|
||||
elapsed = time.monotonic() - started
|
||||
assert r.status_code == 404, r.text
|
||||
assert elapsed < 2.0, f"waited {elapsed:.1f}s on a producer that had already exited"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("audio_langs", [None, ["hu", "en"]])
|
||||
def test_the_name_ffmpeg_is_asked_for_is_a_name_the_guard_understands(tmp_path, audio_langs):
|
||||
"""One vocabulary, enforced. `segment_name` is what the warm-up and the PTS probe wait for, what
|
||||
the argv's own `-hls_segment_filename` pattern is built from, and what `artifact_may_arrive`
|
||||
parses back — they were three separate statements of the layout and two of them were wrong. Pin
|
||||
them to each other instead."""
|
||||
sess = _Session("sid", tmp_path, audio_langs=audio_langs)
|
||||
variant = sess.variants[0] if sess.variants else None
|
||||
name = plex_stream.segment_name(3, variant)
|
||||
prev = plex_stream.segment_name(2, variant)
|
||||
assert not plex_stream.artifact_may_arrive(sess, name)
|
||||
(tmp_path / prev).write_bytes(b"x")
|
||||
assert plex_stream.artifact_may_arrive(sess, name)
|
||||
|
||||
|
||||
def test_the_argv_asks_for_exactly_the_names_the_guard_accepts(tmp_path):
|
||||
"""The producer and the parser, pinned to each other through the argv itself.
|
||||
|
||||
Round 6's HIGH was a guard that read `seg_%v_%d.ts` sequence-first, so it was inert on every
|
||||
multi-audio item, and a warm-up that waited 8 s for a name ffmpeg never writes. The fix was to
|
||||
give the layout one spelling — but a comment saying so is what had just failed, so assert that
|
||||
the `-hls_segment_filename` pattern in the real argv is literally what `segment_name` produces,
|
||||
and that every playlist the argv names is one `playlist_names` accepts."""
|
||||
for audio_langs in (None, ["hu", "en", "de"]):
|
||||
args = plex_stream._ffmpeg_cmd(
|
||||
tmp_path / "in.mkv", 0.0, tmp_path, 0, 0.0, audio_langs, loudnorm=False
|
||||
)
|
||||
variants = plex_stream.session_variants(audio_langs)
|
||||
sess = _Session("sid", tmp_path, audio_langs=audio_langs)
|
||||
# The two patterns in the argv are the vocabulary's own spelling, `%d`/`%v` and all.
|
||||
seg_pattern = args[args.index("-hls_segment_filename") + 1]
|
||||
pl_pattern = args[-1].rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if variants:
|
||||
assert seg_pattern.endswith(plex_stream.segment_name("%d", "%v")), seg_pattern
|
||||
assert pl_pattern == plex_stream.variant_playlist_name("%v"), pl_pattern
|
||||
assert args[args.index("-master_pl_name") + 1] == plex_stream._MASTER_PL
|
||||
else:
|
||||
assert seg_pattern.endswith(plex_stream.segment_name("%d")), seg_pattern
|
||||
assert pl_pattern == plex_stream._INDEX_PL, pl_pattern
|
||||
assert "-master_pl_name" not in args
|
||||
|
||||
# And every name those patterns can actually expand to is one the guard lets through — the
|
||||
# entry playlist hls.js is handed included, since a session whose own manifest is refused
|
||||
# cannot start at all.
|
||||
for v in variants or (None,):
|
||||
assert plex_stream.artifact_may_arrive(sess, plex_stream.segment_name(0, v))
|
||||
if v is not None:
|
||||
assert plex_stream.artifact_may_arrive(sess, plex_stream.variant_playlist_name(v))
|
||||
assert plex_stream.artifact_may_arrive(sess, sess.entry)
|
||||
|
||||
|
||||
def test_a_name_only_the_other_argv_branch_writes_is_refused_at_once(tmp_path, client, sessions):
|
||||
"""The guard is keyed to THIS session's vocabulary, not to HLS in general.
|
||||
|
||||
The two argv branches are disjoint: a single-audio session is given `seg_%d.ts` + `index.m3u8`
|
||||
and no `%v` at all, so `master.m3u8`, `stream_*.m3u8` and every `seg_<variant>_<n>.ts` are names
|
||||
it can never write — and vice versa. Accepting the union read them as "an HLS name, so wait and
|
||||
see", and each one then held a threadpool worker and its pooled DB connection for the full
|
||||
timeout: measured through this route against a live single-audio session, `master.m3u8` took
|
||||
30.0 s and `seg_a0_0.ts` 20.1 s to answer 404 (deep-review round 7)."""
|
||||
single = tmp_path / "m1_0_0_+0.00"
|
||||
single.mkdir()
|
||||
sessions["m1"] = _Session("sid-a", single, running=True)
|
||||
multi = tmp_path / "m2_0_multi_+0.00"
|
||||
multi.mkdir()
|
||||
sessions["m2"] = _Session("sid-b", multi, running=True, audio_langs=["hu", "en"])
|
||||
|
||||
# Each of these belongs to the other shape (or to no shape at all) and must be refused without
|
||||
# a wait — the assertion is the ELAPSED time as much as the status.
|
||||
for key, sid, names in (
|
||||
("m1", "sid-a", ["master.m3u8", "stream_0.m3u8", "seg_0_0.ts", "seg_zzzz_0.ts"]),
|
||||
("m2", "sid-b", ["index.m3u8", "seg_0.ts", "seg_a9_0.ts", "stream_zz.m3u8"]),
|
||||
):
|
||||
for name in names:
|
||||
started = time.monotonic()
|
||||
r = client.get(f"/api/plex/stream/{key}/hls/{sid}/{name}")
|
||||
elapsed = time.monotonic() - started
|
||||
assert r.status_code == 404, f"{name}: {r.text}"
|
||||
assert elapsed < 2.0, (
|
||||
f"{name}: waited {elapsed:.1f}s for a name this session cannot write"
|
||||
)
|
||||
|
||||
# And the control: the names each session CAN write are still waited for, not refused.
|
||||
assert plex_stream.artifact_may_arrive(sessions["m1"], "index.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m1"], "seg_0.ts")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "master.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "stream_a1.m3u8")
|
||||
assert plex_stream.artifact_may_arrive(sessions["m2"], "seg_a1_0.ts")
|
||||
@@ -58,7 +58,12 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
|
||||
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
|
||||
captured["transcode"] = transcode
|
||||
return SimpleNamespace(
|
||||
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, tonemap=False
|
||||
id="sid",
|
||||
media_start_s=start,
|
||||
entry="index.m3u8",
|
||||
transcode=transcode,
|
||||
hw=False,
|
||||
tonemap=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(plex_stream, "start_session", fake_start)
|
||||
|
||||
@@ -483,6 +483,7 @@ class TestColdStartWarmup:
|
||||
src.write_bytes(b"x")
|
||||
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
|
||||
monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2)
|
||||
monkeypatch.setattr(stream.sysconfig, "get_bool", lambda db, k: False)
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", lambda *a, **k: _FakeProc())
|
||||
return tmp_path
|
||||
|
||||
@@ -554,6 +555,12 @@ class TestTranscodeStartSession:
|
||||
"get_str",
|
||||
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
|
||||
)
|
||||
# Loudness normalisation (item 14) is off unless a test turns it on via `stream._loudnorm_on`.
|
||||
monkeypatch.setattr(
|
||||
stream.sysconfig,
|
||||
"get_bool",
|
||||
lambda db, k: {"plex_audio_loudnorm": getattr(stream, "_loudnorm_on", False)}[k],
|
||||
)
|
||||
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True)
|
||||
_CapturingProc.calls = []
|
||||
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
|
||||
@@ -868,3 +875,73 @@ class TestWaitForProcFastFail:
|
||||
seg = tmp_path / "seg_0.ts"
|
||||
seg.write_bytes(b"")
|
||||
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
|
||||
|
||||
|
||||
class TestAudioLoudnorm:
|
||||
"""`plex_audio_loudnorm` (bug hunt 2026-09, item 14): the HLS path always downmixes 5.1 to stereo
|
||||
AAC and never normalised it, so a cinema mix arrives with whispered dialogue and deafening action.
|
||||
The knob is OFF by default — it makes a film ~11 dB louder and narrows its dynamic range, which is
|
||||
a taste call. Measured on a real 5.1 AC3 film: I -27.6 -> -16.6 LUFS, LRA 13.3 -> 9.8 LU.
|
||||
|
||||
Reuses TestTranscodeStartSession's capturing fixture: what matters is the argv ffmpeg is actually
|
||||
spawned with, not `_ffmpeg_cmd` called with literals."""
|
||||
|
||||
capturing_env = TestTranscodeStartSession.capturing_env
|
||||
|
||||
@staticmethod
|
||||
def _spawn(monkeypatch, *, on: bool, multi: bool = False):
|
||||
monkeypatch.setattr(stream, "_loudnorm_on", on, raising=False)
|
||||
monkeypatch.setattr(
|
||||
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
|
||||
)
|
||||
if multi:
|
||||
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
|
||||
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0, multi=multi)
|
||||
assert s is not None
|
||||
(cmd, *_), _ = _CapturingProc.calls[0]
|
||||
return s, cmd
|
||||
|
||||
def test_the_knob_off_leaves_the_audio_exactly_as_it_was(self, capturing_env, monkeypatch):
|
||||
_, cmd = self._spawn(monkeypatch, on=False)
|
||||
assert "-af" not in cmd
|
||||
assert cmd[cmd.index("-c:a") + 1] == "aac" and cmd[cmd.index("-ac") + 1] == "2"
|
||||
|
||||
def test_the_knob_on_normalises_AFTER_the_downmix(self, capturing_env, monkeypatch):
|
||||
# Order is the whole point: measuring 5.1 and folding it down afterwards would normalise
|
||||
# something other than what the viewer hears.
|
||||
_, cmd = self._spawn(monkeypatch, on=True)
|
||||
af = cmd[cmd.index("-af") + 1]
|
||||
stages = af.split(",")
|
||||
assert stages[0] == "aformat=channel_layouts=stereo"
|
||||
assert stages[1].startswith("loudnorm=")
|
||||
# loudnorm runs its own 192 kHz resampler internally; the AAC encoder needs 48 kHz back.
|
||||
assert stages[-1] == "aresample=48000"
|
||||
|
||||
def test_every_audio_rendition_of_a_multi_audio_file_is_normalised(
|
||||
self, capturing_env, monkeypatch
|
||||
):
|
||||
# A multi-audio session emits one AAC output per language. `-af` without a stream index
|
||||
# covers all of them — a per-stream filter would have normalised only the first.
|
||||
_, cmd = self._spawn(monkeypatch, on=True, multi=True)
|
||||
assert cmd.count("-map") == 3 # video + two audio tracks
|
||||
assert cmd.count("-af") == 1
|
||||
assert "loudnorm=" in cmd[cmd.index("-af") + 1]
|
||||
|
||||
def test_the_session_directory_records_the_setting(self, capturing_env, monkeypatch):
|
||||
# The knob changes the AUDIO THE SEGMENTS CONTAIN, so it belongs in the directory name with
|
||||
# the other parameters that do. Otherwise a session left from before the flip is reused.
|
||||
off, _ = self._spawn(monkeypatch, on=False)
|
||||
_CapturingProc.calls = []
|
||||
on, _ = self._spawn(monkeypatch, on=True)
|
||||
assert off.dir.name != on.dir.name
|
||||
assert on.dir.name.endswith("-ln_+0.00")
|
||||
|
||||
@pytest.mark.parametrize("on", [False, True])
|
||||
def test_the_orphan_sweep_still_recognises_its_own_directory(
|
||||
self, capturing_env, monkeypatch, on
|
||||
):
|
||||
# The tag is appended with a DASH, not an underscore, precisely so `_SESSION_DIR_RE` (the only
|
||||
# thing keeping the sweep off a shared scratch path) still matches. A session directory the
|
||||
# sweep no longer recognises is one that never gets cleaned up.
|
||||
s, _ = self._spawn(monkeypatch, on=on)
|
||||
assert _SESSION_DIR_RE.match(s.dir.name)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""The subtitle routes must refuse a session the rest of the app refuses.
|
||||
|
||||
They used to sit behind `_require_session`, the cookie-only gate the poster proxies use — a signed
|
||||
cookie was enough, with none of `current_user`'s checks (is_active, is_suspended, the session epoch
|
||||
that "log out everywhere" bumps). Fine for a poster; not for a route that starts reads of the media
|
||||
library. Found by the 2026-09 bug-hunt deep-review; the gate itself pre-dates that round.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.auth as auth
|
||||
import app.state as app_state
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.plex import subtitles
|
||||
from app.routes.plex import playback
|
||||
|
||||
|
||||
class _NoPool:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: list = []
|
||||
|
||||
def submit(self, fn, *args) -> None:
|
||||
self.jobs.append((fn, args))
|
||||
|
||||
|
||||
def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
|
||||
db, make_user, override_dep, monkeypatch, tmp_path
|
||||
):
|
||||
user = make_user(is_suspended=True)
|
||||
override_dep(get_db, lambda: db)
|
||||
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
||||
for mod in (auth, playback):
|
||||
monkeypatch.setattr(mod, "resolved_user_id", lambda request: (user.id, True))
|
||||
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||
pool = _NoPool()
|
||||
monkeypatch.setattr(subtitles, "_WIN_POOL", pool)
|
||||
monkeypatch.setattr(subtitles, "_FULL_POOL", pool)
|
||||
subtitles.reset_state()
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/plex/subtitle/98614/0/status?at=600").status_code == 401
|
||||
assert client.get("/api/plex/subtitle/98614/0").status_code == 401
|
||||
assert pool.jobs == [], "a refused session still scheduled an extraction"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
"""A progress checkpoint takes a FINITE position.
|
||||
|
||||
The JSON parser under FastAPI accepts `Infinity` and `NaN`, and both progress handlers truncate the
|
||||
position with `int()` — so one hand-written checkpoint came back as a 500 with a traceback. Found in
|
||||
the 2026-09 bug hunt's second review round, as the sibling of the subtitle routes' non-finite
|
||||
`at`/`offset`. Pure: the body is refused before the handler touches the database.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as app_state
|
||||
from app.auth import current_user
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, override_dep) -> TestClient:
|
||||
override_dep(current_user, lambda: SimpleNamespace(id=1))
|
||||
monkeypatch.setattr(app_state, "_configured_cache", True) # pass the instance-setup gate, no DB
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
|
||||
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
|
||||
def test_a_non_finite_position_is_refused(client, path, raw):
|
||||
r = client.post(
|
||||
path,
|
||||
content=f'{{"position_seconds": {raw}, "duration_seconds": 3000}}',
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
|
||||
@pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"])
|
||||
def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field):
|
||||
"""Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback."""
|
||||
body = {"position_seconds": 10, "duration_seconds": 3000, field: 2**31}
|
||||
assert client.post(path, json=body).status_code == 422
|
||||
|
||||
|
||||
def test_the_refusal_keeps_the_shape_the_ui_reads(client):
|
||||
"""The handler replaces FastAPI's own for every validation error in the app, and the UI turns
|
||||
exactly this shape — a LIST of errors — into its localized "check your input" sentence. The
|
||||
refused value is echoed by its name, because JSON cannot carry NaN."""
|
||||
r = client.post(
|
||||
"/api/videos/v1/progress",
|
||||
content='{"position_seconds": NaN, "duration_seconds": 3000}',
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
detail = r.json()["detail"]
|
||||
assert isinstance(detail, list) and detail[0]["loc"] == ["body", "position_seconds"]
|
||||
assert detail[0]["input"] == "nan"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("q", ["start=inf", "start=nan", "aoff=inf", "aoff=-inf", "aoff=nan"])
|
||||
def test_a_session_at_an_impossible_offset_is_refused(client, q):
|
||||
"""`start=inf` reached `int()` in the session builder (a 500); `aoff=inf` spawned an ffmpeg
|
||||
with a non-finite offset."""
|
||||
assert client.post(f"/api/plex/stream/98614/session?{q}").status_code == 422
|
||||
@@ -279,7 +279,8 @@ export default function App() {
|
||||
label: t("common.accessRequestsReview"),
|
||||
onClick: () => {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav).
|
||||
navigateOverOverlays(() => setPage("users"));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
@@ -49,14 +50,19 @@ export default function AddToPlaylist({
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
}
|
||||
|
||||
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
||||
// another device (a 404 `not_found/playlist` — still offered, from a cache up to 30 s old) was an
|
||||
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed.
|
||||
async function toggle(pl: Playlist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||
else await api.addToPlaylist(pl.id, videoId);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -69,8 +75,10 @@ export default function AddToPlaylist({
|
||||
const pl = await api.createPlaylist(name);
|
||||
await api.addToPlaylist(pl.id, videoId);
|
||||
setNewName("");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed user action is said once, and a user who is gone is not left listed (deep-review round 4).
|
||||
// setRole / suspend / delete had no onError at all — "the global error dialog says it" — but a 404
|
||||
// (the user was deleted in another tab) gets no dialog, so the click did nothing and the row stayed.
|
||||
// And the invite approve/deny toasted unconditionally on top of the dialog `req` opens for a 409.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/useMe", () => ({
|
||||
useMe: () => ({
|
||||
id: 1,
|
||||
email: "admin@x",
|
||||
role: "admin",
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
vi.mock("../lib/errorDialog", () => ({ reportError: vi.fn() }));
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
import { api, type AdminUserRow } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const row = {
|
||||
id: 2,
|
||||
email: "gone@x",
|
||||
role: "user",
|
||||
is_suspended: false,
|
||||
is_demo: false,
|
||||
email_verified: true,
|
||||
is_active: true,
|
||||
} as unknown as AdminUserRow;
|
||||
|
||||
function answer(status: number, detail: unknown) {
|
||||
return new Response(JSON.stringify({ detail }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
notify.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const users = vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return users;
|
||||
}
|
||||
|
||||
describe("a user action that fails", () => {
|
||||
it("says a user who is gone, and refetches the list", async () => {
|
||||
const users = mount();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
answer(404, { code: "not_found", reason: "user" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("is not toasted on top of the error dialog the request already opened", async () => {
|
||||
mount();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
|
||||
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
|
||||
const { reportError } = await import("../lib/errorDialog");
|
||||
await waitFor(() => expect(reportError).toHaveBeenCalled());
|
||||
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -120,6 +121,13 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
// A failed user action: said once (notifyActionError skips what `req` already put in its dialog),
|
||||
// and the list refetched — a user deleted in another tab would otherwise stay listed, every click
|
||||
// on the row doing nothing.
|
||||
const onGoneOrFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
};
|
||||
const setRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
|
||||
api.setUserRole(id, role),
|
||||
@@ -129,7 +137,9 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
|
||||
},
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
|
||||
// Guards (last admin / self) surface via the global error dialog with the server's detail; what
|
||||
// it does not show (a user deleted meanwhile, a 404) is said here — see onGoneOrFailed.
|
||||
onError: (e) => onGoneOrFailed(e, t("users.roles.failed")),
|
||||
});
|
||||
|
||||
const suspend = useMutation({
|
||||
@@ -146,7 +156,8 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
});
|
||||
},
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
// Guards (demo / self / last admin) surface via the global error dialog.
|
||||
// Guards (demo / self / last admin) surface via the global error dialog; the rest, as above.
|
||||
onError: (e) => onGoneOrFailed(e, t("users.suspend.failed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
|
||||
@@ -156,6 +167,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
|
||||
},
|
||||
onSettled: (_d, _e, vars) => endPending(vars.id),
|
||||
onError: (e) => onGoneOrFailed(e, t("users.delete.failed")),
|
||||
});
|
||||
|
||||
const onToggle = async (u: AdminUserRow) => {
|
||||
@@ -518,7 +530,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.invites.approveFailed")),
|
||||
});
|
||||
const deny = useMutation({
|
||||
mutationFn: (id: number) => api.denyInvite(id),
|
||||
@@ -526,7 +538,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.denied") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.invites.denyFailed")),
|
||||
});
|
||||
const onDeny = async (i: { id: number; email: string }) => {
|
||||
const ok = await confirm({
|
||||
@@ -630,7 +642,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.addedToWhitelist", { email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.invites.addFailed")),
|
||||
});
|
||||
|
||||
const list = invites.data ?? [];
|
||||
@@ -780,7 +792,7 @@ function AdminDemo() {
|
||||
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
||||
notify({ level: "success", message: t("settings.demo.added", { email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.demo.addFailed")),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
||||
@@ -788,7 +800,7 @@ function AdminDemo() {
|
||||
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
|
||||
notify({ level: "success", message: t("settings.demo.removed") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.demo.removeFailed")),
|
||||
});
|
||||
const onRemove = async (r: { id: number; email: string }) => {
|
||||
const ok = await confirm({
|
||||
@@ -806,7 +818,7 @@ function AdminDemo() {
|
||||
level: "success",
|
||||
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
|
||||
}),
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("settings.demo.resetFailed")),
|
||||
});
|
||||
|
||||
const rows = list.data ?? [];
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
@@ -36,7 +37,7 @@ export default function AuditLog() {
|
||||
qc.invalidateQueries({ queryKey: qk.adminAudit() });
|
||||
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("audit.clearFailed")),
|
||||
});
|
||||
const onClear = async () => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Reported from daily use: middle-clicking the channel name in the YT player did nothing. It looked
|
||||
// like a link — the YouTube icon beside it IS one — but it was a `<button>`, so the browser had no
|
||||
// href to open a tab with. These pin the two halves the fix has to keep true at once: there is a real
|
||||
// href pointing where the in-app navigation would go, AND a plain left click still takes the in-app
|
||||
// path (which does more than navigate — the player has to close and reclaim its history entry first).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
|
||||
beforeEach(() => {
|
||||
// No global auto-cleanup in this project's vitest setup, and several cases render more than once.
|
||||
cleanup();
|
||||
window.history.replaceState({}, "", "/?page=feed&show=unwatched");
|
||||
});
|
||||
|
||||
const renderLink = () => {
|
||||
const onOpenInApp = vi.fn();
|
||||
render(<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={onOpenInApp} />);
|
||||
return { link: screen.getByText("LEMMiNO") as HTMLAnchorElement, onOpenInApp };
|
||||
};
|
||||
|
||||
/** Dispatch a click and report whether the component prevented it (i.e. handled it in-app).
|
||||
* The document-level guard runs AFTER React's handler and swallows the default, so an
|
||||
* un-prevented click on a real href does not make jsdom log "Not implemented: navigation". */
|
||||
const clickWasHandled = (el: HTMLElement, init: MouseEventInit = {}): boolean => {
|
||||
let prevented = false;
|
||||
const guard = (e: Event) => {
|
||||
prevented = e.defaultPrevented;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener("click", guard);
|
||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
|
||||
document.removeEventListener("click", guard);
|
||||
return prevented;
|
||||
};
|
||||
|
||||
describe("ChannelNameLink", () => {
|
||||
it("is an anchor whose href opens this channel on the current page", () => {
|
||||
const { link } = renderLink();
|
||||
expect(link.tagName).toBe("A");
|
||||
// The same URL openChannel pushes: the current page's query plus ?chan=<id>.
|
||||
expect(link.getAttribute("href")).toBe(`/?page=feed&show=unwatched&chan=${CH}`);
|
||||
});
|
||||
|
||||
it("takes the in-app path on a plain left click, and suppresses the browser navigation", () => {
|
||||
const { link, onOpenInApp } = renderLink();
|
||||
expect(clickWasHandled(link, { button: 0 })).toBe(true);
|
||||
expect(onOpenInApp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["middle-click", { button: 1 }], // the reported gesture
|
||||
["ctrl-click", { ctrlKey: true }],
|
||||
["cmd-click", { metaKey: true }],
|
||||
["shift-click", { shiftKey: true }],
|
||||
// Alt/Option-click on a link is "download the target" — the one arm no test dispatched, so
|
||||
// dropping it from the shared predicate hijacked it on every card thumbnail too.
|
||||
["alt-click", { altKey: true }],
|
||||
])("leaves %s to the browser", (_name, mod) => {
|
||||
const { link, onOpenInApp } = renderLink();
|
||||
// Neither hijacked in-app nor prevented — the href is left to do its job.
|
||||
expect(clickWasHandled(link, mod)).toBe(false);
|
||||
expect(onOpenInApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not lose the rest of the address bar", () => {
|
||||
window.history.replaceState({}, "", "/?page=channels&q=abc");
|
||||
const { link } = renderLink();
|
||||
expect(link.getAttribute("href")).toContain("page=channels");
|
||||
expect(link.getAttribute("href")).toContain("q=abc");
|
||||
});
|
||||
|
||||
it("gives a channel with no title yet an accessible name", () => {
|
||||
// A channel first seen through a live search can be a stub with no title. The anchor used to
|
||||
// render empty — still in the tab order, announced by nothing but its href.
|
||||
render(<ChannelNameLink channelId={CH} title={null} onOpenInApp={() => {}} />);
|
||||
expect(screen.getByRole("link", { name: "player.channel" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps every click from a clickable ancestor when asked to — the browser's gestures too", () => {
|
||||
// The video card's row reacts to clicks; its channel name must not trigger it, whichever way it
|
||||
// is clicked.
|
||||
const row = vi.fn();
|
||||
render(
|
||||
<div onClick={row}>
|
||||
<ChannelNameLink channelId={CH} title="LEMMiNO" onOpenInApp={() => {}} stopPropagation />
|
||||
</div>,
|
||||
);
|
||||
const link = screen.getByText("LEMMiNO");
|
||||
// The bubbling guard in clickWasHandled never sees these (that is the point), so stop the
|
||||
// browser's navigation in the capture phase instead.
|
||||
const noNav = (e: Event) => e.preventDefault();
|
||||
document.addEventListener("click", noNav, true);
|
||||
for (const init of [{ button: 0 }, { ctrlKey: true }, { button: 1 }])
|
||||
link.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ...init }));
|
||||
document.removeEventListener("click", noNav, true);
|
||||
expect(row).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { forwardRef, type AnchorHTMLAttributes } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
import { urlWithParams } from "../lib/urlState";
|
||||
|
||||
// A channel name that is a REAL link. The player showed it as a `<button>`, so the browser's own
|
||||
// gestures did nothing on it: middle-click and Ctrl/Cmd-click opened no tab, on a thing that looks
|
||||
// exactly like a link (the YouTube icon right next to it IS an anchor and does open one). The video
|
||||
// card's channel name had the same `<button>`, so it renders through here too.
|
||||
//
|
||||
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
||||
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
||||
// Back-to-close history entry; see closeThenNavigate).
|
||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||
channelId: string;
|
||||
title: string | null | undefined;
|
||||
/** The in-app navigation, run only for a plain left click. */
|
||||
onOpenInApp: () => void;
|
||||
/** The native tooltip (`title` is taken by the channel's name). */
|
||||
tooltip?: string;
|
||||
/** Keep EVERY click on the link — the browser's own gestures too — from reaching a clickable
|
||||
* ancestor (the video card's row). */
|
||||
stopPropagation?: boolean;
|
||||
};
|
||||
|
||||
const ChannelNameLink = forwardRef<HTMLAnchorElement, Props>(function ChannelNameLink(
|
||||
{ channelId, title, onOpenInApp, tooltip, stopPropagation, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
ref={ref}
|
||||
href={urlWithParams({ chan: channelId })}
|
||||
title={tooltip}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
if (!isPlainLeftClick(e)) return; // let the browser open its tab/window
|
||||
e.preventDefault();
|
||||
onOpenInApp();
|
||||
}}
|
||||
>
|
||||
{/* A channel first seen through a live search can have no title yet. An empty anchor would
|
||||
still be in the tab order, announced by nothing but its href. */}
|
||||
{title || t("player.channel")}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChannelNameLink;
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's video grid follows a running full-history backfill (bug hunt 2026-09, item 6).
|
||||
// The rule itself — when a poll reading should refresh the grid — is pinned on the hook in
|
||||
// lib/channelDetailPoll.backfill.dom.test.tsx. This pins that the PAGE runs it: deleting the hook's
|
||||
// one call site left every lane green, knip included (the hook test's import kept the export "used").
|
||||
//
|
||||
// The page's own poll is what feeds the hook; a newer reading is handed to it here through the same
|
||||
// query key the poll writes, rather than waiting out the 4 s interval. The Feed itself is stubbed —
|
||||
// what matters is whether its queries are told they are stale.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type FeedFilters, type Me } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
const reading = (stored_videos: number): ChannelDetail =>
|
||||
({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed: true,
|
||||
deep_requested: true,
|
||||
backfill_done: false,
|
||||
stored_videos,
|
||||
}) as unknown as ChannelDetail;
|
||||
const feedKey = qk.feed({ q: "", channel: CH } as unknown as FeedFilters);
|
||||
|
||||
describe("ChannelPage — the grid follows a running backfill", () => {
|
||||
it("marks the channel's video grid stale when the poll sees more stored videos", async () => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue(reading(19));
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(feedKey, { pages: [{ items: [], next_cursor: null }], pageParams: [null] });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(api.channelDetail).toHaveBeenCalled());
|
||||
await waitFor(() => expect(qc.getQueryData(qk.channel(CH))).toBeTruthy());
|
||||
expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false);
|
||||
|
||||
// The next poll tick: the background job has stored 60 more.
|
||||
act(() => qc.setQueryData(qk.channel(CH), reading(79)));
|
||||
|
||||
await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { channelDetailPollMs } from "../lib/channelDetailPoll";
|
||||
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
@@ -93,6 +93,10 @@ export default function ChannelPage({
|
||||
});
|
||||
const ch = detail.data;
|
||||
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
||||
// The poll above is also the ONLY signal that the running backfill has written videos, so it
|
||||
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
|
||||
// the PATCH response, before the background job has stored anything).
|
||||
useBackfillFeedRefresh(ch, qc);
|
||||
|
||||
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||||
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -182,10 +183,10 @@ export default function Channels() {
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
||||
onError: () => {
|
||||
onError: (e) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
notify({ level: "error", message: t("channels.tags.updateFailed") });
|
||||
notifyActionError(e, t("channels.tags.updateFailed"));
|
||||
},
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
|
||||
@@ -3,13 +3,13 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
|
||||
import { api, type Message, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import KeyGate from "./KeyGate";
|
||||
@@ -132,9 +132,14 @@ export default function ChatThread({
|
||||
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
|
||||
// raised the global error dialog; 404 (recipient gone) and 429 (rate-limited) are "caller-
|
||||
// handled" in api.req (no modal), so surface those here or the send fails silently.
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 429)) {
|
||||
notify({ level: "error", message: e.detail || t("messages.sendFailed") });
|
||||
}
|
||||
//
|
||||
// Through the shared helper, which knows both of those rules: it no-ops on anything already
|
||||
// reported, and it prefers the server's reason only when that reason is a STRUCTURED detail.
|
||||
// The hand-written copy that used to live here did `e.detail || …`, so a Hungarian reader got
|
||||
// the route's English sentence — "That member hasn't set up messaging yet." — which is
|
||||
// exactly what round 5 changed `notifyActionError` to stop doing. A second copy of a contract
|
||||
// is how the fix misses a caller (round 6).
|
||||
notifyActionError(e, t("messages.sendFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
@@ -125,7 +126,7 @@ export default function ConfigPanel() {
|
||||
const testEmail = useMutation({
|
||||
mutationFn: () => api.testEmail(),
|
||||
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
||||
onError: () => notify({ level: "error", message: t("config.testFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("config.testFailed")),
|
||||
});
|
||||
|
||||
// Plex "Test connection": verifies the server URL + token and returns the library sections,
|
||||
@@ -138,9 +139,9 @@ export default function ConfigPanel() {
|
||||
setPlexResult(r);
|
||||
notify({ level: "success", message: t("config.plexTestOk", { name: r.server_name }) });
|
||||
},
|
||||
onError: () => {
|
||||
onError: (e) => {
|
||||
setPlexResult(null);
|
||||
notify({ level: "error", message: t("config.plexTestFailed") });
|
||||
notifyActionError(e, t("config.plexTestFailed"));
|
||||
},
|
||||
});
|
||||
const plexLibs = (draft["plex_libraries"] ?? "")
|
||||
|
||||
@@ -27,13 +27,15 @@ import { notify, resolveVideo } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import {
|
||||
buildSort,
|
||||
DIRECTIONLESS,
|
||||
feedSortOptions,
|
||||
parseSort,
|
||||
SORT_DEFAULT_DIR,
|
||||
SORT_KEYS,
|
||||
sortYtResults,
|
||||
YT_SORT_KEYS,
|
||||
type SortKey,
|
||||
} from "../lib/feedSort";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { type SortOption, type SortState } from "../lib/columnSort";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
@@ -153,6 +155,13 @@ export default function Feed({
|
||||
// How many live-search results to gather (the count selector replaces a manual "load more";
|
||||
// the free scrape source pages through continuations until this many are collected).
|
||||
const [ytCount, setYtCount] = useState(20);
|
||||
// How to order the loaded results. Its OWN pref, not `filters.sort`: sorting a live-search page
|
||||
// must not silently re-sort the library you go back to. Default `relevance` — the provider's
|
||||
// ranking, which is what the view always showed before it had a control.
|
||||
const [ytSort, setYtSort] = usePref<NonNullable<SortState>>("ytSearchSort", {
|
||||
key: "relevance",
|
||||
dir: "desc",
|
||||
});
|
||||
|
||||
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
||||
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
||||
@@ -378,7 +387,11 @@ export default function Feed({
|
||||
if (ytActive) {
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
loadedRef.current = ytLoaded;
|
||||
const ytItems: Video[] = withOverrides(ytLoaded);
|
||||
const ytItems: Video[] = sortYtResults(
|
||||
withOverrides(ytLoaded),
|
||||
ytSort.key as SortKey,
|
||||
ytSort.dir,
|
||||
);
|
||||
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||
const ytError =
|
||||
ytQuery.error instanceof HttpError
|
||||
@@ -430,6 +443,16 @@ export default function Feed({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Sorts the results already on screen (see sortYtResults) — it never re-queries, so it
|
||||
costs nothing and works the same on both search sources. */}
|
||||
<SortControl
|
||||
options={feedSortOptions(YT_SORT_KEYS, (k) => t("feed.sortKey." + k))}
|
||||
sort={ytSort}
|
||||
onChange={(next) => setYtSort(next ?? { key: "relevance", dir: "desc" })}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
/>
|
||||
</div>
|
||||
{ytItems.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||
@@ -558,16 +581,10 @@ export default function Feed({
|
||||
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
|
||||
// The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
|
||||
// ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
|
||||
const sortOptions: SortOption[] = (
|
||||
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys
|
||||
).map((k) => ({
|
||||
key: k,
|
||||
label: t("feed.sortKey." + k),
|
||||
directionless: DIRECTIONLESS.includes(k),
|
||||
defaultDir: DIRECTIONLESS.includes(k)
|
||||
? undefined
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
const sortOptions: SortOption[] = feedSortOptions(
|
||||
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys,
|
||||
(k) => t("feed.sortKey." + k),
|
||||
);
|
||||
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The live-YouTube results' Sort control, wired end to end: the pref it writes, the key and
|
||||
// direction Feed hands to sortYtResults, and the order that reaches the grid. sortYtResults itself
|
||||
// is pinned in feedSort.yt.test.ts; a deep-review of the 2026-09 bug hunt found that the CALL SITE
|
||||
// could ignore the chosen direction (or skip the sort entirely) with every lane green.
|
||||
//
|
||||
// The virtualised grid lays out nothing in jsdom, so it is replaced by a plain list that shows the
|
||||
// order Feed gave it — which is exactly the thing under test.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./WizardProvider", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({ items }: { items: { id: string; title: string }[] }) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>{v.title}</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const hit = (id: string, duration_seconds: number): Video =>
|
||||
({
|
||||
id,
|
||||
title: `hit ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
duration_seconds,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
const order = () =>
|
||||
Array.from(screen.getByRole("list", { name: "results" }).querySelectorAll("li")).map(
|
||||
(li) => li.textContent,
|
||||
);
|
||||
|
||||
describe("Feed — sorting the live YouTube results", () => {
|
||||
it("hands the grid the results in the key AND direction the viewer picked", async () => {
|
||||
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "searchYoutube").mockResolvedValue({
|
||||
items: [hit("a", 300), hit("b", 100), hit("c", 200)],
|
||||
next_cursor: null,
|
||||
source: "scrape",
|
||||
} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch="lemmino"
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Relevance is the provider's own ranking — the order the results arrived in.
|
||||
await waitFor(() => expect(order()).toEqual(["hit a", "hit b", "hit c"]));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^feed\.sortLabel:/ }));
|
||||
const duration = (await screen.findAllByRole("menuitemradio", { hidden: true })).find((o) =>
|
||||
o.textContent?.includes("feed.sortKey.duration"),
|
||||
);
|
||||
fireEvent.click(duration!);
|
||||
fireEvent.click(await screen.findByText("feed.dirAsc"));
|
||||
await waitFor(() => expect(order()).toEqual(["hit b", "hit c", "hit a"]));
|
||||
fireEvent.click(screen.getByText("feed.dirDesc"));
|
||||
await waitFor(() => expect(order()).toEqual(["hit a", "hit c", "hit b"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The wizard is a modal overlay in every way that matters to a user, but it renders its own shell
|
||||
// instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of
|
||||
// the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last
|
||||
// control walked into the app behind the scrim, where the focus ring is invisible (round 5).
|
||||
//
|
||||
// `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the
|
||||
// trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() }));
|
||||
|
||||
import OnboardingWizard from "./OnboardingWizard";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { clearAll, notify } from "../lib/notifications";
|
||||
|
||||
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
|
||||
|
||||
let origDesc: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never);
|
||||
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.isConnected ? document.body : null;
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).offsetParent;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<OnboardingWizard me={me} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("the onboarding wizard as a dialog", () => {
|
||||
it("announces itself as a labelled modal dialog", () => {
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
expect(panel.getAttribute("aria-modal")).toBe("true");
|
||||
// The label is the step's own heading — the wizard has no header row to take a title from.
|
||||
const heading = document.getElementById(panel.getAttribute("aria-labelledby")!);
|
||||
expect(heading?.textContent).toBe("onboarding.read.title");
|
||||
});
|
||||
|
||||
it("opens focused inside itself and keeps Tab from leaving", () => {
|
||||
const outside = document.createElement("button");
|
||||
outside.textContent = "behind the scrim";
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
|
||||
const { unmount } = mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
expect(panel.contains(document.activeElement)).toBe(true);
|
||||
|
||||
// From the last control, Tab wraps back to the first instead of reaching the page behind.
|
||||
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
|
||||
stops[stops.length - 1]!.focus();
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(stops[0]);
|
||||
|
||||
// …and closing hands the focus back to whatever opened it.
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it("holds a Shift+Tab that starts on the panel itself", () => {
|
||||
// `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on
|
||||
// its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab
|
||||
// ran, into the app behind the scrim. That is what the third argument exists for, and this
|
||||
// caller was written without it on the very commit that extracted the helper (round 6).
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
panel.focus();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
|
||||
fireEvent.keyDown(window, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(stops[stops.length - 1]);
|
||||
});
|
||||
|
||||
it("still hands the focus back after it visited the wizard's own toast", () => {
|
||||
// Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host
|
||||
// sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read
|
||||
// against the panel alone it said "the user left", and since removing the toast fires no
|
||||
// focusin to re-arm, closing afterwards stranded the focus on <body> (round 6). Unlike
|
||||
// `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one.
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
const { unmount } = mount();
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast);
|
||||
act(() => clearAll()); // the toast goes away under the focus — no focusin follows
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it("keeps its own toast's dismiss button inside the Tab cycle", () => {
|
||||
// The wizard renders its own `<Toaster overlay>` beside the panel, and the connection notice it
|
||||
// can raise never auto-dismisses — so a trap scoped to the panel alone left that ✕ permanently
|
||||
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
expect(panel.contains(dismissToast)).toBe(false);
|
||||
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
|
||||
stops[stops.length - 1]!.focus();
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
// No longer the edge: Tab carries on (natively) towards the toast instead of wrapping.
|
||||
expect(document.activeElement).toBe(stops[stops.length - 1]);
|
||||
dismissToast.focus();
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(stops[0]);
|
||||
} finally {
|
||||
act(() => clearAll());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Escape closes the topmost layer only — the wizard included (deep-review round 4). Its own Escape
|
||||
// was a raw `document` listener with no layer check, and document listeners run before the window
|
||||
// listener ui/Modal uses: a sync that failed inside the wizard opens the global error dialog, and one
|
||||
// Esc dismissed the error AND ended onboarding, skipping the write-scope step.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() }));
|
||||
|
||||
import OnboardingWizard from "./OnboardingWizard";
|
||||
import Modal from "./ui/Modal";
|
||||
import { api, type Me } from "../lib/api";
|
||||
|
||||
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the onboarding wizard's Escape", () => {
|
||||
const mount = (onClose: () => void, dialog: boolean) =>
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<OnboardingWizard me={me} onClose={onClose} />
|
||||
{dialog && (
|
||||
<Modal title="boom" onClose={() => {}}>
|
||||
<p>the sync failed</p>
|
||||
</Modal>
|
||||
)}
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
it("closes the wizard when nothing is open above it", () => {
|
||||
const onClose = vi.fn();
|
||||
mount(onClose, false);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the wizard alone while a dialog is open over it", () => {
|
||||
const onClose = vi.fn();
|
||||
mount(onClose, true);
|
||||
fireEvent.keyDown(screen.getByText("the sync failed"), { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -7,6 +7,9 @@ import { api, type Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
import { Button } from "./ui/Button";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
@@ -102,15 +105,39 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
endOnboarding(!me.can_read);
|
||||
onClose();
|
||||
};
|
||||
const overlayOrder = useOverlayOrder(); // this overlay's toast-host rank (see ui/Toaster)
|
||||
|
||||
// A layer in the shared stack, so Escape closes the wizard only when it is on top: a raw document
|
||||
// listener ran BEFORE the error dialog's (a sync that failed inside the wizard opens one), and one
|
||||
// Esc closed the error AND ended onboarding, skipping the write-scope step (deep-review round 4).
|
||||
const layer = useLayer(true, close);
|
||||
|
||||
// The rest of the dialog contract. This is a modal overlay in every way that matters to a user, but
|
||||
// it renders its own shell (it is not a titled dialog with a header row), so Escape ordering was all
|
||||
// it had: focus started on <body>, nothing announced a dialog, and Tab past the last control walked
|
||||
// into the app behind the scrim — where the focus ring is invisible (deep-review round 5). The trap
|
||||
// and the roles come from the same place `ui/Modal` gets them, not from a second hand-written copy.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDialogChrome(panelRef, rootRef);
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me.can_read]);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab.
|
||||
if (e.key !== "Tab" || !layer.isTopmost()) return;
|
||||
const panel = panelRef.current;
|
||||
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
|
||||
// neither of them (round 6, on the commit that extracted the helper so the two shells could
|
||||
// not disagree). Scope is the whole dialog, so the toast host beside the panel stays in the
|
||||
// Tab cycle (a sticky connection notice's ✕ was unreachable from the keyboard); `container`
|
||||
// is the tabIndex=-1 panel, because `panel.contains(panel)` is true, so focus sitting ON the
|
||||
// panel — where a click on its dead space puts it — matched no branch at all and a Shift+Tab
|
||||
// walked out into the app behind the scrim.
|
||||
if (panel) trapTab(e, rootRef.current ?? panel, panel);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [layer]);
|
||||
|
||||
// Are we currently importing (or about to)? Show a progress screen instead of the
|
||||
// "you're connected" step until the first import settles.
|
||||
@@ -126,12 +153,19 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
|
||||
<div ref={rootRef} className="fixed inset-0 z-overlay grid place-items-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]"
|
||||
onClick={close}
|
||||
/>
|
||||
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
|
||||
>
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
||||
@@ -145,7 +179,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Youtube className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{t("onboarding.read.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||
<Trans
|
||||
i18nKey="onboarding.read.body"
|
||||
@@ -173,7 +209,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{t("onboarding.importing.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
||||
{t("onboarding.importing.body")}
|
||||
</p>
|
||||
@@ -185,7 +223,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{t("onboarding.write.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||
{importSubs.isError ? (
|
||||
<>{t("onboarding.write.importFailed")}</>
|
||||
@@ -242,7 +282,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{t("onboarding.done.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
||||
{t("onboarding.done.body")}
|
||||
</p>
|
||||
@@ -262,6 +304,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
<Dots index={stepIndex} total={3} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Toasts raised while the wizard is up (the connection notice among them) show above its
|
||||
scrim, not under it — see ui/Toaster. */}
|
||||
<Toaster overlay order={overlayOrder} />
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
|
||||
// had tests; what the modal DOES with them had none — a deep-review of the 2026-09 bug hunt found
|
||||
// that deleting the lines that apply them left every lane green.
|
||||
//
|
||||
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
|
||||
// its promise in a module-level singleton, so this file must not share a module instance with a
|
||||
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
|
||||
// fake records what it was asked for — that is how these tests see where playback starts.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
|
||||
// Their own queries and popovers are not what is under test here.
|
||||
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
|
||||
vi.mock("./DownloadButton", () => ({ default: () => null }));
|
||||
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { clearAll, notify } from "../lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
// --- the fake IFrame API ------------------------------------------------------------------------
|
||||
const clock = { current: 0, duration: 3000 };
|
||||
const started: { videoId: string; start: number | undefined }[] = [];
|
||||
const base = {
|
||||
getCurrentTime: () => clock.current,
|
||||
getDuration: () => clock.duration,
|
||||
getVideoData: () => null,
|
||||
};
|
||||
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
|
||||
// a thenable would be swallowed by the first `await` that touched it.
|
||||
const fakePlayer = new Proxy(base, {
|
||||
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
|
||||
});
|
||||
window.YT = {
|
||||
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) {
|
||||
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
||||
return fakePlayer;
|
||||
} as never,
|
||||
};
|
||||
|
||||
const vid = (id: string, position_seconds: number): Video =>
|
||||
({
|
||||
id,
|
||||
title: `Video ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds,
|
||||
duration_seconds: 3000,
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
function renderModal(ui: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
started.length = 0;
|
||||
});
|
||||
|
||||
const lastStart = () => started[started.length - 1];
|
||||
|
||||
describe("PlayerModal — where each item starts", () => {
|
||||
it("resumes an item played earlier in the SAME session where this session left it", async () => {
|
||||
// The launch queue is frozen at mount, so its rows keep the position they were snapshotted
|
||||
// with (748). Stepping next→prev back to an item already watched to 830 used to start it at
|
||||
// 748 — and the next checkpoint then wrote 748 over the 830 the server held.
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: id === "A" ? 830 : 0,
|
||||
}));
|
||||
const A = vid("A", 748);
|
||||
const B = vid("B", 0);
|
||||
clock.current = 830;
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
expect(lastStart()?.start).toBe(830);
|
||||
});
|
||||
});
|
||||
|
||||
// --- "remove from this playlist", from inside the player ----------------------------------------
|
||||
const trash = (title: string) =>
|
||||
screen.getByRole("button", { name: `playlists.removeItemOf:Video ${title}` });
|
||||
const next = () => fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
const prev = () => fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
|
||||
const settle = () => act(async () => {});
|
||||
|
||||
describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
const [A, B, C, D] = ["A", "B", "C", "D"].map((id) => vid(id, 0));
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: 0,
|
||||
}));
|
||||
clock.current = 0;
|
||||
});
|
||||
|
||||
it("steps to the item that followed, and the removed one is gone from the queue", async () => {
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={B!}
|
||||
queue={[A!, B!, C!]}
|
||||
startIndex={1}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fireEvent.click(trash("B"));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
prev(); // …straight back to A: B is no longer there to step onto
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
});
|
||||
|
||||
it("closes the player when the last item goes", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(trash("A"));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("leaves playback and the queue alone when the removal did not happen", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!, B!]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={async () => false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(trash("A"));
|
||||
await settle();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(started.map((s) => s.videoId)).toEqual(["A"]);
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
prev(); // A is still in the queue
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
});
|
||||
|
||||
it("keeps playback where the viewer went while the confirm was open", async () => {
|
||||
// prev/next keep working under the confirm dialog; deciding from the cursor as it was when the
|
||||
// button was pressed threw the viewer back onto a video they had already moved past.
|
||||
let answer!: (ok: boolean) => void;
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!, B!, C!, D!]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(trash("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
await act(async () => answer(true));
|
||||
expect(lastStart()?.videoId).toBe("C");
|
||||
prev();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
|
||||
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
|
||||
prev();
|
||||
await settle();
|
||||
expect(lastStart()?.videoId).toBe("B");
|
||||
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
|
||||
});
|
||||
|
||||
it("gives focus back to the Remove button when the confirm is cancelled", async () => {
|
||||
// The button used to be disabled for the confirm's whole lifetime, so the dialog's focus
|
||||
// restore landed on a disabled element — a no-op — and a keyboard user was left on <body>,
|
||||
// outside the aria-modal player.
|
||||
function WithConfirm() {
|
||||
const confirm = useConfirm();
|
||||
return (
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!, B!]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={(v) =>
|
||||
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderModal(
|
||||
<ConfirmProvider>
|
||||
<WithConfirm />
|
||||
</ConfirmProvider>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
trash("A").focus();
|
||||
fireEvent.click(trash("A"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "common.cancel" }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerModal — toasts raised while it is open", () => {
|
||||
afterEach(() => act(() => clearAll()));
|
||||
|
||||
it("shows them inside the player, not under its scrim", async () => {
|
||||
// The app's Toaster is in a stacking context below this body-level portal: a failed Remove
|
||||
// reported by a toast was drawn under the 80% black scrim, i.e. still silent.
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
||||
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
|
||||
});
|
||||
|
||||
it("shows them inside the stage in true fullscreen — the element holding the lock", async () => {
|
||||
// Anything outside the fullscreen element is painted under it. The player's host sat beside the
|
||||
// card, so in fullscreen a lost connection was reported to nobody.
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
const stage = document.querySelector(".player-stage")!;
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
||||
try {
|
||||
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
||||
act(() => void notify({ level: "error", message: "Connection lost" }));
|
||||
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
|
||||
} finally {
|
||||
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Settings,
|
||||
Trash2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
@@ -46,6 +47,9 @@ import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||
import { afterRemoval } from "../lib/playerQueue";
|
||||
import {
|
||||
useNativeControlsYield,
|
||||
useYtBigView,
|
||||
@@ -82,6 +86,7 @@ export default function PlayerModal({
|
||||
startIndex,
|
||||
onClose,
|
||||
onState,
|
||||
onRemoveFromList,
|
||||
}: {
|
||||
video: Video;
|
||||
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
|
||||
@@ -94,6 +99,11 @@ export default function PlayerModal({
|
||||
startIndex?: number;
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
|
||||
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
|
||||
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
|
||||
* drops the item itself and steps to the next one, closing when the list runs out. */
|
||||
onRemoveFromList?: (video: Video) => Promise<boolean>;
|
||||
// Open the active video's channel page in-app (closes the player first). The small external
|
||||
// icon next to the name keeps the open-on-YouTube behaviour.
|
||||
}) {
|
||||
@@ -110,7 +120,11 @@ export default function PlayerModal({
|
||||
// wrap back to the first item once earlier items dropped out). Snapshot once at mount via
|
||||
// useState's lazy initializer and ignore later prop changes. Items that turn out gone/
|
||||
// non-embeddable still surface the existing playback-error overlay when reached.
|
||||
const [queue] = useState(() => queueProp);
|
||||
//
|
||||
// The setter exists for exactly ONE writer: removing a video from its playlist (below).
|
||||
// That is not the feed changing under us — it is this player's own action — and leaving the item
|
||||
// in the frozen queue would let prev/next step back onto a video that no longer exists.
|
||||
const [queue, setQueue] = useState(() => queueProp);
|
||||
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||||
const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
|
||||
// Track the playing item by id (not a frozen index) so it survives the queue changing
|
||||
@@ -129,9 +143,17 @@ export default function PlayerModal({
|
||||
if (index < 0) index = 0;
|
||||
const active = queue?.[index] ?? video; // the queue item at index, else the opened video (never undefined)
|
||||
const hasQueue = !!queue && queue.length > 1;
|
||||
// startAt only applies to the item we opened with; advanced items resume their own pos.
|
||||
// Positions this session has checkpointed (server-confirmed), per video id — see useYtProgress.
|
||||
// The launch queue is frozen at mount, so its items keep the position they were snapshotted with;
|
||||
// without this map, stepping next→prev back to an item played earlier in the SAME session would
|
||||
// resume at that snapshot and then persist it over the newer position.
|
||||
const sessionPosRef = useRef<Map<string, number>>(new Map());
|
||||
const sessionPos = sessionPosRef.current.get(active.id);
|
||||
// startAt only applies to the item we opened with; advanced items resume their own pos. A
|
||||
// position this session already stored wins over both — it is the freshest truth we have.
|
||||
const resumeAt =
|
||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||
sessionPos ??
|
||||
(startAt != null && active.id === video.id ? startAt : active.position_seconds || 0);
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
||||
@@ -237,6 +259,45 @@ export default function PlayerModal({
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id);
|
||||
};
|
||||
// "Remove from this playlist", offered right where you are watching the thing you want gone. The
|
||||
// parent owns the removal (confirm + API + caches); 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
|
||||
// cannot tell. Step to the item that followed (clamped when the last one goes), 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 confirm's lifetime was still disabled when the dialog closed and restored focus to it — a
|
||||
// no-op — so a keyboard user who cancelled was left on <body>, outside this aria-modal player.
|
||||
const removingRef = useRef(false);
|
||||
const removeFromList = async () => {
|
||||
if (!onRemoveFromList || removingRef.current) return;
|
||||
const victim = active;
|
||||
removingRef.current = true;
|
||||
let done = false;
|
||||
try {
|
||||
done = await onRemoveFromList(victim);
|
||||
} finally {
|
||||
removingRef.current = false;
|
||||
}
|
||||
if (!done) return; // cancelled at the confirm, or the request failed — leave playback alone
|
||||
// Decide from where the player is NOW, not where it was when the button was pressed: prev/next
|
||||
// and auto-advance keep working under the confirm dialog, and a cursor from before the await
|
||||
// threw the viewer back onto a video they had already moved past.
|
||||
const { queue: q, index: i } = navRef.current;
|
||||
if ((q?.[i] ?? video).id !== victim.id) {
|
||||
// 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));
|
||||
return;
|
||||
}
|
||||
const next = afterRemoval(q, i, victim.id);
|
||||
if (!next) {
|
||||
onClose(); // that was the last one
|
||||
return;
|
||||
}
|
||||
setQueue(next.queue);
|
||||
setPlayingId(next.playingId);
|
||||
};
|
||||
|
||||
// Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
|
||||
const nudgeSeek = (delta: number) => {
|
||||
const p = playerRef.current;
|
||||
@@ -318,6 +379,10 @@ export default function PlayerModal({
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// Its toast hosts' rank (see ui/Toaster): the player's two hosts — the stage one in true fullscreen,
|
||||
// the outer one otherwise — swap on every fullscreen change, and must not outrank a dialog opened
|
||||
// over the player by doing so.
|
||||
const overlayOrder = useOverlayOrder();
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
|
||||
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
@@ -429,6 +494,7 @@ export default function PlayerModal({
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
sessionPosRef,
|
||||
qc,
|
||||
});
|
||||
|
||||
@@ -521,9 +587,9 @@ export default function PlayerModal({
|
||||
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
|
||||
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
|
||||
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
||||
the exit button also re-arms our shortcuts. Add-to-playlist works in true fullscreen (its
|
||||
popover portals INTO the fullscreen element); download stays hidden there because it opens
|
||||
a Modal, which still portals to <body> — outside the fullscreen element. */}
|
||||
the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen:
|
||||
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
|
||||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||
{(maximized || isFullscreen) && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -752,8 +818,10 @@ export default function PlayerModal({
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
<ChannelNameLink
|
||||
channelId={active.channel_id}
|
||||
title={active.channel_title}
|
||||
onOpenInApp={() => {
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// Close first, open the channel only once this player's own Back-to-close
|
||||
@@ -763,9 +831,7 @@ export default function PlayerModal({
|
||||
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
>
|
||||
{active.channel_title}
|
||||
</button>
|
||||
/>
|
||||
<a
|
||||
href={active.channel_url}
|
||||
target="_blank"
|
||||
@@ -854,6 +920,18 @@ export default function PlayerModal({
|
||||
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
/>
|
||||
)}
|
||||
{!navigated && onRemoveFromList && (
|
||||
<button
|
||||
onClick={() => void removeFromList()}
|
||||
title={t("playlists.removeItem")}
|
||||
// Named with its object, like the row control: a bare "Remove" among Fullscreen /
|
||||
// Download / Mark watched reads as "delete this video".
|
||||
aria-label={t("playlists.removeItemOf", { title: active.title })}
|
||||
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-danger hover:bg-surface border border-border transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!navigated && (
|
||||
<button
|
||||
onClick={() => setWatched(!watched)}
|
||||
@@ -887,6 +965,10 @@ export default function PlayerModal({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
|
||||
Toaster is underneath this scrim. See ui/Toaster. In true fullscreen it moves into the
|
||||
stage, the element holding the lock: out here it was painted under it, i.e. not at all. */}
|
||||
{!isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A playlist or tag edit that fails is said, and what the screen shows goes back to what is true
|
||||
// (deep-review round 4). The Plex playlist page, the add-to-playlist popover and the tag manager had
|
||||
// no failure handling at all: a 404 for a list or tag deleted elsewhere — now a structured
|
||||
// `not_found`, which the request layer leaves to the caller — failed in silence, and an optimistic
|
||||
// removal stayed removed.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
import TagManager from "./TagManager";
|
||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => notify.mockClear());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const gone = (what: string) =>
|
||||
new HttpError(404, `gone ${what}`, { code: "not_found", reason: what });
|
||||
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
|
||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
|
||||
describe("the Plex playlist page", () => {
|
||||
it("puts back a row whose removal failed, and says why", async () => {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({
|
||||
id: 5,
|
||||
title: "Films",
|
||||
items: [movie("1"), movie("2")],
|
||||
});
|
||||
vi.spyOn(api, "plexPlaylistRemoveItem").mockRejectedValue(gone("playlist"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 1");
|
||||
fireEvent.click(screen.getAllByLabelText("remove")[0]!);
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so when a rename is refused, and keeps the field open", async () => {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items: [] });
|
||||
vi.spyOn(api, "plexRenamePlaylist").mockRejectedValue(gone("playlist"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Films");
|
||||
fireEvent.click(screen.getByTitle("plex.playlist.rename"));
|
||||
const field = screen.getByDisplayValue("Films");
|
||||
fireEvent.change(field, { target: { value: "Movies" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
|
||||
});
|
||||
|
||||
it("leaves a playlist that is already gone when asked to delete it", async () => {
|
||||
vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items: [] });
|
||||
vi.spyOn(api, "plexDeletePlaylist").mockRejectedValue(gone("playlist"));
|
||||
const onBack = vi.fn();
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistView playlistId={5} onBack={onBack} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByTitle("plex.playlist.delete"));
|
||||
await waitFor(() => expect(onBack).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("the add-to-playlist popover", () => {
|
||||
it("says so when the playlist ticked is gone, and refreshes the list", async () => {
|
||||
const lists = vi
|
||||
.spyOn(api, "playlists")
|
||||
.mockResolvedValue([{ id: 3, name: "Gone", kind: "user", has_video: false }] as never);
|
||||
vi.spyOn(api, "addToPlaylist").mockRejectedValue(gone("playlist"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<AddToPlaylist videoId="v1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
|
||||
it("says so on the PLEX side too, where the same sweep had missed the dialog", async () => {
|
||||
// Same shape, same 404, same silence — `req` leaves 403/404/429 to the caller, and this one had
|
||||
// no catch: the tick did not flip, nothing was said, the gone row stayed, and the floating
|
||||
// promise became an unhandled rejection. Round 5 fixed the collection editor and the YouTube
|
||||
// twin above; this dialog was left behind (round 6).
|
||||
const lists = vi
|
||||
.spyOn(api, "plexPlaylists")
|
||||
.mockResolvedValue({ playlists: [{ id: 9, title: "Gone", has_item: false }] } as never);
|
||||
vi.spyOn(api, "plexPlaylistAddItem").mockRejectedValue(gone("playlist"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexPlaylistAdd
|
||||
target={{ kind: "single", ratingKey: "m1", title: "A Movie" }}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("Gone"));
|
||||
// The server's own localized reason wins over the caller's fallback when the detail is
|
||||
// STRUCTURED — same as the twin above. What is under test is that anything is said at all.
|
||||
await waitFor(() => expect(errors()).toEqual(["gone playlist"]));
|
||||
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tag manager", () => {
|
||||
it("says so when the tag deleted is gone, and refreshes the list", async () => {
|
||||
const tags = vi
|
||||
.spyOn(api, "tags")
|
||||
.mockResolvedValue([{ id: 4, name: "Gone", system: false, channel_count: 0 }] as never);
|
||||
vi.spyOn(api, "channels").mockResolvedValue([] as never);
|
||||
vi.spyOn(api, "deleteTag").mockRejectedValue(gone("tag"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<TagManager onClose={() => {}} onFocusChannel={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("tagManager.delete"));
|
||||
await waitFor(() => expect(errors()).toEqual(["gone tag"]));
|
||||
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Playlists page as the player sees it: which row it is launched with, and what it is told to
|
||||
// do when the viewer removes that row from inside it. PlayerModal itself is replaced by a stand-in
|
||||
// that records its props — its own behaviour is pinned in PlayerModal.dom.test.tsx.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
selectedId: 1 as number | null,
|
||||
mounted: false,
|
||||
mounts: 0,
|
||||
player: null as null | {
|
||||
video: { id: string; position_seconds: number };
|
||||
onClose: () => void;
|
||||
onState: (id: string, status: string) => void;
|
||||
onRemoveFromList?: (v: unknown) => Promise<boolean>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
|
||||
vi.mock("./PlayerModal", async () => {
|
||||
const { useEffect } = await import("react");
|
||||
return {
|
||||
default: function PlayerStandIn(props: NonNullable<typeof h.player>) {
|
||||
h.player = props;
|
||||
useEffect(() => {
|
||||
h.mounted = true;
|
||||
h.mounts++;
|
||||
return () => {
|
||||
h.mounted = false;
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Playlists from "./Playlists";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../lib/progressCache";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const vid = (id: string, position_seconds: number): Video =>
|
||||
({
|
||||
id,
|
||||
title: `Video ${id}`,
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds,
|
||||
duration_seconds: 3000,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
||||
}) as unknown as Video;
|
||||
|
||||
const WATCH_LATER = 9;
|
||||
const list = (id: number, items: Video[]) => ({
|
||||
id,
|
||||
name: `List ${id}`,
|
||||
kind: id === WATCH_LATER ? "watch_later" : "user",
|
||||
yt_playlist_id: null,
|
||||
item_count: items.length,
|
||||
items,
|
||||
});
|
||||
|
||||
let qc: QueryClient;
|
||||
|
||||
function mount(lists: Record<number, Video[]>, prefs: Record<string, unknown> = {}) {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue(
|
||||
Object.entries(lists).map(([id, items]) => list(Number(id), items)) as never,
|
||||
);
|
||||
vi.spyOn(api, "playlist").mockImplementation(
|
||||
async (id: number) => list(id, lists[id] ?? []) as never,
|
||||
);
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: prefs });
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Playlists />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree());
|
||||
return { rerender: () => view.rerender(tree()) };
|
||||
}
|
||||
|
||||
const play = async (title: string) =>
|
||||
fireEvent.click(await screen.findByRole("button", { name: `card.play — ${title}` }));
|
||||
|
||||
beforeEach(() => {
|
||||
h.selectedId = 1;
|
||||
h.player = null;
|
||||
h.mounted = false;
|
||||
h.mounts = 0;
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Playlists — the row the player is launched with", () => {
|
||||
it("carries the position the player just stored, not the one the list first loaded with", async () => {
|
||||
// The page renders from its own undoable copy of the list, re-seeded only when the SET of ids
|
||||
// changes — so the position the player wrote into the cache never reached it: reopen a video
|
||||
// closed at 830 s and it resumed at the old spot, and the next checkpoint wrote that over 830.
|
||||
mount({ 1: [vid("V", 748)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player?.video.position_seconds).toBe(748));
|
||||
act(() => h.player!.onClose());
|
||||
h.player = null;
|
||||
// What the player's cleanup does with the position the server confirmed. react-query hands a
|
||||
// cache write to its observers on a setTimeout(0); outside act, the re-render it triggers is
|
||||
// scheduled by React on its own and raced the click below under the gate's parallel load (the
|
||||
// reopen read 748). The write is SYNCHRONOUS, so its notify timer is registered before ours and
|
||||
// fires first — and inside act, the render it causes is flushed before act returns.
|
||||
await act(async () => {
|
||||
patchCachedProgress(qc, new Map([["V", 830]]));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player?.video.position_seconds).toBe(830));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — removing a video from inside the player", () => {
|
||||
it("removes it from the list the player was launched from, even after the selection moved", async () => {
|
||||
// The selection can change under an open player (the command palette opens over it); the
|
||||
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
|
||||
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" }); // list 2 is on screen now
|
||||
await act(async () => {
|
||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
expect(remove).toHaveBeenCalledWith(1, "V");
|
||||
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
|
||||
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so when the removal fails because the list is gone", async () => {
|
||||
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
|
||||
// it, so a confirmed removal failed in total silence behind the full-screen player.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
|
||||
mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
let done: boolean | undefined;
|
||||
await act(async () => {
|
||||
done = await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
expect(done).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
|
||||
});
|
||||
|
||||
it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => {
|
||||
// Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player
|
||||
// launched from Watch later as soon as another list was selected under it — and deleted from
|
||||
// Watch later, since the removal itself targets the launch list.
|
||||
h.selectedId = WATCH_LATER;
|
||||
const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
expect(h.player!.onRemoveFromList).toBeUndefined();
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" });
|
||||
expect(h.player!.onRemoveFromList).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => {
|
||||
// The player steps to the next item after a removal and stores the last one's position into
|
||||
// every cached list. Landing before the refetch, that write handed the page a list still holding
|
||||
// the video, and the page — seeing a different set of ids — reset: the row came back.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out
|
||||
await act(async () => {
|
||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
await act(async () => {
|
||||
patchCachedProgress(qc, new Map([["V", 99]]));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — a failed refetch of a list that is on screen", () => {
|
||||
it("keeps the list, instead of replacing it with the error", async () => {
|
||||
// A background refetch — on focus, after a move or a removal — failing during an API restart
|
||||
// replaced a loaded list with "couldn't load" (the `isError && <no data>` convention, ignored).
|
||||
mount({ 1: [vid("V", 0)] });
|
||||
await screen.findByRole("button", { name: "card.play — Video V" });
|
||||
vi.mocked(api.playlist).mockRejectedValue(new HttpError(502, "gateway"));
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.playlist(1) });
|
||||
await new Promise((r) => setTimeout(r, 20)); // react-query notifies its observers on a timer
|
||||
});
|
||||
expect(qc.getQueryState(qk.playlist(1))?.status).toBe("error");
|
||||
expect(screen.queryByText("playlists.loadFailed")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — the player outlives a change of selection", () => {
|
||||
it("stays mounted while a newly selected list is still loading", async () => {
|
||||
// The page swaps itself for "Loading…" until the new list arrives; with the player rendered only
|
||||
// in the loaded branch, that took it down mid-playback and put it back at its launch video.
|
||||
const { rerender } = mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.mounted).toBe(true));
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}));
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
// The shared loading state, which a screen reader hears (the old bare line had no live region).
|
||||
expect(
|
||||
(await screen.findByText("playlists.loading")).closest('[role="status"]'),
|
||||
).not.toBeNull();
|
||||
expect(h.mounted).toBe(true);
|
||||
expect(h.mounts).toBe(1); // never went down — a remount would also end `mounted`
|
||||
});
|
||||
|
||||
it("stays mounted when the newly selected list fails to load — a list deleted elsewhere", async () => {
|
||||
const { rerender } = mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.mounted).toBe(true));
|
||||
vi.mocked(api.playlist).mockRejectedValue(new HttpError(404, "gone"));
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByText("playlists.loadFailed");
|
||||
expect(h.mounted).toBe(true);
|
||||
expect(h.mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("stays mounted when nothing is selected any more", async () => {
|
||||
const { rerender } = mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.mounted).toBe(true));
|
||||
h.selectedId = null;
|
||||
rerender();
|
||||
await screen.findByText("playlists.pickOne");
|
||||
expect(h.mounted).toBe(true);
|
||||
expect(h.mounts).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
|
||||
// Invalidating the SELECTED list left the launch list's row showing the old status.
|
||||
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
|
||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" });
|
||||
await act(async () => {
|
||||
h.player!.onState("V", "watched");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const rowsOnScreen = () =>
|
||||
screen
|
||||
.getAllByRole("button", { name: /^card\.play — / })
|
||||
.map((b) => b.getAttribute("aria-label")?.replace("card.play — ", ""));
|
||||
|
||||
async function rowMenu(title: string, item: string) {
|
||||
const row = (await screen.findByRole("button", { name: `card.play — ${title}` })).closest("tr")!;
|
||||
fireEvent.contextMenu(row);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).some((b) => b.textContent === item),
|
||||
).toBe(true),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("menuitem", { hidden: true }).find((b) => b.textContent === item)!,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Playlists — a change the server refused is taken back on screen", () => {
|
||||
// The resync refetch returns the SAME list the server had, react-query keeps the same `data`
|
||||
// object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the
|
||||
// optimistic change used to stay on screen for good.
|
||||
it("puts back a row whose move failed", async () => {
|
||||
vi.spyOn(api, "moveToPlaylist").mockRejectedValue(
|
||||
new HttpError(404, "gone", { code: "not_found", reason: "move_target" }),
|
||||
);
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)], 2: [] }, { lastMovePlaylistId: 2 });
|
||||
await rowMenu("Video V", "playlists.moveToRecent");
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })),
|
||||
);
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
});
|
||||
|
||||
it("puts back the order when saving a reorder failed", async () => {
|
||||
vi.spyOn(api, "reorderPlaylist").mockRejectedValue(new HttpError(500, "boom"));
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await rowMenu("Video V", "playlists.orderDown");
|
||||
await waitFor(() => expect(api.reorderPlaylist).toHaveBeenCalled());
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
});
|
||||
|
||||
it("puts back the order the server ACCEPTED, not the one it had two reorders ago", async () => {
|
||||
// The rollback reads the cached detail, and nothing wrote the accepted order into it — only the
|
||||
// refetch that follows a successful reorder did. So a second reorder rejected while that refetch
|
||||
// was still in flight rolled the rows back past the FIRST one, which the server had accepted.
|
||||
// The membership guard cannot correct an order (its key is the sorted id set), so the wrong
|
||||
// order stayed on screen for good and the next drag persisted it (deep-review round 5).
|
||||
const reorder = vi
|
||||
.spyOn(api, "reorderPlaylist")
|
||||
.mockResolvedValueOnce(undefined as never)
|
||||
.mockRejectedValue(new HttpError(500, "boom"));
|
||||
mount({ 1: [vid("V", 0), vid("W", 0), vid("X", 0)] });
|
||||
await screen.findByRole("button", { name: "card.play — Video V" });
|
||||
// From here no refetch ever lands, so the cache is whatever the page itself put there.
|
||||
vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}) as never);
|
||||
|
||||
await rowMenu("Video V", "playlists.orderDown");
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(1));
|
||||
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);
|
||||
|
||||
await rowMenu("Video X", "playlists.orderUp");
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledTimes(2));
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]);
|
||||
});
|
||||
|
||||
it("puts back a row whose removal failed, even when the resync fails too", async () => {
|
||||
// A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row
|
||||
// back — the list kept showing it as removed while it was still in the playlist.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
mount({ 1: [vid("V", 0), vid("W", 0)] });
|
||||
await screen.findByRole("button", { name: "card.play — Video V" });
|
||||
vi.mocked(api.playlist).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await rowMenu("Video V", "playlists.removeItem");
|
||||
await waitFor(() => expect(api.removeFromPlaylist).toHaveBeenCalled());
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", message: "playlists.removeFailed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
FolderInput,
|
||||
History,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
Pencil,
|
||||
@@ -39,8 +39,9 @@ import {
|
||||
X,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type Playlist, type Video } from "../lib/api";
|
||||
import { api, type Playlist, type PlaylistDetail, type Video } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
@@ -49,6 +50,7 @@ import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { useComfortPin, pinVideo } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
@@ -59,6 +61,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import {
|
||||
ColumnHead,
|
||||
alignClass,
|
||||
@@ -181,27 +184,34 @@ export default function Playlists() {
|
||||
const order = useUndoable<Video[]>([], {
|
||||
onApply: (next) => {
|
||||
if (selectedId == null) return;
|
||||
const listId = selectedId;
|
||||
api
|
||||
.reorderPlaylist(
|
||||
selectedId,
|
||||
listId,
|
||||
next.map((v) => v.id),
|
||||
)
|
||||
.then(() => {
|
||||
// Write the accepted order into the cache BEFORE the refetch. The cache is what
|
||||
// `restoreFromCache` rolls back to, and until this refetch landed it still held the order
|
||||
// from before this reorder — so a SECOND reorder failing in that window rolled the rows
|
||||
// back to an order the server does not have, permanently (the membership guard cannot
|
||||
// correct an order). One accepted write, one cache entry that matches it (round 5).
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: next } : old,
|
||||
);
|
||||
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
|
||||
// and the Reset/Unsynced indicators, show without an F5). The membership guard
|
||||
// keeps the refetch from wiping the undo history on a pure reorder.
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
})
|
||||
.catch((e) => {
|
||||
// Was a bare .then(): a failed reorder left the UI showing an order the server had
|
||||
// rejected, and produced an unhandled rejection. Say so and resync from the server.
|
||||
// rejected, and produced an unhandled rejection. Say so, and put back the order the server
|
||||
// still has — or the rejected one becomes the base for the next drag.
|
||||
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
|
||||
// Clear the membership guard first: a reorder never changes the id SET, so without this
|
||||
// the arriving detail is judged "same list" and the rejected order stays on screen — and
|
||||
// becomes the base for the next drag.
|
||||
lastSetRef.current = "";
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
|
||||
restoreFromCache(listId);
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -214,10 +224,46 @@ export default function Playlists() {
|
||||
// re-confirms our own reorder doesn't wipe the undo history — we reset history only when
|
||||
// the actual set of items changes (different playlist, or an add/remove).
|
||||
const lastSetRef = useRef<string>("");
|
||||
// The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or
|
||||
// a change of view would otherwise slide a different video under the index — or unmount the player
|
||||
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
|
||||
const selectedRef = useRef(selectedId);
|
||||
selectedRef.current = selectedId;
|
||||
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
|
||||
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
|
||||
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
|
||||
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen
|
||||
// (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile.
|
||||
function restoreFromCache(listId: number) {
|
||||
if (listId !== selectedRef.current) return;
|
||||
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
if (!cur) return;
|
||||
lastSetRef.current = `${listId}:${idsKey(cur.items)}`;
|
||||
order.reset(cur.items);
|
||||
}
|
||||
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
|
||||
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
|
||||
// its own cursor from mount — so deriving the mount from `shown` every render meant anything that
|
||||
// dropped the launch row yanked the player off the screen mid-playback. That was a latent edge
|
||||
// (a view change) until "remove this from the playlist" moved INSIDE the player, where it is the
|
||||
// normal case. Only onClose takes the player down now. `listId` is part of that snapshot for the
|
||||
// same reason: the selection can change under an open player (the command palette opens over it),
|
||||
// and "remove this from the playlist" means the list it was LAUNCHED from, not whichever is
|
||||
// selected by the time the button is pressed. So does whether that button exists at all
|
||||
// (`editable`): gating it on the current selection offered Remove on a player launched from
|
||||
// Watch later — which deliberately has none — as soon as another list was selected under it.
|
||||
const [playing, setPlaying] = useState<{
|
||||
video: Video;
|
||||
queue: Video[];
|
||||
index: number;
|
||||
listId: number | null;
|
||||
editable: boolean;
|
||||
} | null>(null);
|
||||
// Where the last move went. `LS.playlist` is the SELECTED (source) list and `updated_at` moves on
|
||||
// any edit, so neither answers "where do I keep sending things"; this does, and it is a per-account
|
||||
// preference like every other remembered choice. Null until the first move of this account's life.
|
||||
const [lastMoveTargetId, setLastMoveTargetId] = usePref<number | null>(
|
||||
"lastMovePlaylistId",
|
||||
null,
|
||||
);
|
||||
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
|
||||
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
|
||||
const rowMenu = useContextMenu<Video>();
|
||||
@@ -250,6 +296,14 @@ export default function Playlists() {
|
||||
if (key !== lastSetRef.current) {
|
||||
lastSetRef.current = key;
|
||||
order.reset(detail.items);
|
||||
} else {
|
||||
// Same membership, fresher ROWS — a watch position the player just stored, a status. The
|
||||
// guard above looks at the id set only, so without this the list kept showing, and handing the
|
||||
// player, each row as it was when the list first loaded: watch a video to 830 s, close it,
|
||||
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
|
||||
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
|
||||
const fresh = new Map(detail.items.map((v) => [v.id, v]));
|
||||
order.rebase((list) => list.map((v) => fresh.get(v.id) ?? v));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detail]);
|
||||
@@ -270,13 +324,39 @@ export default function Playlists() {
|
||||
// push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
|
||||
// every account without the scope, which they have always had.
|
||||
const canEdit = editable;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
|
||||
const onPlay = useCallback((v: Video) => setPlayingId(v.id), []);
|
||||
const onRemove = useCallback((v: Video) => {
|
||||
removeRef.current(v);
|
||||
// Stable (the memoized row callbacks depend on it), so it reads `shown` through the ref rather
|
||||
// than closing over it.
|
||||
const openPlayer = useCallback((id: string) => {
|
||||
const queue = shownRef.current;
|
||||
const index = queue.findIndex((v) => v.id === id);
|
||||
if (index < 0) return;
|
||||
setPlaying({
|
||||
video: queue[index]!,
|
||||
queue,
|
||||
index,
|
||||
listId: selectedRef.current,
|
||||
editable: canEditRef.current,
|
||||
});
|
||||
}, []);
|
||||
const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]);
|
||||
const onRemove = useCallback((v: Video) => {
|
||||
void removeRef.current(v);
|
||||
}, []);
|
||||
// The player's "remove from this playlist" goes through the SAME removeItem the row menu and the
|
||||
// inline ✕ use — one confirm, one optimistic update, one undo-stack rebase, no second copy to
|
||||
// drift. It answers whether the removal actually happened, because the player has to decide
|
||||
// afterwards whether to step to the next item or close. It removes from the list the player was
|
||||
// launched from (see `playing.listId`), not from whatever is selected by now.
|
||||
const launchListId = playing?.listId ?? null;
|
||||
const removeFromPlayer = useCallback(
|
||||
(v: Video) => removeRef.current(v, launchListId),
|
||||
[launchListId],
|
||||
);
|
||||
|
||||
const storedIndex = useMemo(() => {
|
||||
const m = new Map(items.map((v, i) => [v.id, i]));
|
||||
@@ -362,9 +442,9 @@ export default function Playlists() {
|
||||
|
||||
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
|
||||
|
||||
function refreshAll() {
|
||||
function refreshAll(listId: number | null = selectedId) {
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
|
||||
}
|
||||
|
||||
async function revertToYoutube() {
|
||||
@@ -478,27 +558,54 @@ export default function Playlists() {
|
||||
order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
|
||||
}
|
||||
|
||||
async function removeItem(video: Video) {
|
||||
if (selectedId == null) return;
|
||||
/** @returns whether the item was actually removed — the player needs to know, because it must
|
||||
* step to the next item (or close) only when the removal really happened. */
|
||||
async function removeItem(
|
||||
video: Video,
|
||||
listId: number | null = selectedRef.current,
|
||||
): Promise<boolean> {
|
||||
if (listId == null) return false;
|
||||
const ok = await confirm({
|
||||
title: t("playlists.removeItem"),
|
||||
message: t("playlists.confirmRemove", { title: video.title }),
|
||||
confirmLabel: t("playlists.removeItem"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
|
||||
// drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = items.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
|
||||
if (!ok) return false;
|
||||
// Only the list ON SCREEN has rows to take the item out of optimistically — the open player can
|
||||
// be removing from the list it was launched from while another one is selected. Read after the
|
||||
// confirm: the selection may have changed while it was open.
|
||||
if (listId === selectedRef.current) {
|
||||
// A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
|
||||
// drops the item from every snapshot, so the stack stays valid for what's left.
|
||||
const next = order.value.filter((v) => v.id !== video.id);
|
||||
order.rebase((list) => list.filter((v) => v.id !== video.id));
|
||||
lastSetRef.current = `${listId}:${idsKey(next)}`;
|
||||
}
|
||||
// …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry
|
||||
// before the refetch lands handed the membership guard a list that still held the video — the
|
||||
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
|
||||
// so the guard reset the order: the removed row came back and the undo history the rebase had
|
||||
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
|
||||
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
|
||||
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
try {
|
||||
await api.removeFromPlaylist(selectedId, video.id);
|
||||
refreshAll();
|
||||
} catch {
|
||||
// The optimistic removal above didn't stick — resync from the server to restore the row.
|
||||
// api.req already surfaced the error to the user via the global error dialog.
|
||||
refreshAll();
|
||||
await api.removeFromPlaylist(listId, video.id);
|
||||
refreshAll(listId);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
|
||||
// full-screen player there is not even a row snapping back to show it) — unless `req` already
|
||||
// put it in its error dialog, which `notifyActionError` checks.
|
||||
notifyActionError(e, t("playlists.removeFailed"));
|
||||
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
|
||||
// server was unreachable has a resync that fails too, and the list kept showing the row as
|
||||
// removed while it was still in the playlist.
|
||||
if (before) qc.setQueryData(qk.playlist(listId), before);
|
||||
refreshAll(listId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
|
||||
@@ -516,6 +623,9 @@ export default function Playlists() {
|
||||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||||
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
|
||||
// shortcut to failing again.
|
||||
setLastMoveTargetId(target.id);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
|
||||
@@ -525,11 +635,10 @@ export default function Playlists() {
|
||||
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
|
||||
// impossible — the submenu never offers the current list). So the row used to snap back with no
|
||||
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
|
||||
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
|
||||
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
|
||||
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
|
||||
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
|
||||
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
|
||||
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
|
||||
// checks (E10 review round 2 found it reported twice).
|
||||
notifyActionError(e, t("playlists.moveFailed"));
|
||||
restoreFromCache(selectedId); // the row is still in this list
|
||||
}
|
||||
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
|
||||
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
|
||||
@@ -568,14 +677,17 @@ export default function Playlists() {
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
api.setState(id, status).then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
|
||||
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
||||
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
||||
// status just changed. Only lists actually on screen refetch.
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
});
|
||||
}
|
||||
|
||||
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
|
||||
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
||||
const closePlayer = useCallback(() => setPlayingId(null), []);
|
||||
const closePlayer = useCallback(() => setPlaying(null), []);
|
||||
|
||||
// Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
|
||||
const sortLabel =
|
||||
@@ -601,11 +713,15 @@ export default function Playlists() {
|
||||
: filterActive
|
||||
? t("playlists.dragBlockedFilter")
|
||||
: t("playlists.dragBlockedSort");
|
||||
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
|
||||
|
||||
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
|
||||
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
|
||||
const moveTargets = playlists.filter((p) => p.id !== selectedId);
|
||||
// A one-click repeat of the last move — the submenu is two levels deep, and moving a run of videos
|
||||
// into the same list is the shape this actually gets used in. Absent (not disabled) when there is
|
||||
// nothing to repeat: before the first move, or when the remembered list IS the one you are looking
|
||||
// at, which is not a move at all. Nothing to explain, so nothing to leave behind greyed out.
|
||||
const recentMoveTarget = moveTargets.find((p) => p.id === lastMoveTargetId);
|
||||
const storedOrderEditable = canEdit && !viewActive;
|
||||
const ctxRow = rowMenu.target;
|
||||
const ctxItems: ActionMenuItem[] = !ctxRow
|
||||
@@ -615,17 +731,29 @@ export default function Playlists() {
|
||||
id: "play",
|
||||
label: t("card.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
onSelect: () => setPlayingId(ctxRow.id),
|
||||
onSelect: () => openPlayer(ctxRow.id),
|
||||
},
|
||||
...comfortPin.item(pinVideo(ctxRow.id), ctxRow.title ?? ""),
|
||||
...(canEdit
|
||||
? ([
|
||||
...(recentMoveTarget
|
||||
? [
|
||||
{
|
||||
id: "move-recent",
|
||||
label: t("playlists.moveToRecent", { name: plName(recentMoveTarget) }),
|
||||
icon: <History className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => void moveItemTo(ctxRow, recentMoveTarget),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
moveTargets.length
|
||||
? {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
// Only ONE separator opens the move block, wherever it starts.
|
||||
separatorBefore: !recentMoveTarget,
|
||||
items: moveTargets.map((pl) => ({
|
||||
id: `move-${pl.id}`,
|
||||
label: plName(pl),
|
||||
@@ -647,7 +775,7 @@ export default function Playlists() {
|
||||
id: "move",
|
||||
label: t("playlists.moveTo"),
|
||||
icon: <FolderInput className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
separatorBefore: !recentMoveTarget,
|
||||
disabled: true,
|
||||
title: t("playlists.moveNoTargets"),
|
||||
onSelect: () => {},
|
||||
@@ -683,292 +811,311 @@ export default function Playlists() {
|
||||
: []),
|
||||
];
|
||||
|
||||
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
|
||||
// another list under an open player (the command palette works over it) puts the page into
|
||||
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
|
||||
// unmounted it mid-playback and remounted it at the video it was launched with.
|
||||
const player = playing && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={playing.video}
|
||||
queue={playing.queue}
|
||||
startIndex={playing.index}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={playerState}
|
||||
onRemoveFromList={playing.editable ? removeFromPlayer : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
if (selectedId == null) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// The page used to render "Loading…" forever when the detail query failed — an error read as a
|
||||
// slow network that never arrived.
|
||||
if (detailQuery.isError) {
|
||||
// slow network that never arrived. Only with nothing to show, though (the `isError && <no data>`
|
||||
// convention, see ui/QueryState): a background refetch that failed — on focus, after a move or a
|
||||
// removal — replaced a list that was on screen with this message.
|
||||
if (detailQuery.isError && !detail) {
|
||||
return (
|
||||
<div className="text-sm p-4 flex flex-col items-start gap-3">
|
||||
<span className="inline-flex items-center gap-2 text-muted">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
<>
|
||||
<StateMessage tone="error" onRetry={() => detailQuery.refetch()}>
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</StateMessage>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!detail) {
|
||||
return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
|
||||
return (
|
||||
<>
|
||||
<LoadingState label={t("playlists.loading")} />
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||
return (
|
||||
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
|
||||
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
|
||||
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
|
||||
<div className="px-4 pb-4">
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
<>
|
||||
<div className="px-4 pb-4">
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||
<PageToolbar>
|
||||
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
|
||||
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
|
||||
row it sheds is list height won back — the old layout stacked three rows next to the
|
||||
cover plus a fourth block under it, for roughly twice this height. */}
|
||||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
<div className="px-4 py-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
)}
|
||||
{editable && (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||
·
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={saveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("playlists.rename")}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{detail.yt_playlist_id && (
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("playlists.openOnYoutube")}
|
||||
aria-label={t("playlists.openOnYoutube")}
|
||||
className="shrink-0 text-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex gap-1.5 items-center shrink-0">
|
||||
{linked && detail.dirty && (
|
||||
<span
|
||||
title={t("playlists.unsyncedHint")}
|
||||
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
{t("playlists.unsynced")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => shown.length && openPlayer(shown[0]!.id)}
|
||||
disabled={!shown.length}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{editable && canWrite && (
|
||||
<button
|
||||
onClick={pushToYoutube}
|
||||
disabled={pushing}
|
||||
title={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
aria-label={
|
||||
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
|
||||
}
|
||||
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
|
||||
detail.dirty
|
||||
? "border-accent text-accent hover:bg-accent/10"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{pushing ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : linked ? (
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
{linked && detail.dirty && (
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
>
|
||||
{t("playlists.saveOrder")}
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
{editable && (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-xs text-muted/40">
|
||||
·
|
||||
</span>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
{t("playlists.groupByChannel")}
|
||||
</label>
|
||||
{viewActive && (
|
||||
<button
|
||||
onClick={clearView}
|
||||
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
|
||||
>
|
||||
{t("playlists.clearView")}
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t("playlists.saveOrder")}
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
question a user actually has — "did I just change my playlist?". Worse,
|
||||
sorting a list whose stored order ALREADY matches the sort produced a
|
||||
warning, no Save button and no visible change, which reads as a malfunction.
|
||||
Now the line says which of the three situations you are in; the disabled grip
|
||||
still explains itself on hover/focus. */}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
|
||||
{canEdit && (
|
||||
<div className="ml-auto">
|
||||
<UndoToolbar
|
||||
canUndo={order.canUndo}
|
||||
canRedo={order.canRedo}
|
||||
onUndo={order.undo}
|
||||
onRedo={order.redo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
</PageToolbar>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
<div>
|
||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
{t("playlists.empty")}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||
<div>
|
||||
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||
{t("playlists.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||
accessibility={{ screenReaderInstructions, announcements }}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
// dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
|
||||
// drag; now that the keyboard can, they ARE the instructions for the feature.
|
||||
accessibility={{ screenReaderInstructions, announcements }}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={columns}
|
||||
sort={viewSort}
|
||||
onToggleSort={toggleSort}
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<SortableContext
|
||||
items={shown.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{shown.map((v) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
video={v}
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
{shown.length === 0 && (
|
||||
<div className="text-muted py-8 text-center text-sm">
|
||||
{t("playlists.noneMatchFilter")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<ColumnHead
|
||||
columns={columns}
|
||||
sort={viewSort}
|
||||
onToggleSort={toggleSort}
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<SortableContext
|
||||
items={shown.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{shown.map((v) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
video={v}
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
dragHint={dragHint}
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
{shown.length === 0 && (
|
||||
<div className="text-muted py-8 text-center text-sm">
|
||||
{t("playlists.noneMatchFilter")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
|
||||
{playingIndex >= 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<PlayerModal
|
||||
video={shown[playingIndex]!}
|
||||
queue={shown}
|
||||
startIndex={playingIndex}
|
||||
startAt={null}
|
||||
onClose={closePlayer}
|
||||
onState={playerState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
</div>
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
@@ -77,6 +77,11 @@ export default function PlexCollectionEditor({
|
||||
return next;
|
||||
});
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// 403/404 are left to the caller by `req` (see reportFailure), so without this the tick
|
||||
// simply never appeared and the user clicked again — a collection deleted on the Plex server
|
||||
// or flipped read-only failed in silence (deep-review round 5).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -88,6 +93,8 @@ export default function PlexCollectionEditor({
|
||||
try {
|
||||
await api.plexSetCollectionEditable(id, true);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -103,9 +110,9 @@ export default function PlexCollectionEditor({
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
|
||||
// nameTaken rather than surfacing the server's raw English detail.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names), structured — so
|
||||
// it reads in the reader's language (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
@@ -123,6 +130,10 @@ export default function PlexCollectionEditor({
|
||||
return next;
|
||||
});
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// The worst of the three to lose: the user has just confirmed a destructive dialog, and the
|
||||
// row stayed where it was with nothing said.
|
||||
notifyActionError(e, t("plex.collection.deleteFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ beforeAll(() => {
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
// …and the Sort control's popover watches its anchor with an IntersectionObserver.
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -36,7 +38,13 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import { api, type PlexCard, type PlexCollectionDetail, type PlexShowDetail } from "../lib/api";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
type PlexCard,
|
||||
type PlexCollectionDetail,
|
||||
type PlexShowDetail,
|
||||
} from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -126,3 +134,72 @@ describe("PlexCollectionView — Play-all", () => {
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Bug-hunt deep-review: the Sort control put the sort into the detail query's key, and two things
|
||||
// on this page did not follow it.
|
||||
describe("PlexCollectionView — after the sort joined the query key", () => {
|
||||
it("flips a poster's watched state on the page it was clicked on", async () => {
|
||||
// The optimistic write kept the key's OLD spelling — an entry nothing reads — and the resync
|
||||
// only touches the library root, so the click did nothing visible until a reload.
|
||||
// The request never answers, so the resync never runs: only the optimistic write can flip it.
|
||||
vi.spyOn(api, "plexSetState").mockReturnValue(new Promise(() => {}));
|
||||
mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
|
||||
expect(await screen.findByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("puts a toggle that failed back the way it was", async () => {
|
||||
// With the flip landing on the entry the page reads, the resync is what corrects it — and it only
|
||||
// touched the library root, so a failed toggle stayed "watched" beside its own error toast.
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new HttpError(422, "nope"));
|
||||
mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
|
||||
await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
|
||||
expect(await screen.findByRole("button", { name: "plex.markWatched" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "plex.markUnwatched" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the page, and the open Sort panel, on screen while the new order loads", async () => {
|
||||
// Every sort change was a key with no data yet: the whole page — the control and its open panel
|
||||
// included — was swapped for a spinner, so key and direction could not be set in one visit.
|
||||
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
|
||||
vi.spyOn(api, "plexCollection")
|
||||
.mockResolvedValueOnce(detail([card("movie1", "movie"), card("movie2", "movie")]))
|
||||
.mockReturnValueOnce(new Promise(() => {})); // the re-sorted page is still on its way
|
||||
mount([card("movie1", "movie"), card("movie2", "movie")]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^plex\.filter\.sort:/ }));
|
||||
// Popover rows are portalled and only exist while it is open, so `hidden: true`.
|
||||
const other = (await screen.findAllByRole("menuitemradio", { hidden: true })).find(
|
||||
(o) => o.getAttribute("aria-checked") !== "true",
|
||||
);
|
||||
fireEvent.click(other!);
|
||||
await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByText("plex.loading")).toBeNull();
|
||||
expect(screen.getAllByRole("menuitemradio", { hidden: true }).length).toBeGreaterThan(1);
|
||||
expect(screen.getByText("plex.collection.playAll")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not show the previous collection while stepping to another one", async () => {
|
||||
// Keeping the page on screen is for a re-sort of THIS collection only.
|
||||
vi.spyOn(api, "plexCollection")
|
||||
.mockResolvedValueOnce(detail([card("movie1", "movie")]))
|
||||
.mockReturnValueOnce(new Promise(() => {}));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = (id: string) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId={id}
|
||||
onBack={() => {}}
|
||||
onPlay={() => {}}
|
||||
onOpenCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const { rerender } = render(view("col1"));
|
||||
await screen.findByText("plex.collection.playAll");
|
||||
rerender(view("col2"));
|
||||
expect(await screen.findByText("plex.loading")).toBeTruthy();
|
||||
expect(screen.queryByText("plex.collection.playAll")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,13 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import { healPlexSort, plexSortOptions } from "../lib/plexSort";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
@@ -22,6 +26,11 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
|
||||
// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
|
||||
// / imdb / tmdb / … auto-lists) are marked with a lock.
|
||||
// The order this page had before it grew a Sort control, and still its default: a franchise reads in
|
||||
// release order. MUST match the endpoint's `DEFAULT_MEMBER_SORT` — the two only diverge if a request
|
||||
// goes out without the params, which is why the control always sends them.
|
||||
const COLLECTION_SORT_DEFAULT: NonNullable<SortState> = { key: "year", dir: "asc" };
|
||||
|
||||
function shuffled(a: string[]): string[] {
|
||||
const r = [...a];
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
@@ -52,9 +61,27 @@ export default function PlexCollectionView({
|
||||
// One comfort-pin menu for the member grid (see ui/ContextMenu).
|
||||
const pinMenu = useContextMenu<PlexCard>();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
// The member order is a PREFERENCE (server-synced, adopted on login), not an ephemeral filter — the
|
||||
// same treatment the feed's view and the player's auto-advance get. It is deliberately its OWN key
|
||||
// rather than the browse grid's sort: opening a collection must not silently re-sort the library
|
||||
// behind it.
|
||||
const [sortPref, setSortPref] = usePref<NonNullable<SortState>>(
|
||||
"plexCollectionSort",
|
||||
COLLECTION_SORT_DEFAULT,
|
||||
);
|
||||
// ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
|
||||
// optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
|
||||
// exactly), so the click did nothing visible until a reload.
|
||||
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("detail", collectionId),
|
||||
queryFn: () => api.plexCollection(collectionId),
|
||||
queryKey: detailKey,
|
||||
queryFn: () => api.plexCollection(collectionId, sortPref),
|
||||
// A re-sort keeps the current page on screen while the new order loads. Without it every sort
|
||||
// change was a key with no data yet, and the whole page — the Sort control and its open panel
|
||||
// with it — was swapped for a spinner, so key and direction could not be set in one visit. Only
|
||||
// within the SAME collection: stepping to another one must not show this one's hero meanwhile.
|
||||
placeholderData: (prev, prevQuery) =>
|
||||
prevQuery?.queryKey[2] === collectionId ? prev : undefined,
|
||||
});
|
||||
const d = q.data;
|
||||
|
||||
@@ -73,6 +100,11 @@ export default function PlexCollectionView({
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// A collection lives in ONE library, so its members are all movies or all shows. That decides both
|
||||
// what the add-panel searches and which sort keys are on offer (only a movie has a runtime).
|
||||
const scope = items.some((c) => c.type === "show") ? "show" : "movie";
|
||||
const sortState = healPlexSort(sortPref, scope);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
@@ -124,7 +156,7 @@ export default function PlexCollectionView({
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) => {
|
||||
qc.setQueryData<PlexCollectionDetail>(qk.plexCollections("detail", collectionId), (old) =>
|
||||
qc.setQueryData<PlexCollectionDetail>(detailKey, (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
@@ -133,7 +165,14 @@ export default function PlexCollectionView({
|
||||
: old,
|
||||
);
|
||||
},
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||
// This page's OWN key too, not just the library's: the flip above lands on the entry the page
|
||||
// reads, and resync is what corrects it to the truth when the toggle fails — invalidating only
|
||||
// `plex-library` left a failed toggle flipped on screen. The prefix (no sort in it) also
|
||||
// refreshes the other sort orders of this collection cached behind this one.
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections("detail", collectionId) });
|
||||
},
|
||||
});
|
||||
|
||||
async function doRename() {
|
||||
@@ -148,8 +187,8 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
// The backend enforces per-library name uniqueness — localize its 409 to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
// The backend enforces per-library name uniqueness — a taken name comes back localized.
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -334,7 +373,7 @@ export default function PlexCollectionView({
|
||||
{adding && canEdit && (
|
||||
<AddItemPanel
|
||||
collectionId={collectionId}
|
||||
scope={items.some((c) => c.type === "show") ? "show" : "movie"}
|
||||
scope={scope}
|
||||
existing={new Set(items.map((c) => c.id))}
|
||||
onAdded={invalidate}
|
||||
onClose={() => setAdding(false)}
|
||||
@@ -344,30 +383,46 @@ export default function PlexCollectionView({
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="relative">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
menuProps={pinMenu.triggerProps}
|
||||
<>
|
||||
{/* Sort sits directly above the grid, pinned right — the same placement (and the same
|
||||
control, options and labels) as the browse grid, so the two read as one idea. */}
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSortPref(next ?? COLLECTION_SORT_DEFAULT)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => doRemove(c)}
|
||||
disabled={busy}
|
||||
title={t("plex.collection.removeItem")}
|
||||
aria-label={t("plex.collection.removeItem")}
|
||||
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="relative">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
menuProps={pinMenu.triggerProps}
|
||||
/>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => doRemove(c)}
|
||||
disabled={busy}
|
||||
title={t("plex.collection.removeItem")}
|
||||
aria-label={t("plex.collection.removeItem")}
|
||||
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -235,4 +235,31 @@ describe("PlexDiscover", () => {
|
||||
);
|
||||
await screen.findByText("plex.discover.empty");
|
||||
});
|
||||
|
||||
it("puts a Surprise card back when its watched toggle fails", async () => {
|
||||
// The resync refetches the rails but not the Surprise draw (that would reshuffle it), so a
|
||||
// refused toggle stayed flipped there — the sibling of the collection page's fix.
|
||||
plexDiscover.mockResolvedValue({
|
||||
...fullPayload,
|
||||
continue_watching: [],
|
||||
recently_watched: [],
|
||||
recently_added: [],
|
||||
because_you_watched: null,
|
||||
});
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
plexSetState.mockRejectedValueOnce(new Error("refused"));
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("plex.markWatched"));
|
||||
await waitFor(() => expect(plexSetState).toHaveBeenCalledWith("sp1", "watched"));
|
||||
await waitFor(() => expect(screen.getByLabelText("plex.markWatched")).toBeTruthy());
|
||||
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,14 @@ export default function PlexDiscover({
|
||||
|
||||
// Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise
|
||||
// list, then persist + resync via the shared helper — invalidating the grid too so it agrees.
|
||||
const setSurpriseStatus = (card: PlexCard, status: PlexCard["status"]) =>
|
||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
|
||||
qk.plexSurprise(scope, lang, filters),
|
||||
(old) =>
|
||||
old
|
||||
? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status } : c)) }
|
||||
: old,
|
||||
);
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) => {
|
||||
@@ -122,11 +130,11 @@ export default function PlexDiscover({
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
|
||||
qk.plexSurprise(scope, lang, filters),
|
||||
(old) => (old ? { ...old, items: flip(old.items) } : old),
|
||||
);
|
||||
setSurpriseStatus(card, next);
|
||||
},
|
||||
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
|
||||
// toggle was left flipped there — put back in place instead.
|
||||
revert: () => setSurpriseStatus(card, card.status),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's HLS path, with hls.js replaced by a stand-in the test drives (jsdom has no
|
||||
// MediaSource, so the real one never runs here). Its own file because the module mock is file-wide.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, screen, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
// Hoisted with the mock below (vi.mock runs before the module body).
|
||||
const { FakeHls, hlsInstances } = vi.hoisted(() => {
|
||||
type Handler = (event: string, data: unknown) => void;
|
||||
const hlsInstances: InstanceType<typeof FakeHls>[] = [];
|
||||
class FakeHls {
|
||||
handlers = new Map<string, Handler[]>();
|
||||
audioTrack = 0;
|
||||
constructor() {
|
||||
hlsInstances.push(this);
|
||||
}
|
||||
on(event: string, fn: Handler) {
|
||||
this.handlers.set(event, [...(this.handlers.get(event) ?? []), fn]);
|
||||
}
|
||||
emit(event: string, data: unknown = {}) {
|
||||
for (const fn of this.handlers.get(event) ?? []) fn(event, data);
|
||||
}
|
||||
loadSource() {}
|
||||
attachMedia() {}
|
||||
destroy() {}
|
||||
}
|
||||
return { FakeHls, hlsInstances };
|
||||
});
|
||||
vi.mock("hls.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal<{ default: typeof import("hls.js").default }>()).default;
|
||||
return {
|
||||
default: Object.assign(FakeHls, {
|
||||
isSupported: () => true,
|
||||
Events: actual.Events,
|
||||
ErrorTypes: actual.ErrorTypes,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import Hls from "hls.js";
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._t ?? 0;
|
||||
},
|
||||
set(this: Media, v: number) {
|
||||
this._t = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
hlsInstances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "A",
|
||||
title: "Title A",
|
||||
type: "movie",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: 600,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
function mount() {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const session = vi
|
||||
.spyOn(api, "plexSession")
|
||||
.mockResolvedValue({ mode: "hls", url: "/hls/A/index.m3u8", start_s: 600 } as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<PlexPlayer itemId="A" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
const lost404 = {
|
||||
fatal: true,
|
||||
type: Hls.ErrorTypes.NETWORK_ERROR,
|
||||
details: "fragLoadError",
|
||||
response: { code: 404, text: "No active playback session" },
|
||||
};
|
||||
|
||||
describe("an HLS session the server no longer has", () => {
|
||||
it("is restarted where the viewer is, instead of ending playback", async () => {
|
||||
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries
|
||||
// a 4xx, so the next segment's 404 used to end playback with "couldn't play".
|
||||
const session = mount();
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(1));
|
||||
act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
const v = document.querySelector("video") as Media;
|
||||
v._t = 700; // 600 + 700 − the HLS lag
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
|
||||
await waitFor(() => expect(session).toHaveBeenCalledTimes(2));
|
||||
expect(session.mock.calls[1]![1]).toBeCloseTo(1299, 0);
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
});
|
||||
|
||||
it("gives up and says so when the reclaimed session keeps 404ing", async () => {
|
||||
// `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one,
|
||||
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
|
||||
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
|
||||
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
|
||||
// spinner that never became an error (round 5).
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
// Two reclaims, then the honest answer — not a fourth, fifth, hundredth session.
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("keeps giving up even though every reclaimed session plays a fragment first", async () => {
|
||||
// The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed
|
||||
// session essentially always delivers one, because `start_session` does not return until its
|
||||
// first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss
|
||||
// cycles, the player opened 41 sessions and never showed an error (deep-review round 6). A
|
||||
// fragment is not proof the session can be served; surviving the window is.
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() =>
|
||||
hlsInstances[i]!.emit(Hls.Events.FRAG_LOADED, {
|
||||
frag: { type: "main", duration: 6, stats: { total: 1000 } },
|
||||
}),
|
||||
);
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("starts the budget over for a loss long after the last one", async () => {
|
||||
// The other half: a session reaped once an hour is not a session that cannot be served. The
|
||||
// window is what separates the two, so a spent budget must not follow the viewer around.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||||
try {
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
act(() => void vi.setSystemTime(Date.now() + 10 * 60_000));
|
||||
}
|
||||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||||
expect(session).toHaveBeenCalledTimes(5);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives up on a session lost over and over on a SLOW cadence too", async () => {
|
||||
// The window's anchor used to be written only where the window RESET, so it marked the start of
|
||||
// the window instead of the last loss — a tumbling window, not the rolling one the constant is
|
||||
// named for. Any cadence slower than half the window then cleared the budget on every second
|
||||
// loss and the bound never bit: a transcode falling ~70s behind realtime — the case the ERROR
|
||||
// handler itself names — reclaimed forever (measured: 200 losses 70s apart → 200 reclaims,
|
||||
// never an error). The two cases above space their losses 0s and 10min apart, i.e. both sit
|
||||
// outside the 60–120s band where the defect lived (deep-review round 7).
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||||
try {
|
||||
const session = mount();
|
||||
const v = () => document.querySelector("video") as Media;
|
||||
for (let i = 0; i < 6 && !screen.queryByText("plex.player.errGeneric"); i++) {
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||||
v()._t = 700;
|
||||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||||
act(() => void vi.setSystemTime(Date.now() + 70_000));
|
||||
}
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
// Two reclaims, each within two minutes of the loss before it — then the honest answer.
|
||||
expect(session).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("is a failure, not a restart, when the session never played", async () => {
|
||||
// Otherwise a session that 404s from the start would restart itself in a loop.
|
||||
const session = mount();
|
||||
await waitFor(() => expect(hlsInstances).toHaveLength(1));
|
||||
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
expect(session).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,628 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player's session lifecycle, rendered for real (deep-review round 3 of bug hunt 2026-09).
|
||||
//
|
||||
// jsdom has no media pipeline, so the <video> is a small stand-in that behaves like Chrome where it
|
||||
// matters here: `pause()` fires its `timeupdate` in a LATER task (measured in Chrome — which is how the
|
||||
// previous item's position used to arrive after the player had already reset it for the next one).
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, HttpError, type PlexItemDetail } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
|
||||
type Media = HTMLMediaElement & { _t?: number };
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
const later = (el: HTMLMediaElement, ...types: string[]) =>
|
||||
setTimeout(() => types.forEach((ty) => el.dispatchEvent(new Event(ty))), 0);
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
|
||||
configurable: true,
|
||||
get(this: Media) {
|
||||
return this._t ?? 0;
|
||||
},
|
||||
set(this: Media, v: number) {
|
||||
this._t = v;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement) {
|
||||
later(this, "timeupdate", "pause"); // as Chrome does: after the call has returned
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", {
|
||||
configurable: true,
|
||||
value(this: Media) {
|
||||
this._t = 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
|
||||
configurable: true,
|
||||
get: () => ({ length: 0, end: () => 0 }),
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// A signed-in account whose saved subtitle language is English (prefs are per account).
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const item = (id: string, position = 0): PlexItemDetail =>
|
||||
({
|
||||
id,
|
||||
title: `Title ${id}`,
|
||||
type: "episode",
|
||||
duration_seconds: 3000,
|
||||
status: "new",
|
||||
position_seconds: position,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [{ ord: 0, label: "English", language: "en", text: true }],
|
||||
markers: [],
|
||||
}) as unknown as PlexItemDetail;
|
||||
|
||||
const direct = (id: string) =>
|
||||
({ mode: "direct", url: `/media/${id}`, start_s: 0 }) as Awaited<
|
||||
ReturnType<typeof api.plexSession>
|
||||
>;
|
||||
|
||||
/** A session answer the test releases when it wants to. */
|
||||
function deferred() {
|
||||
let resolve!: (v: Awaited<ReturnType<typeof api.plexSession>>) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mount(opts: {
|
||||
details: Record<string, PlexItemDetail>;
|
||||
sessions: Record<string, () => Promise<Awaited<ReturnType<typeof api.plexSession>>>>;
|
||||
queue?: string[];
|
||||
qc?: QueryClient;
|
||||
}) {
|
||||
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
|
||||
vi.spyOn(api, "plexSession").mockImplementation((id: string) => opts.sessions[id]!());
|
||||
const progress = vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
||||
const status = vi
|
||||
.spyOn(api, "plexSubtitleStatus")
|
||||
.mockResolvedValue({ state: "preparing", code: null, gen: 0 });
|
||||
const qc = opts.qc ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const first = opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
|
||||
const view = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, progress, status, qc };
|
||||
}
|
||||
|
||||
const video = () => document.querySelector("video") as Media;
|
||||
|
||||
async function playingAt(src: string, t: number) {
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe(src));
|
||||
const v = video();
|
||||
v._t = t;
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
}
|
||||
|
||||
const tick = () => act(() => new Promise((r) => setTimeout(r, 20)));
|
||||
|
||||
describe("moving to the next item", () => {
|
||||
it("settles the item being left, and gives the next one none of its position", async () => {
|
||||
const { progress, status, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B", 300) },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(status.mock.calls.some(([id]) => id === "B")).toBe(true));
|
||||
await tick(); // the timeupdate that pause() queued has landed by now
|
||||
// B's session has not answered, so where B is is where it was asked to start — not A's 25:00.
|
||||
expect(status.mock.calls.filter(([id]) => id === "B").map(([, , at]) => at)).not.toContain(
|
||||
1500,
|
||||
);
|
||||
expect(status.mock.calls.find(([id]) => id === "B")?.[2]).toBe(300);
|
||||
// A got its final checkpoint at the position it was left at…
|
||||
expect(progress.mock.calls).toContainEqual(["A", 1500, 3000, true]);
|
||||
unmount();
|
||||
// …and leaving B while it was still loading saved nothing of A's position as B's.
|
||||
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a seek that restarts the session is saved at its target while the new session loads", async () => {
|
||||
// seekTo wrote the target into the position ref only — and the restart re-renders at once, which
|
||||
// re-syncs that ref from the state: a page-hide during the restart saved the pre-seek spot.
|
||||
let calls = 0;
|
||||
mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () => (calls++ === 0 ? Promise.resolve(direct("A")) : new Promise(() => {})),
|
||||
},
|
||||
});
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalledTimes(2)); // a restart, not a native seek
|
||||
await tick();
|
||||
act(() => void window.dispatchEvent(new Event("pagehide")));
|
||||
expect(beacon).toHaveBeenLastCalledWith("A", 1510, 3000, true);
|
||||
});
|
||||
|
||||
describe("a session answer that lands late", () => {
|
||||
it("is dropped when the viewer has moved on to another item meanwhile", async () => {
|
||||
const slowB = deferred();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("Title B");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
||||
await act(async () => slowB.resolve(direct("B")));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/C");
|
||||
});
|
||||
|
||||
it("is dropped when the player has been closed meanwhile", async () => {
|
||||
const slowA = deferred();
|
||||
const { unmount } = mount({ details: { A: item("A") }, sessions: { A: () => slowA.promise } });
|
||||
await waitFor(() => expect(api.plexSession).toHaveBeenCalled());
|
||||
const v = video();
|
||||
unmount();
|
||||
await act(async () => slowA.resolve(direct("A")));
|
||||
await tick();
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("closing the player detaches the <video> it was playing", async () => {
|
||||
const { unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const v = video();
|
||||
unmount();
|
||||
expect(v.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
describe("the subtitle poll and notice", () => {
|
||||
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
|
||||
const { status } = mount({
|
||||
details: { A: item("A", 1198) },
|
||||
sessions: { A: () => new Promise(() => {}) },
|
||||
});
|
||||
await waitFor(() => expect(status).toHaveBeenCalled());
|
||||
expect(status.mock.calls[0]).toEqual(["A", 0, 1198]);
|
||||
});
|
||||
|
||||
it("says a failing poll even while an earlier window is still on screen", async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.plexSubtitle("A", 0), { state: "partial", code: null, gen: 1 });
|
||||
const view = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")) },
|
||||
qc,
|
||||
});
|
||||
view.status.mockRejectedValue(new Error("offline"));
|
||||
await waitFor(() => expect(view.status).toHaveBeenCalled());
|
||||
await screen.findByText("plex.player.subUnavailable.failed");
|
||||
expect(document.querySelector("track")).not.toBeNull(); // the window stays mounted meanwhile
|
||||
});
|
||||
|
||||
it("marks which track is selected for assistive tech, not only by colour", async () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.tracks"));
|
||||
const off = await screen.findByText("plex.player.subOff");
|
||||
expect(off.closest("button")?.getAttribute("aria-pressed")).toBe("false");
|
||||
const pressed = document.querySelectorAll('[aria-pressed="true"]');
|
||||
expect([...pressed].map((b) => b.textContent)).toEqual(["English"]); // the saved subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a toast raised while it is open inside itself, not under its scrim", async () => {
|
||||
mount({ details: { A: item("A") }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await screen.findByText("Title A");
|
||||
act(() => notify({ level: "error", message: "Couldn't mark it watched." }));
|
||||
const toast = await screen.findByText("Couldn't mark it watched.");
|
||||
let host: Element | null = video();
|
||||
while (host && !host.contains(toast)) host = host.parentElement;
|
||||
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
|
||||
});
|
||||
|
||||
// --- deep-review round 4 ------------------------------------------------------------------------
|
||||
|
||||
/** A session answer that FAILS when the test says so. */
|
||||
function deferredFailure() {
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((_r, j) => (reject = j));
|
||||
return { promise, reject };
|
||||
}
|
||||
|
||||
const startsAsked = (id: string) =>
|
||||
vi
|
||||
.mocked(api.plexSession)
|
||||
.mock.calls.filter(([i]) => i === id)
|
||||
.map(([, at]) => at);
|
||||
|
||||
describe("leaving an item that was played through", () => {
|
||||
const prefs = (p: object) =>
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
||||
|
||||
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
|
||||
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
|
||||
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up
|
||||
// (deep-review round 5).
|
||||
const endMedia = () => {
|
||||
const el = video();
|
||||
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
|
||||
act(() => void el.dispatchEvent(new Event("pause")));
|
||||
act(() => void el.dispatchEvent(new Event("ended")));
|
||||
};
|
||||
|
||||
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia();
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.prev"));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
||||
expect(startsAsked("A")[1]).toBe(0);
|
||||
});
|
||||
|
||||
it("sends no checkpoint at the credits when the auto-skip leaves it", async () => {
|
||||
// A final checkpoint at the credits raced the watched mark: landing second, it left the row
|
||||
// watched WITH a resume point, and pushed that point to Plex right after the scrobble.
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
// The 'pause' event (and its checkpoint) can land before the player has re-rendered for the
|
||||
// next item — here it lands at once.
|
||||
video().pause = function (this: HTMLMediaElement) {
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
};
|
||||
await playingAt("/media/A", 2910);
|
||||
await screen.findByText("Title B");
|
||||
await tick(); // the pause() that go() queued has fired its events by now
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
||||
});
|
||||
it("takes the finish back when the server refuses the watched mark", async () => {
|
||||
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
|
||||
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
|
||||
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
|
||||
// disagreed with (round 5).
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia();
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
// The cached detail is not left claiming watched: only the server is honest now.
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect(cached?.status).not.toBe("watched");
|
||||
// And the rollback does NOT write the position back. 2995 of 3000 is inside the backend's
|
||||
// finish margin, so that checkpoint would have set watched / position 0 and scrobbled to Plex
|
||||
// — performing, through a second route, the very write that was just refused, while the error
|
||||
// toast said it had failed (round 6). There is no resume point at the end of the media to
|
||||
// restore; the cache drop is the whole of the rollback here.
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
});
|
||||
|
||||
it("performs the refused mark through no OTHER route while it stays on the finished item", async () => {
|
||||
// Round 6 guarded the explicit rollback write, but the same catch lowered the play-through
|
||||
// latch — and that latch is the only gate on the 10s interval, the page-hide beacon and
|
||||
// `settle`. All three post `absRef`, which on this path IS the end of the media, and the
|
||||
// backend reads a position within `_FINISH_MARGIN_S` of the duration as a finish: within ten
|
||||
// seconds of the toast saying the mark had failed, the item was watched, its position cleared
|
||||
// and a scrobble sent to Plex (deep-review round 7). `upNext` is ON here — the default — so the
|
||||
// player STAYS on the finished item and those routes are live; the round-6 test above turns it
|
||||
// off, which tears the effect down before any of them can fire.
|
||||
prefs({ upNext: true });
|
||||
const beacon = vi.spyOn(api, "plexProgressBeacon").mockImplementation(() => {});
|
||||
const { qc, progress, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia(); // the up-next card is up, the item is finished, and the mark comes back refused
|
||||
await tick();
|
||||
progress.mockClear();
|
||||
|
||||
// The viewer closes the tab while the failed-mark toast is still up.
|
||||
act(() => void window.dispatchEvent(new Event("pagehide")));
|
||||
expect(beacon).not.toHaveBeenCalled();
|
||||
|
||||
// The refusal dropped the cached detail — and the open player's own observer refetches it, so
|
||||
// by the time the viewer leaves there is an entry again. `settle` must not re-assert watched
|
||||
// onto it: only the server is honest about this item now.
|
||||
act(() => void qc.setQueryData(qk.plexItem("A", "en"), item("A")));
|
||||
|
||||
// And leaving the player: `settle` must neither post the end position nor re-assert watched.
|
||||
unmount();
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id]) => id === "A")).toEqual([]);
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
||||
});
|
||||
|
||||
it("does restore the position when the refused finish came from the credits skip", async () => {
|
||||
// The other caller of the same helper captures a MID-episode position, and there the rollback
|
||||
// is the point: without it the viewer loses the stretch they watched. One helper, two callers,
|
||||
// and only the near-end one must hold back.
|
||||
prefs({ upNext: false, autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2000, end_s: 2990 }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
|
||||
await playingAt("/media/A", 2010); // inside the credits → auto-skip advances and marks watched
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
||||
["A", 2010, 3000, true],
|
||||
]);
|
||||
expect(qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"))?.status).not.toBe("watched");
|
||||
});
|
||||
|
||||
it("records a second viewing when the viewer plays on from the finished frame", async () => {
|
||||
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
|
||||
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
|
||||
// discarded a whole rewatch with the gate green (round 5).
|
||||
prefs({ upNext: true });
|
||||
const { progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
endMedia(); // the up-next card is up; the item is finished
|
||||
await tick();
|
||||
progress.mockClear();
|
||||
|
||||
// The viewer cancels the card and rewinds: playing again takes the finish back.
|
||||
const el = video() as Media;
|
||||
Object.defineProperty(el, "ended", { configurable: true, get: () => false });
|
||||
act(() => void el.dispatchEvent(new Event("play")));
|
||||
el._t = 2400;
|
||||
act(() => void el.dispatchEvent(new Event("timeupdate")));
|
||||
act(() => void el.dispatchEvent(new Event("pause")));
|
||||
await tick();
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
|
||||
["A", 2400, 3000, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restarting the session before playback has ticked", () => {
|
||||
it("an audio switch or a keyboard seek keeps the resume point", async () => {
|
||||
const twoAudio = {
|
||||
...item("A", 1200),
|
||||
audio_streams: [
|
||||
{ ord: 0, label: "English", language: "en", default: true },
|
||||
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||
fireEvent.keyDown(window, { key: "a" });
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
||||
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a seek made while a restart is pending wins over it", async () => {
|
||||
let calls = 0;
|
||||
const pending = deferred();
|
||||
mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.resolve(direct("A"));
|
||||
return calls === 2 ? pending.promise : new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
|
||||
await playingAt("/media/A", 95);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10")); // 105: past the buffer → restart
|
||||
fireEvent.click(screen.getByLabelText("plex.player.rewind10")); // 95: inside the OLD buffer
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
||||
});
|
||||
|
||||
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||
mount({
|
||||
details: { A: item("A", 1200), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => Promise.resolve(direct("B")) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
|
||||
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
|
||||
expect(video().currentTime).toBe(0); // not A's resume point, 1200
|
||||
});
|
||||
|
||||
it("a refetch of the playing item's detail does not restart it", async () => {
|
||||
// react-query refetches it on reconnect, and the in-player info panel invalidates it after a
|
||||
// watched toggle — each one restarted the film at the last saved checkpoint.
|
||||
const details = { A: item("A", 100) };
|
||||
const { qc } = mount({ details, sessions: { A: () => Promise.resolve(direct("A")) } });
|
||||
await playingAt("/media/A", 1500);
|
||||
details.A = item("A", 1490);
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.plexItem("A") });
|
||||
});
|
||||
await tick();
|
||||
expect(vi.mocked(api.plexItem).mock.calls.length).toBeGreaterThan(1); // it did refetch
|
||||
expect(startsAsked("A")).toEqual([100]);
|
||||
});
|
||||
|
||||
describe("a session request that fails", () => {
|
||||
it("is not reported when it has been superseded", async () => {
|
||||
const slowB = deferredFailure();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("Title B");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
||||
await act(async () => slowB.reject(new HttpError(404)));
|
||||
await tick();
|
||||
expect(screen.queryByText("plex.player.errNotFound")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps following the old stream when a seek-restart fails", async () => {
|
||||
let calls = 0;
|
||||
const { progress, unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () =>
|
||||
calls++ === 0 ? Promise.resolve(direct("A")) : Promise.reject(new HttpError(500)),
|
||||
},
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
await playingAt("/media/A", 1600); // the old stream plays on
|
||||
unmount();
|
||||
expect(progress.mock.calls).toContainEqual(["A", 1600, 3000, true]);
|
||||
});
|
||||
|
||||
it("saves nothing of the previous item's position when moving to the next one fails", async () => {
|
||||
const { progress, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => Promise.reject(new HttpError(404)),
|
||||
},
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("plex.player.errNotFound");
|
||||
await tick();
|
||||
await playingAt("/media/A", 1600); // A's media, still attached, ticks once more
|
||||
unmount();
|
||||
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a session answer that lands while the next item's detail is still loading is dropped", async () => {
|
||||
const slowB = deferred();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const real = vi.mocked(api.plexItem).getMockImplementation()!;
|
||||
vi.mocked(api.plexItem).mockImplementation((id: string, lang?: string) =>
|
||||
id === "C" ? new Promise(() => {}) : real(id, lang),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(startsAsked("B")).toHaveLength(1));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await tick();
|
||||
await act(async () => slowB.resolve(direct("B")));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Plex player must start playing by itself — also when React mounts it, unmounts it and mounts
|
||||
// it again, which StrictMode does to every component in development.
|
||||
//
|
||||
// Bug hunt 2026-09, found on localdev: the player's "still alive" flag (every play() is gated on it,
|
||||
// so a late one cannot start a detached <video> after Back) was only ever LOWERED, in an effect's
|
||||
// cleanup. StrictMode's dry-run unmount lowered it for good: the player never started on its own, and
|
||||
// a seek that restarts the session — back past the loaded region — left it paused. Production does
|
||||
// not double-mount, which is why only dev showed it.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
|
||||
const played: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
// jsdom has no media pipeline: record what the player asks of the element instead.
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value(this: HTMLMediaElement) {
|
||||
played.push(this.getAttribute("src") ?? "");
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
|
||||
configurable: true,
|
||||
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
played.length = 0;
|
||||
});
|
||||
|
||||
const detail = {
|
||||
id: "99079",
|
||||
title: "The Adversary",
|
||||
type: "episode",
|
||||
duration_seconds: 3434,
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
|
||||
subtitle_streams: [],
|
||||
markers: [],
|
||||
} as unknown as PlexItemDetail;
|
||||
|
||||
describe("PlexPlayer under StrictMode", () => {
|
||||
it("starts playing by itself once the media is ready", async () => {
|
||||
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
|
||||
// A direct-play session: the element gets the file URL and plays on `loadedmetadata`. (hls.js is
|
||||
// not supported under jsdom, so this is the path the test can drive; the HLS path gates its
|
||||
// play() on the very same flag.)
|
||||
vi.spyOn(api, "plexSession").mockResolvedValue({
|
||||
mode: "direct",
|
||||
url: "/api/plex/direct/99079",
|
||||
start_s: 0,
|
||||
} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlayer itemId="99079" onClose={() => {}} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
const video = await waitFor(() => {
|
||||
const v = document.querySelector("video");
|
||||
expect(v?.getAttribute("src")).toBe("/api/plex/direct/99079");
|
||||
return v!;
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
await waitFor(() => expect(played).toContain("/api/plex/direct/99079"));
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import {
|
||||
audioOrdForLang,
|
||||
clamp01,
|
||||
@@ -41,8 +42,11 @@ import {
|
||||
import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
import { PlexStatsOverlay } from "./PlexStatsOverlay";
|
||||
import { createFragMeter } from "../lib/plexStats";
|
||||
import { UpNextCard } from "./UpNextCard";
|
||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||
import {
|
||||
playhead,
|
||||
useAutoSkip,
|
||||
useControlsVisibility,
|
||||
useFullscreen,
|
||||
@@ -71,6 +75,17 @@ import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/lay
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
|
||||
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
|
||||
// the stream keep the viewer on a spinner (see the ERROR handler).
|
||||
const MAX_SESSION_RECLAIMS = 2;
|
||||
// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by
|
||||
// the first fragment of any session, and a reclaimed session essentially always delivers one —
|
||||
// `start_session` does not return until its first segment exists. So both cases the ERROR handler
|
||||
// names restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error).
|
||||
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
|
||||
const RECLAIM_WINDOW_MS = 120_000;
|
||||
|
||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||
@@ -107,6 +122,26 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const aliveRef = useRef(true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
// Which loadSession call is the current one. Everything after its `await` checks it: a response
|
||||
// that lands after a newer call, an item change or the player's unmount is dropped — it used to
|
||||
// replace whatever was playing (Next twice quickly, the slower transcode answering last, played the
|
||||
// middle item under the last one's title and recorded its progress there) or attach a stream to the
|
||||
// detached <video> after Back, never destroyed, keeping the server session warm.
|
||||
const sessGenRef = useRef(0);
|
||||
// Removes the media listeners the current session put on the <video> (its canplay / loadedmetadata
|
||||
// handlers). A newer session, an item change and the unmount all call it: the generation check
|
||||
// guards the `await`, and these fire LATER — a superseded direct-play item's loadedmetadata used to
|
||||
// run on the next item's media and seek it to the old item's resume point (whose first checkpoint
|
||||
// then saved it), or start the old media playing under the new title.
|
||||
const detachSessionRef = useRef<() => void>(() => {});
|
||||
// Whether the <video>'s clock belongs to the session `sessionStartRef` describes. False from the
|
||||
// moment a session is asked for until it has answered: the media still attached meanwhile is the
|
||||
// OLD one, so `sessionStartRef + currentTime` was the previous item's position (go()'s pause() fires
|
||||
// a timeupdate AFTER the reset to 0) — sent as the new item's subtitle position and saved as its
|
||||
// progress, or, during a seek-restart, the pre-seek spot overwriting the target.
|
||||
const clockLiveRef = useRef(false);
|
||||
// The item whose session is attached to the <video> right now (see loadSession's failure path).
|
||||
const attachedIdRef = useRef<string | null>(null);
|
||||
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
|
||||
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
|
||||
const hwRef = useRef<boolean | null>(null);
|
||||
@@ -114,6 +149,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
|
||||
const tonemapRef = useRef<boolean | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
// Rolling per-fragment bitrate meter for the stats overlay. Fed from hls.js FRAG_LOADED below and
|
||||
// reset with each session, because the master playlist's own BANDWIDTH is not a measurement — for a
|
||||
// remux it is the AAC track's nominal 192k + 10%, i.e. a constant 211 kbps (see plexStats.ts).
|
||||
const fragMeterRef = useRef(createFragMeter());
|
||||
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
|
||||
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
|
||||
const multiAudioRef = useRef(false);
|
||||
@@ -124,6 +163,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
|
||||
const absRef = useRef(0);
|
||||
absRef.current = abs;
|
||||
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
|
||||
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
|
||||
const loadStartRef = useRef(0);
|
||||
// How often this player may reclaim a session the server answered 404 for, before it stops and
|
||||
// says playback failed. `at` is when the window started; the item-start effect zeroes it, so a
|
||||
// budget spent on one episode cannot deny the next one its first legitimate reclaim.
|
||||
const restartsRef = useRef({ n: 0, at: 0 });
|
||||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||||
const { fs, toggleFs } = useFullscreen(wrapRef);
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -238,6 +284,23 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
async (startAt: number, resumePlay = true) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const gen = ++sessGenRef.current;
|
||||
const current = () => gen === sessGenRef.current && aliveRef.current;
|
||||
detachSessionRef.current();
|
||||
detachSessionRef.current = () => {};
|
||||
const listen = (type: string, fn: () => void, once = false) => {
|
||||
const guarded = () => {
|
||||
if (current()) fn();
|
||||
};
|
||||
video.addEventListener(type, guarded, { once });
|
||||
const prev = detachSessionRef.current;
|
||||
detachSessionRef.current = () => {
|
||||
prev();
|
||||
video.removeEventListener(type, guarded);
|
||||
};
|
||||
};
|
||||
clockLiveRef.current = false;
|
||||
loadStartRef.current = startAt;
|
||||
setReady(false);
|
||||
setBuffering(false);
|
||||
setLoadError(null);
|
||||
@@ -252,6 +315,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const m = multiAudioRef.current;
|
||||
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
|
||||
} catch (e) {
|
||||
if (!current()) return; // superseded or left: not this request's error to show
|
||||
// A failed seek-restart leaves the old session attached and its clock still true; a failed
|
||||
// move to another item leaves the previous item's media, which is not this item's clock.
|
||||
clockLiveRef.current = attachedIdRef.current === id;
|
||||
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
|
||||
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
|
||||
// needs full transcoding (a later phase).
|
||||
@@ -265,6 +332,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!current()) return;
|
||||
// Surface the active mode for the stats overlay + the honest cold-start label: a transcode session
|
||||
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
|
||||
const isTranscode = sess.mode === "hls" && !!sess.transcode;
|
||||
@@ -278,15 +346,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
|
||||
sessionStartRef.current = trueStart;
|
||||
setSessionK(trueStart);
|
||||
// From here the <video>'s clock is this session's: the old source is replaced just below, and a
|
||||
// timeupdate its teardown queues already reads the new start.
|
||||
clockLiveRef.current = true;
|
||||
attachedIdRef.current = id;
|
||||
// Belt-and-suspenders resume: the play() fired on MANIFEST_PARSED / loadedmetadata can be
|
||||
// rejected when the media isn't ready yet (slow first segments over a proxy), which would leave
|
||||
// the video paused after a seek even though the user was watching. Retry once the element can
|
||||
// actually play. Idempotent — only acts if still paused.
|
||||
if (resumePlay) {
|
||||
const onCanPlay = () => {
|
||||
if (aliveRef.current && videoRef.current?.paused) videoRef.current.play().catch(() => {});
|
||||
};
|
||||
video.addEventListener("canplay", onCanPlay, { once: true });
|
||||
listen(
|
||||
"canplay",
|
||||
() => {
|
||||
if (videoRef.current?.paused) videoRef.current.play().catch(() => {});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
@@ -303,10 +378,25 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
|
||||
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
|
||||
hlsRef.current = hls;
|
||||
// Measure the real delivered bitrate: bytes per second of MEDIA, per fragment. A new session
|
||||
// is a new stream (different start offset, possibly a different audio rendition), so the
|
||||
// rolling window starts empty rather than carrying the previous session's numbers over.
|
||||
fragMeterRef.current.reset();
|
||||
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
|
||||
fragMeterRef.current.add({
|
||||
type: data.frag.type,
|
||||
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
|
||||
durationS: data.frag.duration,
|
||||
});
|
||||
});
|
||||
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||
hls.attachMedia(video);
|
||||
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
|
||||
// Whether this session has ever played. Only such a session can be "lost" (below): one that
|
||||
// fails before its manifest is simply a failure, and must not restart itself in a loop.
|
||||
let parsed = false;
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
parsed = true;
|
||||
setReady(true);
|
||||
if (aliveRef.current && resumePlay) video.play().catch(() => {});
|
||||
});
|
||||
@@ -327,20 +417,54 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
});
|
||||
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
|
||||
// otherwise leave the spinner up forever — surface it instead.
|
||||
//
|
||||
// Except a 404 on a session that WAS playing: the server has reaped it (a pause longer than
|
||||
// its idle limit, a deploy) and the stream is simply gone, not broken. hls.js never retries a
|
||||
// 4xx, so this used to end playback with "couldn't play" after the buffer ran out; a new
|
||||
// session at the playhead is what closing and reopening the player did by hand.
|
||||
//
|
||||
// Bounded, because `parsed` cannot bound it: it belongs to ONE hls instance and every restart
|
||||
// builds a fresh one, so it only ever blocks a session that died before its manifest. The
|
||||
// server also answers 404 for "that segment is not there YET" — indistinguishable from "the
|
||||
// session is gone" on the wire — so a transcode that falls behind realtime, or two viewers of
|
||||
// one item taking turns reclaiming its session, restarted ffmpeg forever behind a spinner
|
||||
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
|
||||
// reap needs; past that the honest answer is that playback failed.
|
||||
hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||
if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
|
||||
if (!data.fatal || !current()) return;
|
||||
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
|
||||
const now = Date.now();
|
||||
// A new window: the last loss was long enough ago that this is a fresh incident, not the
|
||||
// same session being lost over and over. The anchor moves with EVERY loss, because with
|
||||
// it left at the window's start the window tumbled instead of rolling: any loss period
|
||||
// longer than half the window satisfied the reset on every second loss, so the budget
|
||||
// never reached its bound and the reclaim loop it exists to end ran forever (measured:
|
||||
// 200 losses 70s apart → 200 reclaims, never an error; 5s apart → 2 and an error, which
|
||||
// is why the committed test, spacing its cases 0s and 10min apart, could not see it —
|
||||
// deep-review round 7).
|
||||
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS) restartsRef.current.n = 0;
|
||||
restartsRef.current.at = now;
|
||||
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
|
||||
restartsRef.current.n += 1;
|
||||
loadSession(playhead(absRef, loadStartRef), !video.paused);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLoadError(tRef.current("plex.player.errGeneric"));
|
||||
});
|
||||
} else {
|
||||
// direct file (or Safari native HLS)
|
||||
video.src = sess.url;
|
||||
video.load();
|
||||
const onMeta = () => {
|
||||
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||
setReady(true);
|
||||
if (aliveRef.current && resumePlay) video.play().catch(() => {});
|
||||
video.removeEventListener("loadedmetadata", onMeta);
|
||||
};
|
||||
video.addEventListener("loadedmetadata", onMeta);
|
||||
listen(
|
||||
"loadedmetadata",
|
||||
() => {
|
||||
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||
setReady(true);
|
||||
if (resumePlay) video.play().catch(() => {});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
},
|
||||
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
|
||||
@@ -361,6 +485,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
textSubs,
|
||||
selectedSub,
|
||||
subTrackOffset,
|
||||
subShowable,
|
||||
subNotice,
|
||||
subGen,
|
||||
subTrackKey,
|
||||
subTrackListeners,
|
||||
} = usePlexTracks({
|
||||
id,
|
||||
detail,
|
||||
@@ -375,11 +504,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
audioRef,
|
||||
aoffRef,
|
||||
absRef,
|
||||
loadStartRef,
|
||||
});
|
||||
|
||||
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
||||
// Start playback when the detail arrives (resume from the saved position unless already watched) —
|
||||
// ONCE per item. The detail object changes under a playing item too: react-query refetches it on
|
||||
// reconnect (Wi-Fi blips, the query is stale after 30 s and every checkpoint moved the position),
|
||||
// the in-player info panel invalidates it after a watched toggle, a language switch fetches it
|
||||
// again. Keyed on its identity, each of those restarted the film at the last saved checkpoint — or
|
||||
// at 0 after marking it watched (deep-review round 4). The trade-off: a detail that was stale in
|
||||
// the cache when the player opened is not corrected by the refetch that follows; the player's own
|
||||
// leave-patch (usePlexProgress) keeps the cache honest for everything played here.
|
||||
const startedForRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!detail) return;
|
||||
if (!detail || detail.id !== id || startedForRef.current === id) return;
|
||||
startedForRef.current = id;
|
||||
// A fresh item is a fresh incident: the reclaim budget must not carry over from the previous
|
||||
// one (the player stays mounted across a queue advance — `go()` only changes `id`).
|
||||
restartsRef.current = { n: 0, at: 0 };
|
||||
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
|
||||
// audio ref synchronously so the loadSession below picks the right track on the first request.
|
||||
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
|
||||
@@ -388,27 +530,47 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
|
||||
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||
loadSession(resume);
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
|
||||
// double-load the session — the picker handlers already reload/toggle).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [detail, loadSession]);
|
||||
}, [detail, id, loadSession]);
|
||||
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
|
||||
useEffect(
|
||||
() => () => {
|
||||
sessGenRef.current++;
|
||||
detachSessionRef.current();
|
||||
detachSessionRef.current = () => {};
|
||||
startedForRef.current = null;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
|
||||
// play() can't leave audio playing on a detached element after leaving the player.
|
||||
//
|
||||
// The setup half matters as much as the cleanup: React may run the cleanup and then MOUNT AGAIN
|
||||
// (StrictMode does exactly that in development). With the flag only ever lowered, it stayed false
|
||||
// for the life of the player — every play() is gated on it, so the player never started by itself,
|
||||
// and a seek that restarts the session left it paused (bug hunt 2026-09, found on localdev).
|
||||
//
|
||||
// The element is captured HERE, not read in the cleanup: React detaches a ref before it runs an
|
||||
// unmount's effect cleanups, so `videoRef.current` was already null there and the stop/detach below
|
||||
// never ran (only StrictMode's simulated unmount, which keeps refs, ever reached it).
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
const v = videoRef.current;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the live counter, as above
|
||||
sessGenRef.current++;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
try {
|
||||
v.pause();
|
||||
@@ -426,6 +588,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const onTime = () => {
|
||||
if (!clockLiveRef.current) return; // the old session's clock (see clockLiveRef)
|
||||
const a = sessionStartRef.current + video.currentTime;
|
||||
setAbs(a);
|
||||
try {
|
||||
@@ -478,7 +641,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
}, [id]);
|
||||
|
||||
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
|
||||
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
|
||||
const { settle: settleProgress, finish: finishProgress } = usePlexProgress({
|
||||
id,
|
||||
qc,
|
||||
videoRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
});
|
||||
|
||||
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||||
const seekTo = useCallback(
|
||||
@@ -488,9 +657,20 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const wasPlaying = !video.paused; // preserve play/pause intent across the seek
|
||||
const dur = durationRef.current || target;
|
||||
const T = Math.max(0, Math.min(target, dur - 1));
|
||||
absRef.current = T; // reflect the target immediately so a save/beacon right after a seek is accurate
|
||||
// Reflect the target immediately so a save/beacon right after a seek is accurate — in the STATE
|
||||
// too: `absRef` is re-synced from `abs` on every render, and the restart below renders at once,
|
||||
// so the ref alone was back at the pre-seek spot before anything could read it.
|
||||
absRef.current = T;
|
||||
setAbs(T);
|
||||
const rel = T - sessionStartRef.current;
|
||||
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
|
||||
// Native only when the attached media IS the current session. While one is still being asked
|
||||
// for, `sessionStartRef` and `seekable` describe the OLD one: a native seek there left the
|
||||
// pending restart current, and its answer then jumped to the earlier target.
|
||||
if (
|
||||
clockLiveRef.current &&
|
||||
rel >= 0 &&
|
||||
rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5
|
||||
) {
|
||||
video.currentTime = rel; // native seek within the loaded region keeps playing on its own
|
||||
} else {
|
||||
loadSession(T, wasPlaying); // session restart: only auto-resume if we were playing
|
||||
@@ -518,21 +698,39 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
helpOpenRef,
|
||||
});
|
||||
|
||||
const go = useCallback((otherId: string | null | undefined) => {
|
||||
if (otherId) {
|
||||
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
|
||||
// file), loadSession returns without replacing the <video> source, leaving the old episode
|
||||
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
|
||||
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
|
||||
try {
|
||||
videoRef.current?.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
const reportWatchedError = useCallback(
|
||||
(e: unknown) => notifyActionError(e, tRef.current("plex.watchedFailed")),
|
||||
[],
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>) => {
|
||||
if (otherId) {
|
||||
// A played-through item settles as watched, with no checkpoint at the credits (see
|
||||
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
|
||||
// 'pause' event can land before the player has re-rendered for the next item. The write is
|
||||
// handed over so a REFUSED one takes the finish back instead of leaving the cache lying.
|
||||
if (watchedWrite) finishProgress(watchedWrite);
|
||||
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
|
||||
// file), loadSession returns without replacing the <video> source, leaving the old episode
|
||||
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
|
||||
// watched (and scrobble it to Plex). Pausing here severs that stale-tail race.
|
||||
try {
|
||||
videoRef.current?.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Settle the item being left while the position still is its own (see usePlexProgress), and
|
||||
// stop trusting the old media's clock: the timeupdate that pause() queues lands after the
|
||||
// reset below and would otherwise hand the old position to the new item.
|
||||
settleProgress();
|
||||
clockLiveRef.current = false;
|
||||
setAbs(0);
|
||||
setId(otherId);
|
||||
}
|
||||
setAbs(0);
|
||||
setId(otherId);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[settleProgress, finishProgress],
|
||||
);
|
||||
|
||||
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
|
||||
const qIdx = queue ? queue.indexOf(id) : -1;
|
||||
@@ -556,6 +754,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
go,
|
||||
seekTo,
|
||||
videoRef,
|
||||
reportWatchedError,
|
||||
});
|
||||
|
||||
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||||
@@ -586,7 +785,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
|
||||
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
|
||||
if (absRef.current <= 0) return;
|
||||
api.plexSetState(id, "watched").catch(() => {});
|
||||
const watchedWrite = api.plexSetState(id, "watched");
|
||||
watchedWrite.catch(reportWatchedError);
|
||||
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
|
||||
// the server refuses the mark, in which case `finish` takes itself back (see usePlexProgress).
|
||||
finishProgress(watchedWrite);
|
||||
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
|
||||
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
|
||||
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
|
||||
@@ -598,7 +801,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
};
|
||||
video.addEventListener("ended", onEnded);
|
||||
return () => video.removeEventListener("ended", onEnded);
|
||||
}, [id, nextId, go, onClose]);
|
||||
}, [id, nextId, go, onClose, finishProgress, reportWatchedError]);
|
||||
|
||||
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
|
||||
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
|
||||
@@ -640,6 +843,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
useKeyScope("plexPlayer");
|
||||
useHelpConditions({ episode: detail?.kind === "episode" });
|
||||
|
||||
// Its toast host's rank among open overlays (see ui/Toaster).
|
||||
const overlayOrder = useOverlayOrder();
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||||
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
@@ -656,7 +861,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
useEffect(() => {
|
||||
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
|
||||
e.preventDefault();
|
||||
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
|
||||
const step = fine ? fineSeekStepRef.current : seekStepRef.current;
|
||||
seekTo(playhead(absRef, loadStartRef) + dir * step);
|
||||
};
|
||||
const step = (e: KeyboardEvent, id: string | null | undefined) => {
|
||||
e.preventDefault();
|
||||
@@ -851,15 +1057,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
>
|
||||
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
||||
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
||||
{selectedSub && (
|
||||
{selectedSub && subShowable && (
|
||||
<track
|
||||
// One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
|
||||
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
|
||||
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}`}
|
||||
// One track = the selected sub only (no multi-track overlap). The key (see usePlexTracks)
|
||||
// is what remounts it for a fresh VTT fetch: a seek-restart, an offset change, a widened
|
||||
// track, or a failed load.
|
||||
key={subTrackKey}
|
||||
kind="subtitles"
|
||||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset)}
|
||||
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset, subGen)}
|
||||
srcLang={selectedSub.language ?? undefined}
|
||||
label={selectedSub.label}
|
||||
{...subTrackListeners}
|
||||
default
|
||||
/>
|
||||
)}
|
||||
@@ -894,6 +1102,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtitle-extraction notice. Pulling a track out of a big MKV is a full read of the container
|
||||
(154 s for a 17 GB film, measured), so it happens in the background — around the playhead
|
||||
first. Until then the <track> is deliberately NOT mounted, and this says why instead of
|
||||
leaving the viewer to conclude that switching subtitles "does nothing" (bug hunt 2026-09,
|
||||
item 8). A failure is said even over a mounted window (see `subNotice`). It sits one rung
|
||||
above the track-switch OSD so the two never overlap. */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-36 z-menu flex justify-center px-4"
|
||||
aria-live="polite"
|
||||
>
|
||||
{selectedSub && subNotice && (
|
||||
<div className="flex max-w-full items-center gap-2 truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
|
||||
{subNotice !== "preparing" ? (
|
||||
<span className="text-danger">{t(`plex.player.subUnavailable.${subNotice}`)}</span>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||||
<span>{t("plex.player.subPreparing", { label: selectedSub.label })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
|
||||
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
|
||||
the whole point is confirming something you cannot otherwise see.
|
||||
@@ -1068,7 +1300,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<Ctrl
|
||||
label={t("plex.player.rewind10")}
|
||||
onClick={() => seekTo(absRef.current - 10)}
|
||||
onClick={() => seekTo(playhead(absRef, loadStartRef) - 10)}
|
||||
align="start"
|
||||
>
|
||||
<Skip10 dir="back" />
|
||||
@@ -1076,7 +1308,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.forward10")} onClick={() => seekTo(absRef.current + 10)}>
|
||||
<Ctrl
|
||||
label={t("plex.player.forward10")}
|
||||
onClick={() => seekTo(playhead(absRef, loadStartRef) + 10)}
|
||||
>
|
||||
<Skip10 dir="forward" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||
@@ -1191,6 +1426,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<button
|
||||
key={a.ord}
|
||||
onClick={() => changeAudio(a.ord)}
|
||||
aria-pressed={(audioOrd ?? 0) === a.ord}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
@@ -1205,6 +1441,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||||
<button
|
||||
onClick={() => changeSubtitle(null)}
|
||||
aria-pressed={subOrd == null}
|
||||
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
subOrd == null ? "text-accent" : "text-white"
|
||||
}`}
|
||||
@@ -1215,6 +1452,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<button
|
||||
key={s.ord}
|
||||
onClick={() => changeSubtitle(s.ord)}
|
||||
aria-pressed={subOrd === s.ord}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||
subOrd === s.ord ? "text-accent" : "text-white"
|
||||
}`}
|
||||
@@ -1279,6 +1517,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
<PlexStatsOverlay
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
fragMeterRef={fragMeterRef}
|
||||
audioLabel={
|
||||
detail?.audio_streams.find((a) => a.ord === (audioOrd ?? 0))?.label ?? undefined
|
||||
}
|
||||
subLabel={
|
||||
subOrd == null
|
||||
? t("plex.player.subOff")
|
||||
: (detail?.subtitle_streams.find((x) => x.ord === subOrd)?.label ?? undefined)
|
||||
}
|
||||
playable={detail?.playable ?? ""}
|
||||
hwRef={hwRef}
|
||||
tonemapRef={tonemapRef}
|
||||
@@ -1318,6 +1565,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The app's Toaster is underneath this full-screen player (and outside it in native
|
||||
fullscreen), so toasts raised while watching show here instead. See ui/Toaster. */}
|
||||
<Toaster overlay order={overlayOrder} />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { api, type PlexPlaylist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
@@ -54,6 +55,11 @@ export default function PlexPlaylistAdd({
|
||||
return isGroup ? (p.group_in ?? 0) : p.has_item ? 1 : 0;
|
||||
}
|
||||
|
||||
// A failure is said, and the list refreshed either way. With no catch, a playlist deleted on
|
||||
// another device (a 404 `not_found/playlist` — still offered here, from a cache up to 30 s old)
|
||||
// was an unhandled rejection: `req` leaves 403/404/429 to the caller, so nothing was shown, the
|
||||
// tick never flipped and the gone row stayed. Round 5 swept the collection editor and its own
|
||||
// YouTube twin (`AddToPlaylist`) and missed this one (round 6).
|
||||
async function toggle(p: PlexPlaylist) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
@@ -67,8 +73,13 @@ export default function PlexPlaylistAdd({
|
||||
else await api.plexPlaylistAddItem(p.id, target.ratingKey);
|
||||
}
|
||||
setOverride((m) => new Map(m).set(p.id, allIn ? 0 : size));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
);
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -87,8 +98,12 @@ export default function PlexPlaylistAdd({
|
||||
setOverride((m) => new Map(m).set(pl.id, 1));
|
||||
}
|
||||
setNewName("");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// The create may already have landed when the add fails — `refresh()` in `finally` is what
|
||||
// makes that half-done state visible instead of leaving a playlist the dialog never lists.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
} finally {
|
||||
refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// One accepted write, one cache entry that matches it — the Plex half.
|
||||
//
|
||||
// `Playlists.tsx` (the YouTube list) got this rule in round 5: the detail cache is what a rollback
|
||||
// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted
|
||||
// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got
|
||||
// the rollback half of that round's fix and not this half (round 6).
|
||||
//
|
||||
// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly —
|
||||
// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture).
|
||||
import { it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const { dragEnd } = vi.hoisted(() => ({ dragEnd: { fire: (_e: unknown) => {} } }));
|
||||
vi.mock("@dnd-kit/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
|
||||
return {
|
||||
...actual,
|
||||
DndContext: ({
|
||||
children,
|
||||
onDragEnd,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onDragEnd: (e: unknown) => void;
|
||||
}) => {
|
||||
dragEnd.fire = onDragEnd;
|
||||
return <>{children}</>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import PlexPlaylistView from "./PlexPlaylistView";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const movie = (id: string) =>
|
||||
({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard;
|
||||
|
||||
it("writes the order the server accepted into the detail cache, without waiting for a refetch", async () => {
|
||||
// The refetch that same handler schedules (round 7) must not be what makes the cache right: the
|
||||
// point of the write is that a re-entry inside the 30 s staleTime renders the accepted order
|
||||
// immediately, with no request at all. So the refetch is HELD OPEN here — if this assertion
|
||||
// needed it to land, deleting the write would still pass and round 6's fix would be unguarded.
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
|
||||
++calls === 1
|
||||
? Promise.resolve({ id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] })
|
||||
: new Promise(() => {}),
|
||||
);
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 1");
|
||||
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
|
||||
// The list key was always invalidated; the DETAIL key — the one `putBack` reads and the one a
|
||||
// re-entry renders from — kept the pre-reorder order, so leaving and coming back inside the
|
||||
// staleTime showed the reorder undone, and the next drag persisted that.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id),
|
||||
).toEqual(["2", "1", "3"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("refetches the detail too, so a membership change that landed meanwhile is not clobbered", async () => {
|
||||
// `next` is the membership of the render that started the drag, and `reorder_playlist` only
|
||||
// touches the rating_keys it is handed — it never ratifies that list. So writing `next` into the
|
||||
// detail cache silently drops anything that landed in between (a row removed from another
|
||||
// surface, an item added elsewhere), and with only the LIST key invalidated nothing corrected it:
|
||||
// staleTime is 30 s and refetchOnWindowFocus is off, so the phantom row stayed on screen for as
|
||||
// long as the view was mounted and the next drag persisted an order built from it. The YouTube
|
||||
// sibling invalidates the detail key right after writing it; this half did not (round 7).
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "plexPlaylist").mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
++calls === 1
|
||||
? { id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] }
|
||||
: { id: 5, title: "Films", items: [movie("2"), movie("1")] }, // "3" is gone server-side
|
||||
),
|
||||
);
|
||||
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Movie 3");
|
||||
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
|
||||
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
|
||||
// The refetch the accepted write schedules brings the real membership back.
|
||||
await waitFor(() => expect(screen.queryByText("Movie 3")).toBeNull());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id),
|
||||
).toEqual(["2", "1"]),
|
||||
);
|
||||
});
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -401,14 +402,54 @@ export default function PlexPlaylistView({
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
};
|
||||
|
||||
// Every edit here is optimistic on the local copy, and none of them used to handle a failure: a
|
||||
// removal that failed stayed removed on screen (the resync never ran), and a 404 — the playlist
|
||||
// deleted on another device, now a structured `not_found` — was an unhandled rejection that said
|
||||
// nothing at all (deep-review round 4; the YouTube side was fixed in round 3). A failure is said,
|
||||
// and the rows are put back from what the server last sent — explicitly, because a resync that
|
||||
// returns the same list keeps the same `q.data` and the re-sync effect above never runs.
|
||||
// Read the cache AT ROLLBACK TIME, not the `q.data` of the render that bound the failing handler:
|
||||
// a removal completing while a reorder was in flight refreshed the list, and the reorder's stale
|
||||
// closure then put the removed row back on screen — where the deep-equal refetch could not clear
|
||||
// it, and the next drag persisted an order containing it (deep-review round 5). `Playlists.tsx`'s
|
||||
// sibling `restoreFromCache` reads it live for the same reason.
|
||||
const putBack = () => {
|
||||
const cur = qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId));
|
||||
if (cur) setItems(cur.items);
|
||||
invalidate();
|
||||
};
|
||||
|
||||
function commit(next: PlexCard[]) {
|
||||
if (busy) return; // a reorder racing a removal is what makes a rollback ambiguous
|
||||
setItems(next);
|
||||
api
|
||||
.plexReorderPlaylist(
|
||||
playlistId,
|
||||
next.map((i) => i.id),
|
||||
)
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }));
|
||||
.then(() => {
|
||||
// Write the accepted order into the DETAIL cache, not just the list. That cache is what
|
||||
// `putBack` rolls back to and what a re-entry inside the 30 s staleTime renders without
|
||||
// refetching — so after an accepted reorder it showed the pre-reorder order again, and the
|
||||
// next drag from there persisted it, silently undoing the one the server took. The
|
||||
// YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round
|
||||
// 6). One accepted write, one cache entry that matches it.
|
||||
qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) =>
|
||||
old ? { ...old, items: next } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
// …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and
|
||||
// the server only reorders the keys it was given — so anything that landed meanwhile (a row
|
||||
// removed from another surface, an item added elsewhere) is clobbered by that write. With
|
||||
// only the list key invalidated nothing corrected it: staleTime is 30 s and
|
||||
// refetchOnWindowFocus is off, so the wrong membership stayed on screen for as long as the
|
||||
// view was mounted and the next drag persisted an order built from it (round 7).
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("plex.playlist.reorderFailed"));
|
||||
putBack();
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemove(keys: string[]) {
|
||||
@@ -420,6 +461,9 @@ export default function PlexPlaylistView({
|
||||
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -428,7 +472,12 @@ export default function PlexPlaylistView({
|
||||
async function rename() {
|
||||
const title = name.trim();
|
||||
if (!title) return;
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
try {
|
||||
await api.plexRenamePlaylist(playlistId, title);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.renameFailed")); // the field stays open to try again
|
||||
return;
|
||||
}
|
||||
setRenaming(false);
|
||||
invalidate();
|
||||
}
|
||||
@@ -440,7 +489,16 @@ export default function PlexPlaylistView({
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await api.plexDeletePlaylist(playlistId);
|
||||
try {
|
||||
await api.plexDeletePlaylist(playlistId);
|
||||
} catch (e) {
|
||||
// Gone already (deleted on another device): what was asked for is true — leave the dead page.
|
||||
// Anything else is a real failure, and the page stays.
|
||||
if (!(e instanceof HttpError && e.status === 404)) {
|
||||
notifyActionError(e, t("plex.playlist.deleteFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onBack();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -447,6 +448,10 @@ function CollectionPicker({
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
enabled: open, // only fetch while the picker is open
|
||||
});
|
||||
// …but a selection restored from a LINK has ids and no titles, and the list above is only fetched
|
||||
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
|
||||
// selected ids (see usePlexCollectionTitles).
|
||||
const resolved = usePlexCollectionTitles(values, titles);
|
||||
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.title,
|
||||
@@ -454,7 +459,7 @@ function CollectionPicker({
|
||||
}));
|
||||
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
|
||||
// selected collection's title (the fetched list is narrowed by the current search).
|
||||
const labelMap: Record<string, string> = { ...titles };
|
||||
const labelMap: Record<string, string> = { ...resolved };
|
||||
for (const o of options) if (o.label) labelMap[o.value] = o.label;
|
||||
|
||||
return (
|
||||
@@ -463,7 +468,7 @@ function CollectionPicker({
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={values}
|
||||
selectedLabels={titles}
|
||||
selectedLabels={resolved}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type RefObject, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Hls from "hls.js";
|
||||
import { X } from "lucide-react";
|
||||
import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
||||
import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats";
|
||||
|
||||
// "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5 <video> + hls.js state once a second
|
||||
// and renders the active mode, resolution, codec/bitrate, dropped frames, buffer, hls level and session
|
||||
@@ -11,6 +11,9 @@ import { buildStatRows, type HlsLevelInfo, type StatRow } from "../lib/plexStats
|
||||
export function PlexStatsOverlay({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
@@ -21,6 +24,11 @@ export function PlexStatsOverlay({
|
||||
}: {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
hlsRef: RefObject<Hls | null>;
|
||||
// Rolling FRAG_LOADED meter owned by the player (it lives as long as the hls.js instance, not as
|
||||
// long as this panel) — the source of the MEASURED bitrate row.
|
||||
fragMeterRef: RefObject<FragMeter>;
|
||||
audioLabel?: string; // selected audio track label, as the tracks menu names it
|
||||
subLabel?: string; // selected subtitle track label (or the menu's "Off" entry)
|
||||
playable: string;
|
||||
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
|
||||
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
|
||||
@@ -88,6 +96,9 @@ export function PlexStatsOverlay({
|
||||
totalFrames: total,
|
||||
bufferAheadS: bufferAhead,
|
||||
hlsLevel: level,
|
||||
measuredBps: fragMeterRef.current?.bitrate(),
|
||||
audioLabel,
|
||||
subLabel,
|
||||
sessionStartS: sessionStartRef.current ?? 0,
|
||||
abs: absRef.current ?? 0,
|
||||
duration: durationRef.current ?? 0,
|
||||
@@ -97,7 +108,19 @@ export function PlexStatsOverlay({
|
||||
sample();
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
|
||||
}, [
|
||||
videoRef,
|
||||
hlsRef,
|
||||
fragMeterRef,
|
||||
audioLabel,
|
||||
subLabel,
|
||||
playable,
|
||||
hwRef,
|
||||
tonemapRef,
|
||||
sessionStartRef,
|
||||
absRef,
|
||||
durationRef,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
||||
@@ -375,14 +376,14 @@ export default function Scheduler() {
|
||||
const pauseResume = useMutation({
|
||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("scheduler.toggleFailed")),
|
||||
});
|
||||
|
||||
const intervalMut = useMutation({
|
||||
mutationFn: (v: { jobId: string; minutes: number }) =>
|
||||
api.updateSchedulerJob(v.jobId, v.minutes),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("scheduler.intervalFailed")),
|
||||
});
|
||||
|
||||
// Manual "run now" triggers. The wrapper runs in a background thread server-side; the
|
||||
|
||||
@@ -7,6 +7,7 @@ import clsx from "clsx";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
@@ -39,7 +40,7 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("downloads.share.failed") }),
|
||||
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
type StatsTab = "overview" | "system";
|
||||
@@ -85,7 +86,7 @@ function Overview({ me }: { me: Me }) {
|
||||
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
|
||||
});
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("stats.sync.fullHistoryFailed") }),
|
||||
onError: (e) => notifyActionError(e, t("stats.sync.fullHistoryFailed")),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
@@ -133,9 +134,16 @@ export default function TagManager({
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
@@ -143,6 +151,7 @@ export default function TagManager({
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
||||
});
|
||||
|
||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||
|
||||
@@ -20,7 +20,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("../lib/api", () => ({ api: { uploadDownload: h.uploadDownload } }));
|
||||
vi.mock("../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
||||
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: h.notify,
|
||||
}));
|
||||
vi.mock("../lib/nav", () => ({ navigateTo: vi.fn() }));
|
||||
|
||||
import UploadDialog from "./UploadDialog";
|
||||
|
||||
@@ -5,6 +5,8 @@ import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { isAborted } from "../lib/api/core";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
@@ -54,9 +56,15 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// An aborted upload (Cancel) or a server rejection (cap/quota — already surfaced by the global
|
||||
// error modal, like every other req call). Re-arm the form so the user can retry or pick again.
|
||||
} catch (e) {
|
||||
// Everything except the user's own Cancel is said out loud. `uploadReq` shares `reportFailure`
|
||||
// with `req`, which deliberately leaves 403/404/429 to the caller and answers a 502/503/504
|
||||
// with only the transient "connection lost" notice — so this empty catch meant a proxy 502 on
|
||||
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
|
||||
// named and no job in the list. The comment here claimed the global error modal had it; it
|
||||
// had not since the shared `reportFailure` landed (deep-review round 5).
|
||||
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
|
||||
// Re-arm the form so the user can retry or pick again.
|
||||
setPct(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The video card's channel name was a <button>: a plain click opened the channel in-app, but
|
||||
// middle-click and Ctrl/Cmd-click did nothing — while the title right above it, an anchor, honoured
|
||||
// them. Found by the 2026-09 bug-hunt deep-review, when the player's channel name had just become a
|
||||
// real link and the card's had not. (The link's own gesture rules are pinned in
|
||||
// ChannelNameLink.dom.test.tsx; this pins that the CARD uses it.)
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({ openChannel: vi.fn() }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ openChannel: h.openChannel }),
|
||||
}));
|
||||
|
||||
import VideoCard from "./VideoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
const video = {
|
||||
id: "abc123",
|
||||
title: "Cicada 3301",
|
||||
channel_id: CH,
|
||||
channel_title: "LEMMiNO",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
duration_seconds: 1200,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=abc123",
|
||||
thumbnail_url: "",
|
||||
} as unknown as Video;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
h.openChannel.mockClear();
|
||||
window.history.replaceState({}, "", "/?page=feed");
|
||||
});
|
||||
|
||||
describe("VideoCard — the channel name", () => {
|
||||
it("is a real link to the channel, and a plain click still opens it in-app", () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
view="cards"
|
||||
width={320}
|
||||
onState={() => {}}
|
||||
onToggleSave={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const link = screen.getByTestId("video-card-channel");
|
||||
expect(link.tagName).toBe("A");
|
||||
expect(link.getAttribute("href")).toBe(`/?page=feed&chan=${CH}`);
|
||||
link.click();
|
||||
expect(h.openChannel).toHaveBeenCalledWith(CH, "LEMMiNO");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
|
||||
function Actions({
|
||||
video,
|
||||
@@ -124,7 +126,7 @@ function openInApp(
|
||||
video: Video,
|
||||
onOpen?: (v: Video, startAt?: number | null) => void,
|
||||
): void {
|
||||
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
||||
if (!onOpen || !isPlainLeftClick(e)) return;
|
||||
e.preventDefault();
|
||||
onOpen(video);
|
||||
}
|
||||
@@ -438,7 +440,7 @@ function VideoCard({
|
||||
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
|
||||
// repeats what's already legible is noise.
|
||||
const titleRef = useRef<HTMLAnchorElement>(null);
|
||||
const channelRef = useRef<HTMLButtonElement>(null);
|
||||
const channelRef = useRef<HTMLAnchorElement>(null);
|
||||
const [titleClamped, setTitleClamped] = useState(false);
|
||||
const [channelClamped, setChannelClamped] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -472,19 +474,22 @@ function VideoCard({
|
||||
{video.title}
|
||||
</a>
|
||||
);
|
||||
const channelButton = (
|
||||
<button
|
||||
// A real link, like the title above it: a <button> here ignored middle-click and Ctrl/Cmd-click,
|
||||
// so the same card honoured the browser's gestures on its title and silently dropped them on its
|
||||
// channel name.
|
||||
const channelLink = (
|
||||
<ChannelNameLink
|
||||
ref={channelRef}
|
||||
data-testid="video-card-channel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
||||
}}
|
||||
title={channelClamped ? (video.channel_title ?? undefined) : undefined}
|
||||
channelId={video.channel_id}
|
||||
title={video.channel_title}
|
||||
onOpenInApp={() =>
|
||||
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"))
|
||||
}
|
||||
stopPropagation
|
||||
tooltip={channelClamped ? (video.channel_title ?? undefined) : undefined}
|
||||
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
|
||||
>
|
||||
{video.channel_title}
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
const actions = (
|
||||
<Actions
|
||||
@@ -546,7 +551,7 @@ function VideoCard({
|
||||
filter panel are doing. Until the first measure lands (width 0) show everything: the
|
||||
row is off-screen for that frame anyway, and guessing narrow would flash. */}
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
|
||||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
|
||||
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
|
||||
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
||||
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
|
||||
@@ -577,7 +582,7 @@ function VideoCard({
|
||||
const textBlock = (
|
||||
<>
|
||||
{title}
|
||||
<div className="mt-0.5">{channelButton}</div>
|
||||
<div className="mt-0.5">{channelLink}</div>
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
{views}
|
||||
{views && " · "}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -322,7 +323,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
notify({ level: "success", message: t("editor.created", { count: files }) });
|
||||
onClose();
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("editor.failed") }),
|
||||
onError: (e) => notifyActionError(e, t("editor.failed")),
|
||||
});
|
||||
|
||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||
|
||||
@@ -29,6 +29,8 @@ import { HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import { isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
@@ -266,21 +268,53 @@ function Preview({
|
||||
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
||||
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
// An `aria-modal` shell that round 5's focus extraction did not sweep in: it declared itself a
|
||||
// modal dialog while leaving focus on the thumbnail behind an 80%-black scrim, announcing nothing,
|
||||
// and letting Tab walk the landing page underneath — the ✕ reachable only after tabbing past every
|
||||
// remaining gallery shot (deep-review round 6). The contract comes from the same place `ui/Modal`
|
||||
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
|
||||
// membership below.
|
||||
//
|
||||
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also
|
||||
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible
|
||||
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep
|
||||
// were finished, which is the false-completeness that let a demo refusal sit unconverted for a
|
||||
// whole round. It is on ROADMAP.md instead (round 7).
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialogChrome(panelRef);
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
useEffect(() => {
|
||||
// Round 6 gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other three
|
||||
// have, so its Escape and Tab fired from a raw window listener whatever was on top of it. That
|
||||
// is the shape the wizard's own comment records being bitten by — "one Esc closed the error AND
|
||||
// ended onboarding" — and it is unreachable today only because `App` returns `<Welcome/>` before
|
||||
// it mounts `ErrorDialog` and the Toaster, i.e. by an ordering nothing enforces. Joining the
|
||||
// stack costs two lines and removes the trap-fights-trap failure mode from the next layer the
|
||||
// landing page grows (deep-review round 7).
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (!isTopmost(id)) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
const panel = panelRef.current;
|
||||
if (e.key === "Tab" && panel) trapTab(e, panel, panel);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden"; // no background scroll while open
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
removeLayer(id);
|
||||
};
|
||||
}, [onClose]);
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-popover grid place-items-center bg-black/80 overlay-backdrop p-4 sm:p-8"
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-popover grid place-items-center bg-black/80 overlay-backdrop p-4 sm:p-8 outline-none"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A write that fails is named, wherever it fails. `req`/`uploadReq` deliberately leave 403/404/429
|
||||
// to the caller (see reportFailure) and answer a 502/503/504 with only the transient "connection
|
||||
// lost" notice — so a handler with no `catch` reports nothing at all. Round 4 swept a dozen such
|
||||
// call sites and missed these two: the collection editor's three siblings, in the very file it
|
||||
// edited, and the one `uploadReq` caller, whose empty catch still claimed the global error modal had
|
||||
// it (deep-review round 5).
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import UploadDialog from "./UploadDialog";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => notify.mockClear());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
|
||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
describe("the Plex collection editor", () => {
|
||||
const collections = [
|
||||
{ id: "c1", title: "Marathon", can_edit: true, child_count: 3, smart: false },
|
||||
// Not marked "mine to edit" yet, so it offers the take-over button.
|
||||
{
|
||||
id: "c2",
|
||||
title: "Theirs",
|
||||
can_edit: false,
|
||||
child_count: 9,
|
||||
smart: false,
|
||||
source: "collection",
|
||||
},
|
||||
];
|
||||
const mount = () =>
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<PlexCollectionEditor
|
||||
item={{ id: "m1", title: "A Movie" }}
|
||||
library="1"
|
||||
memberOf={[]}
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
it("says so when adding to a collection that is gone, instead of nothing happening", async () => {
|
||||
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections } as never);
|
||||
vi.spyOn(api, "plexCollectionAddItem").mockRejectedValue(
|
||||
new HttpError(404, "Unknown collection"),
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("Marathon"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
||||
});
|
||||
|
||||
it("says so when enabling editing is refused", async () => {
|
||||
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections } as never);
|
||||
vi.spyOn(api, "plexSetCollectionEditable").mockRejectedValue(
|
||||
new HttpError(403, "This collection is read-only"),
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"]));
|
||||
});
|
||||
|
||||
it("says so when a CONFIRMED delete does not happen", async () => {
|
||||
// The worst of the three to lose: the user has just confirmed a destructive dialog.
|
||||
vi.spyOn(api, "plexCollections").mockResolvedValue({ collections } as never);
|
||||
vi.spyOn(api, "plexDeleteCollection").mockRejectedValue(
|
||||
new HttpError(404, "Unknown collection"),
|
||||
);
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("plex.collEditor.delete"));
|
||||
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the upload dialog", () => {
|
||||
const pick = () => {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(["x"], "clip.mp4", { type: "video/mp4" });
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
};
|
||||
const mount = () =>
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<UploadDialog onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
it("names an upload the server refused", async () => {
|
||||
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
|
||||
// wipes it, and the dialog snapped back with no error and no job in the list.
|
||||
vi.spyOn(api, "uploadDownload").mockRejectedValue(new HttpError(502, "Bad Gateway"));
|
||||
mount();
|
||||
pick();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"]));
|
||||
});
|
||||
|
||||
it("says nothing when the user cancelled it themselves", async () => {
|
||||
// A cancel is not a failure — and it rejects with the same status 0 a genuine network drop does,
|
||||
// which is why the difference is a named sentinel (`isAborted`) and not a status check.
|
||||
const upload = vi.spyOn(api, "uploadDownload").mockRejectedValue(new HttpError(0, "aborted"));
|
||||
mount();
|
||||
pick();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(upload).toHaveBeenCalled());
|
||||
expect(errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
@@ -95,8 +95,9 @@ function NewCollectionPanel({
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
// The backend is authoritative on per-library name uniqueness — a taken name comes back
|
||||
// structured, and so localized (see localizeDetail).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
|
||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||
@@ -528,19 +527,14 @@ function PopoverSurfaceInner({
|
||||
|
||||
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
|
||||
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
|
||||
// fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
|
||||
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
|
||||
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
|
||||
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
|
||||
// remount instead would rebuild the subtree and lose all of that.)
|
||||
const fsRoot = useFullscreenElement();
|
||||
const [container] = useState<HTMLDivElement | null>(() =>
|
||||
typeof document === "undefined" ? null : document.createElement("div"),
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (container) (fsRoot ?? document.body).appendChild(container);
|
||||
}, [container, fsRoot]);
|
||||
useEffect(() => () => container?.remove(), [container]);
|
||||
// fullscreen element. One persistent container div is re-parented (body ↔ fullscreen element)
|
||||
// rather than handed to the portal as a changing container, so the open panel's caret, scroll
|
||||
// offset and animation state survive the transition, and an element SWAP (A→B without exiting) is
|
||||
// handled by the element-identity dependency. (Keying the portal to remount instead would rebuild
|
||||
// the subtree and lose all of that.) Focus does NOT survive on its own — `appendChild` on an
|
||||
// already-parented node is remove-then-insert, which blurs to <body>; the hook saves and restores
|
||||
// it. This used to be a second copy of Modal's re-parent that lacked exactly that (round 5).
|
||||
const container = useFullscreenPortalHost();
|
||||
|
||||
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
|
||||
style?: CSSProperties;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// The overlay/history machinery (useBackToClose) is exercised in history.dom.test.ts, so it's mocked
|
||||
// out here to isolate the focus-trap behaviour from its module-global history side-effects.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, act, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -20,6 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
|
||||
// Fire a keydown on window (where Modal installs its handler), inside act.
|
||||
const press = (key: string, shiftKey = false) =>
|
||||
@@ -91,6 +93,32 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
expect(document.activeElement).toBe(screen.getByText("beta"));
|
||||
});
|
||||
|
||||
it("Tab reaches a toast shown inside the dialog, and wraps from it", () => {
|
||||
// The dialog's toast host sits beside the panel; trapped to the panel, a toast's Undo or dismiss
|
||||
// could not be reached from the keyboard at all.
|
||||
render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "info", message: "Saved" }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
|
||||
screen.getByText("beta").focus();
|
||||
press("Tab");
|
||||
expect(document.activeElement).toBe(screen.getByText("beta")); // not wrapped back to the top
|
||||
// The toast is the edge now: past it, Tab wraps to the first control, and Shift+Tab reaches it.
|
||||
dismissToast.focus();
|
||||
press("Tab");
|
||||
expect(document.activeElement).toBe(closeBtn());
|
||||
press("Tab", true);
|
||||
expect(document.activeElement).toBe(dismissToast);
|
||||
} finally {
|
||||
act(() => clearAll());
|
||||
}
|
||||
});
|
||||
|
||||
it("Tab pulls focus back inside when it has escaped to the page behind", () => {
|
||||
const outside = document.createElement("button");
|
||||
document.body.appendChild(outside);
|
||||
@@ -172,6 +200,115 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("still restores the trigger after the focus visited the dialog's own toast", () => {
|
||||
// The Tab trap deliberately spans the dialog ROOT so a toast's Undo/✕ is reachable; ownership
|
||||
// was tracked over the PANEL, so touching that ✕ read as "the user left the dialog" and the
|
||||
// restore was skipped — and removing the toast fires no focusin to re-arm it. Measured landing
|
||||
// on <body> (deep-review round 6).
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
const { unmount } = render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Nope" }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
|
||||
act(() => clearAll()); // the toast goes away under the focus — no focusin follows
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("restores the trigger when a dialog and the dialog stacked on it close together", () => {
|
||||
// A nested Modal portals into its OWN host, so it is outside the outer dialog's root however
|
||||
// wide that scope is drawn — reading that as "the user walked away" left the focus on <body>.
|
||||
// The child cannot cover for it: passive cleanups run parent-first, and by then its own restore
|
||||
// target (a control INSIDE the outer panel, captured when it opened) is already detached.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
function Nested() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Modal title="outer" onClose={() => {}}>
|
||||
<button onClick={() => setOpen(true)}>open inner</button>
|
||||
{open && (
|
||||
<Modal title="inner" onClose={() => {}}>
|
||||
<button>inner-body</button>
|
||||
</Modal>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
const { unmount } = render(<Nested />);
|
||||
act(() => void screen.getByText("open inner").click());
|
||||
screen.getByText("inner-body").focus();
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("leaves the focus alone when a dialog above it is still standing", () => {
|
||||
// The other side of the clause above. Counting focus in ANY `aria-modal` subtree as ours cannot
|
||||
// tell a layer closing WITH us from one that stays open, and the layer stack cannot either: at
|
||||
// the moment this cleanup runs the child's `removeLayer` has not fired, so both cases read "a
|
||||
// layer is above me" (measured). So a dialog that closes on its own — an in-flight mutation's
|
||||
// `onSuccess`, not a click, since a scrim covers it — under a modal raised above it yanked the
|
||||
// focus out of that standing dialog onto its own trigger, behind the scrim, where Enter still
|
||||
// activated it (deep-review round 7). Sibling dialogs, not nested: that is the arrangement
|
||||
// `ConfirmProvider` and the app-wide `ErrorDialog` actually use.
|
||||
const trigger = document.createElement("button");
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
function Siblings() {
|
||||
const [lower, setLower] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{lower && (
|
||||
<Modal title="lower" onClose={() => {}}>
|
||||
<button onClick={() => setLower(false)}>close lower</button>
|
||||
</Modal>
|
||||
)}
|
||||
<Modal title="upper" onClose={() => {}}>
|
||||
<button>upper-body</button>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Siblings />);
|
||||
const upper = screen.getByText("upper-body");
|
||||
upper.focus();
|
||||
// The lower dialog closes by itself while the upper one holds the focus.
|
||||
act(() => void screen.getByText("close lower").click());
|
||||
expect(screen.queryByText("close lower")).toBeNull(); // it really did unmount
|
||||
expect(document.activeElement).toBe(upper);
|
||||
document.body.removeChild(trigger);
|
||||
});
|
||||
|
||||
it("leaves the focus alone when the user moved it out of the dialog themselves", () => {
|
||||
// The other half of the same rule, and the branch nothing drove: taking focus back from where
|
||||
// the user deliberately put it is the rudest thing a closing dialog can do.
|
||||
const trigger = document.createElement("button");
|
||||
const elsewhere = document.createElement("button");
|
||||
document.body.append(trigger, elsewhere);
|
||||
trigger.focus();
|
||||
const { unmount } = render(
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
elsewhere.focus();
|
||||
unmount();
|
||||
expect(document.activeElement).toBe(elsewhere);
|
||||
document.body.removeChild(trigger);
|
||||
document.body.removeChild(elsewhere);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal focus trap (raw jsdom — every offsetParent null)", () => {
|
||||
@@ -189,3 +326,22 @@ describe("Modal focus trap (raw jsdom — every offsetParent null)", () => {
|
||||
expect(document.activeElement).toBe(panelEl());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal — toasts raised while it is open", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
act(() => clearAll());
|
||||
});
|
||||
|
||||
it("shows them inside the dialog, above its scrim", () => {
|
||||
// The app's Toaster is in a stacking context under this body-level portal: a failed share or
|
||||
// rename was drawn under the scrim, and clicking it landed on the backdrop and closed the dialog.
|
||||
render(
|
||||
<Modal title="Share" onClose={() => {}}>
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't share it." }));
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A dialog opened from a player in true fullscreen portals INTO the fullscreen element (a node under
|
||||
// <body> is painted beneath it, i.e. invisible). Leaving fullscreen changes that container — and a
|
||||
// React portal whose container changes is torn down and recreated, so everything the dialog held went
|
||||
// with it: a half-filled download form reset to its defaults and focus fell to <body> (a verifier's
|
||||
// side finding in deep-review round 4).
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import Modal from "./Modal";
|
||||
|
||||
const setFullscreen = (el: Element | null) =>
|
||||
act(() => {
|
||||
Object.defineProperty(document, "fullscreenElement", { value: el, configurable: true });
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setFullscreen(null);
|
||||
});
|
||||
|
||||
describe("a dialog open while fullscreen is left", () => {
|
||||
it("keeps what the viewer had typed in it, and the focus inside it", async () => {
|
||||
const stage = document.createElement("div");
|
||||
document.body.appendChild(stage);
|
||||
await setFullscreen(stage);
|
||||
render(
|
||||
<Modal title="Download" onClose={() => {}}>
|
||||
<input aria-label="name" defaultValue="" />
|
||||
</Modal>,
|
||||
);
|
||||
const field = screen.getByLabelText("name") as HTMLInputElement;
|
||||
fireEvent.change(field, { target: { value: "my cut" } });
|
||||
field.focus();
|
||||
expect(stage.contains(field)).toBe(true);
|
||||
|
||||
await setFullscreen(null); // the browser's own Esc leaves fullscreen; the dialog stays open
|
||||
|
||||
const after = screen.getByLabelText("name") as HTMLInputElement;
|
||||
expect(after.value).toBe("my cut");
|
||||
expect(document.activeElement).toBe(after);
|
||||
stage.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a dialog with an autoFocus child", () => {
|
||||
it("opens with the focus in that child, not on the close button", () => {
|
||||
// The portal host is created detached and attached by the hook. If it is attached a phase too
|
||||
// late (a `useLayoutEffect` instead of `useInsertionEffect`), every descendant's `autoFocus`
|
||||
// has already called `.focus()` on a disconnected node — a no-op — and Modal's own fallback
|
||||
// then puts the focus on the first focusable in the panel, the header's ✕. That is what the
|
||||
// command palette, the download rename dialog and every non-danger confirm shipped with after
|
||||
// deep-review round 4 (found in round 5).
|
||||
render(
|
||||
<Modal title="Search" onClose={() => {}}>
|
||||
<input aria-label="query" autoFocus />
|
||||
</Modal>,
|
||||
);
|
||||
expect(document.activeElement).toBe(screen.getByLabelText("query"));
|
||||
});
|
||||
|
||||
it("still has it after a fullscreen transition moves the host", async () => {
|
||||
const stage = document.createElement("div");
|
||||
document.body.appendChild(stage);
|
||||
render(
|
||||
<Modal title="Search" onClose={() => {}}>
|
||||
<input aria-label="query" autoFocus />
|
||||
</Modal>,
|
||||
);
|
||||
const field = screen.getByLabelText("query");
|
||||
expect(document.activeElement).toBe(field);
|
||||
|
||||
await setFullscreen(stage); // the player goes fullscreen under the open dialog
|
||||
|
||||
expect(stage.contains(field)).toBe(true);
|
||||
expect(document.activeElement).toBe(field);
|
||||
stage.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The dialog's focus behaviour under React 18 StrictMode — its own file because StrictMode's
|
||||
// mount→cleanup→mount is what the code under test reacts to, and the module-level layer stack in
|
||||
// ui/Modal (and the overlay-host registry in ui/Toaster) leaks between tests in one file.
|
||||
//
|
||||
// Round 5 fixed this in Chrome and shipped it with NO test: the second mount found the focus
|
||||
// outside the dialog (its own cleanup had just handed it back to the trigger) and moved it to the
|
||||
// first focusable — taking it off an `autoFocus` field a beat after the browser had put it there.
|
||||
// The command palette opened focused on its ✕ in dev and on its search field in prod, from the same
|
||||
// code. Round 6 measured that deleting the fix left all 1009 tests green, which is why this exists.
|
||||
//
|
||||
// Same offsetParent stub as Modal.dom.test.tsx: jsdom never lays out, so every element reports
|
||||
// offsetParent null and `focusablesIn` would see nothing.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import Modal from "./Modal";
|
||||
|
||||
describe("Modal focus under StrictMode", () => {
|
||||
let origDesc: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.isConnected ? document.body : null;
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
|
||||
});
|
||||
|
||||
it("keeps the focus its autoFocus child asked for across the double mount", () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<Modal title="t" onClose={() => {}}>
|
||||
<input aria-label="field" autoFocus />
|
||||
</Modal>
|
||||
</StrictMode>,
|
||||
);
|
||||
// Without the remembered focus the second mount lands on the first focusable — the header ✕.
|
||||
expect(document.activeElement).toBe(screen.getByLabelText("field"));
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,13 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { useBackToClose } from "../../lib/history";
|
||||
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
import { trapTab, useDialogChrome } from "./dialogFocus";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// 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
|
||||
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||
import Toaster, { useOverlayOrder } from "./Toaster";
|
||||
|
||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||
@@ -43,14 +37,18 @@ export default function Modal({
|
||||
const { t } = useTranslation();
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
useBackToClose(onClose);
|
||||
const fsRoot = useFullscreenElement();
|
||||
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
|
||||
// element (deep-review round 4) — and attached before the children commit, so an `autoFocus` child
|
||||
// still gets the focus (round 5). Both rules live in the hook; `ui/AnchoredPopover` shares it.
|
||||
const host = useFullscreenPortalHost();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
|
||||
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
|
||||
// focus on <body>. useState initializer runs exactly once, on first render.
|
||||
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
||||
// Focus lands in the panel; OWNERSHIP is the whole dialog — the same element the Tab trap below
|
||||
// is scoped to, toast host included. Tracking ownership over the panel alone meant tabbing to an
|
||||
// in-dialog toast counted as leaving, and the close-time restore was then skipped (round 6).
|
||||
useDialogChrome(panelRef, rootRef);
|
||||
const overlayOrder = useOverlayOrder();
|
||||
|
||||
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
||||
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
|
||||
@@ -74,51 +72,25 @@ export default function Modal({
|
||||
if (e.key === "Tab") {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const items = focusablesIn(panel);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
if (!panel.contains(active) || active === panel) {
|
||||
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
||||
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
||||
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
// The whole dialog, not just the panel: its toast host sits beside the panel (see ui/Toaster),
|
||||
// and a toast's Undo or dismiss must be reachable from the keyboard like everything else.
|
||||
trapTab(e, rootRef.current ?? panel, panel);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||
// focus to the first control so the dialog opens focused, not stranded on the background.
|
||||
const panel = panelRef.current;
|
||||
if (panel && !panel.contains(document.activeElement)) {
|
||||
(focusablesIn(panel)[0] ?? panel).focus();
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
removeLayer(id);
|
||||
document.body.style.overflow = prev;
|
||||
// Restore focus to the trigger, if it's still in the document and focusable.
|
||||
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo]);
|
||||
}, []);
|
||||
|
||||
const drawer = placement === "drawer";
|
||||
const top = placement === "top";
|
||||
// Only on a server render, where the hook has no `document` to build a host from.
|
||||
if (!host) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
|
||||
drawer
|
||||
? "justify-end"
|
||||
@@ -161,7 +133,11 @@ export default function Modal({
|
||||
</div>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
{/* Toasts raised while a dialog is open show inside it: the app's own host is under this
|
||||
scrim, where a failed share or rename was unreadable and a click on it closed the dialog
|
||||
(see ui/Toaster). */}
|
||||
<Toaster overlay order={overlayOrder} />
|
||||
</div>,
|
||||
fsRoot ?? document.body,
|
||||
host,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Which Toaster shows the toasts. The app's own sits in the content column, which is a stacking
|
||||
// context — so a full-screen player portalled to <body> at the same z painted over every toast: a
|
||||
// failed Remove raised while watching was drawn under the player's scrim, and clicking it closed
|
||||
// the player. An overlay mounts its own host, and exactly one host shows the toasts.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
act(() => clearAll());
|
||||
});
|
||||
|
||||
const hosts = () => Array.from(document.querySelectorAll("[aria-live]"));
|
||||
|
||||
describe("Toaster — one host shows the toasts", () => {
|
||||
it("moves the toasts into an open overlay, and back when it closes", () => {
|
||||
const app = render(
|
||||
<div data-testid="app">
|
||||
<Toaster />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
||||
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
||||
|
||||
const overlay = render(
|
||||
<div data-testid="overlay">
|
||||
<Toaster overlay />
|
||||
</div>,
|
||||
);
|
||||
expect(screen.getByTestId("overlay").textContent).toContain("Couldn't remove the video.");
|
||||
expect(screen.getByTestId("app").textContent).not.toContain("Couldn't remove the video.");
|
||||
// Both live regions stay mounted — a polite region has to pre-exist to be announced.
|
||||
expect(hosts()).toHaveLength(2);
|
||||
// The taking-over host arms itself a task later (see the "inherits" test below).
|
||||
expect(hosts().map((h) => h.getAttribute("aria-live"))).toEqual(["off", "off"]);
|
||||
|
||||
overlay.unmount();
|
||||
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
it("does not let a click on a toast reach the overlay's backdrop", () => {
|
||||
const backdrop = vi.fn();
|
||||
render(
|
||||
<div onClick={backdrop}>
|
||||
<Toaster overlay />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
fireEvent.click(screen.getByText("boom"));
|
||||
expect(backdrop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toaster — handing the toasts to another host", () => {
|
||||
it("does not announce the toasts it inherits again, only the ones that come after", () => {
|
||||
// The new host gets them as NEW nodes: a screen reader read every one out again, an error
|
||||
// assertively, each time a player (or any dialog) opened or closed.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
render(<Toaster overlay />);
|
||||
const old = screen.getByText("old news").closest(".toast-card")!;
|
||||
expect(old.getAttribute("role")).toBeNull();
|
||||
act(() => void notify({ level: "error", message: "fresh" }));
|
||||
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
|
||||
});
|
||||
|
||||
it("keeps its live region off while the inherited toasts go in, and arms it a task later", async () => {
|
||||
// The other half of "silently": a region that is already polite when the inherited nodes are
|
||||
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the
|
||||
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
const overlay = render(<Toaster overlay />);
|
||||
const region = overlay.container.firstElementChild!;
|
||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||
expect(region.getAttribute("aria-live")).toBe("off"); // …while still off
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
||||
expect(region.getAttribute("aria-live")).toBe("polite"); // and polite for what comes next
|
||||
});
|
||||
|
||||
it("announces a toast RAISED in the same commit as the takeover, and still not the inherited ones", () => {
|
||||
// A dialog that notifies and closes in ONE handler (UploadDialog: "added to the queue" + close)
|
||||
// puts its toast into the very batch that hands the app host the top spot. Classifying it from
|
||||
// the store live at the takeover render counted it among the inherited, so it went in with no
|
||||
// role into a region that was still `aria-live="off"` — announced by neither path (round 5).
|
||||
function Stage() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
{open && (
|
||||
<>
|
||||
<Toaster overlay />
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "upload queued" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
upload
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Stage />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
fireEvent.click(screen.getByText("upload"));
|
||||
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
expect(card("upload queued").getAttribute("role")).toBe("status");
|
||||
expect(card("old news").getAttribute("role")).toBeNull();
|
||||
});
|
||||
|
||||
it("ranks hosts by when their OVERLAY opened, not by when the host mounted", async () => {
|
||||
// PlayerModal's host moves into the fullscreen stage and back: remounted, it used to jump over a
|
||||
// dialog opened above the player, and the toasts went back under that dialog's scrim.
|
||||
const player = render(
|
||||
<div data-testid="player">
|
||||
<Toaster overlay order={1} />
|
||||
</div>,
|
||||
);
|
||||
render(
|
||||
<div data-testid="dialog">
|
||||
<Toaster overlay order={2} />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
||||
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
||||
player.rerender(
|
||||
<div data-testid="player">
|
||||
<span />
|
||||
<Toaster overlay order={1} />
|
||||
</div>,
|
||||
);
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
||||
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
||||
expect(screen.getByTestId("player").textContent).not.toContain("boom");
|
||||
});
|
||||
|
||||
it("keeps the countdown where it was, instead of starting it again from full", () => {
|
||||
// The bar is a CSS animation on the node; the dismiss timer was armed once, at notify time. A
|
||||
// new node restarted the bar, and the toast vanished with it still two-thirds full.
|
||||
const t0 = Date.now();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
now.mockReturnValue(t0 + 4000);
|
||||
render(<Toaster overlay />);
|
||||
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
|
||||
expect(bar.style.animation).toContain("-4000ms");
|
||||
});
|
||||
|
||||
it("keeps the players' control band free inside an overlay", () => {
|
||||
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
|
||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||
render(<Toaster overlay />);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
const host = hosts()[0]!;
|
||||
expect(host.className).toContain("bottom-24");
|
||||
expect(host.className).toContain("pointer-events-none");
|
||||
expect(screen.getByText("boom").closest(".toast-card")!.className).toContain(
|
||||
"pointer-events-auto",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// Every full-screen overlay hosts its own toasts (see ui/Toaster).
|
||||
//
|
||||
// The app's Toaster lives in the content column, a stacking context: anything that covers the
|
||||
// viewport at z-overlay paints over it, so a toast raised while that overlay is open is unreadable and
|
||||
// a click on it lands on the overlay's backdrop. The fix is a convention — the overlay mounts
|
||||
// `<Toaster overlay order={useOverlayOrder()} />` inside itself — and a convention stated in a comment
|
||||
// is one the next overlay forgets (deep-review round 3 found every ui/Modal and the onboarding wizard
|
||||
// without one). So it is checked here: a component that renders a viewport-covering layer at
|
||||
// z-overlay or above mounts an overlay host, ranked by when the overlay opened.
|
||||
//
|
||||
// The class TOKENS are matched in any order, and the layer need not be `inset-0`: the first version
|
||||
// matched the exact string "fixed inset-0 z-overlay", so PlexPlayer's own root (`fixed left-0 top-0
|
||||
// z-overlay`) was never checked at all, and a new viewer copying it would have slipped past
|
||||
// (deep-review round 4).
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
|
||||
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
|
||||
// (deep-review round 4).
|
||||
const sources = import.meta.glob(["../**/*.tsx", "./*.tsx"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
|
||||
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
|
||||
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
|
||||
// there — App renders Welcome instead of the shell).
|
||||
const EXEMPT = new Set(["../PlexBrowse.tsx", "../Welcome.tsx"]);
|
||||
|
||||
/** Every class string in the file (quoted or templated), including multi-line ones. */
|
||||
const classStrings = (src: string): string[] =>
|
||||
[...src.matchAll(/className=\{?[`"']([^`"']*)[`"']/g)].map((m) => m[1]!);
|
||||
|
||||
const coversTheViewport = (src: string) =>
|
||||
classStrings(src).some(
|
||||
(cls) =>
|
||||
/(^|\s)fixed(\s|$)/.test(cls) &&
|
||||
/(^|\s)z-(overlay|popover|tooltip)(\s|$)/.test(cls) &&
|
||||
(/(^|\s)inset-0(\s|$)/.test(cls) ||
|
||||
(/(^|\s)left-0(\s|$)/.test(cls) && /(^|\s)top-0(\s|$)/.test(cls))),
|
||||
);
|
||||
|
||||
const overlays = () =>
|
||||
Object.entries(sources)
|
||||
.filter(([path]) => !path.endsWith(".test.tsx") && !EXEMPT.has(path))
|
||||
.filter(([, src]) => coversTheViewport(src as string));
|
||||
|
||||
describe("full-screen overlays host their own toasts", () => {
|
||||
it("every one that covers the viewport mounts an overlay host", () => {
|
||||
const missing = overlays()
|
||||
.filter(([, src]) => !(src as string).includes("<Toaster overlay"))
|
||||
.map(([path]) => path);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("and ranks it by when the overlay opened, not by when the host mounted", () => {
|
||||
// A host that takes its own rank jumps to the top whenever it remounts — which PlayerModal's does
|
||||
// on every fullscreen change, putting it back over a dialog opened above the player.
|
||||
const unranked = overlays()
|
||||
.filter(([, src]) => !(src as string).includes("useOverlayOrder()"))
|
||||
.map(([path]) => path);
|
||||
expect(unranked).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds the overlays it is meant to check", () => {
|
||||
// Modal, PlayerModal, PlexPlayer and the wizard at least — PlexPlayer is the one the exact-string
|
||||
// check used to miss.
|
||||
const paths = overlays().map(([p]) => p);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
"./Modal.tsx",
|
||||
"../PlayerModal.tsx",
|
||||
"../PlexPlayer.tsx",
|
||||
"../OnboardingWizard.tsx",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The toast handover under React 18 StrictMode — its own file, because `lastTopIds` is module-level
|
||||
// state that leaks between tests.
|
||||
//
|
||||
// Round 5 made a taking-over host inherit "what the PREVIOUS top host last had on screen" by reading
|
||||
// a module global in a `useMemo` and overwriting it in the same render pass. React re-invokes a
|
||||
// component body — and every `useMemo` factory in it — on a StrictMode double render, and the LAST
|
||||
// invocation is what commits: so the second pass re-read what the first had just written, the host
|
||||
// classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5
|
||||
// replaced. Measured in Chrome and in jsdom: role "status" without StrictMode, null with it.
|
||||
//
|
||||
// The named case is UploadDialog's — notify and close the overlay in ONE handler, so the toast is
|
||||
// raised in the very commit that hands the app's host the top spot.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { StrictMode, useState } from "react";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import Toaster, { useOverlayOrder } from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearAll();
|
||||
});
|
||||
|
||||
/** An overlay with its own toast host, plus the app's — the shape every dialog produces. */
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "Uploaded" });
|
||||
setOpen(false); // …in the SAME handler: one batch, one commit
|
||||
}}
|
||||
>
|
||||
finish
|
||||
</button>
|
||||
{open && <Overlay />}
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
function Overlay() {
|
||||
const order = useOverlayOrder();
|
||||
return <Toaster overlay order={order} />;
|
||||
}
|
||||
|
||||
describe("the toast handover", () => {
|
||||
it("announces a toast raised in the takeover commit, even under StrictMode", () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<Harness />
|
||||
</StrictMode>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("finish"));
|
||||
// Not inherited → it carries its own role, because the live region is not armed yet.
|
||||
expect(screen.getByText("Uploaded").closest("[role]")?.getAttribute("role")).toBe("status");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
|
||||
import { dismiss, getActiveToasts, subscribe, type NotifLevel } from "../../lib/notifications";
|
||||
import { createStore } from "../../lib/store";
|
||||
|
||||
export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string; bar: string }> = {
|
||||
info: { icon: Info, color: "text-info", bar: "bg-info" },
|
||||
@@ -11,20 +12,125 @@ export const LEVEL_STYLE: Record<NotifLevel, { icon: typeof Info; color: string;
|
||||
fatal: { icon: XCircle, color: "text-danger", bar: "bg-danger" },
|
||||
};
|
||||
|
||||
export default function Toaster() {
|
||||
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
|
||||
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
|
||||
// same z-overlay painted over every toast: a failure raised while watching was drawn under an 80%
|
||||
// black, blurred scrim, and a click on it closed the player. So every full-screen overlay mounts its
|
||||
// OWN `<Toaster overlay order={…} />` inside itself — ui/Modal does it for every dialog, the players
|
||||
// for themselves — and exactly one host shows the toasts: the one of the overlay OPENED last, else
|
||||
// the app's. Same component, same look — and inside the overlay's aria-modal dialog, where a screen
|
||||
// reader can still reach it.
|
||||
//
|
||||
// "Opened last", not "host mounted last": a host can remount while its overlay stays open —
|
||||
// PlayerModal's moves into the fullscreen stage and back — and ranked by mount time it came back on
|
||||
// top of a dialog opened over the player (the download dialog after leaving true fullscreen), so the
|
||||
// toasts were drawn under that dialog's scrim again (deep-review round 4). So the OVERLAY takes its
|
||||
// rank once, when it opens (`useOverlayOrder`), and hands it to every host it renders.
|
||||
let overlaySeq = 0;
|
||||
/** This overlay's rank among open overlays — taken once, when it mounts. Call it in the overlay
|
||||
* itself, never in a component that can remount while the overlay stays open. */
|
||||
export function useOverlayOrder(): number {
|
||||
const [order] = useState(() => ++overlaySeq);
|
||||
return order;
|
||||
}
|
||||
type Host = { id: string; order: number };
|
||||
const overlayHosts = createStore<readonly Host[]>([]);
|
||||
const topHost = () => {
|
||||
let top: Host | null = null;
|
||||
for (const h of overlayHosts.get()) if (!top || h.order >= top.order) top = h;
|
||||
return top?.id ?? null;
|
||||
};
|
||||
|
||||
// What the host that is currently top last rendered, for the next host to inherit. See `inherited`.
|
||||
let lastTopIds: number[] = [];
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set<number>();
|
||||
|
||||
export default function Toaster({
|
||||
overlay = false,
|
||||
order,
|
||||
}: {
|
||||
overlay?: boolean;
|
||||
/** The owning overlay's `useOverlayOrder()`. Without one, the host ranks by its own mount. */
|
||||
order?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
const id = useId();
|
||||
const ownOrder = useOverlayOrder();
|
||||
const rank = order ?? ownOrder;
|
||||
useEffect(() => {
|
||||
if (!overlay) return;
|
||||
overlayHosts.set((hosts) => [...hosts, { id, order: rank }]);
|
||||
return () => overlayHosts.set((hosts) => hosts.filter((h) => h.id !== id));
|
||||
}, [overlay, id, rank]);
|
||||
const top = useSyncExternalStore(overlayHosts.subscribe, topHost, topHost);
|
||||
const active = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||
const isTop = top === (overlay ? id : null);
|
||||
const toasts = isTop ? active : [];
|
||||
// A host that takes over (an overlay opening, or the app's own once it closes) receives the toasts
|
||||
// that were already up as NEW nodes — and a screen reader announced every one of them again, an
|
||||
// error assertively. So the ones it inherits are shown silently: no `role="alert"`, and the region
|
||||
// stays `aria-live="off"` until they are in, turning polite (for the toasts that come after) in a
|
||||
// LATER task. Not in an effect: React flushes those in the same task as the render that inserted the
|
||||
// toasts, and a screen reader reads the region's state after the task — by which time it was polite
|
||||
// again, i.e. the arming did nothing.
|
||||
//
|
||||
// "Inherited" is what the PREVIOUS top host last had on screen — not whatever is in the store at
|
||||
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
|
||||
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
|
||||
// toast up in the very batch that handed the app host the top spot, so it was classified as
|
||||
// inherited and announced by neither path (deep-review round 5). Written during render on purpose
|
||||
// — the handover must be readable by the taking-over host in the SAME commit, and an effect
|
||||
// cleanup runs one phase too late; only the host that IS top writes, so the value is the losing
|
||||
// host's last screen either way.
|
||||
//
|
||||
// Snapshotted through a ref keyed on `isTop`, not a `useMemo`: React re-invokes a component body
|
||||
// — and every `useMemo` factory in it — on a StrictMode double render and on a restarted one, and
|
||||
// the LAST invocation is what commits. With a memo the second pass re-read `lastTopIds` after the
|
||||
// first pass had already overwritten it on the line below, so the takeover host classified its
|
||||
// OWN new toast as inherited and the dev build behaved exactly like the code this replaced
|
||||
// (measured in jsdom: role "status" without StrictMode, null with it — round 6). The ref only
|
||||
// recomputes when `isTop` actually flips, so a repeated invocation of one render is a no-op.
|
||||
const handover = useRef<{ top: boolean; ids: ReadonlySet<number> }>({
|
||||
top: false,
|
||||
ids: EMPTY_IDS,
|
||||
});
|
||||
if (handover.current.top !== isTop)
|
||||
handover.current = { top: isTop, ids: new Set(isTop ? lastTopIds : []) };
|
||||
const inherited = handover.current.ids;
|
||||
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
|
||||
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
|
||||
// same value twice is harmless; that is the point of the ref.
|
||||
if (isTop) lastTopIds = toasts.map((n) => n.id);
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isTop) {
|
||||
setArmed(false);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setArmed(true), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isTop]);
|
||||
|
||||
return (
|
||||
// Stable, always-mounted polite live region — a polite region must PRE-EXIST in the DOM for a
|
||||
// screen reader to announce content later inserted into it (a region created together with its
|
||||
// first message is unreliably announced). `aria-live` (not role="status") on purpose:
|
||||
// role="status" implies aria-atomic="true", which would re-read the WHOLE stack on every toast;
|
||||
// aria-live defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts
|
||||
// additionally carry role="alert", which announces assertively on insertion regardless.
|
||||
// Stable, always-mounted live region — a polite region must PRE-EXIST in the DOM for a screen
|
||||
// reader to announce content later inserted into it (a region created together with its first
|
||||
// message is unreliably announced). `aria-live` (not role="status") on purpose: role="status"
|
||||
// implies aria-atomic="true", which would re-read the WHOLE stack on every toast; aria-live
|
||||
// defaults atomic=false, so only the newly-added toast is spoken. Error/fatal toasts additionally
|
||||
// carry role="alert", which announces assertively on insertion regardless.
|
||||
//
|
||||
// Inside an overlay it sits ABOVE the bottom band: that is where the players keep their control
|
||||
// bars (the Plex transport row, PlayerModal's docked bar), and the toast stack — on top of them at
|
||||
// z-overlay, and catching the click — took play/pause, ±10, stop, prev/next and mute for as long
|
||||
// as a toast was up (the connection notice: until the server answered). The same rung UpNextCard
|
||||
// uses for the same reason. Clicks between the cards go through to what is underneath.
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]"
|
||||
aria-live={isTop && armed ? "polite" : "off"}
|
||||
className={`absolute left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)] ${
|
||||
overlay ? "bottom-24 pointer-events-none" : "bottom-4"
|
||||
}`}
|
||||
// Inside an overlay, a click on a toast is not a click on the overlay's backdrop.
|
||||
onClick={overlay ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||
@@ -32,8 +138,14 @@ export default function Toaster() {
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
role={urgent ? "alert" : undefined}
|
||||
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||
// A toast this host did not inherit must be announced even in the unarmed window, where
|
||||
// the region is still `off`: urgent ones assertively, the rest with their own polite
|
||||
// `role="status"` (single-element, so its implied atomic re-read is just this toast).
|
||||
// Once armed, the region itself does the announcing and the role comes back off.
|
||||
role={
|
||||
inherited.has(toast.id) ? undefined : urgent ? "alert" : armed ? undefined : "status"
|
||||
}
|
||||
className="toast-card glass pointer-events-auto relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -66,10 +178,8 @@ export default function Toaster() {
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{toast.duration && (
|
||||
<div
|
||||
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
|
||||
style={{ animation: `toastbar ${toast.duration}ms linear forwards` }}
|
||||
/>
|
||||
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
|
||||
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -77,3 +187,17 @@ export default function Toaster() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The auto-dismiss countdown, started as far in as the toast already is. A toast handed to another
|
||||
* host is a new node, and its bar used to start again from full while the dismiss timer — armed
|
||||
* once, at notify time — did not: the toast vanished with the bar still two-thirds full. The offset
|
||||
* is taken once, at mount; recomputed on a later render it would jump the running animation ahead. */
|
||||
function CountdownBar({ since, duration, bar }: { since: number; duration: number; bar: string }) {
|
||||
const [elapsed] = useState(() => Math.min(duration, Math.max(0, Date.now() - since)));
|
||||
return (
|
||||
<div
|
||||
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
|
||||
style={{ animation: `toastbar ${duration}ms linear -${elapsed}ms forwards` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
// The focus half of the dialog contract, in ONE place. `ui/Modal` owns it for every dialog that goes
|
||||
// through the shell; the onboarding wizard renders its own shell (a bare `ui/Overlay`, because it is
|
||||
// not a titled dialog with a header row) and used to have NONE of this — no trap, no initial focus,
|
||||
// no restore — so a keyboard user tabbed straight out of the first-login wizard into the app behind
|
||||
// the scrim, where the focus ring is invisible (deep-review round 5). A second hand-written copy is
|
||||
// how two shells drift apart; this is the shared one.
|
||||
|
||||
// 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
|
||||
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||
|
||||
/** Keep Tab / Shift+Tab cycling inside `scope` instead of reaching the inert page behind the
|
||||
* backdrop. Call from a keydown handler that has already decided this dialog is the topmost layer —
|
||||
* a nested dialog must defer to its child. `container` is the scope's own tabIndex=-1 panel, if it
|
||||
* has one: focus can land there by clicking dialog dead space, and `Node.contains(self)` is true, so
|
||||
* without naming it a Shift+Tab from the panel would leak to the background. */
|
||||
export function trapTab(
|
||||
e: KeyboardEvent,
|
||||
scope: HTMLElement,
|
||||
container?: HTMLElement | null,
|
||||
): void {
|
||||
const items = focusablesIn(scope);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
if (!scope.contains(active) || (container != null && active === container)) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
||||
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
||||
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
||||
* disagree about what counts as "inside" (see `heldRef`). */
|
||||
export function useDialogChrome(
|
||||
scopeRef: RefObject<HTMLElement | null>,
|
||||
ownerRef?: RefObject<HTMLElement | null>,
|
||||
): void {
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial focus) and moves activeElement into the dialog. Reading it
|
||||
// later (in the effect) would capture a now-inside element that unmounts on close, stranding focus
|
||||
// on <body>. useState initializer runs exactly once, on first render.
|
||||
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
||||
// What the dialog is focused ON, so a RE-mount can put the focus back where it was rather than on
|
||||
// the first control. React 18's StrictMode runs every effect mount→cleanup→mount in development:
|
||||
// the cleanup hands focus back to the trigger, and the second mount then found the focus outside
|
||||
// the dialog and moved it to the first focusable — taking it off the `autoFocus` field a beat
|
||||
// after the browser had put it there. Measured in Chrome: the command palette opened with the
|
||||
// focus on its ✕ in dev and on its search field in prod, from the same code (round 5).
|
||||
const focusedRef = useRef<HTMLElement | null>(null);
|
||||
// Whether the dialog still holds the focus. Read at cleanup rather than computed there: by the
|
||||
// time a passive cleanup runs on a real unmount the panel is already out of the document and the
|
||||
// focus has fallen to <body>, so "does the panel contain activeElement" answers false for both a
|
||||
// close and a user who clicked away — and those two want opposite things.
|
||||
//
|
||||
// Measured against the DIALOG, not the panel. The two scopes must be the same one the Tab trap
|
||||
// uses, and `ui/Modal`'s trap deliberately spans the dialog root so an in-dialog toast's Undo/✕
|
||||
// stays reachable — so tabbing to that ✕ read as "the user left the dialog", and since removing
|
||||
// the toast fires no `focusin` to re-arm, closing the dialog afterwards stranded the focus on
|
||||
// <body> instead of handing it back to the trigger. Measured in jsdom, and a regression: before
|
||||
// this hook existed the restore was unconditional (deep-review round 6).
|
||||
const heldRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||
// focus to where this dialog was focused before — or, the first time, to the first control, so
|
||||
// the dialog opens focused rather than stranded on the background.
|
||||
const scope = scopeRef.current;
|
||||
if (scope && !scope.contains(document.activeElement)) {
|
||||
const back = focusedRef.current;
|
||||
const target =
|
||||
(back?.isConnected && scope.contains(back) ? back : null) ??
|
||||
focusablesIn(scope)[0] ??
|
||||
scope;
|
||||
target.focus();
|
||||
}
|
||||
const onFocusIn = () => {
|
||||
const owner = ownerRef?.current ?? scopeRef.current;
|
||||
const active = document.activeElement;
|
||||
// "Still ours" also means a dialog stacked ABOVE us. A nested `ui/Modal` portals into its own
|
||||
// host on <body>, so it is outside our root BY CONSTRUCTION — widening the scope cannot see
|
||||
// it — and while it held the focus we read that as the user walking away. Then both closing
|
||||
// together left the focus on <body>: ours skipped the restore, and the child's own
|
||||
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
|
||||
// and it is the ordinary case — an error dialog raised over a dialog (round 6).
|
||||
heldRef.current =
|
||||
(owner?.contains(active) ?? false) ||
|
||||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
|
||||
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
|
||||
// gone by then, so restoring onto it would strand the focus exactly as before.
|
||||
if (scopeRef.current?.contains(document.activeElement))
|
||||
focusedRef.current = document.activeElement as HTMLElement;
|
||||
};
|
||||
onFocusIn();
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocusIn);
|
||||
document.body.style.overflow = prev;
|
||||
// Restore focus to the trigger, if it's still in the document and focusable — and only if the
|
||||
// dialog still held the focus. Once the user has moved it elsewhere, taking it back is the
|
||||
// rudest thing a closing dialog can do.
|
||||
//
|
||||
// Not, though, when a dialog stacked above us is STILL STANDING. `heldRef` counts focus in any
|
||||
// `aria-modal` subtree as ours (see above), which cannot tell a layer closing WITH us from one
|
||||
// that stays open — and the layer stack cannot tell them apart either: at this instant the
|
||||
// child's `removeLayer` has not run, because passive cleanups are parent-first and this hook
|
||||
// is declared before that effect, so BOTH cases read "a layer is above me" (measured). What
|
||||
// does separate them is the DOM: React detaches a deleted subtree before passive cleanups, so
|
||||
// a layer closing with us is already disconnected while one that stays open is not. Without
|
||||
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
|
||||
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
||||
// scrim — where Enter still activated it (deep-review round 7).
|
||||
const owner = ownerRef?.current ?? scopeRef.current;
|
||||
const active = document.activeElement;
|
||||
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
||||
const standingAbove =
|
||||
above !== null &&
|
||||
above.isConnected &&
|
||||
!above.contains(owner as Node | null) &&
|
||||
!(owner?.contains(above) ?? false);
|
||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
||||
restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo, scopeRef, ownerRef]);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A failed playlist delete is reported once (deep-review round 3 of bug hunt 2026-09). `req` already
|
||||
// opens the blocking error dialog for a 422 (a YouTube-side delete failure) and a 5xx; the delete
|
||||
// then toasted the same failure as well — in English, before the router's errors were structured.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn(), reportError: vi.fn() }));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("../lib/errorDialog", () => ({ reportError: (...a: unknown[]) => h.reportError(...a) }));
|
||||
vi.mock("./ConfirmProvider", () => ({ useChoice: () => async () => "both" }));
|
||||
vi.mock("./PlaylistsProvider", () => ({
|
||||
usePlaylists: () => ({ selectedId: null }),
|
||||
usePlaylistsActions: () => ({ setSelectedId: () => {} }),
|
||||
}));
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
|
||||
|
||||
import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { HttpError, api } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockReset();
|
||||
h.reportError.mockReset();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(() => usePlaylistActions(), { wrapper });
|
||||
}
|
||||
|
||||
const linked = { id: 4, name: "Mix", kind: "user", yt_playlist_id: "PL1" } as const;
|
||||
|
||||
describe("usePlaylistActions — deleting a list", () => {
|
||||
it("does not toast a failure the request layer already put on screen", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: { code: "playlist", reason: "youtube_failed" } }), {
|
||||
status: 422,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
expect(h.reportError).toHaveBeenCalledTimes(1);
|
||||
expect(h.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does say one it was left to handle — in the reader's language", async () => {
|
||||
// A plain-string `detail` is whatever English the route hand-wrote; the caller's own translated
|
||||
// fallback is the one a Hungarian reader can read (deep-review round 5).
|
||||
vi.spyOn(api, "deletePlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "playlists.deleteYtFailed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers a STRUCTURED reason, which the request layer has already localized", async () => {
|
||||
vi.spyOn(api, "deletePlaylist").mockRejectedValue(
|
||||
new HttpError(404, "Ez a lista már nem létezik.", { code: "not_found", reason: "playlist" }),
|
||||
);
|
||||
const { result } = mount();
|
||||
await act(() => result.current.remove(linked));
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Ez a lista már nem létezik." }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user