Compare commits

..
93 Commits
Author SHA1 Message Date
peter 891507dc75 release: 0.105.0 (feature/e38-ad-hoc-clips) 2026-10-10 23:38:13 +02:00
peter e6894ac0fc release: 0.105.0 — ad-hoc clips 2026-10-10 23:13:22 +02:00
peter 663a7e8012 fix(player): the Plex player's fills hold on its chrome: an amber head on a white played part
A light theme's accent as a fill on the always-dark chrome was ~2.8:1 (Slate), under the 3:1 a
control needs. A native checkbox and slider tint with `accent-chosen` (the accent elsewhere, the
media amber under `media-marks`), and so does the skip button's countdown. The progress bar plays
white with an amber head: amber there marks a place, as the intro / credits markers do, and an amber
played part made a marker ahead of the head read as already played (owner's choice of three).
2026-10-10 22:56:09 +02:00
peter 93192dadd8 fix(player): a chosen tab's underline and an open or pressed icon read the scope's chosen colour
The underline named `--color-chosen`, which is resolved once on :root, so it stayed the accent under
`media-marks` (seen in the browser: fuchsia on Slate light); it reads `--chosen` where it is used
now. An icon button pressed or with its menu open tinted its glyph with the accent; it takes the
chosen colour — the accent everywhere but the player chrome, where it is the media amber.
2026-10-10 22:26:22 +02:00
peter 5b48018e92 fix(downloads): a produced file is held to the space left by its real size
yt-dlp's `max_filesize` only stops a plain HTTP file that announces its size: YouTube's fragmented
formats never meet it — measured on localdev, a 718 MB download ran to the end with 1 MB left and
put its owner four times over the cap. The download now re-reads what is left once its file is made
(after the phone-safe pass) and refuses one larger than that as the owner's refusal; an editor clip,
which nothing checked, is held the same way, as a player clip already was.
2026-10-10 22:10:56 +02:00
peter 8312c6d592 fix(player): what is chosen on the player chrome is marked amber, not written in the accent
A light theme's accent as text on the always-dark chrome was 2.84:1 (Slate). `--chosen` is the
accent unless a scope names its own; `media-marks` (was media-focus) points it, with the focus
ring, at the media amber. A chosen track stays white with an amber tick (MenuCheck reads
`text-chosen`); a chosen settings tab or toggle is white with an amber underline (MEDIA_CHOSEN).
The theme contract test holds the token, the tick and the player components free of accent text.
2026-10-10 21:56:47 +02:00
peter e1d325b942 fix(downloads): a download larger than the space left says so, not "processing failed"
yt-dlp refuses a file announced over `max_filesize` by logging one line and returning with no file
(measured, and pinned in test_formats against a local server): the worker found no output and
failed the job as `postprocess`. `formats.watching_size_cap` now sees that line through our yt-dlp
logger, and the download (and, should it ever happen, a YouTube clip's range fetch) fails as the
owner's refusal with DOWNLOAD_TOO_BIG_MESSAGE, classified `quota_exceeded`. The download's quota
copy says it does not fit in what is left — true at a full cap too — instead of "Storage full".
A pre-existing type error the change touched is fixed (baseline down by one).
2026-10-10 21:56:47 +02:00
peter 977ba244fa fix(clip): a time's error line sits in the flow, the remove button off the cut, and every player surface rings amber
- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role
  alert) in its own flow under the fields' row: it no longer lies over the crop row taking its
  clicks, runs out of the editor's card, or joins the field's <label> name.
- The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the
  cut's grab area, where a press drags it; RangeTrack's adornment slot is gone.
- media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every
  bg-media-chrome surface; a contract test holds each such class string to media-focus or
  media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme.
- An upload's quota headline says the converted file does not fit, not "Storage full".
- DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all
  three colours on the chrome.
2026-10-10 21:29:55 +02:00
peter 89d18b46a6 fix(downloads): a converted upload too big says so, and a suspended owner's waiting rebuild is not "preparing"
- A converted upload over the space left (not zero) fails with CONVERTED_TOO_BIG_MESSAGE, not
  "storage is full"; it classifies as quota_exceeded by the phrase it shares with the clip's.
- The public watch page says "preparing" only for a job a worker will take: a suspended owner's
  queued job waits for as long as the suspension lasts, so the link says the video is gone, as
  for a paused rebuild.
- The worker's claim docstring names the restart case: a running job put back in the queue waits.
2026-10-10 21:29:55 +02:00
peter c27cfdcb9c fix(clip): typed times belong to their value, and every mark on the player chrome can be seen
TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so
leaving the field never commits it to another cut; it is invalid only once offered, with a line
under it saying how to type a time. On the always-dark chrome media-scope points every focus ring
at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under
3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of
locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut
button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
2026-10-10 20:28:52 +02:00
peter a867145bcb fix(downloads): a suspended user's queued jobs wait, and a clip too big for the space left says so
A suspension now blocks the user's downloads as quota.resolve already advised: the worker leaves
their queued jobs unclaimed until it is lifted (the confirm says so). A cut clip larger than the
remaining storage is refused with its own message (not "storage is full"), and a clip's quota
refusal reads as a clip's in both languages. The cancel poll compares to JobStatus.RUNNING.
2026-10-10 20:28:52 +02:00
peter 4d4eea2c54 fix(clip): marks on the player chrome keep their amber in a light theme, and a removed cut hands the focus on
- A media amber (`--media-mark`), the same in both themes, for what marks a place on the always-dark
  chrome or over a video: the clip strip's playhead and focused end, the crop frame's ring, Plex's
  intro / credits markers. A light theme's warn was a dark amber there, under 3:1 (contract-tested).
- The editor's remove-cut button rings inside itself (the track clipped its ring), and once it goes
  with its cut the focus moves to the cut beside it, or to Split here, not to <body>.
2026-10-10 15:57:02 +02:00
peter c489b60c8b fix(clip): clip mode keeps its range, its focus and its keys through the player's own moves
- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item
  neither closes the player nor steps the queue, and Plex's auto-skip waits.
- Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the
  clip; Back under a dialog is the dialog's; no range opens while the length is unknown.
- YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps
  its name, Loop and focus when the view is maximised or restored under it.
- A typed time stays in its field until taken, then shows where it landed; unreadable stays marked
  invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too).
- RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the
  slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's.
- The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the
  cut's hit area.
2026-10-10 15:35:21 +02:00
peter b4bf71ea64 fix(clip): a clip is held to its owner's storage cap, and its limits and failures say the server's truth
- The cut file is measured against what its owner has left, for a YouTube range (whose fetch never
  reads yt-dlp's size ceiling) and for a Plex clip (which fetches nothing), before the fetch and
  again before it is placed; a refusal leaves the asset pending for a Retry.
- A silent Plex file is cut silent; a file the probe cannot read says so.
- A second job on a failed or renamed clip gets the same YouTube diagnosis and the fetched title.
- The "too long" refusal names the server's cap; a contract test holds the client's copies of the
  clip limits to clip.py; the shipped player clients are pinned against the installed yt-dlp.
- One HDR transfer set and one rating_key lookup; job status words from JobStatus; the display-name
  guard sees subscript writes and the clip route; stale comments and types follow the clip job.
2026-10-10 15:35:12 +02:00
peter 72df11f2c0 fix(ui): a small crop frame takes smaller handles, so it can still be grabbed to move
The handles reach 10 px into the frame from every side; a frame at its minimum on a small picture
(the Downloads editor at 800 px: 41 x 23) was then nearly all handle - 74 of its 920 px still moved
it. Below 64 px on its shorter side the frame takes 12 px corners and 8 px edges: 262 px move it.
2026-10-10 13:54:04 +02:00
peter 0a16bc40ae fix(ui): a crop frame grabbed with the pointer takes the focus, so the arrows go on from there
The press is held back from the page (no text selection, no play / pause underneath), which also
kept it from focusing: after a drag the arrows still went to whatever had the focus before, and
Escape did not reach the frame. The press now focuses the frame or the corner it grabbed (an edge
focuses its frame).
2026-10-10 13:38:49 +02:00
peter 520b0afb90 feat(clip): crop a clip to a frame drawn on the picture, and give the clip panel the room it needs
An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1;
the whole picture stays the default). The frame is sent in fractions of the box it was drawn in,
with that box's shape; the worker fits the real picture into the box by its display aspect (sample
aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A
frame covering the whole box is no crop at all, and one off the picture is refused by name.

The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable.
YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel
docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
2026-10-10 12:52:59 +02:00
peter e301632765 fix(clip): a clip refusal speaks the user's language, and a choice in the clip form no longer sends it
The backend refuses a clip with a structured reason, but the client showed every one as the generic
"couldn't make the clip": each reason now has its own message in both languages, held to the
backend's reasons in both directions by the contract test. A segmented toggle's and the priority
stepper's buttons had no type, so inside a form one of them submitted it.
2026-10-10 12:52:12 +02:00
peter 50157cc2fd feat(player): clip mode - mark a range of what is playing and cut it into a download
Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
2026-10-10 03:58:27 +02:00
peter f221c1962a fix(downloads): a YouTube clip goes with the user's own cookie and is diagnosed like a download
A clip fetched its range signed out, so a video behind YouTube's per-video "not a bot" wall could
never be clipped while its download went through with the user's cookie - and the failure copy told
the user to add a cookie the clip would not use. The clip asset is private by construction
(`clip.asset_ref` names its user), so `usable_clip_cookie` attaches the user's YouTube cookie on that
identity, and a failed range fetch now carries the download catalog's diagnosis
(`youtube.bot_wall` / `youtube.cookie_rejected`) instead of a bare category.
2026-10-10 03:58:27 +02:00
peter d466ae915b fix(downloads): a YouTube clip outside the catalog takes its title from the fetch
It was queued as "Clip (clip)" and stayed so: nothing renamed it once yt-dlp had answered.
2026-10-10 02:43:13 +02:00
peter 14598ec320 feat(downloads): ad-hoc clips cut straight from a YouTube video or a Plex item
POST /api/downloads/clip takes a source (a YouTube id or a Plex rating key), a range of at most
ten minutes and, for Plex, the audio track being heard. The clip becomes a per-user asset in the
library like an editor clip, shareable as soon as it is ready. A YouTube clip fetches only its
range (yt-dlp download_ranges: 3-7 s whatever the video's length); a Plex clip reads the library
file in place. Both then go through one re-encode: frame-exact at the marks, H.264 at most 1080p
with HDR tonemapped to SDR, stereo AAC, no chapter track, .mp4.

Job kinds are a JobKind StrEnum now, and Re-download refuses every kind but a download.
2026-10-10 02:31:38 +02:00
peter b16cf7668c fix(downloads): YouTube downloads reach their profile's height again
The primary player client, android_vr, was still offered 1080p and 2160p, but every fetch of
those formats died with HTTP 403: it now needs a stream PO token the bgutil sidecar cannot mint.
The fallback set then served 360p only, so every download finished at 360p without failing.
tv_simply is offered the full ladder and its fetch completes (measured: 1080p, and a localdev
download through the pipeline reached the default profile's 720p). yt-dlp 2026.8.19 marks
the clients that need such a token, so it stops fetching into the 403.
2026-10-10 02:08:43 +02:00
peter 3438aeb695 fix(player): the player's own focus grabs leave a tabbed-to control alone
Turning ready, the pointer leaving the video, the window regaining focus, a wheel over it and
leaving fullscreen each put the focus back on the card unconditionally, so a viewer who had
tabbed to a control right after opening started over from the first button on the next Tab.
They now take it back only when it sits in the iframe or off the card.
2026-10-10 01:14:31 +02:00
peter c003a48244 release: 0.104.0 (improvement/adhoc-batch-oct) 2026-10-10 00:24:27 +02:00
peter 8a2334e198 release: 0.104.0 notes and version 2026-10-10 00:15:23 +02:00
peter 1f010328df fix(plex): plain text survives a decimal reference too long to parse
int() refuses a decimal run past 4300 digits, so one such reference in a summary raised out of the sync and the text migration. Any number past seven significant digits is beyond U+10FFFF, so it becomes U+FFFD like any out-of-range reference; leading zeros are dropped first. The rails test's comment now says what it guards: both rails agree with the rollup.
2026-10-10 00:03:08 +02:00
peter 77638f7a0b fix(player): a link that opens the cut text is scrolled back into view
Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes.
2026-10-09 23:46:40 +02:00
peter 2cff4ad954 fix(feed): an override yields only to a refetch that set out after its write was answered
Send order let a refetch read before an unanswered mark committed and drop it. A reset's position 0 is written again after a refetch that set out before the server took the reset. The player-session refetch also waits for the player's closing checkpoint, which the player now tracks (progressFlushes); refetched first, the list put the older position back.
2026-10-09 23:46:39 +02:00
peter 8776586ca3 fix(plex): a matched episode and a collection member offer Details in their menu
Their titles open the item's page, a pointer-only door; the card menu is the keyboard's.
2026-10-09 23:46:39 +02:00
peter 92a17aba83 fix(plex): a reshuffle survives a refresh landing mid-deal; the ↻ spins for a deal only
The ↻ intent was spent as its fetch set out, so a watched toggle's resync cancelling that fetch re-ran it as a re-read of the old draw. It now lasts until the latest deal answers. The button spun on every re-read of the same titles; it now follows the deal. Comments that said the Surprise draw is never refetched are brought in line.
2026-10-09 23:46:39 +02:00
peter fc53bc03bc fix(plex): the rails' Restart comes from the one rollup; plain text decodes whole entity names only
Both Continue-watching rails built a show's card state inline; they now take it from rollup.show_state, so a show part watched with nothing to resume offers Play alone there too. A named reference counts only when its whole name is an HTML5 entity: html.unescape still decoded the legacy "&not" inside "&notice;". Tests cover the grid's in-progress-but-not-resumable case through its SQL and both rails.
2026-10-09 23:46:39 +02:00
peter 6df257d7df fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn
Show cards carry resumable (ShowState, from the same rollup) so a part-watched
show whose next episode is untouched offers Play, not a Continue and a Restart
that do the same thing; plexStarted is the one rule for cards and menus. The
Surprise rail's key sits under the discover root: whatever refreshes the rails
re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc,
and only the reshuffle deals. Closing the player refreshes the Discover rails.
A show card's play acts on the last click only, and only where it was made.
2026-10-09 22:01:28 +02:00
peter 5572499b56 fix(player): the shut panel keeps the dialog's name; Tab onto a cut link opens the text
With the side panel shut the title heading was gone, and the dialog it named
went unnamed: the strip carries an sr-only copy. A link below a clamp's cut
stayed tabbable out of sight; reaching it now opens the text, measured from the
text's top since focus scrolls the clamped box. DESIGN describes the modal as
built, with log rows for its chrome and the wheel rule.
2026-10-09 22:01:27 +02:00
peter 76738f8ea9 fix(plex): plain text decodes only well-formed character references
html.unescape also decodes the legacy forms without a semicolon, so prose like
"AT&notice" came out as "AT¬ice". The show and collection sync paths are
now tested too.
2026-10-09 22:01:27 +02:00
peter 568bf93510 fix(feed): an optimistic write outlives only the server answers older than it
A reset wrote a position mask over the cached list, so a replay's saved
position stayed hidden: a reopen resumed at 0 and its first checkpoint wrote
over the progress. The reset now writes 0 into the cache, where the player's
closing patch writes the next one. Overrides carry their write order, and a
landing refetch drops only those written before it set out. The player
session ends after the modal unmounts, so a clip auto-marked watched as it
closes still refetches the list.
2026-10-09 22:01:27 +02:00
peter 942154cf49 fix(plex): a Surprise draw stays put until its reshuffle
The rail's query was always stale, so Back from a card's player or page remounted it into a new
draw, as did a return to the window: the card just looked at was gone. It now stands until the
reshuffle (or another scope, language or filter) asks for a new one.
2026-10-09 01:29:12 +02:00
peter 3eb87e7108 feat(plex): every show card plays the episode it would put on
A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.

The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
2026-10-09 01:22:00 +02:00
peter 851b97b09d fix(ui): a card's hover chrome answers keyboard focus, not any focus
Back from a channel page hands the card its focus again after a mouse trip;
revealed on focus-within, its hover row stayed up under no pointer until a
reload (owner). The row, the Play layer, the Continue + Restart pair and the
global .reveal rule now show on hover or on :focus-visible on the card or
inside it, so Tab and the arrow keys still reveal it.
2026-10-09 00:44:46 +02:00
peter ee94d1949a fix(feed): a video marked watched in the player stays out of an Unwatched view
Marked watched in the player (by hand, or by the player at its end), a card
left an Unwatched list at once, then came back as "new" when the player
closed, until a reload. The player's closing position patch writes into the
cached feed; the feed took the bumped dataUpdatedAt for fresh server data,
dropped its optimistic overrides, and nothing refetched. The overrides now
clear only when a refetch lands, and a player session that changed a state
refetches the list and its count by the view's filters on close, once its
writes have landed.
2026-10-09 00:44:46 +02:00
peter 4f076b5d45 feat(feed): a card's menu filters the feed by its channel, and back
Under "Open the channel" a video card's menu (the right-click on its
channel name included) adds the channel to the feed's channel filter, or,
when it is already in, takes it out. Channels OR together, so it widens a
filter that names others, as "Show in feed" does. Not on a channel page,
whose view is that channel already, nor in a live YouTube search or on Home.
2026-10-09 00:44:46 +02:00
peter ece74a42ab feat(player): a roomier YouTube player, its panel shut on demand
- The full-height previous/next strips beside the card are gone: the panel's
  transport row and Shift+arrows step on their own, and the video gains the
  7rem the strips' gutters took.
- Close is one icon in the card's top-right corner (the wide panel's head,
  the stacked card's title row).
- The panel's description is cut to three lines with More / Less
  (ClampedText now takes the linked text as children).
- Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for
  a bigger video; it stays shut while the queue steps on, and a video opened
  from a card starts it open. Focus moves to the counterpart toggle.
- The wheel has one meaning per place: over a panel (or the stacked card)
  with something to scroll it only scrolls, never falling through to the
  volume at the end; over the video, or a panel that fits, it is the volume.
- A long channel name is cut on the panel's line instead of running the
  20rem panel into a sideways scroll.
2026-10-09 00:27:29 +02:00
peter 600d5c39da feat(ui): a button over a picture answers the pointer with an accent ring
The media variant's hover darkened its body from 60 to 75% black, which no
one could see on a poster; a rail's scroll arrow over a card could not be told
from the card's own Play under it. Its hover is now a 2px accent ring over an
80% body (owner pick B), on every card control, Plex and YouTube, and the rail
arrows. DESIGN.md B3 names it beside the edged pills, with its log row.
2026-10-09 00:27:28 +02:00
peter 4c7f73af9d feat(plex): a title opens its page, the picture plays; an episode links up
A card title lit like a link but played the item. Now the picture plays and
the title opens the page of what it names: a movie's or an episode's info
page, a show's page, and for an episode standing as its show (Recently
watched) the show. Every poster and episode card carries it (grid, rails,
person, collection, season, related, Home), a card with no page keeps a plain
title, and the season page gains the menu's Details row too.

The episode page had no way up: the detail now serves the show's and the
season's rating keys, and on the page the show title opens the show and
"S1·E25" the season (Back returns to where the page was).
2026-10-09 00:06:18 +02:00
peter 5f42ff61c8 feat(plex): a Surprise reshuffle deals the previous draw last
The rail draws three unseen titles per kind under the sidebar filters; on a
small filtered pool every reshuffle dealt the same few again. The redraw now
sends the draw on screen (exclude=), the server deals the rest of the pool
first and tops up from the excluded titles only when the pool is short, so a
small pool still fills the rail. (The two discover.py baseline entries went
with the rewritten draw.)
2026-10-09 00:06:11 +02:00
peter 6dd82ef5e2 fix(plex): summaries and taglines are stored as plain text
A Plex agent can hand a summary over as HTML (<p> paragraphs, &aacute;
entities); the info page and the hover card showed the tags. One cleaner,
app/plex/plaintext.py, now runs on every summary and tagline the sync, the
Hungarian overlay and the rich-detail fetch store, and migration 0101 cleans
the rows written before through the same function. Paragraph breaks survive
as newlines, which the info page and the hover card now keep.
2026-10-09 00:06:02 +02:00
peter 75f4efde98 release: 0.103.0 (bug/yt-fullscreen-button) 2026-10-08 22:50:52 +02:00
peter c0059a0c4b release: 0.103.0 notes within the item length cap 2026-10-08 22:36:15 +02:00
peter a29cadf03e release: 0.103.0 notes and version 2026-10-08 22:30:56 +02:00
peter 83886494ce fix(ui): a membership menu re-homes focus a frame later, once its new rows are on the list
Rows that just mounted join the menu's navigation list a render after the status effect ran: a row
focused that early was unknown to it, and the arrows did nothing from it (seen live after a keyboard
Retry). The retry test now arrows on from the row it lands on.
2026-10-08 22:16:47 +02:00
peter 4eaa55385f fix(plex): a collection kept after a failed refresh holds its seed title
When Plex made the collection and only the read after it failed, the kept row had no members: the
title showed unticked in the collection it had just been added to, the collection empty, and the
name stayed in the field for a retry that could only be refused as taken. The seed's row now names
the collection (child_count 1), and the panel clears the field on that refusal as on success. Also
held: the panel's lines sit beside its list, and a keyboard Retry lands on the first row.
2026-10-08 22:12:43 +02:00
peter 7541b1b016 fix(ui): membership menus keep their lines beside the list and focus in the panel
The loading / failed-load lines and their Retry sat inside role=menu, which may own its items only:
the three surfaces now open with `listInside` and the rows sit in a PopoverList. A keyboard Retry
unmounted the focused button and left focus on <body> with the menu open, the arrows dead: focus is
put back on the first row, else the field, else the new Retry. The channel tag editor said a failed
tag load as "no tags yet"; it reads the catalog query's state now. The playlist panel's name and the
tag editor's error state are held by tests.
2026-10-08 22:12:43 +02:00
peter 24ad17fc7d test: hold the show collection's type, an unknown title's 404 and Brave's fullscreen stand-in 2026-10-08 21:18:48 +02:00
peter 48bd038c5f fix(plex): a collection created on Plex keeps its editable row when the refresh read fails
The rollback dropped the new row: the next sync recreated it read-only, out of every 'add to' list, and the duplicate-name check let a retry make a second same-named collection on Plex.
2026-10-08 21:18:48 +02:00
peter 0fce732497 fix(plex): a playlist row's menu adds its title to a collection too
The row menu called the title's action list without the anchor the panel opens beside, and the optional parameter dropped the row silently. The anchor is now required, so every host menu decides.
2026-10-08 21:18:48 +02:00
peter 7e94da72b9 fix(ui): one membership menu for playlists, collections and tags
Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page.
2026-10-08 21:18:48 +02:00
peter fadb4f17a9 fix(plex): subtitles draw only the player's own track, lifted in the player's own pixels
A direct-played file's own subtitle stream (WebKit lists an mp4's mov_text) was switched to hidden and drawn under any choice, Off included: only the player's <track> is kept hidden and read, other subtitle tracks are switched off. The lift was measured in screen pixels and applied inside the x1.25 windowed player, and re-measured off a box carrying its own (or a mid-slide) transform: an untransformed outer box is measured, the distance divided by the scale, and a cue that just appeared is placed, not slid from where the last one was left.
2026-10-08 21:18:48 +02:00
peter 57a81c5643 fix(plex): subtitles survive hls.js and a remounted track
hls.js emptied the cues of every text track on the element when a manifest
loaded or the media attached, our own <track> included, so a VTT that loaded
first showed nothing for the session; its subtitle and caption controllers are
no longer built. A remounted default <track> was switched to showing by the
browser after the hide had run, drawing the cues natively under ours; the hide
now follows every added track and mode change.
2026-10-08 19:35:47 +02:00
peter dae0b80f6a feat(plex): add a movie or show to a collection from its card menu
"Add to collection…" opens a panel beside the card: the title's library's
manual collections tick it in or out, and a name + Enter creates a new one with
the title in it. It used to take the Collections page, a new collection, its
name and a title search, then another search per title. A lean endpoint lists
the collections with whether each holds the title. Admin only, as on the
title's page.

Also: a collection seeded with a show is made as a show collection (Plex type
2). Measured: as a movie one it came out empty and refused the show after.
2026-10-08 03:09:55 +02:00
peter 75093458c9 feat(plex): subtitles rise above the control bar while it shows
On an iPad the bar covered the text whenever it was up: the browser places a
native cue by its line, which nothing animates. The track now stays loaded as
hidden and PlexSubtitles draws the active cues, measured against the bar and
lifted above it in 200 ms, back down when it hides. The viewer's size, colour,
background and outline settings style the text directly. Also fixes a test
typing slip from the search pill change.
2026-10-08 02:59:13 +02:00
peter 5db6dc8ee6 docs(design): log the touch header search 2026-10-08 02:52:39 +02:00
peter 45a9ceae25 fix(ui): the header search has room on a tablet
On touch the 44 px key cap and Go filled a 44 px pill edge to edge and the
placeholder was cut. The key cap is left out on touch (⌘K still works), the
pill grows to 52 px so a ring shows round Go, the box's floor counts the
controls beside the text, and Go drops to its icon by the column's width,
not the screen's.
2026-10-08 02:52:32 +02:00
peter 5249c41edc fix(player): a fullscreen button the browser cannot honour is not shown
Brave on iPadOS switches element fullscreen off, patches requestFullscreen with
a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button
and the YouTube docked bar's did nothing. Support is now read off
Element.prototype and the buttons are left out without it (owner). A stand-in
that returns a boolean no longer throws. A test's bare matchMedia stub no
longer outlives its describe.
2026-10-08 02:50:51 +02:00
peter c3664826fe fix(ui): a caption never opens under a finger
On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the
tap as a hover and swallowed its click, so every rail button needed two taps,
and with no mouseleave from a finger the caption stayed up. Tooltip now opens
for a mouse and a key's focus only (owner: no captions on touch).
2026-10-08 02:45:48 +02:00
peter c9b8fe15a3 fix(ui): a scroller with no edge to fade carries no mask
iPad WebKit kept a scrolled container's mask stuck to its content: at the
bottom of a scrolled page the last strip, a table's pager, sat under a dark
band. With nothing to fade there is no mask; isolation keeps its layer.
2026-10-08 02:43:58 +02:00
peter 1044eaf8a9 fix(plex): the player holds the page still and covers it whole; the row drops the countdown
On an iPad a drag on the video scrolled the page behind the player, and in Brave
dvh did not follow the toolbar, so a strip of the page showed under it. The
page is now locked without overscroll on a black root while the player is open,
and the player is sized from its fixed box. The time row keeps the position,
the length and the end clock; the countdown is gone (owner).
2026-10-08 02:42:31 +02:00
peter ca949d22de fix(player): the channel line wraps instead of squeezing the meta into a column
On a narrow card (a tablet, a zoomed window) the unwrapped row of name, meta and
actions left the meta a one-word column. The row now wraps: the meta keeps at
least 12rem, and the actions move down together.
2026-10-08 02:25:58 +02:00
peter 20178f0ec4 release: 0.102.0 (improvement/e37-player) 2026-10-08 01:58:01 +02:00
peter 49a9319f84 fix(player): the click layer clears only the fullscreen button's column, not a full-width band
Measured: across the full width YouTube needs only its seek bar (up to 96 px
from the bottom); the 140 px is the fullscreen button's, at the right end. A
full-width 140 px band handed clicks on the picture to the iframe, which then
takes the keyboard focus and our shortcuts with it. The layer is now two boxes,
hidden from assistive tech (a mouse surface; the keys are its shortcuts), and
the tests render the big view too, where the docked bar sits under the iframe.
2026-10-08 01:42:39 +02:00
peter 70d56ab939 fix(player): YouTube's fullscreen button stays clickable on a short player
The click layer over the embed left the bottom 22 % of the stage free. YouTube's
new embed keeps its fullscreen button 80-128 px above the iframe's bottom, so on
a stage under ~580 px (a small window, a browser zoomed to 150 %) the layer lay
over it and a click paused instead. The cutouts now have px floors (56 top, 140
bottom) and are measured against the iframe's own box.
2026-10-08 01:36:36 +02:00
peter 3865689c26 chore(release): 0.102.0 2026-10-08 01:33:39 +02:00
peter c32429ba69 fix(plex): an episode with no number is named by its title on every card
The cards, the up-next card and the mark-next label printed a question
mark where Plex has no number; the player's header already showed the
title alone. One helper (episodeNumbers/episodeName) for every place.
2026-10-08 01:16:30 +02:00
peter a504550196 docs: the superseded fallback answers its own session; who uses beacon() 2026-10-08 01:16:30 +02:00
peter 5095047c81 test(player): pin the stop URL's ids, the beacon's settle, the kept point's edges
The stop-URL contract passed with the two ids swapped and read the backend
from the working directory; nothing called a real beacon's onSettled or its
library pass-through; the kept-point rule was held by its constants only.
2026-10-08 01:16:30 +02:00
peter 4e70f168b1 fix(player): a layer that opens by itself takes no history entry
The auto-skip countdown and the up-next card pushed an entry without a
user gesture (Chrome's Back button skips those), and one pushed under the
open shortcuts sheet sat above it, so Back closed the hidden layer.
2026-10-08 01:16:30 +02:00
peter 7f17f488ab fix(nav): a sub-view step closes the overlays standing over it first
Leaving the Plex player while one of its layers was up (the info panel when
the film ended, the countdown under the back arrow) landed as a user Back:
the overlay listener spent it closing the top layer and the player stayed.
useHistorySubview's open/back now go through closeOverlaysThenNavigate,
like setPage. The RECLOSE comment no longer cites the shared Plex entry.
2026-10-08 01:16:30 +02:00
peter d1704a5519 revert: the Modal Escape guard for a fullscreen own-content video
Chrome does not hand the page the Escape it spends on leaving fullscreen (checked with a real
keypress: the first Esc leaves fullscreen, the second closes the player). The guard defended a case
that does not happen, under a comment saying it does. This reverts 121ebeeb.
2026-10-07 23:59:32 +02:00
peter 121ebeebc1 fix(ui): an Escape while a dialog's own video is fullscreen does not close the dialog
The page gets the Escape keydown with the video still the fullscreen element. When that element is
the dialog's own content (the library player), closing the dialog took the player away with the
fullscreen. A dialog opened over something else that is fullscreen still closes.
2026-10-07 23:57:04 +02:00
peter ee5290ba01 refactor(plex): the item progress body extends the shared checkpoint
ItemProgressIn repeated ProgressIn's config, fields and bounds; it now adds only `final`. The
threshold comment points where the constants live now, and plexProgressBeacon's comment is back
on it.
2026-10-07 23:16:38 +02:00
peter dd6a666ad1 test(player): hold the session stop and the kept-point rule to the server
- The client's resume thresholds equal the server's (read from both sources).
- The session stop's URL matches the route the server serves it on.
- The player stops its server session by its item and its own id on an unload and on close,
  and keeps it when the page only goes into the back/forward cache.
2026-10-07 23:16:38 +02:00
peter 392099770a fix(plex): an episode Plex has no number for is named by its title alone
The OS media title printed the missing number as "Enull", the player and info headers as a bare
"E". One helper for the three lines.
2026-10-07 23:16:38 +02:00
peter 2aa3ac6e85 fix(player): the browser Back peels each layer over the Plex video, one press at a time
All the layers shared one history entry, so the first Back spent it on one layer and the second
left the player with the stats readout (or the countdown) still up. Each layer now holds its own
entry; Back closes the one opened last.
2026-10-07 23:16:38 +02:00
peter 891dc628f3 fix(library): a listing poll can no longer put back an older resume point
A poll already out when the player closed answered with the point the server held before the
closing beacon, over the row's patch; Continue then opened there and wrote it back. The close now
cancels the listings' fetches before patching and reads them again once the beacon has landed.
2026-10-07 23:16:25 +02:00
peter 0a35f2a3b1 fix(player): a stale session answer can no longer stop the playback that won
- The engine tracks the request out per item: A, then B, then back to A sent a second request
  for A beside the one still out.
- A hardware transcode superseded during its fallback answered with the NEWER session; the losing
  request's player then stopped it as stale. It now answers with its own (dead) session.
- A replaced hls.js instance's late manifest no longer marks the player ready or plays the
  outgoing media.
- A pause given after the answer stands: the manifest and the first canplay read the live intent.
- The debounced A/V-offset restart is dropped on an item change instead of restarting the
  previous item under the next one's title.
2026-10-07 23:16:25 +02:00
peter d25a132495 fix(ui): a dialog whose own video goes fullscreen no longer crashes the app
The portal host followed the fullscreen element into it. When that element is the dialog's own
content (the library player's <video>, by its f key or its own button), the move is into the host's
own descendant, which the DOM refuses: HierarchyRequestError in a layout effect, and the crash
screen replaced the app. The host now stays where it is then — the content already shows.
2026-10-07 23:16:25 +02:00
peter 61d3a1a189 chore(db): drop plex_items.markers, a column nothing writes or reads
Markers are read live from Plex for the item the player opens; the cached copy was never filled.
2026-10-07 02:23:37 +02:00
peter d482a5e8e2 chore(dev): the dev proxy answers a failed connect with 502, as the prod proxy does
Vite's own answer was a bodiless 500, which the app reads as a real server fault and shows the blocking error dialog; a 502 is the passing connection loss it is.
2026-10-07 02:23:37 +02:00
peter 2064373ac2 fix(history): a step back never leaves the app for an older page
Chrome keeps 50 entries a tab, and when the list is full a new entry drops the
oldest one the page left without a user gesture, which can be the page's own
entry under a sub-view or an overlay. A programmatic back() then left the
document; once it landed on an older entry whose restored Plex player started
playing on its own. Every programmatic back() now asks the Navigation API first
and, with no entry of this document below, settles in place: the sub-view turns
to its root, an overlay's entry drops its mark, the channel page and the YouTube
search clear.
2026-10-07 02:23:37 +02:00
peter 0cda5b6dd4 fix(player): only the credits that end an item move on to the next one
An item can carry several credits blocks with mid- and post-credit scenes between
them, and Plex flags the one that really ends it. The player left at the first
block. Now the Plex-final block moves on (by the auto-skip or the Skip button), an
earlier block is only skipped to its end by the button, and with no final flag the
last block ends the item by the button alone.
2026-10-07 01:48:10 +02:00
peter f3c94641a7 fix(player): a library item played from the start keeps its point
A cached file that autoplays from 0:00 fires its one playing event before the resume hook
subscribes, so the hook never counted it as played and reported nothing - not on pause, not on
close. An element already playing when the hook subscribes now counts as played.
2026-10-07 00:36:28 +02:00
peter 59b27467ab feat(player): the OS media controls, the basic keys and resume for the library and shared-link players
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
2026-10-06 23:55:41 +02:00
peter 02feaa9a1c refactor(player): one panel at a time over the Plex video, and one Back cascade
The ⋯ menu, the tracks menu, the tuning panel, the info overlay and the up-next card were a flag
each, so two could stand on top of each other, and Back/Escape were written out three times. They
are one `panel` value now; the stats readout stays a separate toggle beside it. One `back()` peels
the countdown, the panel, then the stats, then leaves — for the arrow, ⌫, Escape and the mouse
Back alike. The mouse Back now also closes the ⋯ menu instead of leaving the player.
2026-10-06 23:04:35 +02:00
peter c1058f1d5a refactor(player): the Plex session machine is a tested engine, one request per item in flight
PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.

- A seek, track change or reclaim asked for while a session request is out replaces the queued
  target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
  requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
  (POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
  the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
  media as the viewer's pause.
2026-10-06 22:31:48 +02:00
249 changed files with 15882 additions and 2665 deletions
+44
View File
@@ -7,6 +7,50 @@ 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.105.0 — 2026-10-10
### New
- Clips: mark a range of what you're watching — a YouTube video or a Plex item — set its ends to the tenth, crop it to a frame drawn on the picture, and cut it straight into your downloads.
### Fixed
- Downloads: a download, a clip or an editor clip that doesn't fit in the storage you have left says so, and is never stored over your cap; YouTube downloads reach their format's height again.
- Player: focus rings, the chosen track or tab, sliders and the progress bar's head are amber on the dark player in every theme, so they show in a light theme too.
## 0.104.0 — 2026-10-10
### New
- YouTube: a card's menu filters the feed to its channel and back, and the player gives the video more room — its side panel shuts on demand.
- Plex: a card's picture plays it and its title opens its page; a show card puts on the episode it would continue or start, and an episode links up to its show.
### Fixed
- Plex: summaries written as HTML read as clean text, and a Surprise draw stays put until you reshuffle. A video marked watched in the player stays out of an Unwatched feed. Plus smaller keyboard and focus fixes.
## 0.103.0 — 2026-10-08
### New
- Plex: “Add to collection…” in a movie's or show's menu ticks the title into or out of your collections, or names a new one with it inside — one step instead of a trip to the Collections page.
### Fixed
- Plex player: subtitles rise above the control bar while it shows and slide back when it hides, so the bar never covers them — on an iPad too.
- iPad: the header search fits the touch screen, tooltips no longer stick, the Plex player page no longer scrolls or shows a blue band, and the fullscreen button stays hidden where the browser cannot really go fullscreen.
- The playlist, collection and tag menus say when their list is loading or failed to load (with a retry) instead of showing it as empty, and work from the keyboard. Plus smaller fixes in the YouTube player's title line.
## 0.102.0 — 2026-10-08
### New
- The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped.
### Fixed
- Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream.
## 0.101.0 — 2026-10-06
### New
+1
View File
@@ -102,6 +102,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
+3 -2
View File
@@ -23,8 +23,9 @@ blocker and SponsorBlock keep working.
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
- **In-app player** with resume, plus keyboard/scroll controls.
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
to your device, share with another user, or hand out a public watch link.
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them
where you left off, then save to your device, share with another user, or hand out a public watch
link.
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
movies and shows alongside your feed, with watch state kept in sync.
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
+1 -1
View File
@@ -1 +1 @@
0.101.0
0.105.0
-40
View File
@@ -437,14 +437,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 12,
"endColumn": 13,
"lineCount": 10
}
},
{
"code": "reportAttributeAccessIssue",
"range": {
@@ -743,14 +735,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 37,
"endColumn": 47,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
@@ -1142,22 +1126,6 @@
"endColumn": 48,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 39,
"endColumn": 45,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 38,
"endColumn": 43,
"lineCount": 1
}
}
],
"./app/routes/plex/playback.py": [
@@ -1463,14 +1431,6 @@
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 38,
"endColumn": 43,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
@@ -0,0 +1,42 @@
"""library: a resume point per user and library item
The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job
(their own download, or one shared with them) — the same shape as `video_states`' position, without a
status: the library has no "watched". It starts empty; the downgrade drops it.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0099_library_states"
down_revision: str | None = "0098_drop_demo_account"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"library_states",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"job_id",
sa.Integer(),
sa.ForeignKey("download_jobs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("position_seconds", sa.Integer(), nullable=False),
sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),
)
op.create_index("ix_library_states_job_id", "library_states", ["job_id"])
def downgrade() -> None:
op.drop_index("ix_library_states_job_id", table_name="library_states")
op.drop_table("library_states")
@@ -0,0 +1,22 @@
"""plex_items: drop `markers`, a column nothing writes or reads
Intro/credits markers are read live from Plex for the item the player opens (with Plex's `final` flag
on the block that ends it); this cached copy was never filled — every row is NULL. The downgrade
restores the empty column.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0100_drop_plex_item_markers"
down_revision: str | None = "0099_library_states"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.drop_column("plex_items", "markers")
def downgrade() -> None:
op.add_column("plex_items", sa.Column("markers", sa.JSON(), nullable=True))
@@ -0,0 +1,46 @@
"""Plex summaries and taglines as plain text
Revision ID: 0101_plex_text_plain
Revises: 0100_drop_plex_item_markers
Create Date: 2026-10-08
Some Plex agents write a summary as HTML (`<p>…</p>`, `&aacute;`), and the app showed the tags as
text. The sync now stores every summary and tagline through `plain_text` (app/plex/plaintext.py);
this cleans the rows it wrote before, through the same function, so nothing waits for a full resync.
"""
import sqlalchemy as sa
from alembic import op
from app.plex.plaintext import plain_text
revision: str = "0101_plex_text_plain"
down_revision: str | None = "0100_drop_plex_item_markers"
branch_labels: str | None = None
depends_on: str | None = None
_COLUMNS = {
"plex_items": ("summary", "summary_hu", "tagline", "tagline_hu"),
"plex_shows": ("summary", "summary_hu", "tagline", "tagline_hu"),
"plex_collections": ("summary",),
}
def upgrade() -> None:
conn = op.get_bind()
for table, columns in _COLUMNS.items():
for col in columns:
rows = conn.execute(
sa.text(f"SELECT id, {col} FROM {table} WHERE {col} ~ '[<&]'")
).fetchall()
updates = [
{"id": rid, "v": clean}
for rid, value in rows
if (clean := plain_text(value)) != value
]
if updates:
conn.execute(sa.text(f"UPDATE {table} SET {col} = :v WHERE id = :id"), updates)
def downgrade() -> None:
pass # the markup is not wanted back; the next sync would clean it again
+9 -6
View File
@@ -181,15 +181,18 @@ class Settings(BaseSettings):
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
download_pot_base_url: str = "http://bgutil-pot:4416"
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
# yt-dlp player clients (comma-separated). PRIMARY = tv_simply: measured 2026-10-10 as the one
# client that is offered 1080p+ AND finishes the fetch, its stream token minted by the bgutil
# sidecar. android_vr (the primary until then) was still offered 2160p, but every fetch died
# with HTTP 403 — it now needs a stream PO token the sidecar cannot mint — and the fallback
# below served 360p only, so every download came out at 360p without failing. On a
# bot/DRM/format failure the worker retries with the FALLBACK set — the POT-capable web clients
# (web needs the token + Deno) plus android, whose 360p is the last resort. Both admin-tunable
# (env) when YouTube shifts what works.
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
download_player_clients: str = "android_vr"
download_player_clients: str = "tv_simply"
download_player_clients_fallback: str = "web_safari,web,android"
@staticmethod
+3 -3
View File
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from app.discover import cards, queries
from app.models import Channel, ComfortPin, PlexItem, PlexShow, Playlist, User, Video
from app.plex import rollup
from app.plex.cards import leaf_card, show_card
from app.plex.cards import SHOW_NEW, leaf_card, show_card
from app.plex.lookups import by_rating_key
# kind -> the ComfortPin column that holds it. The client's vocabulary and the schema's, in one place.
@@ -203,8 +203,8 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: s
sh = db.get(PlexShow, pin.plex_show_id)
if sh is None:
return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
card = cards.from_plex(show_card(sh, status, lang=lang))
state = rollup.state_map(db, user.id, [sh.id]).get(sh.id, SHOW_NEW)
card = cards.from_plex(show_card(sh, state, lang=lang))
ep = queries.next_episode(db, user.id, sh.id, lang=lang)
nxt = cards.from_plex(ep) if ep else None
+7 -3
View File
@@ -256,16 +256,20 @@ def plex_in_progress(
if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter(
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
prog, prog.c.show_id == PlexShow.id
),
prog.c.total,
prog.c.watched,
prog.c.inprog,
"in_progress",
).where(PlexShow.library_id.in_(show_libs))
for sh, resumed_at in db.execute(
for sh, resumed_at, total, watched, inprog in db.execute(
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all():
out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
out.append(
(resumed_at, show_card(sh, rollup.show_state(total, watched, inprog), lang=lang))
)
return out
+2 -1
View File
@@ -106,7 +106,8 @@ class YouTubeAdapter(SourceAdapter):
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
# rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every
# rather than a hard failure. Our primary set is tv_simply ALONE, which cannot carry cookies either
# (measured on yt-dlp 2026.08.19: `SUPPORTS_COOKIES` False), so without this filter every
# signed-in download would burn its first attempt that way before reaching the POT-backed web
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
+289
View File
@@ -0,0 +1,289 @@
"""Ad-hoc clips: a marked range of a YouTube video or a Plex item, cut into a new per-user asset.
A clip never needs the whole source first. A YouTube clip fetches only its range (yt-dlp's
`download_ranges`; measured: 3-7 s whether the video runs four minutes or an hour, and the fetched
range starts on the marked frame), and a Plex clip reads the library file in place. Either way the
range then goes through ONE re-encode built here, so every clip has the same shape whatever its
source: frame-exact at both ends, H.264 at most 1080p (an HDR source tonemapped to SDR — kept as
10-bit PQ it played washed out), stereo AAC, in an .mp4 that plays on any phone.
A clip may also crop the picture to a frame the user drew over the playing video. The frame is
given in fractions of the box it was drawn in, with that box's aspect: the YouTube iframe never
tells its picture's size, so the frame there is drawn over the player's 16:9 box (a short's 9:16),
and the cut maps it onto the real picture (`crop_filter`) — what lies over a letterbox bar is dropped.
The spec is stored on the job (`DownloadJob.edit_spec`) and is the clip's whole identity: its
signature is the asset's cache key, so marking the same range again is an instant cache hit, and a
different range or audio track is a different clip.
"""
import hashlib
import json
import math
import re
from pathlib import Path
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
from app.plex.sync import HDR_TRANSFERS
SOURCES = ("youtube", "plex")
# Owner's cap: a clip is a moment, not a way to export a whole film to a public link.
MAX_CLIP_S = 600
MIN_CLIP_S = 1
MAX_HEIGHT = 1080
# What yt-dlp fetches the range in (formats.normalize fills the rest): the clip's own ceiling, so the
# re-encode never upscales and never throws away a better source than it keeps.
YOUTUBE_FETCH_SPEC = {"mode": "av", "max_height": MAX_HEIGHT, "container": "mp4"}
# Bump when the spec → output-bytes mapping changes, so a cached clip re-derives.
_SIG_VERSION = 1
_YOUTUBE_ID = re.compile(r"[A-Za-z0-9_-]{11}")
_RATING_KEY = re.compile(r"[0-9]{1,32}")
_MAX_AUDIO_TRACK = 63
# HDR → SDR as measured on the owner's 4K HDR files (2026-10-10): linearise, tonemap (hable),
# convert the primaries and transfer to BT.709 and back to 8-bit 4:2:0.
_TONEMAP = (
"zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable:desat=0,"
"zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
)
_SCALE = f"scale=-2:'min({MAX_HEIGHT},ih)'"
# A crop frame is at least this share of its box on each side (the player's frame holds the same
# floor), and what is left of it on the real picture at least _MIN_VISIBLE of it.
MIN_CROP = 0.1
_MIN_VISIBLE = 0.05
# The box aspects a frame may be drawn in: a 9:16 short to a 21:9 film, with room either side.
_CROP_ASPECTS = (0.25, 4.0)
# Float slack on the frame's far edges (x + w, y + h), which the client rounds to 4 decimals.
_EDGE_SLACK = 1e-3
class ClipError(Exception):
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
message names (the cap itself, so the client never says a stale one)."""
def __init__(self, reason: str, **params: float):
self.reason = reason
self.params = params
super().__init__(reason)
def normalize(
source: str,
ref: str,
start_s: float,
end_s: float,
audio_track: int | None = None,
crop: dict | None = None,
) -> dict:
"""The validated, canonical clip spec. Times are rounded to the millisecond and a crop to four
decimals, so the same marks always give the same cache key. Raises ClipError."""
if source not in SOURCES:
raise ClipError("bad_source")
pattern = _YOUTUBE_ID if source == "youtube" else _RATING_KEY
if not isinstance(ref, str) or not pattern.fullmatch(ref):
raise ClipError("bad_source")
if not (math.isfinite(start_s) and math.isfinite(end_s)) or start_s < 0:
raise ClipError("bad_range")
start, end = round(start_s, 3), round(end_s, 3)
if end - start < MIN_CLIP_S:
raise ClipError("too_short")
if end - start > MAX_CLIP_S:
raise ClipError("too_long", limit_s=MAX_CLIP_S)
spec: dict = {"source": source, "ref": ref, "start_s": start, "end_s": end}
if source == "plex" and audio_track is not None:
if not 0 <= audio_track <= _MAX_AUDIO_TRACK:
raise ClipError("bad_audio")
spec["audio_track"] = audio_track
frame = _crop(crop)
if frame is not None:
spec["crop"] = frame
return spec
def _crop(raw: dict | None) -> dict | None:
"""A crop frame, canonical — or None for no crop, which a frame over the whole box also is (the
same clip as one cut without a frame, so the same cache key)."""
if raw is None:
return None
try:
x, y, w, h, aspect = (float(raw[k]) for k in ("x", "y", "w", "h", "aspect"))
except (KeyError, TypeError, ValueError) as e:
raise ClipError("bad_crop") from e
if not all(math.isfinite(v) for v in (x, y, w, h, aspect)):
raise ClipError("bad_crop")
lo, hi = _CROP_ASPECTS
if not lo <= aspect <= hi or x < 0 or y < 0:
raise ClipError("bad_crop")
if w < MIN_CROP - _EDGE_SLACK or h < MIN_CROP - _EDGE_SLACK:
raise ClipError("bad_crop")
if x + w > 1 + _EDGE_SLACK or y + h > 1 + _EDGE_SLACK:
raise ClipError("bad_crop")
frame = {"x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y), "aspect": aspect}
frame = {k: round(v, 4) for k, v in frame.items()}
if (frame["x"], frame["y"], frame["w"], frame["h"]) == (0, 0, 1, 1):
return None
return frame
def duration(spec: dict) -> float:
return spec["end_s"] - spec["start_s"]
def sig(spec: dict) -> str:
"""The clip's cache signature: the spec itself, versioned."""
body = json.dumps({**spec, "_v": _SIG_VERSION}, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(body.encode()).hexdigest()[:24]
def asset_ref(user_id: int, spec: dict) -> str:
"""The asset's `source_ref`. The user id makes a clip private to whoever cut it, like an edit:
never dedup-shared, never cache-served to someone else."""
return f"{user_id}:{spec['source']}:{spec['ref']}"
def reference_url(spec: dict) -> str | None:
"""Where a YouTube clip came from: its video at the clip's start. A Plex clip has no public page."""
if spec.get("source") != "youtube" or not spec.get("ref"):
return None
return f"https://www.youtube.com/watch?v={spec['ref']}&t={int(spec.get('start_s') or 0)}s"
def is_hdr(probe: dict) -> bool:
"""Whether the first video stream of an ffprobe `-show_streams` JSON carries an HDR transfer."""
for s in probe.get("streams") or []:
if s.get("codec_type") == "video":
# PQ / HLG: an 8-bit SDR encode must tonemap them (the one set the Plex stream uses).
return s.get("color_transfer") in HDR_TRANSFERS
return False
def display_aspect(probe: dict) -> float | None:
"""The picture's shape as a player shows it, from an ffprobe `-show_streams` JSON: the first video
stream's width × its sample aspect ratio over its height, turned a quarter when the stream says
it is rotated (ffmpeg turns such a stream upright before any filter of ours, so a crop is cut
from the upright picture). None when the stream has no size."""
for s in probe.get("streams") or []:
if s.get("codec_type") != "video":
continue
w, h = s.get("width"), s.get("height")
if not isinstance(w, int) or not isinstance(h, int) or w <= 0 or h <= 0:
return None
dar = w * (_ratio(s.get("sample_aspect_ratio")) or 1.0) / h
return 1 / dar if _quarter_turned(s) else dar
return None
def _ratio(v: object) -> float | None:
"""'32:27' → 1.185…; '0:1', 'N/A', a missing or malformed value → None."""
if not isinstance(v, str) or ":" not in v:
return None
num, _, den = v.partition(":")
try:
n, d = float(num), float(den)
except ValueError: # silent-ok: a malformed ratio reads as none given; the caller takes 1:1
return None
return n / d if n > 0 and d > 0 else None
def _quarter_turned(stream: dict) -> bool:
rotation: object = (stream.get("tags") or {}).get("rotate")
for sd in stream.get("side_data_list") or []:
if "rotation" in sd:
rotation = sd["rotation"]
try:
return round(float(str(rotation))) % 180 == 90
except ValueError: # silent-ok: no (None) or a malformed rotation: the stream is upright
return False
def crop_filter(crop: dict, dar: float) -> str | None:
"""The ffmpeg `crop` for a frame drawn in a box of `crop["aspect"]`, over a picture of display
aspect `dar` fitted into that box as a player fits it (whole, centred, bars on the short sides).
The frame's part over a bar is dropped: "" when what is left is the whole picture, None when too
little of the frame lies on the picture at all.
In fractions of the input's own width and height, so a sample aspect ratio other than 1:1 (an
anamorphic DVD) needs nothing of its own, and floored to even pixels: an even 4:2:0 frame, and a
window that never runs past the picture's far edge."""
box = crop["aspect"]
pic_w, pic_h = (1.0, box / dar) if dar > box else (dar / box, 1.0)
def span(start: float, size: float, extent: float) -> tuple[float, float]:
origin = (1 - extent) / 2
a = min(1.0, max(0.0, (start - origin) / extent))
b = min(1.0, max(0.0, (start + size - origin) / extent))
return a, b - a
x, w = span(crop["x"], crop["w"], pic_w)
y, h = span(crop["y"], crop["h"], pic_h)
if w < _MIN_VISIBLE or h < _MIN_VISIBLE:
return None
if w > 1 - 1e-4 and h > 1 - 1e-4:
return ""
def px(axis: str, f: float) -> str:
return f"trunc({axis}*{f:.6f}/2)*2"
return f"crop={px('iw', w)}:{px('ih', h)}:{px('iw', x)}:{px('ih', y)}"
def audio_track_count(probe: dict) -> int:
return sum(1 for s in probe.get("streams") or [] if s.get("codec_type") == "audio")
def build_cmd(
src: Path,
dest: Path,
start_s: float,
dur_s: float,
*,
audio_track: int | None,
hdr: bool,
threads: int,
crop: str = "",
) -> list[str]:
"""ffmpeg for one clip: an input seek (decodes from the keyframe before `start_s` and drops the
frames up to it, so the clip starts on the marked frame), the first video stream, the chosen audio
track (the first when none was chosen; none at all is fine), no subtitles, data or chapters.
A `crop` (crop_filter) comes first: the tonemap and the scale then work on the frame it keeps."""
vf = ",".join([*([crop] if crop else []), *([_TONEMAP] if hdr else []), _SCALE, EVEN_FRAME])
audio = f"0:a:{audio_track}" if audio_track is not None else "0:a:0?"
return [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-progress",
"pipe:1",
"-nostats",
"-ss",
f"{start_s:.3f}",
*INPUT_GUARD,
"-i",
str(src),
"-t",
f"{dur_s:.3f}",
"-map",
"0:v:0",
"-map",
audio,
"-sn",
"-dn",
# A source's chapters would ride into the .mp4 as a data track of their own (an mkv's
# chapter list came out as `bin_data`); a clip of a moment has no chapters to keep.
"-map_chapters",
"-1",
"-vf",
vf,
*X264_ARGS,
"-threads",
str(threads),
*AAC_ARGS,
"-ac",
"2",
"-movflags",
"+faststart",
str(dest),
]
+31
View File
@@ -30,6 +30,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app import logs
from app.downloads import clip as clipmod
from app.downloads import formats
from app.downloads.adapters import _host_is
from app.models import DownloadCredential, MediaAsset
@@ -259,6 +260,28 @@ def usable_cookie(
return None
if not is_authed_asset(asset, spec, user_id, url):
return None # shared asset — never attach a cookie to a non-private identity
return _readable(cred, user_id, host_key)
def usable_clip_cookie(
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict
) -> str | None:
"""`usable_cookie` for an ad-hoc clip's range fetch. The second gate is the clip's own identity:
a clip asset is private by construction (`clip.asset_ref` names its user, see downloads/clip),
so a cookie goes with it exactly when that asset is THIS user's clip of `clip_spec`. Without it a
video YouTube gates signed out could never be clipped, while its download goes through."""
host_key = host_key_for_url(url)
if host_key is None:
return None
cred = get_credential(db, user_id, host_key)
if cred is None:
return None
if asset.source_kind != "clip" or asset.source_ref != clipmod.asset_ref(user_id, clip_spec):
return None
return _readable(cred, user_id, host_key)
def _readable(cred: DownloadCredential, user_id: int, host_key: str) -> str | None:
raw = decrypt_optional(cred.cookies_enc)
if not raw:
log.warning(
@@ -284,6 +307,14 @@ def resolve_cookiefile(
return _write_cookiefile(raw, staging) if raw else None
def resolve_clip_cookiefile(
db: Session, user_id: int, url: str | None, asset: MediaAsset, clip_spec: dict, staging: Path
) -> Path | None:
"""`resolve_cookiefile` for an ad-hoc clip (`usable_clip_cookie`)."""
raw = usable_clip_cookie(db, user_id, url, asset, clip_spec)
return _write_cookiefile(raw, staging) if raw else None
def _write_cookiefile(raw: str, staging: Path) -> Path:
"""Write the cookie blob to a private 0600 file under `staging/.auth/` and return its path.
+18 -3
View File
@@ -37,9 +37,10 @@ from app.downloads.credentials import host_key_for_url
# Worker-authored strings that this module owns because each is coupled to `_RULES`.
#
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
# rule drifting apart is exactly how a category silently becomes unreachable.
# QUOTA_FULL_MESSAGE and the three *_TOO_BIG_MESSAGEs are raised by the worker and must classify
# back to `quota_exceeded`; their rules below match a fragment of each, and test_download_errors pins
# each one, because a message and its rule drifting apart is exactly how a
# category silently becomes unreachable.
#
# NETWORK_REDACTED_MESSAGE REPLACES a message after classification (`user_error_text`). It is written
# to the shared asset too, and a second job that inherits that failed asset re-diagnoses the stored
@@ -53,6 +54,19 @@ from app.downloads.credentials import host_key_for_url
# constants, imported above; each has a rule below that gives its category back, and a catalog entry
# that gives the site's sentence.
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
# A download, a cut clip, or an upload's converted file, held to the space left, which is not zero:
# "full" would be untrue. All three classify by the one phrase they share.
DOWNLOAD_TOO_BIG_MESSAGE = (
"The video is larger than the download storage you have left — free up space, then try again."
)
CLIP_TOO_BIG_MESSAGE = (
"The clip is larger than the download storage you have left — "
"free up space or cut a shorter clip, then try again."
)
CONVERTED_TOO_BIG_MESSAGE = (
"The converted file is larger than the download storage you have left — "
"free up space, then try again."
)
UPLOAD_MISSING_MESSAGE = "The uploaded file is missing."
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
@@ -62,6 +76,7 @@ _RULES: tuple[tuple[str, str], ...] = (
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
# first — it is our wording, so it can never be a false match on an upstream message.
("download storage is full", "quota_exceeded"),
("larger than the download storage you have left", "quota_exceeded"),
# Our own redaction of a transport failure (see NETWORK_REDACTED_MESSAGE) — our wording too.
(NETWORK_REDACTED_MESSAGE.lower(), "network"),
# Our own verdicts on a post link (adapters.py) — our wording too: its page holds no single video,
+29 -2
View File
@@ -105,6 +105,28 @@ def log_safe(text: object) -> str:
return _PATH_RE.sub("…", text)
# yt-dlp's HTTP downloader, refusing a file announced larger than `max_filesize`, logs this line and
# RETURNS — no exception and no file (measured; test_formats holds it against the pinned yt-dlp).
_OVER_CAP_LINE = "File is larger than max-filesize"
_OVER_CAP: contextvars.ContextVar[list[bool] | None] = contextvars.ContextVar(
"formats_over_cap", default=None
)
@contextlib.contextmanager
def watching_size_cap() -> Iterator[list[bool]]:
"""While yt-dlp works inside it: a one-slot flag, set when it refused a file over the size cap
(`build_ydl_opts(max_bytes=…)`). It then returns as if it had downloaded, and all the caller
would find is no file — a processing fault, to anyone reading it, for what is the owner's
storage. A context variable, like `hiding`: yt-dlp's logger calls run on the extraction's thread."""
flag = [False]
token = _OVER_CAP.set(flag)
try:
yield flag
finally:
_OVER_CAP.reset(token)
class _YdlLog:
"""yt-dlp's logger protocol, pointed at ours.
@@ -124,6 +146,8 @@ class _YdlLog:
is the one line whose level should say so."""
def _emit(self, level: int, msg: str) -> None:
if _OVER_CAP_LINE in msg and (over := _OVER_CAP.get()) is not None:
over[0] = True
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
# a record that has args, this way the question never arises — and it is the shape ruff's
@@ -281,8 +305,11 @@ def build_ydl_opts(
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
merge so a source adapter can never clobber it.
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
than it rather than filling the disk and only then being caught by the quota accounting."""
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp's HTTP downloader refuses
a file announced larger than it rather than filling the disk and only then being caught by the
quota accounting. Its ffmpeg downloader (a `download_ranges` fetch) does not read it, nor does
a fragmented download (YouTube's formats: measured, 718 MB fetched with 1 MB left) — so the
pipeline also holds every produced file to what is left by its real size."""
s = normalize(spec)
h = s["max_height"]
opts: dict = {
+294 -16
View File
@@ -7,10 +7,12 @@ from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import yt_dlp
from yt_dlp.utils import download_range_func
from app import logs, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.downloads import clip as clipmod
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import adapters, credentials, formats, mediakind, playable, probe, quota
@@ -26,7 +28,9 @@ from app.downloads.jobdb import (
)
from app.downloads.stated import StatedFailure
from app.downloads.status import JobStatus
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset, PlexItem
from app.plex import lookups as plex_lookups
from app.plex import paths as plex_paths
from app.notifications import create_notification
from app.titles import normalize_title
@@ -37,6 +41,15 @@ class _Aborted(Exception):
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
class _ClipFetchFailed(Exception):
"""A clip's YouTube range fetch failed: its message and the download catalog's diagnosis, so a
clip on YouTube's wall says what a download of the same video would."""
def __init__(self, msg: str, diagnosis: dlerrors.Diagnosis):
super().__init__(msg)
self.diagnosis = diagnosis
class _OwnerRefused(Exception):
"""A refusal that belongs to THIS job's owner, not to the source.
@@ -213,7 +226,7 @@ def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, no
def cancelled() -> bool:
if not state["cancelled"] and allow():
state["cancelled"] = _job_status(job_id) not in ("running", None)
state["cancelled"] = _job_status(job_id) not in (JobStatus.RUNNING, None)
return state["cancelled"]
return cancelled
@@ -333,7 +346,7 @@ def _media_info(info: dict) -> dict:
def _extract_with_fallback(
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None, section=None
):
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
@@ -347,9 +360,16 @@ def _extract_with_fallback(
download as itself.
`cookiefile` is the acting user's per-host cookies.txt, already gated by
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
`credentials.resolve_cookiefile` (a clip: `resolve_clip_cookiefile`); passed to every attempt so
the fallback stays authenticated.
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt. A
`section` fetch goes through yt-dlp's ffmpeg downloader, which does not read it: a clip holds
its own output to the cap (`_process_clip`).
`section` = (start_s, end_s) fetches only that range (an ad-hoc clip, see downloads/clip): the
range is stream-copied, and its first frame is the marked one (measured), so the clip's own
re-encode starts at 0 instead of re-fetching keyframes at the cut."""
client_sets = adapter.client_sets(authed=cookiefile is not None)
outtmpl = str(staging / "%(id)s.%(ext)s")
# Resolving may read a page first — network time the stall watchdog must not see as a stall.
@@ -375,6 +395,9 @@ def _extract_with_fallback(
max_bytes=max_bytes,
)
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
if section is not None:
opts["download_ranges"] = download_range_func([], [section])
opts["force_keyframes_at_cuts"] = False
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
@@ -502,8 +525,8 @@ def _download(
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
# single job could previously blow through the cap by any amount. Handing the remainder
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
# front instead.
# to yt-dlp as `max_filesize` makes it refuse an oversized file (its HTTP downloader
# checks the announced size) instead.
remaining_bytes = quota.remaining_bytes(cdb, user_id)
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
@@ -512,8 +535,8 @@ def _download(
# free space, which is what the `quota_exceeded` category tells them.
if remaining_bytes == 0:
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
info = _media_info(
_extract_with_fallback(
with formats.watching_size_cap() as over_cap:
fetched = _extract_with_fallback(
job_id,
asset_id,
url,
@@ -523,7 +546,11 @@ def _download(
cookiefile=str(cookiefile) if cookiefile else None,
max_bytes=remaining_bytes,
)
)
info = _media_info(dict(fetched))
# yt-dlp refused a file larger than what is left: it returns with no file, which would
# otherwise read as a processing fault. The owner's storage, so the owner's refusal.
if over_cap[0]:
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
@@ -536,6 +563,13 @@ def _download(
CONVERTING,
int(info["duration"]) if info.get("duration") else None,
)
# Held to the cap by its real size, as a clip is: yt-dlp's `max_filesize` only stops a plain
# HTTP file that announces its size, and YouTube's fragmented formats never meet it
# (measured: 718 MB fetched with 1 MB left). Read again — other jobs may have landed.
with SessionLocal() as qdb:
room = quota.remaining_bytes(qdb, user_id)
if room is not None and produced.stat().st_size > room:
raise _OwnerRefused(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE)
ext = produced.suffix.lstrip(".").lower()
# Codecs/dims/duration come from a PROBE of the produced file — the ground truth — NOT yt-dlp's
# `info` dict, whose fields are absent for some sources (Facebook among them): deriving
@@ -736,11 +770,18 @@ def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseExcep
def _fail_edit(
job_id: int, asset_id: int, msg: str, exc: BaseException | None = None, *, what: str = "edit"
job_id: int,
asset_id: int,
msg: str,
exc: BaseException | None = None,
*,
what: str = "edit",
diagnosis: dlerrors.Diagnosis | None = None,
) -> None:
"""Fail a local ffmpeg job — an edit, or an upload's conversion (`what="upload"`) — and say so
ONCE: every way one fails comes through here, so this is where its line and its event code
(`<what>.job.failed`) live. `exc` is the exception when there was one.
"""Fail a local ffmpeg job — an edit, a clip, or an upload's conversion (`what="upload"`) — and
say so ONCE: every way one fails comes through here, so this is where its line and its event code
(`<what>.job.failed`) live. `exc` is the exception when there was one; `diagnosis` replaces the
plain category when the failure was a fetch the download catalog can name (a clip's).
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
Logged after the write, as on the download error path."""
@@ -755,9 +796,12 @@ def _fail_edit(
job_id,
status=JobStatus.ERROR,
error=msg,
error_code=dlerrors.classify_conversion(msg)
error_code=diagnosis.code
if diagnosis
else dlerrors.classify_conversion(msg)
if what == "upload"
else dlerrors.classify(msg),
error_detail=diagnosis.detail if diagnosis else None,
phase=None,
)
db.commit()
@@ -834,6 +878,13 @@ def _process_edit(job_id: int, asset_id: int) -> None:
# playable as its source: hold it to the same rule as every other ready file.
dest_staging = _ensure_phone_safe(job_id, dest_staging, staging, "editing", total_s)
out_ext = dest_staging.suffix.lstrip(".").lower() or out_ext
# New storage on the owner's footprint, held to what is left by its real size, as a
# download and a player clip are.
if user_id is not None:
with SessionLocal() as qdb:
room = quota.remaining_bytes(qdb, user_id)
if room is not None and dest_staging.stat().st_size > room:
raise StatedFailure(dlerrors.CLIP_TOO_BIG_MESSAGE)
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
storage.place_file(dest_staging, settings.download_root, rel)
@@ -896,6 +947,233 @@ def _process_edit(job_id: int, asset_id: int) -> None:
shutil.rmtree(staging, ignore_errors=True)
def _clip_room(user_id: int) -> int | None:
"""What the clip's owner may still store (`quota.remaining_bytes`), refusing at zero — see
`_download`: zero is a refusal, not "no limit"."""
with SessionLocal() as db:
remaining = quota.remaining_bytes(db, user_id)
if remaining == 0:
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
return remaining
def _clip_source(
job_id: int, asset_id: int, spec: dict, staging: Path, remaining: int | None
) -> tuple[Path, float, str | None]:
"""The file a clip is cut from, where in it the clip starts, and the source's title when only
the fetch could tell it. A YouTube clip fetches ONLY its range into `staging` (so it starts at
0 there); a Plex clip reads the library file in place."""
if spec["source"] == "youtube":
url = service.source_url("youtube", spec["ref"])
cookiefile: Path | None = None
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
asset = db.get(MediaAsset, asset_id)
# Signed in when the user has a YouTube cookie, as their downloads are: YouTube gates
# single videos signed out ("not a bot"), and the clip is theirs alone.
if job and asset:
cookiefile = credentials.resolve_clip_cookiefile(
db, job.user_id, url, asset, spec, staging
)
_set_job(job_id, phase="downloading", progress=0)
try:
with formats.watching_size_cap() as over_cap:
info = _extract_with_fallback(
job_id,
asset_id,
url,
formats.normalize(clipmod.YOUTUBE_FETCH_SPEC),
staging,
adapters.resolve_adapter("youtube", spec["ref"]),
cookiefile=str(cookiefile) if cookiefile else None,
max_bytes=remaining,
section=(spec["start_s"], spec["end_s"]),
)
# Should yt-dlp's own cap ever refuse the range (see `_download`): the owner's storage.
if over_cap[0]:
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
except (_Aborted, _OwnerRefused):
raise
except Exception as exc: # re-raised below with the download catalog's diagnosis
msg = str(exc)[:500]
raise _ClipFetchFailed(
msg, dlerrors.diagnose(msg, url=url, cookie_attached=cookiefile is not None)
) from exc
finally:
# Never leave a plaintext session cookie on disk past the fetch (see `_download`).
if cookiefile is not None:
cookiefile.unlink(missing_ok=True)
info = _media_info(dict(info))
title = normalize_title(adapters.resolve_adapter("youtube", spec["ref"]).title(info))
return _produced_file(info, staging), 0.0, title
with SessionLocal() as db:
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
src = plex_paths.local_media_path(db, item.file_path) if item else None
if src is None:
raise StatedFailure("The library file is not reachable from here.")
return src, spec["start_s"], None
def _clip_crop(spec: dict, data: dict) -> str:
"""The clip's crop filter, from its frame and the picture the file really holds ("" for none)."""
frame = spec.get("crop")
if not frame:
return ""
dar = clipmod.display_aspect(data)
if dar is None:
raise StatedFailure("The picture's size could not be read, so the crop cannot be placed.")
crop = clipmod.crop_filter(frame, dar)
if crop is None:
raise StatedFailure("The crop frame lies outside the picture.")
return crop
def _process_clip(job_id: int, asset_id: int) -> None:
"""Cut an ad-hoc clip (downloads/clip): fetch or open its source, ONE re-encode to the clip
shape, then into the user's library beside their edits."""
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
if job is None:
return
spec = dict(job.edit_spec or {})
user_id = job.user_id
staging = _edit_staging_dir(asset_id)
try:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True, exist_ok=True)
# Refused before the fetch and the encode when the owner's storage is already full.
remaining = _clip_room(user_id)
src, start, fetched_title = _clip_source(job_id, asset_id, spec, staging, remaining)
data = probe.probe_json(src)
if not data:
raise StatedFailure("The source file could not be read.")
audio_track = spec.get("audio_track")
tracks = clipmod.audio_track_count(data)
if tracks == 0:
# A silent source: the player still names its track 0, and the clip is silent too.
audio_track = None
elif audio_track is not None and audio_track >= tracks:
raise StatedFailure("The chosen audio track is not in the file.")
crop = _clip_crop(spec, data)
total_s = clipmod.duration(spec)
out = staging / "clip.mp4"
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
editmod.run_ffmpeg(
clipmod.build_cmd(
src,
out,
start,
total_s,
audio_track=audio_track,
hdr=clipmod.is_hdr(data),
threads=editmod.encode_thread_cap(),
crop=crop,
),
total_s,
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
should_cancel=_cancel_poll(job_id),
)
if not out.exists():
raise StatedFailure("ffmpeg produced no output file")
# The phone-safe shape by construction; still held to the rule every ready file meets.
out = _ensure_phone_safe(job_id, out, staging, "editing", round(total_s))
# Held to the cap by its real size: a range fetch passes yt-dlp's `max_filesize` to its
# ffmpeg downloader, which never reads it, and a Plex clip fetches nothing at all. Read
# again, since other jobs of this owner may have landed during the encode.
remaining = _clip_room(user_id)
if remaining is not None and out.stat().st_size > remaining:
raise _OwnerRefused(dlerrors.CLIP_TOO_BIG_MESSAGE)
ext = out.suffix.lstrip(".").lower() or "mp4"
rel = storage.edit_rel_path(user_id, asset_id, ext)
storage.place_file(out, settings.download_root, rel)
final = storage.abs_path(settings.download_root, rel)
vcodec, acodec, width, height, _ = probe.probe_media(final)
_, retention_days = _download_settings()
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
if asset:
asset.status = "ready"
asset.rel_path = rel
asset.size_bytes = final.stat().st_size if final.exists() else None
asset.container = ext
asset.vcodec, asset.acodec, asset.width, asset.height = (
vcodec,
acodec,
width,
height,
)
asset.duration_s = round(total_s)
asset.media_kind = mediakind.derive(vcodec, acodec)
# The clip's own frame as its poster: the link preview shows the moment, and a Plex
# item has no public thumbnail at all.
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
asset.error = None
asset.last_access_at = datetime.now(timezone.utc)
asset.expires_at = datetime.now(timezone.utc) + timedelta(days=retention_days)
fields: dict = {"status": JobStatus.DONE, "progress": 100, "phase": None}
# A video outside the catalog was queued as "Clip (clip)": the fetch has its title now.
placeholder = service.clip_title(None, None)
if asset and fetched_title and asset.title == placeholder:
named = service.clip_title(fetched_title, None)
asset.title = named
if job_row := db.get(DownloadJob, job_id):
if job_row.display_name == placeholder:
fields["display_name"] = named[:DISPLAY_TEXT_MAX]
title = asset.title if asset else None
finalized = service.finalize_if_running(db, job_id, **fields)
db.commit()
if finalized:
_notify_download_done(db, user_id, title, job_id, kind="edit")
log.info(
"clip job %s done: asset %s",
job_id,
asset_id,
extra=logs.event(
"clip.job.done", job_id=job_id, asset_id=asset_id, source=spec["source"]
),
)
except (_Aborted, editmod.EditAborted):
# Paused/canceled mid-fetch or mid-encode: free the asset for a later retry.
_reset_asset_pending(asset_id)
log.info(
"clip job %s aborted (pause/cancel)",
job_id,
extra=logs.event("clip.job.aborted", job_id=job_id),
)
except _OwnerRefused as exc:
# Quota full: the job fails, the asset goes back to pending for a Retry once there is room
# (the download's handling; see `_download`).
msg = str(exc)[:500]
with SessionLocal() as db:
service.finalize_if_running(
db,
job_id,
status=JobStatus.ERROR,
error=msg,
error_code=dlerrors.classify(msg),
error_detail=None,
phase=None,
)
db.commit()
_reset_asset_pending(asset_id)
log.info(
"clip job %s refused for its owner: %s",
job_id,
formats.log_safe(msg),
extra=logs.event("clip.job.refused", job_id=job_id),
)
except _ClipFetchFailed as exc:
# silent-ok: not silent — `_fail_edit` is the report, with the fetch's diagnosis.
_fail_edit(job_id, asset_id, str(exc), exc, what="clip", diagnosis=exc.diagnosis)
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
# silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
_fail_edit(job_id, asset_id, str(exc)[:500], exc, what="clip")
finally:
shutil.rmtree(staging, ignore_errors=True)
def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) -> None:
"""Refuse a converted upload that would push its owner over the storage cap: the upload was
charged at its original size, and a re-encode can come out larger. The original stays, counted,
@@ -904,7 +1182,7 @@ def _check_converted_fits(owner_id: int | None, old_size: int, new_size: int) ->
return
with SessionLocal() as db:
if not quota.fits_after_conversion(db, owner_id, old_size, new_size):
raise StatedFailure(dlerrors.QUOTA_FULL_MESSAGE)
raise StatedFailure(dlerrors.CONVERTED_TOO_BIG_MESSAGE)
def _unplace(placed: Path | None) -> None:
+6 -4
View File
@@ -15,7 +15,8 @@ the footprint as it stood when the job was created and earlier jobs may have fil
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
"""
from dataclasses import dataclass
@@ -24,7 +25,7 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app import sysconfig
from app.downloads.status import JobStatus
from app.downloads.status import JobKind, JobStatus
from app.models import DownloadJob, DownloadQuota, MediaAsset
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
@@ -66,7 +67,8 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
# "at limit" checks — the worker's claim-window exclusion vs at_concurrency_limit — since a
# 0-limit user is "capped" with zero running jobs, which the SQL exclusion can't see. Keep
# them consistent by never letting the effective limit drop below 1. (To BLOCK a user's
# downloads, suspend them or set max_jobs=0, not max_concurrent=0.)
# downloads, suspend them — the worker leaves a suspended user's queued jobs unclaimed — or
# set max_jobs=0, not max_concurrent=0.)
return QuotaLimits(row.max_bytes, max(1, row.max_concurrent), row.max_jobs, row.unlimited)
return QuotaLimits(
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
@@ -86,7 +88,7 @@ def footprint(db: Session, user_id: int) -> int:
DownloadJob.user_id == user_id,
or_(
DownloadJob.status.in_(_HOLDING),
and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR),
and_(DownloadJob.job_kind == JobKind.UPLOAD, DownloadJob.status == JobStatus.ERROR),
),
DownloadJob.asset_id.is_not(None),
)
+6 -3
View File
@@ -22,6 +22,7 @@ from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import formats
from app.downloads.status import JobKind
from app.models import DownloadJob
log = logs.get_logger(__name__)
@@ -95,14 +96,16 @@ def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) ->
(`cause=child_exit`)."""
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
from app.downloads.pipeline import _convert_upload, _download, _process_edit
from app.downloads.pipeline import _convert_upload, _download, _process_clip, _process_edit
with formats.hiding(source_ref):
try:
if job_kind == "edit":
if job_kind == JobKind.EDIT:
_process_edit(job_id, asset_id)
elif job_kind == "upload":
elif job_kind == JobKind.UPLOAD:
_convert_upload(job_id, asset_id)
elif job_kind == JobKind.CLIP:
_process_clip(job_id, asset_id)
else:
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
+113 -12
View File
@@ -16,11 +16,22 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import adapters
from app.downloads import clip as clipmod
from app.downloads import credentials
from app.downloads import edit as editmod
from app.downloads import formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobStatus
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
from app.downloads.status import HOLDING, JobKind, JobStatus
from app.models import (
DISPLAY_TEXT_MAX,
Channel,
DownloadJob,
MediaAsset,
PlexItem,
PlexShow,
Video,
)
from app.plex import lookups as plex_lookups
from app.plex import paths as plex_paths
class EditError(Exception):
@@ -40,10 +51,13 @@ def source_url(source_kind: str, source_ref: str) -> str:
def reference_url(job, asset) -> str | None:
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
if job is None or job.job_kind == "edit":
own earlier download, not a web page) and for a Plex clip (the library is not a public page); a
YouTube clip links its video at the clip's start; else the canonical page URL yt-dlp recorded; else
one derived from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
if job is None or job.job_kind == JobKind.EDIT:
return None
if job.job_kind == JobKind.CLIP:
return clipmod.reference_url(job.edit_spec or {})
if asset is not None and asset.source_webpage_url:
return asset.source_webpage_url
if job.source_kind in ("youtube", "url"):
@@ -180,7 +194,7 @@ def enqueue(
profile_snapshot=spec,
display_name=display_name
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
status="queued",
status=JobStatus.QUEUED,
queue_pos=next_queue_pos(db),
)
db.add(job)
@@ -188,7 +202,7 @@ def enqueue(
# Cache hit: the file already exists — no worker round-trip needed.
if asset.status == "ready":
job.status = "done"
job.status = JobStatus.DONE
job.progress = 100
asset.last_access_at = datetime.now(timezone.utc)
@@ -197,7 +211,7 @@ def enqueue(
return job
def _clip_title(source_title: str | None, display_name: str | None) -> str:
def clip_title(source_title: str | None, display_name: str | None) -> str:
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
# rather than run through `display_text` — blank here means "no title", not None.
@@ -239,7 +253,7 @@ def enqueue_edit(
ref = f"{user_id}:{src_asset.id}"[:512]
asset = get_or_create_asset(db, "edit", ref, sig)
if not asset.title:
asset.title = _clip_title(src_asset.title, display_name)
asset.title = clip_title(src_asset.title, display_name)
asset.uploader = src_asset.uploader
asset.thumbnail_url = src_asset.thumbnail_url
asset.upload_date = src_asset.upload_date
@@ -251,7 +265,7 @@ def enqueue_edit(
job = DownloadJob(
user_id=user_id,
asset_id=asset.id,
job_kind="edit",
job_kind=JobKind.EDIT,
source_kind="edit",
source_ref=ref,
source_job_id=source_job.id,
@@ -259,14 +273,101 @@ def enqueue_edit(
edit_spec=spec,
profile_snapshot={},
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
status="queued",
status=JobStatus.QUEUED,
queue_pos=next_queue_pos(db),
)
db.add(job)
incr_ref_count(db, asset.id)
if asset.status == "ready": # identical clip already produced for this user → instant
job.status = "done"
job.status = JobStatus.DONE
job.progress = 100
asset.last_access_at = datetime.now(timezone.utc)
db.commit()
db.refresh(job)
return job
def _clip_source_facts(db: Session, spec: dict) -> dict:
"""Title, uploader, thumbnail and length of what a clip is cut from, read from our own catalog.
Raises ClipError when a Plex item is unknown or its file cannot be read here — said at enqueue,
rather than as a job that fails in the queue a minute later."""
if spec["source"] == "youtube":
video = db.get(Video, spec["ref"])
if video is None: # not in the catalog: titled "Clip" until yt-dlp names it (worker)
return {"title": None, "uploader": None, "thumbnail_url": None, "length_s": None}
channel = db.get(Channel, video.channel_id)
return {
"title": video.title,
"uploader": channel.title if channel else None,
"thumbnail_url": video.thumbnail_url,
"length_s": video.duration_seconds,
}
item = plex_lookups.by_rating_key(db, PlexItem, spec["ref"])
if item is None:
raise clipmod.ClipError("not_found")
if plex_paths.local_media_path(db, item.file_path) is None:
raise clipmod.ClipError("media_missing")
title = item.title
show = db.get(PlexShow, item.show_id) if item.show_id else None
if show is not None:
episode = (
f"S{item.season_number}·E{item.episode_number}"
if item.season_number is not None and item.episode_number is not None
else None
)
title = " — ".join(p for p in (show.title, episode, item.title) if p)
return {
"title": title,
"uploader": None,
# Plex art is served through our own authenticated routes, never a public URL; the worker
# cuts the clip's poster from the clip itself.
"thumbnail_url": None,
"length_s": item.duration_s,
}
def enqueue_clip(
db: Session, user_id: int, spec: dict, display_name: str | None = None
) -> DownloadJob:
"""Queue an ad-hoc clip (`clipmod.normalize`d spec) for `user_id`: a NEW per-user asset cut
straight from the source — a YouTube range or a Plex library file (see downloads/clip).
Raises ClipError (unknown or unreachable source, a range past its end) or quota.QuotaExceeded."""
facts = _clip_source_facts(db, spec)
# One second of grace: a mark set on the last frame may round past the catalog's whole seconds.
if facts["length_s"] and spec["end_s"] > facts["length_s"] + 1:
raise clipmod.ClipError("past_end")
quota.check_enqueue(db, user_id)
ref = clipmod.asset_ref(user_id, spec)[:512]
asset = get_or_create_asset(db, "clip", ref, clipmod.sig(spec))
if not asset.title:
asset.title = clip_title(facts["title"], display_name)
asset.uploader = facts["uploader"]
asset.thumbnail_url = facts["thumbnail_url"]
asset.duration_s = round(clipmod.duration(spec))
asset.media_kind = mediakind.VIDEO
job = DownloadJob(
user_id=user_id,
asset_id=asset.id,
job_kind=JobKind.CLIP,
source_kind="clip",
source_ref=ref,
edit_spec=spec,
profile_snapshot={},
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
status=JobStatus.QUEUED,
queue_pos=next_queue_pos(db),
)
db.add(job)
incr_ref_count(db, asset.id)
if asset.status == "ready": # the same range was already cut for this user → instant
job.status = JobStatus.DONE
job.progress = 100
asset.last_access_at = datetime.now(timezone.utc)
+17
View File
@@ -33,6 +33,23 @@ HOLDING = frozenset(
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
)
class JobKind(StrEnum):
"""What a job produces, which decides the worker path that runs it and what the library may do
with the result (only a DOWNLOAD has a web source to fetch again)."""
DOWNLOAD = "download" # yt-dlp fetches a source into a shared, deduplicated asset
EDIT = "edit" # a per-user trim/crop of a file already in the library
UPLOAD = "upload" # a file the user sent; the worker only converts it
CLIP = (
"clip" # a per-user range of a YouTube video or a Plex item, cut straight from the source
)
# A job kind with no web source to fetch again: Re-download refuses these.
NOT_REFETCHABLE = frozenset({JobKind.EDIT, JobKind.UPLOAD, JobKind.CLIP})
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
+2 -2
View File
@@ -34,7 +34,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import mediakind, playable, probe, quota, service, storage
from app.downloads.status import JobStatus
from app.downloads.status import JobKind, JobStatus
from app.models import DownloadJob, MediaAsset, display_text
@@ -113,7 +113,7 @@ def ingest_upload(
job = DownloadJob(
user_id=user_id,
asset_id=asset.id,
job_kind="upload",
job_kind=JobKind.UPLOAD,
source_kind="upload",
source_ref=ref,
profile_snapshot={},
+29 -6
View File
@@ -123,7 +123,8 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
# account can't sign in by any method and its live sessions are rejected; the user is told
# why and pointed at the operator's contact.
# why and pointed at the operator's contact. The worker leaves its queued downloads unclaimed
# until the block is lifted (`worker._claim_job`).
is_suspended: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Server-side session revocation (SA4): a monotonic counter bumped on password reset, password
# change, or "log out everywhere". A signed session cookie records the epoch it was minted under;
@@ -462,6 +463,27 @@ class VideoState(Base, UpdatedAtMixin):
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class LibraryState(Base):
"""Where a user stopped in a library item (their own download, or one shared with them) — the
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
__tablename__ = "library_states"
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
job_id: Mapped[int] = mapped_column(
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
)
# 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, nullable=False)
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class SearchFind(Base, TimestampMixin):
"""Per-user record of a video the user surfaced via their own live YouTube search.
@@ -912,8 +934,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
)
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
source_ref: Mapped[str] = mapped_column(String(512))
# "download" (default), "edit" (a per-user trim/crop derivative of another job) or "upload" (a
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`).
# "download" (default), "edit" (a per-user trim/crop derivative of another job), "upload" (a
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`) or
# "clip" (a range cut straight from a YouTube video or a Plex item — see `downloads.clip`).
job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
source_job_id: Mapped[int | None] = mapped_column(
@@ -922,7 +945,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
source_asset_id: Mapped[int | None] = mapped_column(
ForeignKey("media_assets.id", ondelete="SET NULL")
)
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}}.
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
edit_spec: Mapped[dict | None] = mapped_column(JSON)
profile_id: Mapped[int | None] = mapped_column(
ForeignKey("download_profiles.id", ondelete="SET NULL")
@@ -1253,8 +1278,6 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
+18 -2
View File
@@ -3,9 +3,24 @@ playlists, and detail views render. Extracted from routes/plex/browse.py so the
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
router. Pure functions — no DB, no request state."""
from typing import NamedTuple
from app.models import PlexItem, PlexShow, PlexState
class ShowState(NamedTuple):
"""A show's watch state rolled up from its episodes, as its card carries it. `resumable`: an unwatched
episode has a resume point, the one the card's Continue resumes and its Restart replays from 0. A
show part watched with its next episode untouched has none: Continue and Restart would both play
that episode from its start, so the card offers Play alone."""
status: str
resumable: bool
SHOW_NEW = ShowState("new", False)
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
@@ -54,7 +69,7 @@ def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
}
def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
def show_card(sh: PlexShow, state: ShowState = SHOW_NEW, *, lang: str) -> dict:
return {
"id": sh.rating_key,
"type": "show",
@@ -66,7 +81,8 @@ def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
"art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
"status": state.status,
"resumable": state.resumable,
}
+7 -3
View File
@@ -167,12 +167,16 @@ class PlexClient:
keys = ",".join(str(k) for k in rating_keys)
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
def create_collection(
self, section_key: str, title: str, item_rating_key: str, item_type: int = 1
) -> dict:
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).
`item_type` is the Plex metadata type of its members — 1 movie, 2 show. Measured: a collection
made as type 1 in a TV section came out EMPTY with a show seed, and refused the show after."""
uri = quote(self._metadata_uri(item_rating_key), safe="")
mc = self._write(
"POST",
f"/library/collections?type=1&smart=0&sectionId={section_key}"
f"/library/collections?type={item_type}&smart=0&sectionId={section_key}"
f"&title={quote(title, safe='')}&uri={uri}",
)
items = mc.get("Metadata", []) or []
+2 -1
View File
@@ -7,6 +7,7 @@ the two never diverge — the same one-parser rule that keeps `people.py`'s cast
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
from app.plex import people
from app.plex.plaintext import plain_text
def rich_detail(meta: dict) -> dict:
@@ -42,7 +43,7 @@ def rich_detail(meta: dict) -> dict:
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"),
"tagline": meta.get("tagline"),
"tagline": plain_text(meta.get("tagline")),
"cast": people.cast_from_meta(meta),
}
+3 -2
View File
@@ -17,6 +17,7 @@ from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex import sync as plex_sync
from app.plex.client import PlexClient
from app.plex.genres import hu_genres
from app.plex.plaintext import plain_text
log = logs.get_logger(__name__)
@@ -109,8 +110,8 @@ def _overlay_pass(
for g, meta in hu_by_guid.items():
targets = by_guid[g]
title_hu = (meta.get("title") or "").strip() or None
summary_hu = meta.get("summary")
tagline_hu = (meta.get("tagline") or "").strip() or None
summary_hu = plain_text(meta.get("summary"))
tagline_hu = plain_text(meta.get("tagline"))
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
for row in targets:
row.title_hu = title_hu
+51
View File
@@ -0,0 +1,51 @@
"""Plex text fields as plain text.
Plex hands a summary over as the agent wrote it, and some agents (a Hungarian TV listing, for one)
write HTML: `<p>…</p>` paragraphs and `&aacute;`-style entities. The app renders summaries as text,
so the tags showed literally on the info page and the hover card, and a card's 300-character cut
could land inside one. Every summary and tagline is stored through `plain_text`, and the migration
that cleaned the existing rows calls it too, so the two cannot disagree.
"""
import html
import re
from html.entities import html5
_BREAK = re.compile(r"<br\s*/?>", re.IGNORECASE)
# A paragraph or block boundary: a blank line between the two. Only a tag name ends the match, so a
# bare "<" in prose ("a < b", "<3") is left alone.
_BLOCK = re.compile(r"</?(?:p|div|li|ul|ol|h[1-6]|blockquote)\b[^>]*>", re.IGNORECASE)
_TAG = re.compile(r"</?[a-zA-Z][^>]*>")
_BLANKS = re.compile(r"[ \t]*\n[ \t]*\n\s*")
# A well-formed character reference only. `html.unescape` alone also decodes the legacy forms that
# have no semicolon, so prose like "AT&notice" or "R&copy" came out as "AT¬ice" and "R©". A named
# one counts only when its whole name is an entity: for "&notice;" `html.unescape` still falls back
# to the legacy "&not" inside it.
_REF = re.compile(r"&(?:#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);")
def _decode(m: re.Match[str]) -> str:
ref = m.group()
if ref[1] != "#":
return html.unescape(ref) if ref[1:] in html5 else ref
if ref[2] in "xX":
return html.unescape(ref)
# `int()` refuses a decimal run past 4300 digits, and any number past seven significant digits is
# beyond U+10FFFF, which the parser turns into U+FFFD anyway.
digits = ref[2:-1].lstrip("0") or "0"
return "�" if len(digits) > 7 else html.unescape(f"&#{digits};")
def plain_text(value: str | None) -> str | None:
"""`value` with its markup gone: line breaks kept as newlines, paragraphs as a blank line,
every other tag dropped and entities decoded. None or an all-blank result stays None."""
if value is None:
return None
if "<" in value or "&" in value:
value = _BREAK.sub("\n", value)
value = _BLOCK.sub("\n\n", value)
value = _TAG.sub("", value)
value = _REF.sub(_decode, value)
value = _BLANKS.sub("\n\n", value)
return value.strip() or None
+9 -3
View File
@@ -9,6 +9,7 @@ from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState
from app.plex.cards import ShowState
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
# same at every rail that merges two type-homogeneous lists in Python.
@@ -32,6 +33,11 @@ def show_status(total: int, watched: int, inprog: int) -> str:
return "new"
def show_state(total: int, watched: int, inprog: int) -> ShowState:
"""`show_status` plus whether an episode holds a resume point (the card's `ShowState`)."""
return ShowState(show_status(total, watched, inprog), (inprog or 0) > 0)
def rollup(cards: list[dict]) -> dict:
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
@@ -92,16 +98,16 @@ def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
)
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, ShowState]:
if not show_ids:
return {}
rows = db.execute(agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))).all()
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
return {r.show_id: show_state(r.total, r.watched, r.inprog) for r in rows}
def item_state_map(db: Session, user_id: int, item_ids: list[int]) -> dict[int, PlexState]:
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
leaf analogue of `state_map` (which rolls a show's episodes up to a `ShowState`). One batched
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
if not item_ids:
return {}
+30 -10
View File
@@ -35,7 +35,7 @@ from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS
from app.plex.sync import HDR_TRANSFERS, HW_DECODABLE_CODECS
log = logs.get_logger(__name__)
_HLS_ROOT = (
@@ -203,7 +203,8 @@ _HW_DECODABLE = HW_DECODABLE_CODECS
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
# THE single source is plex.sync (a clip's tonemap reads it too).
_HDR_TRANSFERS = HDR_TRANSFERS
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
@@ -744,8 +745,9 @@ def start_session(
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
# whoever is registered now). The INITIAL spawn never gets None — see its call site.
# registered session is no longer `replace_only`, we abort and return None, and the caller
# answers with its own dead session — never the newer one, whose id a stale requester would
# stop. The INITIAL spawn never gets None — see its call site.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
@@ -840,12 +842,13 @@ def start_session(
)
respawned = _spawn(False, False, replace_only=s)
if respawned is None:
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
# "Not available yet") the client re-requests.
return current_session(user_id, key) or s
# Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one
# registered meanwhile: this answer goes to the request that LOST, whose player drops it as
# stale and stops the session it names (`stop_session`), which stopped the playback that won.
# Never None either: the POST /session route turns a None return into a "Media file not found
# on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a
# retryable 404 ("No active playback session" / "Not available yet") the client re-requests.
return s
s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready:
@@ -1117,6 +1120,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
return dropped
def stop_session(user_id: int, key: str, session_id: str) -> bool:
"""Stop this viewer's session for `key` now, because the player that asked for it is gone (left
the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the
film first — measured: ~4 cores for a minute after leaving a resumed 480p film.
Only while `session_id` is still the registered one: the player names the session it played, and
a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's
current playback, not this request's to end."""
with _lock:
s = _sessions.get((user_id, key))
if s is None or s.id != session_id:
return False
_kill(s)
_sessions.pop((user_id, key), None)
return True
def touch_session(user_id: int, key: str) -> None:
"""Say this 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
+10 -7
View File
@@ -26,6 +26,7 @@ from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres
from app.plex.lookups import by_rating_key
from app.plex.plaintext import plain_text
log = logs.get_logger(__name__)
# Codecs/containers a browser can play in a native <video> without any transcoding.
@@ -63,9 +64,11 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
# own encoder).
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
# Color-transfer values marking an HDR source (PQ / HLG). THE single source of this set: plex.stream
# (the tonemap choice) and downloads.clip (a clip's tonemap) import it here, so a transfer added for
# one is tonemapped by all. A 10-bit/HDR source breaks the VAAPI scale=nv12 path on Gen 9.5 (see
# plex.stream) → software transcode, so it counts as NOT HW-capable.
HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
def transcode_hw_capable(
@@ -106,7 +109,7 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
# whole library would drown the sync's own lines.
except (TypeError, ValueError):
depth = None
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
hdr = str(s.get("colorTrc") or "").lower() in HDR_TRANSFERS
return depth, hdr
return None, False
@@ -468,7 +471,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
row.guid = _canonical_guid(meta) or row.guid
row.title = (meta.get("title") or "").strip() or "Untitled"
row.summary = meta.get("summary")
row.summary = plain_text(meta.get("summary"))
row.year = meta.get("year")
row.duration_s = _dur_s(meta)
row.thumb_key = meta.get("thumb")
@@ -576,7 +579,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
_canonical_guid(meta) or sh.guid
) # EN<->HU pairing key; keep prior if listing has none
sh.title = (meta.get("title") or "").strip() or "Untitled"
sh.summary = meta.get("summary")
sh.summary = plain_text(meta.get("summary"))
sh.year = meta.get("year")
sh.thumb_key = meta.get("thumb")
sh.art_key = meta.get("art")
@@ -659,7 +662,7 @@ def _upsert_collection(
db.add(col)
col.library_id = lib_id
col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary")
col.summary = plain_text(meta.get("summary"))
col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1"
+37 -1
View File
@@ -9,16 +9,52 @@ one existence-leak policy.
from typing import IO, Any, TypeVar
from fastapi import HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.db import Base
from app.models import User
from app.models import POSITION_MAX_S, User
from app.issues import gitea
from app.refusal import refused
from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base)
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
# library's): positions below this are "didn't really start" and within this of the end are
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by
# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin
# (30s) because it marks long-form items watched, not just clears a resume bar — see
# routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
class ProgressIn(BaseModel):
"""A player's resume checkpoint (sent every few seconds while it plays, and as a beacon when the
page goes away)."""
# 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 (`kept_position`) — 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 progress hot path, losing the resume position; `float` keeps the old tolerance.
# 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)
def kept_position(body: ProgressIn) -> int:
"""The whole-second resume point worth storing for `body`, or 0 for "clear it" (too early to
count as started, or close enough to the end to count as finished)."""
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS
return position if position >= PROGRESS_MIN_SECONDS and not near_end else 0
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
+2 -1
View File
@@ -484,7 +484,8 @@ def set_user_suspended(
db: Session = Depends(get_db),
) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
live session is dropped on its next request; their queued downloads wait (the worker skips them)
until they are un-suspended. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in)."""
suspended = body.suspended
+118 -9
View File
@@ -8,7 +8,8 @@ state, serves finished files (range-aware, with the user's custom display name),
import os
import re
import tempfile
from collections.abc import AsyncIterator
from typing import Literal
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -16,8 +17,9 @@ from urllib.parse import parse_qs, unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from pydantic import BaseModel, Field
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
@@ -26,24 +28,33 @@ from app import audit, sysconfig
from app.auth import admin_user, current_user
from app.config import settings
from app.db import get_db, release_read_connection
from app.downloads import clip as clipmod
from app.downloads import credentials as dlcreds
from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import upload as uploadmod
from app.downloads import urlguard
from app.downloads.status import JobStatus
from app.downloads.status import NOT_REFETCHABLE, JobStatus
from app.models import (
DownloadJob,
DownloadLink,
DownloadProfile,
DownloadQuota,
DownloadShare,
LibraryState,
MediaAsset,
User,
display_text,
)
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
from app.routes._common import (
ProgressIn,
capped_stream_to,
get_or_404,
kept_position,
not_found,
owned_or_404,
)
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso
@@ -165,6 +176,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
return {a.id: a for a in rows}
def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
ids = [j.id for j in jobs]
if not ids:
return {}
rows = db.execute(
select(LibraryState.job_id, LibraryState.position_seconds).where(
LibraryState.user_id == user.id, LibraryState.job_id.in_(ids)
)
).all()
return {job_id: pos for job_id, pos in rows}
def _serialize_job(db: Session, job: DownloadJob) -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares."""
@@ -554,6 +578,49 @@ def enqueue_edit(
return _serialize_job(db, job)
class ClipCropIn(BaseModel):
x: float
y: float
w: float
h: float
aspect: float
class EnqueueClipIn(BaseModel):
source: Literal["youtube", "plex"]
# The YouTube video id or the Plex rating key.
ref: str = Field(max_length=32)
start_s: float
end_s: float
# Plex only: which of the file's audio tracks (0-based, in file order) — the one being heard.
audio_track: int | None = None
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
crop: ClipCropIn | None = None
display_name: str | None = None
@router.post("/clip")
def enqueue_clip(
body: EnqueueClipIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Queue an ad-hoc clip: the marked range of a YouTube video or a Plex item, cut straight from
the source into a new asset in this user's library (downloads/clip)."""
try:
crop = body.crop.model_dump() if body.crop else None
spec = clipmod.normalize(
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
)
display_name = display_text(body.display_name)
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
except clipmod.ClipError as e:
status = 404 if e.reason == "not_found" else 422
detail = {"code": "clip", "reason": e.reason, **e.params}
raise HTTPException(status_code=status, detail=detail) from e
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
return _serialize_job(db, job)
def _quota_detail(e: quota.QuotaExceeded) -> dict:
"""Structured error the trilingual client localizes (reason key + numbers), instead of a
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail()."""
@@ -573,7 +640,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get
.all()
)
assets = _assets_for(db, jobs)
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
positions = _positions_for(db, user, jobs)
return [
{
**_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None),
"position_seconds": positions.get(j.id, 0),
}
for j in jobs
]
@router.get("/usage")
@@ -634,10 +708,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get
.all()
)
assets = _assets_for(db, jobs)
positions = _positions_for(db, user, jobs)
out = []
for j in jobs:
data = _serialize(j, assets.get(j.asset_id))
data["shared"] = True
data["position_seconds"] = positions.get(j.id, 0)
out.append(data)
return out
@@ -737,9 +813,9 @@ def redownload_download(
never deletes a file another job/user still shares: the old asset is only removed if THIS job
was its last holder."""
job = _own_job_for_update(db, user, job_id)
if job.job_kind in ("edit", "upload"):
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
if job.job_kind in NOT_REFETCHABLE:
# None has a re-fetchable source: an editor clip is derived locally, an upload IS the
# source, an ad-hoc clip is a range of a source the clip itself does not re-fetch.
raise refused(409, "not_redownloadable")
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED):
raise refused(409, "still_downloading")
@@ -825,6 +901,39 @@ def download_file(
# --- editor filmstrip (scrub track) --------------------------------------------------------
@router.post("/{job_id}/progress")
def set_library_progress(
job_id: int,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Checkpoint the library player's resume point in a download the caller can play (their own, or
one shared with them). A position too early or too close to the end clears it instead."""
_accessible_job(db, user, job_id)
position = kept_position(body)
mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id)
if not position:
# A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of
# one stop may both clear it, and the second then deletes nothing rather than failing.
db.execute(delete(LibraryState).where(mine))
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": 0}
now = datetime.now(timezone.utc)
# One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a
# read-then-insert that the second would fail on the unique key.
db.execute(
pg_insert(LibraryState)
.values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now)
.on_conflict_do_update(
constraint="uq_library_user_job",
set_={"position_seconds": position, "progress_updated_at": now},
)
)
db.commit()
return {"ok": True, "job_id": job_id, "position_seconds": position}
@router.get("/{job_id}/storyboard")
def storyboard_meta(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
+11 -34
View File
@@ -1,7 +1,7 @@
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel
from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -26,14 +26,19 @@ from app.feedquery import (
windowed_relevance_rows,
)
from app.models import (
POSITION_MAX_S,
ChannelTag,
Tag,
User,
Video,
VideoState,
)
from app.routes._common import get_or_404, youtube_refusal, not_found
from app.routes._common import (
ProgressIn,
get_or_404,
kept_position,
not_found,
youtube_refusal,
)
from app.sync.videos import parse_iso8601_duration
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
@@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"}
# Resume-position thresholds (mirror the client): positions below this are "didn't
# really start" and within this of the end are "basically finished" — neither is worth
# storing, so we clear the position instead (the video shows no progress bar).
# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks
# long-form items watched, not just clears a resume bar — see routes/plex/playback.py.
PROGRESS_MIN_SECONDS = 5
FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count.
def _feed_params(
@@ -326,23 +323,10 @@ def clear_video_state(
return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
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.
# 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")
def set_video_progress(
video_id: str,
body: VideoProgressIn,
body: ProgressIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -350,22 +334,15 @@ def set_video_progress(
Stores a per-user position on the video_states row without touching watch status, so a
partially-watched video can render a progress bar and match the "in progress" filter.
Trivially-early and near-finished positions clear the position rather than store it."""
position = int(body.position_seconds)
duration = int(body.duration_seconds)
if position < 0:
position = 0
position = kept_position(body)
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
keep = position >= PROGRESS_MIN_SECONDS and not near_end
row = db.execute(
select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id)
).scalar_one_or_none()
if not keep:
if not position:
# Nothing meaningful to store: drop a status-less row entirely, otherwise just
# zero the position (a saved/hidden video keeps its status).
if row is not None:
+4
View File
@@ -164,6 +164,7 @@ def unified_library(
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
literal(False).label("resumable"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
@@ -211,6 +212,8 @@ def unified_library(
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
(inp > 0).label("resumable"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
@@ -276,6 +279,7 @@ def unified_library(
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
"resumable": r.resumable,
}
)
+60 -6
View File
@@ -24,7 +24,7 @@ from app.models import (
)
from app.plex import rollup
from app.plex import sync as plex_sync
from app.plex.cards import display_title, movie_card, norm_lang, show_card
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
@@ -176,6 +176,40 @@ def collections(
}
@router.get("/collections/for/{item_rating_key}")
def collections_for_item(
item_rating_key: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's
library (a collection lives in one library, and only a manual one can take a title — the same
`can_edit` the editor offers), each saying whether the title is in it already, by name."""
movie = by_rating_key(db, PlexItem, item_rating_key)
title: PlexItem | PlexShow | None = (
movie if movie is not None and movie.kind == "movie" else None
) or by_rating_key(db, PlexShow, item_rating_key)
if title is None:
raise not_found("item")
member_of = set(title.collection_keys or [])
rows = (
db.execute(
select(PlexCollection)
.where(PlexCollection.library_id == title.library_id)
.order_by(collated(PlexCollection.title, lang_for_user(user)))
)
.scalars()
.all()
)
return {
"collections": [
{**_collection_card(c), "contains": c.rating_key in member_of}
for c in rows
if _collection_editable(c)
]
}
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
@@ -284,7 +318,7 @@ def collection_detail(
if shows
else []
)
sstate = {r.show_id: rollup.show_status(r.total, r.watched, r.inprog) for r in show_rows}
sstate = {r.show_id: rollup.show_state(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:
@@ -325,7 +359,7 @@ def collection_detail(
items = [
movie_card(m.obj, mstate.get(m.obj.id), lang=lang)
if m.kind == "movie"
else show_card(m.obj, sstate.get(m.obj.id, "new"), lang=lang)
else show_card(m.obj, sstate.get(m.obj.id, SHOW_NEW), lang=lang)
for m in members
]
years = [m.year for m in members if m.year is not None]
@@ -407,27 +441,47 @@ def create_collection(
seed = body.item_rating_key
if not title or not seed:
raise refused(400, "collection_fields_required")
seed_show = by_rating_key(db, PlexShow, seed)
if library:
lib = (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
)
else:
# The "New collection" flow only knows the seed title → derive its library.
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
seed_item = by_rating_key(db, PlexItem, seed) or seed_show
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
if lib is None:
raise not_found("library")
_reject_duplicate_name(db, lib.id, title)
wrote = False
new_rk = ""
try:
with PlexClient(db) as plex:
new = plex.create_collection(lib.plex_key, title, seed)
# A show collection is made as one (Plex type 2): as a movie one it stayed empty.
new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1)
wrote = True
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
new_rk = str(new.get("ratingKey") or "")
col = plex_sync.resync_collection(db, plex, lib, new_rk)
col.editable = True
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
if wrote and new_rk:
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the
# full sync preserves `editable` but creates a missing row read-only, so the collection
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
# the collection it was just added to, and the collection empty, until that sync.
db.add(
PlexCollection(
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
)
)
Model = PlexItem if lib.kind == "movie" else PlexShow
if row := by_rating_key(db, Model, seed):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
db.commit()
raise _write_or_pending_error(wrote, e) from e
return _collection_card(col)
+29 -2
View File
@@ -14,6 +14,7 @@ from app.collation import collated, sorted_words
from app import logs
from app.auth import current_user
from app.db import get_db
from app.discover import queries
from app.models import (
PlexCollection,
PlexItem,
@@ -28,7 +29,7 @@ from app.plex import people as plex_people
from app.plex import rollup, subtitles
from app.plex.query import title_col
from app.plex import tmdb as plex_tmdb
from app.plex.cards import episode_card, movie_card, show_card
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404
@@ -221,6 +222,24 @@ def show_detail(
}
@router.get("/show/{rating_key}/next")
def show_next(
rating_key: str,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""The episode a show's card plays: the one in progress, else the first unwatched, else the first
(`discover.queries.next_episode`, the rule Home's shelves and the show page's Resume follow).
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
`next` is null for a show with no episodes."""
sh = by_rating_key(db, PlexShow, rating_key)
if sh is None:
raise not_found("show")
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
season subpage, read from the same cached show-detail payload)."""
@@ -260,7 +279,7 @@ def _relatedshow_cards(
if sh is None or rk in seen:
continue
seen.add(rk)
out.append(show_card(sh, statuses.get(sh.id, "new"), lang=lang))
out.append(show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang))
if len(out) >= 20:
break
return out
@@ -404,6 +423,10 @@ def item_detail(
"type": m.get("type"),
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
# An item can carry several credits blocks with scenes between them; Plex
# flags the one that really ends it. Which credits may leave the item is the
# player's rule (`lib/plexMarkers`), so the flag goes over as Plex sent it.
"final": bool(m.get("final")),
}
)
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
@@ -500,6 +523,7 @@ def item_detail(
prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
return {
"id": it.rating_key,
"kind": it.kind,
@@ -533,6 +557,9 @@ def item_detail(
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
"show_title": ((show.title_hu or show.title if hu else show.title) if show else None),
# The episode page links up to its show and its season (their rating keys = the view keys).
"show_id": show.rating_key if show else None,
"season_id": season.rating_key if season else None,
"season_number": it.season_number,
"episode_number": it.episode_number,
"prev_id": prev_id,
+104 -37
View File
@@ -23,7 +23,13 @@ from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup
from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.cards import (
SHOW_NEW,
display_title,
leaf_card,
movie_card,
show_card,
)
from app.plex.query import (
LibraryFilters,
apply_meta_filters,
@@ -69,7 +75,7 @@ def _movie_cards(db: Session, uid: int, items: list[PlexItem], lang: str) -> lis
def _show_cards(db: Session, uid: int, shows: list[PlexShow], lang: str) -> list[dict]:
statuses = rollup.state_map(db, uid, [s.id for s in shows])
return [show_card(s, statuses.get(s.id, "new"), lang=lang) for s in shows]
return [show_card(s, statuses.get(s.id, SHOW_NEW), lang=lang) for s in shows]
def _recently_added(
@@ -232,7 +238,9 @@ def _continue_watching(
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
sq = apply_show_state_filter(
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
select(PlexShow, prog.c.resumed_at, prog.c.total, prog.c.watched, prog.c.inprog).join(
prog, prog.c.show_id == PlexShow.id
),
prog.c.total,
prog.c.watched,
prog.c.inprog,
@@ -243,8 +251,9 @@ def _continue_watching(
.order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
for sh, resumed_at in srows:
rows.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
for sh, resumed_at, total, watched, inprog in srows:
state = rollup.show_state(total, watched, inprog)
rows.append((resumed_at, show_card(sh, state, lang=lang)))
rows.sort(key=lambda r: r[0] or rollup.NO_TS, reverse=True)
return [card for _, card in rows[:RAIL_LIMIT]]
@@ -402,11 +411,71 @@ def discover(
}
def _draw(db: Session, stmt, key_col, per: int, exclude: list[str]) -> list:
"""`per` random rows of `stmt`, the previous draw's (`exclude`) left out while the pool allows: a
reshuffle over a small filtered pool kept dealing the same few titles. What the fresh rows leave
short is topped up from the excluded ones, so a pool smaller than a draw still fills the rail."""
fresh = (
db.execute(stmt.where(key_col.not_in(exclude)).order_by(func.random()).limit(per))
.scalars()
.all()
if exclude
else []
)
if len(fresh) == per:
return list(fresh)
taken = [key_col.not_in([r.rating_key for r in fresh])] if fresh else []
rest = db.execute(stmt.where(*taken).order_by(func.random()).limit(per - len(fresh))).scalars()
return [*fresh, *rest.all()]
def _kept(
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], keep: list[str], lang: str
) -> list[dict]:
"""The `keep` titles' cards in their given order, as they stand now: a title watched since the draw
stays in it, marked watched, and one gone from the scope's libraries drops out."""
movies = (
db.execute(
select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
PlexItem.rating_key.in_(keep),
)
)
.scalars()
.all()
if movie_ids
else []
)
shows = (
db.execute(
select(PlexShow).where(PlexShow.library_id.in_(show_ids), PlexShow.rating_key.in_(keep))
)
.scalars()
.all()
if show_ids
else []
)
cards = {
c["id"]: c
for c in [
*_movie_cards(db, uid, list(movies), lang),
*_show_cards(db, uid, list(shows), lang),
]
}
return [cards[k] for k in keep if k in cards]
@router.get("/surprise")
def surprise(
scope: str = "both",
lang: str = "en",
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
# The previous draw's rating keys: a reshuffle deals other titles first.
exclude: list[str] = Query(default=[], max_length=40),
# The draw on screen, read again rather than redrawn: its titles' watch state moves (a play, a
# toggle) while the draw itself stands until the ↻.
keep: list[str] = Query(default=[], max_length=40),
f: LibraryFilters = Depends(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -419,6 +488,8 @@ def surprise(
lang = _norm_lang(lang)
p = f.as_dict()
movie_ids, show_ids = _scope_libs(db, scope)
if keep:
return {"scope": scope, "items": _kept(db, user.id, movie_ids, show_ids, keep, lang)}
per = min(limit, SURPRISE_PER_KIND)
movies = []
if movie_ids:
@@ -432,23 +503,21 @@ def surprise(
)
.exists()
)
movies = (
db.execute(
_movie_meta(
select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
~watched_movie,
),
p,
lang,
f,
)
.order_by(func.random())
.limit(per)
)
.scalars()
.all()
movies = _draw(
db,
_movie_meta(
select(PlexItem).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_ids),
~watched_movie,
),
p,
lang,
f,
),
PlexItem.rating_key,
per,
exclude,
)
shows = []
if show_ids:
@@ -458,21 +527,19 @@ def surprise(
not_fully_watched = or_(
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
)
shows = (
db.execute(
apply_meta_filters(
select(PlexShow)
.outerjoin(prog, prog.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
PlexShow,
p,
lang,
)
.order_by(func.random())
.limit(per)
)
.scalars()
.all()
shows = _draw(
db,
apply_meta_filters(
select(PlexShow)
.outerjoin(prog, prog.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
PlexShow,
p,
lang,
),
PlexShow.rating_key,
per,
exclude,
)
mcards = _movie_cards(db, user.id, movies, lang)
scards = _show_cards(db, user.id, shows, lang)
+3 -3
View File
@@ -19,7 +19,7 @@ from app.models import (
)
from app.plex import rollup
from app.plex import tmdb as plex_tmdb
from app.plex.cards import movie_card, norm_lang, show_card
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
from app.plex.query import content_libs, split_csv, title_col
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -57,7 +57,7 @@ def _resolve_known_for(
# A credit resolves against its OWN media_type's bucket only.
if k.get("media_type") == "tv":
if g in shows:
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
out.append(show_card(shows[g], show_statuses.get(shows[g].id, SHOW_NEW), lang=lang))
elif g in movies:
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
return out[:_KNOWN_FOR_SHOWN]
@@ -245,7 +245,7 @@ def person_detail(
.all()
)
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
known_for = _resolve_known_for(
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
+24 -17
View File
@@ -15,7 +15,7 @@ from fastapi import (
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -23,7 +23,6 @@ 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,
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404
from app.refusal import refused
from app.routes._common import not_found
from app.routes._common import ProgressIn, not_found
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -160,11 +159,11 @@ def subtitle(
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
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence,
# not drift. Keep them separate on purpose (see routes/feed.py).
# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
# library's margin only clears a resume bar — different domains, intentional divergence, not drift.
# Keep them separate on purpose (see routes/_common.py).
_PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
return False
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)
class ItemProgressIn(ProgressIn):
"""The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
# 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).
# 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
@@ -637,12 +629,27 @@ def stream_session(
# 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}",
# What the player names when it no longer wants the session (POST …/session/{id}/stop).
"id": s.id,
"transcode": s.transcode,
"hw": s.hw,
"tonemap": s.tonemap,
}
@router.post("/stream/{rating_key}/session/{session_id}/stop")
def stream_session_stop(
rating_key: str,
session_id: str,
user: User = Depends(current_user),
) -> dict:
"""The player no longer wants this session (it left the item, closed, or the page unloaded):
its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a
keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and
only while it is still the one registered for the item (see `stop_session`)."""
return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)}
@router.get("/stream/{rating_key}/file")
def stream_file(
rating_key: str,
+13 -2
View File
@@ -17,7 +17,7 @@ from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import storage
from app.downloads.status import JobStatus
from app.models import DownloadJob, DownloadLink, MediaAsset
from app.models import DownloadJob, DownloadLink, MediaAsset, User
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
from app.refusal import refused
@@ -44,6 +44,17 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
return link
def _being_made(db: Session, job: DownloadJob) -> bool:
"""The job is running, or queued for a worker that will take it: the worker leaves a suspended
owner's queued jobs where they are (`worker._claim_job`), as long as the suspension lasts."""
if job.status == JobStatus.RUNNING:
return True
if job.status != JobStatus.QUEUED:
return False
owner = db.get(User, job.user_id)
return owner is not None and not owner.is_suspended
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
@@ -51,7 +62,7 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
job is not None
and asset is not None
and asset.status in ("pending", "downloading")
and job.status in (JobStatus.QUEUED, JobStatus.RUNNING)
and _being_made(db, job)
):
# Being (re)made — a re-download, or a conversion — so the link works again once it is ready:
# its own reason, so the page can say "try again shortly" rather than "this link is gone".
+39 -11
View File
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
from functools import partial
from sqlalchemy import text
from sqlalchemy.orm import Session
from app import logs
from app import logsink
@@ -44,7 +45,7 @@ from app.downloads.jobdb import (
_reset_asset_pending,
_staging_dir,
)
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
from app.downloads.status import STOPPED_BY_ROUTE, JobKind, JobStatus
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
log = logs.get_logger(__name__)
@@ -162,7 +163,12 @@ def _claim_job() -> int | None:
seen.) The per-user limit is the quota row's `max_concurrent` (else the sysconfig default);
`unlimited` users are never capped. A final Python `at_concurrency_limit` re-check still guards
the tiny race between the exclusion subquery and the claim (it can only ever reject, never
over-claim), so correctness never depends on the two observations agreeing."""
over-claim), so correctness never depends on the two observations agreeing.
A suspended user's queued jobs are left where they are: suspension blocks their downloads (not
only their sign-in), and lifting it lets them run in their old place. A job already running
when the user is suspended finishes on that worker; one a worker restart puts back in the queue
(`_requeue_if_running`, `_recover_orphans`) waits with the rest."""
with SessionLocal() as db:
default_mc = sysconfig.get_int(db, "download_default_max_concurrent")
rows = db.execute(
@@ -170,6 +176,10 @@ def _claim_job() -> int | None:
"""
SELECT id, user_id FROM download_jobs
WHERE status='queued'
AND NOT EXISTS (
SELECT 1 FROM users u
WHERE u.id = download_jobs.user_id AND u.is_suspended
)
AND NOT EXISTS (
SELECT 1 FROM media_assets a
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
@@ -437,7 +447,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
if status is None or status in STOPPED_BY_ROUTE:
reason = "cancelled" # a route stopped it (or the row vanished)
break
if status == "running":
if status == JobStatus.RUNNING:
if stale is not None and stale > stall_s:
reason = "stalled"
break
@@ -448,7 +458,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
# teardown. A still-running job over the cap is failed ("timeout"); a terminal one (already
# done/error) just gets its lingering child killed, its DB status left intact ("abandon").
if time.monotonic() - started > wall_s:
reason = "timeout" if status == "running" else "abandon"
reason = "timeout" if status == JobStatus.RUNNING else "abandon"
break
# else: a terminal status under the cap (done/error, or a re-queued contended asset) means
# the child is exiting on its own — keep polling; NEVER kill a finishing child (that could
@@ -479,7 +489,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
_reset_asset_pending(asset_id)
if proc.returncode != _CHILD_OK:
status, _ = _job_liveness(job_id)
if status == "running":
if status == JobStatus.RUNNING:
_cleanup_staging(asset_id)
_fail_running(
job_id,
@@ -560,15 +570,12 @@ def _process_job(job_id: int) -> None:
asset_id = asset.id if asset else None
asset_status = asset.status if asset else None
asset_error = asset.error if asset else None
if asset_status == "error":
if asset is not None and asset_status == "error":
# A job that meets an already-failed asset gets that failure's explanation — not a bare
# "Download failed". Only the text is on the asset, so it is diagnosed again, for this
# link, with whether a cookie goes with it asked of the same gate the download child
# used (`credentials.usable_cookie`) — as it stands now, so the advice fits this job's Retry.
url = service.source_url(job.source_kind, job.source_ref)
cookie = credentials.usable_cookie(
db, job.user_id, url, asset, formats.normalize(job.profile_snapshot)
)
url, cookie = _failed_link_and_cookie(db, job, asset)
diagnosis = dlerrors.diagnose(asset_error, url=url, cookie_attached=cookie is not None)
del cookie # only whether there is one matters here
@@ -605,13 +612,34 @@ def _process_job(job_id: int) -> None:
_run_supervised(job_id, asset_id)
def _failed_link_and_cookie(
db: Session, job: DownloadJob, asset: MediaAsset
) -> tuple[str | None, str | None]:
"""The link a job's failed asset was fetched from, and the cookie that fetch would go with now.
A clip's link is its source's (a `clip` ref names the user's range, no page), asked of the
clip's own cookie gate, as its fetch was (`_clip_source`); a Plex clip fetches nothing."""
if job.job_kind == JobKind.CLIP:
spec = dict(job.edit_spec or {})
if spec.get("source") != "youtube":
return None, None
url = service.source_url("youtube", spec["ref"])
return url, credentials.usable_clip_cookie(db, job.user_id, url, asset, spec)
url = service.source_url(job.source_kind, job.source_ref)
spec = formats.normalize(job.profile_snapshot)
return url, credentials.usable_cookie(db, job.user_id, url, asset, spec)
def _finish_from_cache(job_id: int, asset_id: int) -> None:
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
asset = db.get(MediaAsset, asset_id)
if job and asset:
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
if not job.display_name and asset.title:
# A clip queued under the placeholder takes the title its sibling's fetch found.
unnamed = not job.display_name or (
job.job_kind == JobKind.CLIP and job.display_name == service.clip_title(None, None)
)
if unnamed and asset.title:
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
asset.last_access_at = datetime.now(timezone.utc)
+5 -4
View File
@@ -2,9 +2,10 @@
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
# The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh)
# scans it, blocking. A security fix may be ONE pin bumped in place instead of a full
# regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must
# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177).
# scans it, blocking. A security fix, or a yt-dlp release YouTube's changes force, may be ONE pin
# bumped in place instead of a full regeneration, which would move every other package too — then
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403).
APScheduler==3.11.3
Authlib==1.7.2
Mako==1.4.1
@@ -62,4 +63,4 @@ uvloop==0.22.1
watchfiles==1.2.0
websockets==17.0.1
yt-dlp-ejs==0.8.0
yt-dlp==2026.7.4
yt-dlp==2026.8.19
+6 -4
View File
@@ -16,12 +16,14 @@ argon2-cffi>=23.1,<26
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
# keep the floor loose and bump it when extraction breaks.
yt-dlp[default]>=2025.5.22
# keep the floor loose and bump it when extraction breaks. 2026.8.19 marks the clients that need a
# stream PO token, which the shipped player clients (config.download_player_clients) rely on.
yt-dlp[default]>=2026.8.19
# Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some
# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in
# 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping
# (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imported curl_cffi only in
# 0.10.x–0.15.x and silently reported every target "unavailable" outside it (2026.08.19 takes up to
# 0.16.x; the cap stays on the measured 0.15 until a bump re-measures it). Move it when bumping
# yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome`
# fails the gate if the locked pair can no longer impersonate.
curl_cffi>=0.10,<0.16
+15
View File
@@ -175,6 +175,21 @@ def test_the_cookie_capable_clients_match_the_installed_ytdlp():
assert set(YouTubeAdapter._AUTHED_DEFAULT) <= live
def test_the_shipped_player_clients_exist_in_the_installed_ytdlp():
# yt-dlp skips a client it does not know with a warning and falls back to its own defaults, so
# a bump that renames or drops one (tv_simply, the one measured to deliver HD) would change
# what every download is fetched with, silently. Read from the Settings defaults, not from
# `settings`, which an environment may override.
from yt_dlp.extractor.youtube._base import INNERTUBE_CLIENTS
from app.config import Settings
fields = Settings.model_fields
for knob in ("download_player_clients", "download_player_clients_fallback"):
names = Settings._clients(fields[knob].default)
assert names and set(names) <= set(INNERTUBE_CLIENTS), (knob, names)
def test_youtube_ydl_opts_extra_carries_player_client_and_pot():
ea = YouTubeAdapter().ydl_opts_extra(["android_vr"])["extractor_args"]
assert ea["youtube"]["player_client"] == ["android_vr"]
+810
View File
@@ -0,0 +1,810 @@
"""Ad-hoc clips (downloads/clip): the spec, the one ffmpeg command every clip goes through, the route,
and the worker path for both sources.
The ffmpeg command is judged at argv level: where the seek sits, which streams are mapped, what the
video filter does to an HDR source. The worker runs with ffmpeg / yt-dlp replaced by stand-ins that
write a file where they were told to, so what is checked is what the pipeline asks of them and what
it stores afterwards.
"""
from pathlib import Path
import pytest
from app import worker as workermod
from app.config import settings
from app.downloads import clip as clipmod
from app.downloads import errors as dlerrors
from app.downloads import formats, pipeline, quota, runner, service
from app.downloads import probe as probemod
from app.downloads.edit import INPUT_GUARD
from app.models import Channel, DownloadJob, MediaAsset, PlexItem, PlexLibrary, PlexShow, Video
from app.plex import paths as plex_paths
# --- the spec -----------------------------------------------------------------------------------
def test_a_spec_is_canonical_so_the_same_marks_hit_the_same_clip():
a = clipmod.normalize("youtube", "abcdefghijk", 10.00049, 40.0)
b = clipmod.normalize("youtube", "abcdefghijk", 10.0, 40.0004)
assert a == b == {"source": "youtube", "ref": "abcdefghijk", "start_s": 10.0, "end_s": 40.0}
assert clipmod.sig(a) == clipmod.sig(b)
assert clipmod.sig(a) != clipmod.sig({**a, "end_s": 41.0})
@pytest.mark.parametrize(
("args", "reason"),
[
(("vimeo", "abcdefghijk", 0, 10), "bad_source"),
(("youtube", "short", 0, 10), "bad_source"),
(("youtube", "abcdefghij/", 0, 10), "bad_source"),
(("plex", "12a", 0, 10), "bad_source"),
(("youtube", "abcdefghijk", -1, 10), "bad_range"),
(("youtube", "abcdefghijk", 0, float("nan")), "bad_range"),
(("youtube", "abcdefghijk", 5, 5.5), "too_short"),
(("youtube", "abcdefghijk", 30, 10), "too_short"),
(("youtube", "abcdefghijk", 0, 600.001), "too_long"),
],
)
def test_a_range_it_cannot_cut_is_refused_with_its_reason(args, reason):
with pytest.raises(clipmod.ClipError) as e:
clipmod.normalize(*args)
assert e.value.reason == reason
def test_the_ten_minute_cap_itself_is_allowed():
assert clipmod.duration(clipmod.normalize("plex", "42", 0, 600)) == 600
def test_an_audio_track_is_kept_for_plex_only_and_must_be_sane():
assert clipmod.normalize("plex", "42", 0, 10, 2)["audio_track"] == 2
assert "audio_track" not in clipmod.normalize("youtube", "abcdefghijk", 0, 10, 2)
with pytest.raises(clipmod.ClipError, match="bad_audio"):
clipmod.normalize("plex", "42", 0, 10, -1)
def test_a_youtube_clip_links_its_video_at_the_clip_start_and_a_plex_clip_links_nothing():
yt = clipmod.normalize("youtube", "abcdefghijk", 75.9, 90)
assert clipmod.reference_url(yt) == "https://www.youtube.com/watch?v=abcdefghijk&t=75s"
assert clipmod.reference_url(clipmod.normalize("plex", "42", 0, 10)) is None
# --- the ffmpeg command ----------------------------------------------------------------------------
def _cmd(**kw):
args = {"audio_track": None, "hdr": False, "threads": 3, **kw}
return clipmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.mp4"), 61.5, 30.0, **args)
def test_the_seek_is_on_the_input_so_the_clip_starts_on_the_marked_frame():
cmd = _cmd()
i = cmd.index("-i")
assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i
assert cmd[i + 1] == "/m/in.mkv"
assert cmd[cmd.index("-t") + 1] == "30.000" and cmd.index("-t") > i
# The single-file demuxer allowlist goes with every library read.
g = cmd.index(INPUT_GUARD[0])
assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i
assert cmd[-1] == "/s/out.mp4"
def test_it_maps_the_first_video_and_the_heard_audio_track_and_nothing_else():
cmd = _cmd(audio_track=2)
maps = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"]
assert maps == ["0:v:0", "0:a:2"]
assert "-sn" in cmd and "-dn" in cmd
# An mkv's chapters would ride into the .mp4 as a data track of their own (measured on a Blu-ray
# rip: `bin_data`), which -dn does not drop.
assert cmd[cmd.index("-map_chapters") + 1] == "-1"
# No track chosen: the first one, and a source with no audio at all still cuts.
maps = [c for i, c in enumerate(_cmd()) if i and _cmd()[i - 1] == "-map"]
assert maps == ["0:v:0", "0:a:0?"]
def test_the_output_is_phone_safe_stereo_h264_at_most_1080p():
cmd = _cmd()
assert cmd[cmd.index("-c:v") + 1] == "libx264"
assert cmd[cmd.index("-pix_fmt") + 1] == "yuv420p"
assert cmd[cmd.index("-c:a") + 1] == "aac" and cmd[cmd.index("-ac") + 1] == "2"
assert cmd[cmd.index("-threads") + 1] == "3"
vf = cmd[cmd.index("-vf") + 1]
assert "min(1080,ih)" in vf and "tonemap" not in vf
def test_an_hdr_source_is_tonemapped_to_sdr_before_it_is_scaled():
vf = _cmd(hdr=True)[_cmd(hdr=True).index("-vf") + 1]
assert vf.index("tonemap") < vf.index("zscale=t=bt709") < vf.index("min(1080,ih)")
@pytest.mark.parametrize(
("transfer", "hdr"),
[("smpte2084", True), ("arib-std-b67", True), ("bt709", False), (None, False)],
)
def test_hdr_is_read_from_the_first_video_stream(transfer, hdr):
data = {
"streams": [{"codec_type": "audio"}, {"codec_type": "video", "color_transfer": transfer}]
}
assert clipmod.is_hdr(data) is hdr
# --- the crop ----------------------------------------------------------------------------------------
_FRAME = {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
def test_a_crop_frame_is_canonical_and_a_frame_over_the_whole_box_is_no_crop():
a = clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "x": 0.50004})
b = clipmod.normalize("plex", "42", 0, 10, crop=_FRAME)
assert a["crop"] == b["crop"] == {"x": 0.5, "y": 0.25, "w": 0.25, "h": 0.5, "aspect": 1.7778}
assert clipmod.sig(a) == clipmod.sig(b)
whole = clipmod.normalize(
"plex", "42", 0, 10, crop={"x": 0, "y": 0, "w": 1, "h": 1, "aspect": 1}
)
assert whole == clipmod.normalize("plex", "42", 0, 10)
# A far edge a hair past the box (the client's rounding) is the box's edge.
assert clipmod.normalize("plex", "42", 0, 10, crop={**_FRAME, "w": 0.5004})["crop"]["w"] == 0.5
@pytest.mark.parametrize(
"frame",
[
{**_FRAME, "w": 0.05},
{**_FRAME, "h": 0.09},
{**_FRAME, "x": -0.1},
{**_FRAME, "x": 0.8},
{**_FRAME, "y": 0.6},
{**_FRAME, "aspect": 0},
{**_FRAME, "aspect": 9},
{**_FRAME, "x": float("nan")},
{"x": 0.1, "y": 0.1, "w": 0.5},
{**_FRAME, "h": "half"},
],
)
def test_a_crop_frame_it_cannot_cut_is_refused(frame):
with pytest.raises(clipmod.ClipError) as e:
clipmod.normalize("plex", "42", 0, 10, crop=frame)
assert e.value.reason == "bad_crop"
@pytest.mark.parametrize(
("stream", "dar"),
[
({"width": 1920, "height": 1080}, 16 / 9),
# An anamorphic DVD: 720×480 stored, shown 16:9.
({"width": 720, "height": 480, "sample_aspect_ratio": "32:27"}, 16 / 9),
({"width": 640, "height": 480, "sample_aspect_ratio": "0:1"}, 4 / 3),
# A phone video stored landscape, shown upright.
({"width": 1280, "height": 720, "side_data_list": [{"rotation": -90}]}, 9 / 16),
({"width": 1280, "height": 720, "tags": {"rotate": "270"}}, 9 / 16),
({"width": 1280, "height": 720, "side_data_list": [{"rotation": 180}]}, 16 / 9),
({"width": None, "height": 720}, None),
],
)
def test_the_display_aspect_is_the_picture_as_a_player_shows_it(stream, dar):
got = clipmod.display_aspect(
{"streams": [{"codec_type": "audio"}, {"codec_type": "video", **stream}]}
)
assert got == pytest.approx(dar) if dar else got is None
def _crop_args(f: str) -> list[float]:
# crop=trunc(iw*W/2)*2:trunc(ih*H/2)*2:trunc(iw*X/2)*2:trunc(ih*Y/2)*2 → [W, H, X, Y]
assert f.startswith("crop=")
return [float(p.split("*")[1].split("/")[0]) for p in f[5:].split(":")]
def test_a_frame_on_a_picture_that_fills_its_box_is_cut_as_drawn():
assert _crop_args(clipmod.crop_filter(_FRAME, 16 / 9)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
def test_a_frame_drawn_in_a_16_9_box_over_a_4_3_picture_maps_onto_the_picture():
# The 4:3 picture fills 3/4 of the box's width from 1/8 in: a frame over its right half's middle.
frame = {"x": 0.5, "y": 0.25, "w": 0.1875, "h": 0.5, "aspect": 16 / 9}
assert _crop_args(clipmod.crop_filter(frame, 4 / 3)) == pytest.approx([0.25, 0.5, 0.5, 0.25])
def test_the_part_of_a_frame_over_a_bar_is_dropped_and_a_frame_on_a_bar_alone_is_no_cut():
over = {"x": 0.0, "y": 0.0, "w": 0.25, "h": 0.5, "aspect": 16 / 9}
w, h, x, y = _crop_args(clipmod.crop_filter(over, 4 / 3))
assert (w, h, x, y) == pytest.approx([1 / 6, 0.5, 0, 0], abs=1e-6)
assert clipmod.crop_filter({**over, "w": 0.13}, 4 / 3) is None
# A letterboxed film: the frame over the whole box is the whole picture.
whole = {"x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0, "aspect": 16 / 9}
assert clipmod.crop_filter(whole, 2.39) == ""
def test_the_crop_comes_first_so_the_tonemap_and_the_scale_work_on_what_it_keeps():
f = clipmod.crop_filter(_FRAME, 16 / 9)
vf = _cmd(hdr=True, crop=f)[_cmd(hdr=True, crop=f).index("-vf") + 1]
assert vf.startswith(f + ",") and vf.index("tonemap") < vf.index("min(1080,ih)")
assert "crop" not in _cmd()[_cmd().index("-vf") + 1]
# --- the route ---------------------------------------------------------------------------------------
@pytest.fixture
def yt_video(db):
db.add(Channel(id="UC_clip", title="The Channel"))
db.flush()
db.add(
Video(
id="abcdefghijk",
channel_id="UC_clip",
title="A Funny Video",
duration_seconds=300,
thumbnail_url="https://i.ytimg.com/vi/abcdefghijk/hq.jpg",
)
)
db.commit()
return "abcdefghijk"
@pytest.fixture
def plex_episode(db, tmp_path, monkeypatch):
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
show = PlexShow(rating_key="s9", library_id=lib.id, title="The Show")
db.add(show)
db.flush()
db.add(
PlexItem(
rating_key="777",
library_id=lib.id,
kind="episode",
title="Pilot",
show_id=show.id,
season_number=1,
episode_number=2,
duration_s=1800,
file_path="/data/shows/pilot.mkv",
)
)
db.commit()
media = tmp_path / "pilot.mkv"
media.write_bytes(b"m" * 8)
monkeypatch.setattr(
plex_paths,
"local_media_path",
lambda _db, p: media if p == "/data/shows/pilot.mkv" else None,
)
return media
def _clip(client, **body):
return client.post("/api/downloads/clip", json={"start_s": 10, "end_s": 40, **body})
def test_a_youtube_clip_is_queued_as_its_own_job_titled_after_its_video(
db, make_user, as_user, yt_video
):
r = _clip(as_user(make_user()), source="youtube", ref=yt_video)
assert r.status_code == 200, r.text
job = r.json()
assert (job["job_kind"], job["status"], job["display_name"]) == (
"clip",
"queued",
"A Funny Video (clip)",
)
assert job["edit_spec"] == {"source": "youtube", "ref": yt_video, "start_s": 10, "end_s": 40}
assert job["source_url"] == f"https://www.youtube.com/watch?v={yt_video}&t=10s"
asset = db.get(MediaAsset, job["asset"]["id"])
assert (asset.duration_s, asset.uploader, asset.media_kind) == (30, "The Channel", "video")
def test_the_same_range_again_is_the_same_clip_but_never_another_users(
db, make_user, as_user, yt_video
):
a, b = make_user(), make_user()
first = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"]
again = _clip(as_user(a), source="youtube", ref=yt_video).json()["asset"]["id"]
other = _clip(as_user(b), source="youtube", ref=yt_video).json()["asset"]["id"]
longer = _clip(as_user(a), source="youtube", ref=yt_video, end_s=41).json()["asset"]["id"]
assert first == again
assert len({first, other, longer}) == 3
def test_a_ready_clip_asked_for_again_is_done_at_once(db, make_user, as_user, yt_video):
u = make_user()
first = _clip(as_user(u), source="youtube", ref=yt_video).json()
asset = db.get(MediaAsset, first["asset"]["id"])
asset.status, asset.rel_path = "ready", ".edits/1/clip_1.mp4"
db.commit()
again = _clip(as_user(u), source="youtube", ref=yt_video).json()
assert (again["status"], again["progress"]) == ("done", 100)
@pytest.mark.parametrize(
("body", "status", "reason", "params"),
[
# The cap travels with its refusal, so the client never states a stale one.
({"end_s": 700}, 422, "too_long", {"limit_s": clipmod.MAX_CLIP_S}),
({"start_s": 299.5, "end_s": 330}, 422, "past_end", {}),
({"ref": "nope"}, 422, "bad_source", {}),
],
)
def test_a_youtube_clip_it_cannot_cut_says_why(
db, make_user, as_user, yt_video, body, status, reason, params
):
r = _clip(as_user(make_user()), **{"source": "youtube", "ref": yt_video, **body})
assert r.status_code == status
assert r.json()["detail"] == {"code": "clip", "reason": reason, **params}
assert db.query(DownloadJob).count() == 0
def test_a_plex_clip_is_titled_show_episode_title_and_keeps_its_audio_track(
db, make_user, as_user, plex_episode
):
r = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1)
assert r.status_code == 200, r.text
job = r.json()
assert job["display_name"] == "The Show — S1·E2 — Pilot (clip)"
assert job["edit_spec"]["audio_track"] == 1
assert job["source_url"] is None
def test_a_plex_clip_of_an_unknown_item_is_a_404_and_of_an_unreachable_file_a_422(
db, make_user, as_user, plex_episode
):
c = as_user(make_user())
assert _clip(c, source="plex", ref="999").json()["detail"]["reason"] == "not_found"
assert _clip(c, source="plex", ref="999").status_code == 404
item = db.query(PlexItem).one()
item.file_path = "/data/elsewhere.mkv"
db.commit()
r = _clip(c, source="plex", ref="777")
assert (r.status_code, r.json()["detail"]["reason"]) == (422, "media_missing")
def test_a_clip_over_quota_is_refused_like_a_download(
db, make_user, as_user, yt_video, monkeypatch
):
def full(_db, _uid):
raise quota.QuotaExceeded("max_jobs", limit=5, current=5)
monkeypatch.setattr(quota, "check_enqueue", full)
r = _clip(as_user(make_user()), source="youtube", ref=yt_video)
assert r.status_code == 422
assert db.query(DownloadJob).count() == 0
def test_a_clip_is_not_redownloadable(db, make_user, as_user, yt_video):
c = as_user(make_user())
job = _clip(c, source="youtube", ref=yt_video).json()
row = db.get(DownloadJob, job["id"])
row.status = "done"
db.commit()
r = c.post(f"/api/downloads/{job['id']}/redownload")
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "not_redownloadable")
# --- the worker ----------------------------------------------------------------------------------------
def test_a_clip_job_reaches_the_clip_entry(monkeypatch):
called = []
for name in ("_convert_upload", "_process_edit", "_download", "_process_clip"):
monkeypatch.setattr(pipeline, name, lambda *a, _n=name: called.append(_n))
runner._run(5, 7, "clip", "clip", "1:youtube:abcdefghijk", {}, 1)
assert called == ["_process_clip"]
@pytest.fixture
def worker(db, tmp_path, monkeypatch):
"""The worker path with its external programs replaced: ffmpeg writes the file it was given, the
probe answers what the test says, the phone-safe step and the poster pass through."""
monkeypatch.setattr(settings, "download_root", str(tmp_path / "root"))
seen: dict = {}
def ffmpeg(cmd, *_a, **_k):
seen["cmd"] = cmd
Path(cmd[-1]).write_bytes(b"clip")
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", ffmpeg)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, p, *_a, **_k: p)
monkeypatch.setattr(pipeline.editmod, "ensure_poster", lambda *_a, **_k: None)
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 1920, 1080, 30))
seen["probe"] = {"streams": [{"codec_type": "video"}, {"codec_type": "audio"}]}
monkeypatch.setattr(probemod, "probe_json", lambda _p: seen["probe"])
def run(job_json):
job = db.get(DownloadJob, job_json["id"])
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading"
db.commit()
pipeline._process_clip(job.id, asset.id)
db.expire_all()
return db.get(DownloadJob, job.id), db.get(MediaAsset, asset.id)
seen["run"] = run
return seen
def test_a_plex_clip_is_cut_from_the_library_file_with_its_audio_track(
db, make_user, as_user, plex_episode, worker
):
worker["probe"] = {
"streams": [
{"codec_type": "video", "color_transfer": "smpte2084"},
{"codec_type": "audio"},
{"codec_type": "audio"},
]
}
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=1).json()
job, asset = worker["run"](job)
assert (job.status, asset.status, asset.container, asset.height) == (
"done",
"ready",
"mp4",
1080,
)
cmd = worker["cmd"]
assert cmd[cmd.index("-i") + 1] == str(plex_episode)
assert cmd[cmd.index("-ss") + 1] == "10.000"
assert "0:a:1" in cmd and "tonemap" in cmd[cmd.index("-vf") + 1]
assert asset.rel_path.startswith(".edits/") and asset.rel_path.endswith(".mp4")
def test_a_cropped_plex_clip_cuts_the_frame_from_the_picture_and_is_its_own_clip(
db, make_user, as_user, plex_episode, worker
):
worker["probe"] = {
"streams": [
{"codec_type": "video", "width": 720, "height": 480, "sample_aspect_ratio": "32:27"}
]
}
c = as_user(make_user())
plain = _clip(c, source="plex", ref="777").json()
job = _clip(c, source="plex", ref="777", crop=_FRAME).json()
assert job["asset"]["id"] != plain["asset"]["id"]
assert job["edit_spec"]["crop"]["w"] == 0.25
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
vf = worker["cmd"][worker["cmd"].index("-vf") + 1]
# Stored to four decimals, the box's 16:9 is a hair off the file's: well under a pixel.
assert _crop_args(vf.split(",")[0]) == pytest.approx([0.25, 0.5, 0.5, 0.25], abs=1e-4)
def test_a_crop_frame_that_misses_the_picture_fails_and_says_so(
db, make_user, as_user, plex_episode, worker
):
# A 4:3 file, a frame drawn over its pillar bar in a 16:9 box.
worker["probe"] = {"streams": [{"codec_type": "video", "width": 640, "height": 480}]}
frame = {"x": 0.0, "y": 0.0, "w": 0.12, "h": 0.5, "aspect": 16 / 9}
job = _clip(as_user(make_user()), source="plex", ref="777", crop=frame).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("error", "error")
assert "crop" in job.error
assert "cmd" not in worker
def test_a_crop_frame_the_route_cannot_cut_says_why(db, make_user, as_user, yt_video):
r = _clip(as_user(make_user()), source="youtube", ref=yt_video, crop={**_FRAME, "w": 0.01})
assert r.status_code == 422
assert r.json()["detail"] == {"code": "clip", "reason": "bad_crop"}
def test_a_plex_clip_of_a_track_the_file_does_not_have_fails_and_says_so(
db, make_user, as_user, plex_episode, worker
):
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=3).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("error", "error")
assert "audio track" in job.error
assert "cmd" not in worker
def test_a_youtube_clip_fetches_only_its_range_and_cuts_it_from_zero(
db, make_user, as_user, yt_video, worker, monkeypatch
):
asked: dict = {}
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
asked.update(url=url, spec=spec, **kw)
f = Path(staging) / "abcdefghijk.mp4"
f.write_bytes(b"range")
return {"requested_downloads": [{"filepath": str(f)}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
assert asked["section"] == (10, 40)
assert asked["spec"]["max_height"] == 1080
assert asked["url"].endswith(yt_video)
cmd = worker["cmd"]
assert cmd[cmd.index("-ss") + 1] == "0.000" and cmd[cmd.index("-t") + 1] == "30.000"
assert cmd[cmd.index("-i") + 1].endswith("abcdefghijk.mp4")
def test_a_youtube_clip_hands_the_range_to_yt_dlp(monkeypatch, tmp_path):
seen = {}
class FakeYDL:
def __init__(self, opts):
seen.update(opts)
def __enter__(self):
return self
def __exit__(self, *_a):
return False
def extract_info(self, *_a, **_k):
return {}
monkeypatch.setattr(pipeline.yt_dlp, "YoutubeDL", FakeYDL)
monkeypatch.setattr(pipeline, "_touch_job", lambda *_a: None)
adapter = pipeline.adapters.resolve_adapter("youtube", "abcdefghijk")
pipeline._extract_with_fallback(
1,
2,
"https://www.youtube.com/watch?v=abcdefghijk",
pipeline.formats.normalize({}),
tmp_path,
adapter,
section=(10.0, 40.0),
)
ranges = list(seen["download_ranges"]({}, None))
assert [(r["start_time"], r["end_time"]) for r in ranges] == [(10.0, 40.0)]
assert seen["force_keyframes_at_cuts"] is False
def test_service_reference_url_follows_the_clip_spec(db, make_user, as_user, yt_video):
job = _clip(as_user(make_user()), source="youtube", ref=yt_video, start_s=65, end_s=90).json()
row = db.get(DownloadJob, job["id"])
assert service.reference_url(row, None) == f"https://www.youtube.com/watch?v={yt_video}&t=65s"
def test_a_youtube_clip_outside_the_catalog_takes_its_title_from_the_fetch(
db, make_user, as_user, worker, monkeypatch
):
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
f = Path(staging) / "zyxwvutsrqp.mp4"
f.write_bytes(b"range")
return {"requested_downloads": [{"filepath": str(f)}], "title": "Found On YouTube"}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
job = _clip(as_user(make_user()), source="youtube", ref="zyxwvutsrqp").json()
assert job["display_name"] == "Clip (clip)"
job, asset = worker["run"](job)
assert (job.status, asset.title, job.display_name) == (
"done",
"Found On YouTube (clip)",
"Found On YouTube (clip)",
)
# --- a YouTube clip goes with the user's own cookie, and its failure is diagnosed -----------------
_YT_COOKIES = "# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tabc\n"
@pytest.fixture
def fernet(monkeypatch):
from cryptography.fernet import Fernet
from app import security
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
def _capture_extract(monkeypatch, asked: dict):
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
cookiefile = kw.get("cookiefile")
asked["cookiefile"] = cookiefile
asked["cookie_text"] = Path(cookiefile).read_text() if cookiefile else None
f = Path(staging) / "abcdefghijk.mp4"
f.write_bytes(b"range")
return {"requested_downloads": [{"filepath": str(f)}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
def test_a_youtube_clip_goes_signed_in_with_the_users_own_cookie_and_leaves_none_behind(
db, make_user, as_user, yt_video, worker, monkeypatch, fernet
):
from app.downloads import credentials
user = make_user()
credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES)
db.commit()
asked: dict = {}
_capture_extract(monkeypatch, asked)
job, _asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
assert job.status == "done"
assert asked["cookie_text"] == _YT_COOKIES
assert not Path(asked["cookiefile"]).exists()
def test_a_youtube_clip_never_goes_with_another_users_cookie(
db, make_user, as_user, yt_video, worker, monkeypatch, fernet
):
from app.downloads import credentials
other = make_user()
credentials.upsert_credential(db, other.id, "youtube", _YT_COOKIES)
db.commit()
asked: dict = {}
_capture_extract(monkeypatch, asked)
job, _asset = worker["run"](_clip(as_user(make_user()), source="youtube", ref=yt_video).json())
assert job.status == "done" and asked["cookiefile"] is None
@pytest.mark.parametrize(
("with_cookie", "detail"), [(False, "youtube.bot_wall"), (True, "youtube.cookie_rejected")]
)
def test_a_youtube_clip_on_the_bot_wall_is_diagnosed_like_a_download(
db, make_user, as_user, yt_video, worker, monkeypatch, fernet, with_cookie, detail
):
from app.downloads import credentials
user = make_user()
if with_cookie:
credentials.upsert_credential(db, user.id, "youtube", _YT_COOKIES)
db.commit()
def walled(*_a, **_k):
raise RuntimeError(f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot.")
monkeypatch.setattr(pipeline, "_extract_with_fallback", walled)
job, asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
assert (job.status, asset.status) == ("error", "error")
assert (job.error_code, job.error_detail) == ("login_required", detail)
# --- held to the owner's storage cap, and a clip that cannot or need not go on ---------------------
def _fetch_range(monkeypatch):
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
f = Path(staging) / "abcdefghijk.mp4"
f.write_bytes(b"range")
return {"requested_downloads": [{"filepath": str(f)}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
@pytest.mark.parametrize("source", ["youtube", "plex"])
def test_a_clip_for_an_owner_whose_storage_is_full_is_refused_before_it_is_cut(
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
):
_fetch_range(monkeypatch)
ref = yt_video if source == "youtube" else "777"
job = _clip(as_user(make_user()), source=source, ref=ref).json()
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 0)
job, asset = worker["run"](job)
# Refused like a download at its cap: the job says why, the asset waits for a Retry.
assert (job.status, job.error, asset.status) == (
"error",
dlerrors.QUOTA_FULL_MESSAGE,
"pending",
)
assert "cmd" not in worker
@pytest.mark.parametrize("source", ["youtube", "plex"])
def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
):
# The range fetch's own ceiling never reaches yt-dlp's ffmpeg downloader, and a Plex clip
# fetches nothing: the cut file itself is held to the cap.
_fetch_range(monkeypatch)
ref = yt_video if source == "youtube" else "777"
job = _clip(as_user(make_user()), source=source, ref=ref).json()
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 3) # the cut is 4 bytes
job, asset = worker["run"](job)
# Not "full": there is room left, just not this much.
assert (job.status, job.error, asset.status, asset.rel_path) == (
"error",
dlerrors.CLIP_TOO_BIG_MESSAGE,
"pending",
None,
)
def test_a_youtube_clip_range_yt_dlp_refuses_for_its_cap_says_it_is_too_big(
db, make_user, as_user, yt_video, worker, monkeypatch
):
# Should yt-dlp's own `max_filesize` refuse the range, it returns with no file (test_formats):
# the owner's storage, not a processing fault, and nothing is cut.
def refused(*_a, max_bytes=None, **_k):
formats._YDL_LOG.debug(
f"[download] File is larger than max-filesize (9000 bytes > {max_bytes} bytes). Aborting."
)
return {"requested_downloads": [{}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", refused)
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 500)
job, asset = worker["run"](job)
assert (job.status, job.error_code, job.error, asset.status) == (
"error",
"quota_exceeded",
dlerrors.CLIP_TOO_BIG_MESSAGE,
"pending",
)
assert "cmd" not in worker
def test_a_plex_clip_of_a_silent_file_is_cut_silent(db, make_user, as_user, plex_episode, worker):
# The player names the track it plays 0 even when the file has none.
worker["probe"] = {"streams": [{"codec_type": "video"}]}
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("done", "ready")
assert "0:a:0?" in worker["cmd"]
def test_a_plex_clip_of_a_file_the_probe_cannot_read_says_so(
db, make_user, as_user, plex_episode, worker
):
worker["probe"] = None
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
job, asset = worker["run"](job)
assert (job.status, asset.status) == ("error", "error")
assert job.error == "The source file could not be read."
assert "cmd" not in worker
@pytest.mark.parametrize("where", ["fetch", "encode"])
def test_a_paused_clip_frees_its_asset_for_a_later_retry(
db, make_user, as_user, yt_video, worker, monkeypatch, where
):
def stopped(*_a, **_k):
raise pipeline._Aborted() if where == "fetch" else pipeline.editmod.EditAborted()
if where == "fetch":
monkeypatch.setattr(pipeline, "_extract_with_fallback", stopped)
else:
_fetch_range(monkeypatch)
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", stopped)
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
job, asset = worker["run"](job)
# The route already wrote the job's own state (paused / canceled); the asset is free again.
assert (job.status, job.error, asset.status) == ("running", None, "pending")
# --- a second job on the same clip -----------------------------------------------------------------
def _second_job(db, client, **body):
"""Two jobs for one range: the second shares the first's asset."""
first = _clip(client, **body).json()
second = _clip(client, **body).json()
assert first["asset"]["id"] == second["asset"]["id"]
return db.get(DownloadJob, first["id"]), db.get(DownloadJob, second["id"])
def test_a_second_clip_job_on_a_failed_clip_gets_the_same_youtube_diagnosis(
db, make_user, as_user, yt_video
):
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref=yt_video)
asset = db.get(MediaAsset, second.asset_id)
asset.status = "error"
asset.error = f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot."
second.status = "running"
db.commit()
workermod._process_job(second.id)
db.expire_all()
job = db.get(DownloadJob, second.id)
assert (job.status, job.error_code, job.error_detail) == (
"error",
"login_required",
"youtube.bot_wall",
)
def test_a_second_clip_job_takes_the_title_its_sibling_found(db, make_user, as_user):
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref="zyxwvutsrqp")
assert second.display_name == "Clip (clip)"
asset = db.get(MediaAsset, second.asset_id)
asset.status, asset.title = "ready", "Found On YouTube (clip)"
second.status = "running"
db.commit()
workermod._finish_from_cache(second.id, asset.id)
db.expire_all()
job = db.get(DownloadJob, second.id)
assert (job.status, job.display_name) == ("done", "Found On YouTube (clip)")
+3
View File
@@ -63,6 +63,9 @@ def test_our_own_worker_refusals_classify_as_themselves():
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
# from silently orphaning the category — the UI would then fall back to "Download failed".
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CLIP_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.CONVERTED_TOO_BIG_MESSAGE) == "quota_exceeded"
assert classify(dlerrors.DOWNLOAD_TOO_BIG_MESSAGE) == "quota_exceeded"
def test_the_redacted_network_text_classifies_back_to_network():
+115 -1
View File
@@ -11,7 +11,8 @@ its OWN session, so every test COMMITS its setup before calling it.
"""
from app.config import settings
from app.downloads import pipeline, quota
from app.downloads import errors as dlerrors
from app.downloads import formats, pipeline, quota
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
from app.worker import _claim_job
@@ -155,6 +156,25 @@ def test_unlimited_user_is_never_excluded(db):
assert _claim_job() == q.id
def test_a_suspended_users_queued_jobs_wait(db):
# Suspending a user blocks their downloads (quota.resolve's advice): a job they queued before
# stays queued, unclaimed, and runs once the suspension is lifted.
s = User(email="suspended@example.com", is_suspended=True)
o = User(email="other@example.com")
db.add_all([s, o])
db.flush()
waits = _job(db, s.id, queue_pos=1)
served = _job(db, o.id, queue_pos=2)
db.commit()
assert _claim_job() == served.id
assert _claim_job() is None
db.expire_all()
assert db.get(DownloadJob, waits.id).status == "queued"
db.get(User, s.id).is_suspended = False
db.commit()
assert _claim_job() == waits.id
# --- #3 · list cap --------------------------------------------------------------------------
@@ -278,3 +298,97 @@ def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch
assert db.get(MediaAsset, shared.id).status == "pending"
assert db.get(MediaAsset, shared.id).error is None
assert db.get(DownloadJob, theirs.id).status == "queued"
def test_a_download_over_the_space_left_says_so_and_releases_the_shared_asset(
db, monkeypatch, tmp_path
):
# yt-dlp refuses a file over `max_filesize` by saying so and returning with no file (pinned in
# test_formats). That is the owner's storage, not a processing fault: their refusal, in words
# true of a cap with space still left, and the shared asset goes back for the next job.
monkeypatch.setattr(settings, "download_root", str(tmp_path))
u = User(email="nearcap@example.com")
db.add(u)
db.flush()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
)
held = _sized_asset(db, 400, "near-held")
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
shared = MediaAsset(
source_kind="youtube",
source_ref="y" * 11,
format_sig="s",
status="downloading",
ref_count=1,
)
db.add(shared)
db.flush()
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
db.commit()
caps = []
def refuses_over_the_cap(*args, max_bytes=None, **kwargs):
caps.append(max_bytes)
formats._YDL_LOG.debug(
f"[download] File is larger than max-filesize (2000 bytes > {max_bytes} bytes). Aborting."
)
return {"id": "y" * 11, "requested_downloads": [{}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", refuses_over_the_cap)
pipeline._download(mine.id, shared.id, "youtube", "y" * 11, {}, u.id)
db.expire_all()
job = db.get(DownloadJob, mine.id)
assert caps == [600]
assert (job.status, job.error_code) == ("error", "quota_exceeded")
assert job.error == dlerrors.DOWNLOAD_TOO_BIG_MESSAGE
assert db.get(MediaAsset, shared.id).status == "pending"
def test_a_download_that_outgrows_the_space_left_unannounced_is_refused_and_not_placed(
db, monkeypatch, tmp_path
):
# yt-dlp's `max_filesize` only stops a plain HTTP file that announces its size: YouTube's
# fragmented formats never meet it (measured on localdev: 718 MB fetched with 1 MB left). The
# file it produced is held to what is left by its real size, as a clip is.
monkeypatch.setattr(settings, "download_root", str(tmp_path))
u = User(email="unannounced@example.com")
db.add(u)
db.flush()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
)
held = _sized_asset(db, 400, "unannounced-held")
_job(db, u.id, queue_pos=0, status="done", asset_id=held.id)
shared = MediaAsset(
source_kind="youtube",
source_ref="z" * 11,
format_sig="s",
status="downloading",
ref_count=1,
)
db.add(shared)
db.flush()
mine = _job(db, u.id, queue_pos=1, status="running", asset_id=shared.id)
db.commit()
def fetched_past_the_cap(job_id, asset_id, url, spec, staging, adapter, **kw):
f = staging / "zzzzzzzzzzz.mp4"
f.write_bytes(b"v" * 2000) # 600 left
return {"id": "z" * 11, "requested_downloads": [{"filepath": str(f)}]}
monkeypatch.setattr(pipeline, "_extract_with_fallback", fetched_past_the_cap)
monkeypatch.setattr(pipeline, "_ensure_phone_safe", lambda _j, produced, *_a: produced)
pipeline._download(mine.id, shared.id, "youtube", "z" * 11, {}, u.id)
db.expire_all()
job = db.get(DownloadJob, mine.id)
assert (job.status, job.error_code, job.error) == (
"error",
"quota_exceeded",
dlerrors.DOWNLOAD_TOO_BIG_MESSAGE,
)
asset = db.get(MediaAsset, shared.id)
assert (asset.status, asset.rel_path) == ("pending", None)
assert not any(p.suffix == ".mp4" for p in tmp_path.rglob("*") if "staging" not in p.parts)
+63
View File
@@ -0,0 +1,63 @@
"""An editor clip is held to its owner's storage cap by its real size, as a download and a player
clip are: its file is new storage on the owner's footprint, and nothing else checked it.
Real ffmpeg — the size the check reads is the encoder's.
"""
import shutil
import subprocess
import pytest
from app.config import settings
from app.downloads import errors as dlerrors
from app.downloads import pipeline, quota, service
from app.downloads import upload as uploadmod
from app.models import DownloadJob, DownloadQuota, MediaAsset
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
def test_an_editor_clip_larger_than_what_is_left_is_refused_and_not_placed(
db, make_user, tmp_path, monkeypatch
):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
src = tmp_path / "in.mp4"
subprocess.run(
[
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(src),
],
check=True,
) # fmt: skip
u = make_user()
source = uploadmod.ingest_upload(
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
)
job = service.enqueue_edit(db, u.id, source, {"trim": {"start_s": 0, "end_s": 1.5}})
asset = db.get(MediaAsset, job.asset_id)
job.status, asset.status = "running", "downloading" # the worker's claim
# Ten bytes left: no clip of a real video fits.
db.add(
DownloadQuota(
user_id=u.id,
max_bytes=quota.footprint(db, u.id) + 10,
max_concurrent=2,
max_jobs=100,
unlimited=False,
)
)
db.commit()
pipeline._process_edit(job.id, asset.id)
db.expire_all()
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
assert (job.status, job.error_code, job.error) == (
"error",
"quota_exceeded",
dlerrors.CLIP_TOO_BIG_MESSAGE,
)
assert asset.rel_path is None
assert not list(tmp_path.glob(".edits/**/*.mp4"))
+44
View File
@@ -3,6 +3,13 @@ playability guarantee) and must SELECT H.264 (not merely prefer it), so shared d
every browser incl. iOS/Safari. Non-mp4 containers and non-H.264 vcodecs are coerced, not honoured.
See the iPad AV1 broken-player fix + the format narrowing."""
import contextlib
import functools
import http.server
import threading
import yt_dlp
from app.downloads import edit as editmod
from app.downloads import formats
@@ -162,3 +169,40 @@ def test_the_mp3_fix_moved_the_audio_cache_identity(monkeypatch):
current = formats.format_sig(audio)
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", 4)
assert formats.format_sig(audio) != current
class _QuietFiles(http.server.SimpleHTTPRequestHandler):
def log_message(self, *args): # the test's output is yt-dlp's, not the server's
pass
def handle(self):
# yt-dlp hangs up on a file over the cap as soon as it reads its size.
with contextlib.suppress(ConnectionError):
super().handle()
def test_yt_dlp_refuses_a_file_over_the_cap_without_raising_and_the_watch_sees_it(tmp_path):
# The boundary `watching_size_cap` reads, held against the yt-dlp this image pins: over
# `max_filesize` its HTTP downloader logs one line and RETURNS — no exception, no file — so
# without the watch the pipeline would only find "no output file" and call it a processing fault.
served = tmp_path / "served"
served.mkdir()
(served / "v.mp4").write_bytes(b"\0" * 200_000)
srv = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), functools.partial(_QuietFiles, directory=str(served))
)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_port}/v.mp4"
try:
for cap, refused in ((1_000, True), (10_000_000, False)):
out = tmp_path / f"out-{cap}"
opts = formats.build_ydl_opts(
{}, str(out / "%(id)s.%(ext)s"), lambda d: None, max_bytes=cap
)
with formats.watching_size_cap() as over, yt_dlp.YoutubeDL(opts) as ydl:
ydl.extract_info(url, download=True)
assert over[0] is refused, cap
files = [p for p in out.glob("*") if p.suffix == ".mp4"] if out.exists() else []
assert bool(files) is not refused, cap
finally:
srv.shutdown()
+135
View File
@@ -0,0 +1,135 @@
"""The library player's resume point (`library_states`).
A checkpoint stores where the caller stopped in a library item they can play — their own download,
or one shared with them — and the two library listings hand it back per item, so the player opens
there. Too early or too close to the end clears it. Nobody else's checkpoint, and no item the caller
cannot reach, is ever read or written. DB-backed (real Postgres).
"""
import pytest
from sqlalchemy import select
from app.models import DownloadJob, DownloadShare, LibraryState
from app.routes._common import ProgressIn, kept_position
def _done_job(db, user) -> DownloadJob:
job = DownloadJob(
user_id=user.id,
source_kind="url",
source_ref="https://example.com/a.mp4",
status="done",
profile_snapshot={},
)
db.add(job)
db.commit()
return job
def _checkpoint(client, job_id: int, position: float, duration: float = 3000) -> int:
"""Send a checkpoint the caller may make; the point the server kept (0 = cleared)."""
r = client.post(
f"/api/downloads/{job_id}/progress",
json={"position_seconds": position, "duration_seconds": duration},
)
assert r.status_code == 200, r.text
return r.json()["position_seconds"]
def _states(db) -> list[tuple[int, int, int]]:
db.expire_all()
rows = db.execute(
select(LibraryState.user_id, LibraryState.job_id, LibraryState.position_seconds)
).all()
return [tuple(r) for r in rows]
def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
c = as_user(me)
r = c.post(
f"/api/downloads/{job.id}/progress",
json={"position_seconds": 754.9, "duration_seconds": 3000},
)
assert r.status_code == 200 and r.json() == {
"ok": True,
"job_id": job.id,
"position_seconds": 754,
}
[item] = c.get("/api/downloads").json()
assert item["position_seconds"] == 754
# A second checkpoint moves it rather than adding a row.
assert _checkpoint(c, job.id, 900) == 900
assert _states(db) == [(me.id, job.id, 900)]
def test_an_item_never_played_lists_zero(db, make_user, as_user):
me = make_user()
_done_job(db, me)
[item] = as_user(me).get("/api/downloads").json()
assert item["position_seconds"] == 0
def test_too_early_or_near_the_end_clears_it(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
c = as_user(me)
assert _checkpoint(c, job.id, 600) == 600
assert _checkpoint(c, job.id, 2995) == 0
assert _states(db) == []
assert _checkpoint(c, job.id, 600) == 600
assert _checkpoint(c, job.id, 3) == 0
assert _states(db) == []
# Clearing what is not there is not an error (the beacon and the last checkpoint of one stop).
assert _checkpoint(c, job.id, 0) == 0
# The edges themselves, one second either side — the same rows as the client's
# frontend/src/lib/resume.dom.test.ts: the rule must be applied alike on both sides, and the shared
# constants alone (resume.contract.test.ts) would not see a `>=` that became `>` on one side.
@pytest.mark.parametrize(
("position", "duration", "kept"),
[(4, 3000, 0), (5, 3000, 5), (2990, 3000, 2990), (2991, 3000, 0)],
)
def test_the_kept_point_at_its_exact_edges(position, duration, kept):
body = ProgressIn(position_seconds=position, duration_seconds=duration)
assert kept_position(body) == kept
def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user):
owner, friend = make_user(), make_user()
job = _done_job(db, owner)
db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id))
db.commit()
assert _checkpoint(as_user(owner), job.id, 100) == 100
assert _checkpoint(as_user(friend), job.id, 2000) == 2000
assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100
assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000
def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user):
owner, stranger = make_user(), make_user()
job = _done_job(db, owner)
r = as_user(stranger).post(
f"/api/downloads/{job.id}/progress",
json={"position_seconds": 600, "duration_seconds": 3000},
)
assert r.status_code == 404
assert _states(db) == []
def test_deleting_the_job_deletes_its_places(db, make_user, as_user):
me = make_user()
job = _done_job(db, me)
assert _checkpoint(as_user(me), job.id, 600) == 600
db.delete(db.get(DownloadJob, job.id))
db.commit()
assert _states(db) == []
+9 -2
View File
@@ -73,10 +73,17 @@ def test_every_path_to_ready_runs_the_rule():
found.append(where)
if not runs_rule:
skipped.append(where)
# The paths known today: a download, an editor clip, an upload's conversion, an upload kept as is.
# The paths known today: a download, an editor clip, an ad-hoc clip, an upload's conversion, an
# upload kept as is.
# Fewer means the scan went blind (a rename, a new status spelling) — not that a path is gone.
names = {w.rsplit(" ", 1)[1] for w in found}
assert {"_download", "_process_edit", "_convert_upload", "ingest_upload"} <= names, found
assert {
"_download",
"_process_edit",
"_process_clip",
"_convert_upload",
"ingest_upload",
} <= names, found
assert skipped == [], f"marks an asset ready without the phone-safe rule: {skipped}"
+4
View File
@@ -159,6 +159,10 @@ def test_a_collection_is_created_with_a_well_formed_uri(plex):
assert plex.seen[-1].method == "POST"
assert params["title"] == "Best of: 90s" and params["sectionId"] == "3"
assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42"
assert params["type"] == "1" # a movie collection unless told otherwise
# A show collection is made as one: made as a movie one, it came out empty in a TV section.
plex.create_collection("3", "Shows", "42", 2)
assert plex.seen[-1].url.params["type"] == "2"
plex.routes["/library/collections"] = {}
with pytest.raises(PlexError):
+33 -1
View File
@@ -5,6 +5,7 @@ DB lane (`db` fixture) — skipped without Postgres.
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
@@ -14,6 +15,7 @@ from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
from app.plex.client import PlexError
from app.plex.lookups import by_rating_key
from app.routes.plex import collections as coll_routes
@@ -224,8 +226,9 @@ class _CollStub:
if self.fail_on == name:
raise PlexError("boom")
def create_collection(self, section_key, title, item_rating_key):
def create_collection(self, section_key, title, item_rating_key, item_type=1):
self._maybe("create_collection")
self.created_type = item_type
return {"ratingKey": "NEW"}
def add_collection_item(self, rk, item):
@@ -320,6 +323,22 @@ def test_create_writes_through_to_plex(db, monkeypatch, override_dep):
assert body["editable"] is True # create marks the new collection editable
@pytest.mark.parametrize(("seed", "plex_type"), [("s1", 2), ("m1", 1)])
def test_create_makes_a_show_collection_as_one(db, monkeypatch, override_dep, seed, plex_type):
# Measured on a real Plex: a collection made as a movie one (type 1) in a TV section came out
# empty with a show seed, and refused the show when it was added after.
lib, user = _seed_editable(db)
db.add(PlexShow(rating_key="s1", library_id=lib.id, title="Series"))
db.commit()
stub = _CollStub()
client = _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_create)
r = client.post(
"/api/plex/collections", json={"library": "", "title": "New", "item_rating_key": seed}
)
assert r.status_code == 200, r.text
assert stub.created_type == plex_type
def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
# the honest "reconcile on the next sync" detail, not a false "write failed".
@@ -337,6 +356,19 @@ def test_create_resync_failure_is_422(db, monkeypatch, override_dep):
assert r.status_code == 422, r.text
assert r.json()["detail"] == {"code": "refused", "reason": "collection_pending_sync"}
assert "create_collection" in stub.calls
# It exists on Plex: its row stays, editable, until the next sync fills it in — so it is offered
# where collections are added to, and the same name is not made twice.
row = by_rating_key(db, PlexCollection, "NEW")
assert row is not None and row.editable and row.title == "Fresh Coll"
# Plex made it with the seed in it: the seed's row says so, so the title shows ticked in it.
assert row.child_count == 1
assert "NEW" in (by_rating_key(db, PlexItem, "m1").collection_keys or [])
again = client.post(
"/api/plex/collections",
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
)
assert again.status_code == 409, again.text
assert stub.calls.count("create_collection") == 1
def test_mutation_failure_returns_422_write_failed(db, monkeypatch, override_dep):
@@ -0,0 +1,86 @@
"""A movie or show card's "Add to collection": the collections it can go into, each saying whether the
title is in it already. DB lane (`db` fixture)."""
from datetime import datetime, timezone
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 PlexCollection, PlexItem, PlexLibrary, PlexShow, User
def _seed(db):
movies = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
shows = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movies, shows])
db.flush()
enriched = datetime(2024, 1, 1, tzinfo=timezone.utc)
db.add_all(
[
PlexItem(
rating_key="m1",
title="Film",
kind="movie",
library_id=movies.id,
collection_keys=["cA"],
people_enriched_at=enriched,
),
PlexItem(
rating_key="e1",
title="An episode",
kind="episode",
library_id=shows.id,
people_enriched_at=enriched,
),
PlexShow(rating_key="s1", title="Series", library_id=shows.id, collection_keys=[]),
# The movie library's: two manual ones it can go into, and three it cannot.
PlexCollection(rating_key="cA", library_id=movies.id, title="Alpha", editable=True),
PlexCollection(rating_key="cB", library_id=movies.id, title="Beta", editable=True),
PlexCollection(rating_key="cP", library_id=movies.id, title="Plain", editable=False),
PlexCollection(
rating_key="cS", library_id=movies.id, title="Smart", editable=True, smart=True
),
PlexCollection(
rating_key="cI", library_id=movies.id, title="IMDb Top 250", editable=True
),
# The show library's own.
PlexCollection(rating_key="cT", library_id=shows.id, title="Sci-fi", editable=True),
]
)
user = User(email="collfor@example.com")
db.add(user)
db.commit()
return user
def _get(db, monkeypatch, override_dep, rk):
user = _seed(db)
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
return TestClient(app).get(f"/api/plex/collections/for/{rk}")
def test_a_movie_lists_its_librarys_manual_collections_and_which_hold_it(
db, monkeypatch, override_dep
):
resp = _get(db, monkeypatch, override_dep, "m1")
assert resp.status_code == 200
got = [(c["id"], c["contains"]) for c in resp.json()["collections"]]
assert got == [("cA", True), ("cB", False)]
def test_a_show_lists_its_own_librarys(db, monkeypatch, override_dep):
resp = _get(db, monkeypatch, override_dep, "s1")
assert [(c["id"], c["contains"]) for c in resp.json()["collections"]] == [("cT", False)]
def test_an_episode_is_not_a_collection_member(db, monkeypatch, override_dep):
assert _get(db, monkeypatch, override_dep, "e1").status_code == 404
def test_an_unknown_key_is_not_found(db, monkeypatch, override_dep):
assert _get(db, monkeypatch, override_dep, "nope").status_code == 404
+107 -2
View File
@@ -19,7 +19,7 @@ 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, PlexShow, User
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import detail
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
@@ -276,8 +276,16 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
db.add(show)
db.flush()
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
db.add(season)
db.flush()
ep = PlexItem(
rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id
rating_key="e1",
library_id=lib.id,
kind="episode",
title="Pilot",
show_id=show.id,
season_id=season.id,
)
db.add(ep)
db.commit()
@@ -289,6 +297,8 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
# The page's links up to the show and the season.
assert (body["show_id"], body["season_id"]) == ("s1", "se1")
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
db.refresh(ep)
assert ep.full_cast is None # episodes are not persisted/enriched
@@ -401,3 +411,98 @@ def test_item_detail_names_tracks_from_their_language_not_plexs_title(
("magyar Forced", "hu", True, False, None),
("English SDH", "en", False, True, None),
]
def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep):
"""An item can carry several credits markers — mid- and post-credit scenes sit between them —
and only the one Plex flags `final` is the real end; the player needs that flag to know which
credits may move on to the next item."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Iron Man"))
db.commit()
user = _user(db, "markers@example.com")
db.commit()
meta = {
"Marker": [
{"type": "intro", "startTimeOffset": 1000, "endTimeOffset": 60000},
{"type": "credits", "startTimeOffset": 6811000, "endTimeOffset": 6831000},
{
"type": "credits",
"startTimeOffset": 7549000,
"endTimeOffset": 7559000,
"final": True,
},
{"type": "commercial", "startTimeOffset": 1, "endTimeOffset": 2},
]
}
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2")
assert resp.status_code == 200, resp.text
assert resp.json()["markers"] == [
{"type": "intro", "start_s": 1.0, "end_s": 60.0, "final": False},
{"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False},
{"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True},
]
def _show_with_episodes(db, n=3):
lib = PlexLibrary(plex_key="3", title="Shows", kind="show", enabled=True)
db.add(lib)
db.flush()
show = PlexShow(rating_key="sn1", library_id=lib.id, title="Next Show")
db.add(show)
db.flush()
eps = [
PlexItem(
rating_key=f"sn1e{i}",
library_id=lib.id,
kind="episode",
title=f"Episode {i}",
show_id=show.id,
season_number=1,
episode_number=i,
)
for i in range(1, n + 1)
]
db.add_all(eps)
db.flush()
return eps
def test_show_next_plays_the_first_episode_of_an_untouched_show(db, monkeypatch, override_dep):
"""A show card's picture plays: never started → its first episode."""
_show_with_episodes(db)
user = _user(db, "next1@example.com")
db.commit()
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
assert resp.status_code == 200, resp.text
nxt = resp.json()["next"]
assert nxt["id"] == "sn1e1"
assert nxt["show_title"] == "Next Show"
def test_show_next_continues_the_episode_in_progress(db, monkeypatch, override_dep):
"""Started → the episode in progress (the show page's Resume), not the first unwatched."""
eps = _show_with_episodes(db)
user = _user(db, "next2@example.com")
db.add(PlexState(user_id=user.id, item_id=eps[0].id, status="watched"))
db.add(PlexState(user_id=user.id, item_id=eps[2].id, status="new", position_seconds=120))
db.commit()
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next")
assert resp.json()["next"]["id"] == "sn1e3"
assert resp.json()["next"]["position_seconds"] == 120
def test_show_next_is_null_for_an_empty_show_and_404_for_an_unknown_one(
db, monkeypatch, override_dep
):
_show_with_episodes(db, n=0)
user = _user(db, "next3@example.com")
db.commit()
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/sn1/next").json() == {
"next": None
}
assert _get(db, user, monkeypatch, override_dep, "/api/plex/show/nope/next").status_code == 404
+53
View File
@@ -165,6 +165,30 @@ def test_continue_watching_movies_and_shows(db, monkeypatch, override_dep):
assert "m1" not in ids
def test_a_continue_watching_show_says_whether_it_has_an_episode_to_resume(
db, monkeypatch, override_dep
):
# Both in-progress rails (Discover's and Home's) agree with the one rollup: a show part watched
# with nothing to resume offers Play alone, not Continue + Restart.
from app.discover.queries import plex_in_progress
user = _seed(db)
def rails():
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=both").json()
home = {c["id"]: c for _, c in plex_in_progress(db, user.id, lang="en")}
return {c["id"]: c for c in d["continue_watching"]}["sh1"], home["sh1"]
assert [c["resumable"] for c in rails()] == [True, True]
ep1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "sh1e1")).scalar_one()
st = db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == ep1.id)
).scalar_one()
st.status, st.position_seconds = "watched", 0
db.commit()
assert [(c["status"], c["resumable"]) for c in rails()] == [("in_progress", False)] * 2
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch, override_dep):
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
@@ -316,6 +340,35 @@ def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch, override_d
assert len([c for c in items if c["type"] == "show"]) <= 3
def test_a_reshuffle_deals_other_titles_first(db, monkeypatch, override_dep):
# The ↻ sends the previous draw: what is left of the pool comes first, and a pool smaller than a
# draw is topped up from the excluded titles rather than leaving the rail short.
user = _seed(db)
def movies(url: str) -> list[str]:
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
return [c["id"] for c in items if c["type"] == "movie"]
for _ in range(5): # random order: one lucky pass would prove nothing
got = movies("/api/plex/surprise?scope=movie&exclude=m2")
assert got[0] == "m3" and set(got) == {"m2", "m3"}
assert set(movies("/api/plex/surprise?scope=movie&exclude=m2&exclude=m3")) == {"m2", "m3"}
def test_a_kept_draw_is_read_again_not_redrawn(db, monkeypatch, override_dep):
# The rail's draw stands until the ↻, but its cards' watch state moves: `keep` answers the titles on
# screen, in their order and as they stand now, a watched one included; the scope still bounds it.
user = _seed(db)
url = "/api/plex/surprise?scope=both&keep=m3&keep=sh1&keep=m1&keep=nope"
items = _get(db, user, monkeypatch, override_dep, url).json()["items"]
assert _ids(items) == ["m3", "sh1", "m1"]
by_id = {c["id"]: c for c in items}
assert by_id["m1"]["status"] == "watched"
assert by_id["m3"]["position_seconds"] > 0
movie_only = "/api/plex/surprise?scope=movie&keep=sh1&keep=m2"
assert _ids(_get(db, user, monkeypatch, override_dep, movie_only).json()["items"]) == ["m2"]
def test_discover_scope_movie_excludes_shows(db, monkeypatch, override_dep):
user = _seed(db)
d = _get(db, user, monkeypatch, override_dep, "/api/plex/discover?scope=movie").json()
+159
View File
@@ -0,0 +1,159 @@
"""Plex summaries and taglines are stored as plain text.
A Hungarian TV agent hands summaries over as HTML (`<p>A műsorban … p&aacute;rosnak …</p>`), and
the info page and the hover card showed the tags. `plain_text` is the one cleaner: the sync's every
summary path and the HU overlay store through it, and migration 0101 cleaned the rows written before.
"""
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import engine
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow
from app.plex.detail import rich_detail
from app.plex.overlay import sync_overlay
from app.plex.plaintext import plain_text
from app.plex import sync
from app.plex.sync import _apply_item, _upsert_collection
HU_SUMMARY = "<p>A műsorban minden p&aacute;rosnak stoppal kell eljutnia.</p>"
@pytest.mark.parametrize(
"raw, clean",
[
(HU_SUMMARY, "A műsorban minden párosnak stoppal kell eljutnia."),
("<p>One.</p><p>Two.</p>", "One.\n\nTwo."),
("Line one<br>line two<BR/>three", "Line one\nline two\nthree"),
("<b>Bold</b> &amp; <i>more</i>", "Bold & more"),
# Prose that only looks like markup stays as written.
("a < b and 1 <3 you", "a < b and 1 <3 you"),
# Only a reference with its semicolon is one: an ampersand in prose stays as written.
("AT&notice, R&copy and H&M", "AT&notice, R&copy and H&M"),
# ...and only a whole entity name: a legacy one at its start ("&not", "&amp") is not.
("AT&notice; and &ampx;", "AT&notice; and &ampx;"),
("&#233;t&#xE9; &copy;", "été ©"),
# A decimal reference too long for int() to parse is out of range, like &#x110000;.
("a &#" + "9" * 5000 + "; b", "a � b"),
("&#" + "0" * 5000 + "65;", "A"),
("Plain summary.", "Plain summary."),
(" padded ", "padded"),
("<p></p>", None),
("", None),
(None, None),
],
)
def test_plain_text(raw, clean):
assert plain_text(raw) == clean
def test_the_item_sync_stores_a_summary_as_plain_text():
row = PlexItem()
_apply_item(row, lib_id=1, kind="episode", meta={"title": "Ep", "summary": HU_SUMMARY})
assert row.summary == "A műsorban minden párosnak stoppal kell eljutnia."
def test_the_show_sync_stores_a_summary_as_plain_text(db, monkeypatch):
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add(lib)
db.commit()
meta = {"ratingKey": "s1", "title": "Show", "summary": HU_SUMMARY}
def listing(_plex, _key, typ, progress=None):
if progress is not None:
progress["complete"] = True
return [meta] if typ == sync._TYPE_SHOW else []
monkeypatch.setattr(sync, "_paginate", listing)
sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0})
db.commit()
show = db.execute(select(PlexShow).where(PlexShow.rating_key == "s1")).scalar_one()
assert show.summary == "A műsorban minden párosnak stoppal kell eljutnia."
def test_a_collection_stores_its_summary_as_plain_text(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
col = _upsert_collection(db, None, "c1", lib.id, {"title": "C", "summary": "<p>A &amp; B</p>"})
assert col.summary == "A & B"
def test_a_fetched_tagline_is_plain_text():
assert rich_detail({"tagline": "<i>An offer</i> &amp; more"})["tagline"] == "An offer & more"
class _StubPlex:
def __init__(self, items: list[dict]):
self._items = items
def section_items(self, key, item_type, start, size):
return self._items[start : start + size], len(self._items)
def test_the_hungarian_overlay_stores_plain_text(db):
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
db.add_all([en_lib, hu_lib])
db.flush()
hu_lib.overlay_of = en_lib.id
row = PlexItem(rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
db.add(row)
db.commit()
meta = {
"ratingKey": "h1",
"title": "Szemtől szemben",
"summary": HU_SUMMARY,
"tagline": "<i>Egy &eacute;let</i>",
"Guid": [{"id": "tmdb://1"}],
}
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {"overlaid": 0})
db.commit()
db.refresh(row)
assert row.summary_hu == "A műsorban minden párosnak stoppal kell eljutnia."
assert row.tagline_hu == "Egy élet"
def test_0101_cleans_the_stored_rows(db):
cfg = Config("alembic.ini")
command.downgrade(cfg, "0100_drop_plex_item_markers")
upgraded = False
try:
with Session(engine) as s:
lib = PlexLibrary(plex_key="1", title="Shows", kind="show", enabled=True)
s.add(lib)
s.flush()
ep = PlexItem(
rating_key="e1",
library_id=lib.id,
kind="episode",
title="Ep",
summary=HU_SUMMARY,
tagline_hu="<i>x</i>",
)
plain = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie", title="M", summary="a < b"
)
show = PlexShow(rating_key="s1", library_id=lib.id, title="S", summary_hu="<p>Hu</p>")
col = PlexCollection(rating_key="c1", library_id=lib.id, title="C", summary="A &amp; B")
s.add_all([ep, plain, show, col])
s.commit()
ids = (ep.id, plain.id, show.id, col.id)
command.upgrade(cfg, "head")
upgraded = True
with Session(engine) as s:
ep2, plain2 = s.get(PlexItem, ids[0]), s.get(PlexItem, ids[1])
show2, col2 = s.get(PlexShow, ids[2]), s.get(PlexCollection, ids[3])
assert ep2 and plain2 and show2 and col2
assert ep2.summary == "A műsorban minden párosnak stoppal kell eljutnia."
assert ep2.tagline_hu == "x"
assert plain2.summary == "a < b"
assert show2.summary_hu == "Hu"
assert col2.summary == "A & B"
finally:
if not upgraded:
command.upgrade(cfg, "head")
@@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep):
assert body["mode"] == "hls"
assert body["transcode"] is False
assert captured["transcode"] is False
# The id the player names when it stops the session (POST …/session/{id}/stop).
assert body["id"] == "sid"
def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep):
+40 -7
View File
@@ -18,6 +18,7 @@ from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import rollup, sync
from app.plex.cards import ShowState
def _dt(day: int) -> datetime:
@@ -184,21 +185,53 @@ class TestRollup:
def test_state_map_new_inprogress_watched(self, db):
s = _seed(db)
show, user = s["show"], s["user"]
# e1 in-progress, e2 new -> in_progress
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
# e1 in-progress, e2 new -> in_progress, and e1's resume point makes it resumable
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("in_progress", True)}
# empty input short-circuits
assert rollup.state_map(db, user.id, []) == {}
# a fresh user with no state -> new
other = User(email="rollup-other@example.com")
db.add(other)
db.commit()
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
# mark every episode watched for `user` -> watched
assert rollup.state_map(db, other.id, [show.id]) == {show.id: ShowState("new", False)}
# e1 finished, e2 untouched: part watched, but no episode to resume — Continue and Restart
# would both play e2 from its start
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
e1, e2 = db.execute(
select(PlexItem).where(PlexItem.kind == "episode").order_by(PlexItem.episode_number)
).scalars()
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
db.commit()
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
assert rollup.state_map(db, user.id, [show.id]) == {
show.id: ShowState("in_progress", False)
}
# mark every episode watched for `user` -> watched
db.add(PlexState(user_id=user.id, item_id=e2.id, status="watched"))
db.commit()
assert rollup.state_map(db, user.id, [show.id]) == {show.id: ShowState("watched", False)}
def test_the_grid_card_says_whether_a_show_has_an_episode_to_resume(
self, db, monkeypatch, override_dep
):
s = _seed(db)
user = s["user"]
cards = {
i["id"]: i
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
}
assert cards["s1"]["resumable"] is True
# e1 finished, e2 untouched: in progress, but nothing to resume
e1 = db.execute(select(PlexItem).where(PlexItem.rating_key == "e1")).scalar_one()
db.execute(
delete(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == e1.id)
)
db.add(PlexState(user_id=user.id, item_id=e1.id, status="watched"))
db.commit()
cards = {
i["id"]: i
for i in _get(db, user, monkeypatch, override_dep, "/api/plex/library").json()["items"]
}
assert (cards["s1"]["status"], cards["s1"]["resumable"]) == ("in_progress", False)
def _fake_paginate(by_type):
+101
View File
@@ -0,0 +1,101 @@
"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on.
Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on
localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it
played; the server stops it only while it is still that viewer's registered session for the item, so
a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback.
No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale).
"""
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
VIEWER = 41
class _Proc:
def __init__(self):
self.terminated = False
def poll(self):
return None
def terminate(self):
self.terminated = True
def wait(self, timeout=None):
return 0
class _Session:
def __init__(self, session_id: str, directory):
self.id = session_id
self.dir = directory
self.proc = _Proc()
self.last_access = time.time()
@pytest.fixture
def sessions(monkeypatch):
live: dict = {}
monkeypatch.setattr(plex_stream, "_sessions", live)
return live
@pytest.fixture
def client(monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True)
user = User(id=VIEWER, email="stop@example.com")
override_dep(current_user, lambda: user)
return TestClient(app)
def _session(tmp_path, sid: str) -> _Session:
d = tmp_path / sid
d.mkdir()
return _Session(sid, d)
def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions):
s = _session(tmp_path, "sid-a")
sessions[(VIEWER, "m1")] = s
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "stopped": True}
assert s.proc.terminated
assert sessions == {}
assert not s.dir.exists()
def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions):
"""The stop of the session a seek-restart replaced arrives after the new one registered."""
newer = _session(tmp_path, "sid-b")
sessions[(VIEWER, "m1")] = newer
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.json() == {"ok": True, "stopped": False}
assert not newer.proc.terminated
assert sessions == {(VIEWER, "m1"): newer}
def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions):
theirs = _session(tmp_path, "sid-a")
sessions[(7, "m1")] = theirs
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.json() == {"ok": True, "stopped": False}
assert not theirs.proc.terminated
def test_a_stop_with_no_session_left_is_not_an_error(client, sessions):
"""The reaper or a cap may have dropped it first; the beacon must not fail loudly for that."""
r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={})
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "stopped": False}
+7 -2
View File
@@ -695,7 +695,9 @@ class TestTranscodeStartSession:
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
# The identity guard must detect it's been superseded and yield the currently-registered session.
# The identity guard must detect it's been superseded. It answers with its OWN (dead) session,
# never the newer one: the answer goes to the request that lost, whose player drops it as stale
# and stops the session it names — naming the newer one stopped the playback that won.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
@@ -708,9 +710,12 @@ class TestTranscodeStartSession:
newer = _Newer()
captured = {}
def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing.
captured["s"] = stream._sessions[(7, "12345")]
stream._sessions[(7, "12345")] = newer
return False
@@ -720,7 +725,7 @@ class TestTranscodeStartSession:
)
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it
assert s is captured["s"] # its own session, not the newer one
assert stream._sessions[(7, "12345")] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
+8 -2
View File
@@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient:
return TestClient(app)
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
@pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"],
)
@pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"])
def test_a_non_finite_position_is_refused(client, path, raw):
r = client.post(
@@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw):
assert r.status_code == 422, r.text
@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"])
@pytest.mark.parametrize(
"path",
["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/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."""
+4 -4
View File
@@ -7,7 +7,7 @@ from pydantic import ValidationError
from app.routes.channels import AttachTagIn, ChannelUpdateIn
from app.routes.downloads import AdminSetQuotaIn
from app.routes.feed import VideoProgressIn
from app.routes._common import ProgressIn
from app.routes.setup import SetupAdminIn
from app.routes.tags import TagUpdateIn
@@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit():
def test_progress_accepts_fractional_seconds():
# The player may post a raw currentTime (a float). The field must accept it (truncated in the
# handler) — a plain `int` field would 422 on the progress hot path.
assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12
assert ProgressIn(position_seconds=12.7).position_seconds == 12.7
assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12
with pytest.raises(ValidationError):
VideoProgressIn(position_seconds="not-a-number")
ProgressIn(position_seconds="not-a-number")
# --- PATCH models: model_fields_set separates "absent" from "explicit null". ---
+37 -5
View File
@@ -451,7 +451,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip(
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
db, as_user, make_user, _root, monkeypatch, upload_headers
):
"""The sweep, held end to end rather than at one site.
"""The sweep, held end to end rather than at one site (the clip route joined it later).
`display_text` was introduced for the upload path and applied to three of the five writers of
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
@@ -503,15 +503,32 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
assert patched.json()["display_name"] == bound
assert patched.json()["display_uploader"] == bound
# 6. the clip route — the clip panel's name field.
clip = c.post(
"/api/downloads/clip",
json={
"source": "youtube",
"ref": "dQw4w9WgXcQ",
"start_s": 0,
"end_s": 5,
"display_name": long_name,
},
)
assert clip.status_code == 200, clip.text
assert clip.json()["display_name"] == bound
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
"""Every place under `backend/app/` that puts a value into a display field, as
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
THREE SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
the site the guard could not see.
the site the guard could not see. The third, `fields["display_name"] = …`, is a dict a finalize
call is spread from (the worker's), which the first two missed as well. A value passed
POSITIONALLY is still unseen: a writer binds it to a name or a keyword first, as every route here
does.
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
@@ -537,6 +554,13 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
t.id if isinstance(t, ast.Name) else t.attr
for t in node.targets
if isinstance(t, (ast.Name, ast.Attribute))
} | {
# `fields["display_name"] = …` — a dict the write is then spread from.
t.slice.value
for t in node.targets
if isinstance(t, ast.Subscript)
and isinstance(t.slice, ast.Constant)
and isinstance(t.slice.value, str)
}
value = node.value
elif isinstance(node, ast.keyword):
@@ -558,9 +582,12 @@ def _display_write_sites() -> dict[tuple[str, str], set[str]]:
# Bounded by `display_text` unless noted:
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
# one value that needs no bound, and a clear is still a write.
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
# slices the YouTube title to the same width.
# service.enqueue / enqueue_edit / `display_name` is already bounded by its caller; the fallback
# enqueue_clip slices the source's title to the same width.
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
# routes enqueue_clip the `display_name=` keyword passes on the name bound above it.
# pipeline._download / _process_clip / worker._finish_from_cache
# slice a fetched or stored title to the column's width.
#
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
@@ -573,11 +600,16 @@ DISPLAY_WRITE_SITES = {
"if asset.status == 'ready' and asset.title else None)"
},
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/service.py", "enqueue_clip"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_download"): {"meta.title[:DISPLAY_TEXT_MAX]"},
("downloads/pipeline.py", "_process_clip"): {"named[:DISPLAY_TEXT_MAX]"},
("worker.py", "_finish_from_cache"): {"asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"},
("routes/downloads.py", "update_download_meta"): {
"display_text(body.display_name)",
"display_text(body.display_uploader)",
+30
View File
@@ -15,6 +15,7 @@ import pytest
from app import sysconfig
from app.config import settings
from app.downloads import errors as dlerrors
from app.downloads import gc as gcmod
from app.downloads import pipeline, quota, storage
from app.downloads import probe as probemod
@@ -124,6 +125,8 @@ def test_a_conversion_that_outgrows_the_cap_is_refused_and_keeps_the_original(
db.expire_all()
job = db.get(DownloadJob, job.id)
assert (job.status, job.error_code) == ("error", "quota_exceeded")
# 90 of the 100 are still free: the converted file is too big, the storage is not full.
assert job.error == dlerrors.CONVERTED_TOO_BIG_MESSAGE
assert original.exists()
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
@@ -558,6 +561,33 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made(
assert r.json()["detail"]["reason"] == reason
def test_a_link_to_a_suspended_owners_waiting_rebuild_says_it_is_gone(
db, as_user, make_user, _root, monkeypatch, upload_headers
):
# The worker leaves a suspended user's queued jobs where they are, maybe for good: like a paused
# rebuild, nothing is making the video, so "try again shortly" would never come true.
_probe(monkeypatch, vcodec="h264")
owner = make_user()
c = as_user(owner)
job = c.post(
"/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4")
).json()
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
db.get(MediaAsset, job["asset"]["id"]).status = "pending"
db.get(DownloadJob, job["id"]).status = "queued"
owner.is_suspended = True
db.commit()
r = c.get(f"/api/public/watch/{link['token']}")
assert r.status_code == 404
assert r.json()["detail"]["reason"] == "video_gone"
# A rebuild already running when the owner was suspended still finishes.
db.get(DownloadJob, job["id"]).status = "running"
db.commit()
assert c.get(f"/api/public/watch/{link['token']}").json()["detail"]["reason"] == (
"video_preparing"
)
def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch):
# The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`).
_probe(monkeypatch)
+180 -112
View File
@@ -112,7 +112,18 @@ style guide.
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
(owner decision F16). On that chrome `--media-mark` is a marked place (the playhead, the crop frame, a
Plex marker), the focus ring and what is chosen, and `--media-danger` an error, as a light theme's
accent and danger are dark on the chrome: `media-marks` re-points `--focus` and `--chosen` on the
player (the Plex player, the YouTube stage) and on every surface painted `--media-chrome` (its menus,
panels and bars), and a form there (`media-scope`) re-points `--danger` too. A chosen item there is
white like the rest, marked by an amber tick (a track) or underline (a tab, `ui/mediaChrome`), never
the accent as text; a choice filled with the accent keeps it, its own text holds on it. A fill that
must be seen there — a native checkbox or slider (`accent-chosen`), the skip button's countdown — is
the amber too; the Plex progress bar plays white with an amber head, so an amber marker ahead of
the head never reads as already played.
`theme.contract.test.ts` measures the red on the chrome and holds every class string that paints it
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
@@ -135,9 +146,11 @@ style guide.
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The
exceptions are an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14); and a dark
translucent button on a picture (the `media` variant: a card's hover row, a rail's scroll arrows, Restart),
whose hover is a 2px accent ring over a darker body, since a layer is lost in the picture.
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
badge read as broken; owner, E28 — `AdminUsers` user rows).
@@ -398,7 +411,9 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
`mediaTitleClass`, `mediaPictureBox`).
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
one round media Play (the button family's `media` variant), only on a card whose click starts
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
playback. Every Plex picture plays, a show too: the episode in progress (Continue + Restart, offered only
when an episode holds a resume point), else the first unwatched, else the first, which the click asks the server for (`/show/{id}/next`); its title
and an ⓘ open the show's page, and one with no episodes opens there. The hover row (watched, save or
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
@@ -411,7 +426,8 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
had become the strip's.
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
`useMediaPreview`); a show's adds the episode it would play ("Next: S6·E16 — Title"), asked as the
panel opens. Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
native `title` tooltip.
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
@@ -528,10 +544,48 @@ of a full-width panel and nothing on the right.
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library
row is not a card and keeps its one play button: started, that button continues, its "…" has Continue +
"Play from the beginning", and its picture carries the cards' progress bar.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
(its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row
under the video (stacked). Wide, the panel shows the description outright, clamped to three
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
strip of close, expand and Previous / Next, kept shut as the queue steps on and open again on a new
open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured
live). The wheel: over the video it sets the volume; over a panel with something to scroll it scrolls
only, never the volume, even at its end; over one with nothing to scroll it sets the volume. [WCAG 1.4.13]
- **Clip mode (both players, owner's picks A + P1 + a):** a scissors in the control row (Plex: the C5 row,
before the ⋯; YouTube: the actions beside Download, and the docked bar) or `C` opens a clip panel IN the
player; `[` / `]` mark the start / end at the playhead and open it when shut. The video being watched is the
preview — no second stream. Plex: the panel takes the seek bar's and the row's place, and the picture stands
above it. YouTube: it sits under the video (the card's fit counts it, so the video shrinks to make room)
and the wide side panel shuts to its strip while the clip panel is open (owner; it opens again after, unless
the user opened it meanwhile); maximised, the panel takes the docked bar's place; the browser's fullscreen
steps back to maximised. The panel: an overview of the whole item (the selection and the zoomed window on it), a zoomed strip
around the selection with the two ends (`ui/RangeTrack`: each end a slider — arrows step a tenth,
Shift a second; drawn 4px, hit 1.5rem), Start / End fields with a tenth back / forward and "to the
playhead", the length, Play selection (loops), a name, Cancel / Cut clip. On the always-dark chrome the
ends are white, every focus ring in the panel and the playhead the media amber `--media-mark` (the same
in both themes; a light theme's accent and warn all but vanished there). A typed time that cannot be
read stays in its field once offered (Enter, leaving it), marked invalid with a line under the fields'
row saying how to type one (in the flow: the row below moves down rather than being covered); a time typed over a mark that then moves (a nudge, another segment) is dropped; one
past the end shows where it landed (decimal point or comma).
The strip's window follows the selection when it settles, never under a dragging pointer. Plex seeks once a drag is
released (a seek may restart the transcode); YouTube follows the drag; a mark on the very end seeks a
moment short of it, and while a clip is marked the end of the item never closes the player or steps the
queue, and Plex's auto-skip waits. Escape closes the panel before the player, but after anything over
the picture (Plex: the info, the up-next card, the countdown); ⌫ under a dialog is the dialog's. The
scissors the user can see gets the focus back (maximised: the docked bar's), else the player. The panel
keeps its name, Loop and focus when the view is maximised or restored under it. **Crop (owner's picks K1 + a):** a Crop toggle in the panel puts a
frame over the picture (`ui/CropFrame`: on the picture, not its bars; the outside dimmed; drag to move, edges
and corners to resize, thirds while dragging; the arrows move it, a focused corner's resize it, Shift
further; focus ring amber) with a shape — Free, 16:9, 9:16, 1:1 (the largest of it the picture holds) — and
Whole picture; off, the clip is the whole picture. Escape turns the crop off before it closes the panel. A
press outside the frame is still the video's. A cut is a toast with a Downloads link and the usual
"Ready to watch" notification. Limits are the server's: 1 s to 10 min (`lib/clipRange`, held to
`downloads/clip.py` by `clipLimits.contract.test.ts`; the "too long" message names the server's cap).
## Dialogs — _Decided_ (C4, built in S3f)
@@ -541,7 +595,9 @@ of a full-width panel and nothing on the right.
fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus
there when it arrives, as long as the focus still rests on the ✕;
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
browse or manage. [WAI-ARIA APG]
browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the
panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a
focused ✕ closed the film. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
@@ -605,104 +661,116 @@ of a full-width panel and nothing on the right.
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
+49 -77
View File
@@ -2,23 +2,19 @@ import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListPlus, Plus } from "lucide-react";
import { ListPlus } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import { MenuCheck } from "./ui/MenuCheck";
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 } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { MembershipMenu } from "./ui/MembershipMenu";
import { SURFACE } from "./ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (the popover pilot): radio-style
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
export default function AddToPlaylist({
videoId,
variant = "ghost",
@@ -94,9 +90,7 @@ function MembershipPanel({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
@@ -115,13 +109,17 @@ function MembershipPanel({
// 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.
// unhandled rejection: the tick did not change, nothing was said, the gone row stayed. A write that
// landed flips the tick at once: a second tap before the refetch returned would otherwise repeat it.
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);
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
);
} catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
}
@@ -129,19 +127,20 @@ function MembershipPanel({
setBusy(false);
}
async function createAndAdd() {
const name = newName.trim();
if (!name || busy) return;
async function createAndAdd(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
const pl = await api.createPlaylist(name);
await api.addToPlaylist(pl.id, videoId);
setNewName("");
made = true;
} catch (e) {
notifyActionError(e, t("playlists.addFailed"));
}
refresh();
setBusy(false);
return made;
}
// All of the user's playlists are add targets, including YouTube-linked ones — adding
@@ -149,67 +148,40 @@ function MembershipPanel({
const lists = membership.data ?? [];
return (
<>
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fadeRef}
style={fadeStyle}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
// A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video}
onSelect={() => toggle(pl)}
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss; every other key is text editing here and
// must not reach the surrounding menu's list navigation.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
void createAndAdd();
}
}}
placeholder={t("playlists.newPlaylist")}
/>
</div>
</div>
</PopoverSurface>
</>
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-60 p-2`}
ariaLabel={t("playlists.addToPlaylistTitle")}
listInside
>
<MembershipMenu
heading={t("playlists.addToPlaylistTitle")}
rows={lists.map((pl) => ({
id: pl.id,
label: playlistName(pl, t),
checked: !!pl.has_video,
extra:
pl.source === "youtube" || pl.yt_playlist_id ? (
<YouTubeIcon className="w-3 h-3 shrink-0 text-muted" />
) : undefined,
}))}
onToggle={(row) => {
const pl = lists.find((p) => p.id === row.id);
if (pl) void toggle(pl);
}}
status={
membership.isError && !membership.data
? "error"
: membership.isLoading
? "loading"
: "ready"
}
onRetry={() => void membership.refetch()}
emptyText={t("playlists.noneYet")}
errorText={t("common.loadError")}
busy={busy}
create={{ placeholder: t("playlists.newPlaylist"), onCreate: createAndAdd }}
/>
</PopoverSurface>
);
}
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
//
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/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 { ChannelTagsControl } from "./ChannelTags";
import { api } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("the channel tag editor", () => {
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<ChannelTagsControl
userTags={[]}
attachedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
/>
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("common.loadError");
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
expect(alert.closest("[role=menu]")).toBeNull();
expect(await screen.findByText("common.retry")).toBeTruthy();
});
});
+26 -70
View File
@@ -1,17 +1,15 @@
import { useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { Input } from "./ui/Input";
import { MembershipMenu } from "./ui/MembershipMenu";
import { useTags } from "../lib/useTags";
import { SURFACE } from "./ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
@@ -194,8 +192,9 @@ function SuggestedChipsLane({
}
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
function TagEditorMenu({
userTags,
attachedIds,
@@ -208,71 +207,27 @@ function TagEditorMenu({
onCreate: (name: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState("");
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
const tags = useTags();
const attached = new Set(attachedIds);
const submit = () => {
const name = draft.trim();
if (!name) return;
onCreate(name);
setDraft("");
};
return (
<div className="flex flex-col">
<div ref={fadeRef} style={fadeStyle} className="max-h-60 overflow-y-auto no-scrollbar">
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<PopoverItem
key={tg.id}
// Several tags can be on at once — a checkbox set, not one exclusive choice.
role="menuitemcheckbox"
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
<MenuCheck kind="many" on={on} />
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
})}
{userTags.length === 0 && (
<p className="text-2xs text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
// list-navigation/typeahead handlers on the surrounding surface.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
/>
<IconButton
variant="outline"
onClick={submit}
disabled={!draft.trim()}
label={t("channels.tags.create")}
>
<Plus />
</IconButton>
</div>
</div>
<MembershipMenu
dense
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
onToggle={(row) => onToggle(row.id as number)}
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
onRetry={() => void tags.refetch()}
emptyText={t("channels.tags.noneYet")}
errorText={t("common.loadError")}
create={{
placeholder: t("channels.tags.createPlaceholder"),
maxLength: 64,
submitLabel: t("channels.tags.create"),
onCreate: (name) => {
onCreate(name);
return true;
},
}}
/>
);
}
@@ -473,6 +428,7 @@ export function ChannelTagsControl({
<PopoverSurface
popover={editPop}
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
listInside
>
<TagEditorMenu
userTags={userTags}
@@ -0,0 +1,265 @@
// @vitest-environment jsdom
//
// The clip panel: it opens on its In handle, every way of marking keeps a range the server takes,
// Cut sends the range (and a Plex clip's audio track) and closes, a refused Cut stays open and says
// so, and "Play selection" loops back to In. The range is the caller's state, so the test holds it.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import type { ClipRequest } from "../lib/api";
import type { ClipRange } from "../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
});
const notify = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (n: unknown) => notify(n) };
});
const notifyActionError = vi.fn();
vi.mock("../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
});
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// A block, not an expression: a function returned from beforeEach is run as its teardown.
enqueueClip.mockResolvedValue({ id: 1 });
});
function Harness({
src = { source: "plex", ref: "777", audioTrack: 1 },
start = { start: 100, end: 130 },
current = 100,
onClose = () => {},
onPlayFrom = () => {},
onSeek = () => {},
duration = 3600,
}: {
src?: ClipSource;
start?: ClipRange;
current?: number;
onClose?: () => void;
onPlayFrom?: (t: number) => void;
onSeek?: (t: number, final: boolean) => void;
duration?: number;
}) {
const [clip, setClip] = useState(start);
const [crop, setCrop] = useState<ClipCrop | null>(null);
return (
<QueryClientProvider client={new QueryClient()}>
<output data-testid="range">{`${clip.start}-${clip.end}`}</output>
<output data-testid="crop">{crop ? JSON.stringify(crop) : "none"}</output>
<ClipPanel
src={src}
clip={clip}
onChange={setClip}
duration={duration}
current={current}
picture={16 / 9}
crop={crop}
onCrop={setCrop}
onSeek={onSeek}
onPlayFrom={onPlayFrom}
onClose={onClose}
/>
</QueryClientProvider>
);
}
const range = () => screen.getByTestId("range").textContent;
describe("ClipPanel", () => {
it("opens with the focus on its In handle, so the arrows move the mark at once", () => {
render(<Harness />);
const handle = screen.getByRole("slider", { name: "clip.in" });
expect(document.activeElement).toBe(handle);
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
expect(range()).toBe("99-130");
});
it("takes a typed time on Enter and carries the other end along past it", () => {
render(<Harness />);
const [inField] = screen.getAllByRole("textbox", { name: "clip.in" });
fireEvent.change(inField!, { target: { value: "2:20" } });
fireEvent.keyDown(inField!, { key: "Enter" });
expect(range()).toBe("140-170");
});
it("keeps the focus in a time field that took a typed time on Enter", () => {
render(<Harness />);
const field = () => screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
field().focus();
fireEvent.change(field(), { target: { value: "2:20" } });
fireEvent.keyDown(field(), { key: "Enter" });
expect(range()).toBe("140-170");
expect(document.activeElement).toBe(field());
expect((field() as HTMLInputElement).value).toBe("0:02:20.0");
});
it("shows the mark it holds, not a time it could not take", () => {
render(<Harness start={{ start: 3500, end: 3600 }} />);
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
// Past the end: clamped back to the end, and the field says so.
fireEvent.change(out(), { target: { value: "1:15:00" } });
fireEvent.blur(out());
expect(range()).toBe("3500-3600");
expect(out().value).toBe("1:00:00.0");
expect(out().getAttribute("aria-invalid")).toBeNull();
// Not a time at all: kept for fixing, and marked as not taken.
fireEvent.change(out(), { target: { value: "abc" } });
fireEvent.blur(out());
expect(out().value).toBe("abc");
expect(out().getAttribute("aria-invalid")).toBe("true");
expect(range()).toBe("3500-3600");
});
it("says an unreadable time on a line of its own under the fields, read out, over nothing", () => {
render(<Harness />);
const out = () => screen.getAllByRole("textbox", { name: "clip.out" })[0] as HTMLInputElement;
fireEvent.change(out(), { target: { value: "abc" } });
fireEvent.blur(out());
const line = screen.getByRole("alert");
expect(line.textContent).toBe("common.timecodeInvalidHours");
expect(out().getAttribute("aria-describedby")).toBe(line.id);
// In the panel's flow, after the fields' row: it pushes the crop row down rather than lying over
// its buttons and taking their clicks.
expect(line.closest('[role="group"]')).toBeNull();
expect(line.className).not.toMatch(/\babsolute\b/);
expect(line.parentElement?.className).toMatch(/\bmedia-scope\b/);
});
it("takes a decimal comma, as a Hungarian hand types it", () => {
render(<Harness />);
const field = screen.getAllByRole("textbox", { name: "clip.in" })[0]!;
fireEvent.change(field, { target: { value: "1:42,5" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(range()).toBe("102.5-130");
});
it("never seeks the player onto the item's very end (that would finish the item)", () => {
const onSeek = vi.fn();
render(<Harness onSeek={onSeek} duration={600} start={{ start: 590, end: 595 }} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "clip.out" }), { key: "End" });
expect(range()).toBe("590-600");
const [time, final] = onSeek.mock.lastCall!;
expect(final).toBe(true);
expect(time).toBeLessThan(600);
expect(time).toBeGreaterThan(599);
});
it("marks an end at the playhead", () => {
render(<Harness current={115.5} />);
fireEvent.click(screen.getByRole("button", { name: "clip.outHere" }));
expect(range()).toBe("100-115.5");
});
it("cuts the range with the Plex audio track and closes", async () => {
const onClose = vi.fn();
render(<Harness onClose={onClose} start={{ start: 100.12345, end: 130 }} />);
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
target: { value: " The bit " },
});
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(enqueueClip).toHaveBeenCalledWith({
source: "plex",
ref: "777",
start_s: 100.123,
end_s: 130,
audio_track: 1,
display_name: "The bit",
});
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }),
);
});
it("sends no audio track for a YouTube clip", async () => {
render(<Harness src={{ source: "youtube", ref: "abcdefghijk" }} />);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
const body = enqueueClip.mock.calls[0]![0];
expect(body.source).toBe("youtube");
expect(Object.keys(body)).not.toContain("audio_track");
});
it("stays open and says so when the cut is refused", async () => {
const onClose = vi.fn();
const err = new Error("boom");
enqueueClip.mockRejectedValue(err);
render(<Harness onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed");
expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false,
);
});
it("loops the selection: playing past End goes back to Start", () => {
const onPlayFrom = vi.fn();
const { rerender } = render(<Harness onPlayFrom={onPlayFrom} current={110} />);
fireEvent.click(screen.getByRole("button", { name: "clip.loop" }));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
onPlayFrom.mockClear();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={125} />));
expect(onPlayFrom).not.toHaveBeenCalled();
act(() => rerender(<Harness onPlayFrom={onPlayFrom} current={130.2} />));
expect(onPlayFrom).toHaveBeenLastCalledWith(100);
});
it("cuts the whole picture until the crop is turned on, then the frame with its box's shape", async () => {
render(<Harness />);
expect(screen.queryByRole("group", { name: "clip.shape" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
expect(screen.getByRole("button", { name: "clip.crop" }).getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueueClip).toHaveBeenCalled());
expect(enqueueClip.mock.calls[0]![0].crop).toEqual({
x: 0.1,
y: 0.1,
w: 0.8,
h: 0.8,
aspect: 1.7778,
});
});
it("holds the frame to a shape without cutting, and goes back to the whole picture", () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
// A segmented choice inside the panel's form: never a submit.
fireEvent.click(screen.getByRole("button", { name: "9:16" }));
expect(enqueueClip).not.toHaveBeenCalled();
const tall = JSON.parse(screen.getByTestId("crop").textContent!) as ClipCrop;
expect(tall.shape).toBe("9:16");
expect(tall.rect.h).toBeCloseTo(1, 6);
expect((tall.rect.w * 16) / 9 / tall.rect.h).toBeCloseTo(9 / 16, 6);
fireEvent.click(screen.getByRole("button", { name: "clip.whole" }));
expect(JSON.parse(screen.getByTestId("crop").textContent!)).toEqual({
rect: { x: 0, y: 0, w: 1, h: 1 },
shape: "free",
});
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
expect(screen.getByTestId("crop").textContent).toBe("none");
});
});
+468
View File
@@ -0,0 +1,468 @@
import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../lib/api";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav";
import { log } from "../lib/log";
import { formatTimecode } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
import { CropFrame } from "./ui/CropFrame";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField";
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
// being watched is the preview: dragging a handle or pressing a timeline moves the player there.
//
// Two tracks: the whole item, with the selection and the zoomed window on it, and a zoomed strip
// around the selection where the handles are. The strip's window follows the selection when it
// settles, not while a handle is being dragged — a window that moved under the pointer would run
// away from the drag. A film is hours long and a clip seconds: only the strip gives a hand the
// precision, and the fields and their tenth-of-a-second steps give the rest.
//
// The range itself is the caller's state (`clip`), so the player's `[` / `]` keys and this panel
// mark the same range by the same rules (lib/clipRange).
//
// A clip is the whole picture unless the user turns the crop on: then a frame over the video
// (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held
// to an output shape. The crop is the caller's state too, so the player's Escape can turn it off
// before it closes the panel.
/** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */
type ClipShape = "free" | "16:9" | "9:16" | "1:1";
const SHAPES: Record<Exclude<ClipShape, "free">, number> = {
"16:9": 16 / 9,
"9:16": 9 / 16,
"1:1": 1,
};
/** A clip's crop: the frame, in fractions of the picture, and the shape it is held to. */
export interface ClipCrop {
rect: Frac;
shape: ClipShape;
}
const CROP_START: ClipCrop = { rect: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 }, shape: "free" };
const round4 = (v: number) => Math.round(v * 10_000) / 10_000;
const lockOf = (crop: ClipCrop, picture: number) =>
crop.shape === "free" ? null : fracRatio(SHAPES[crop.shape], picture);
export interface ClipSource {
source: ClipRequest["source"];
ref: string;
/** Plex: the audio track that is playing, which the clip carries. */
audioTrack?: number | null;
}
interface Props {
src: ClipSource;
clip: ClipRange;
onChange: (r: ClipRange) => void;
duration: number;
/** The player's position, live. */
current: number;
/** The shape (width / height) of the picture the crop frame is drawn on. */
picture: number;
crop: ClipCrop | null;
onCrop: (c: ClipCrop | null) => void;
/** Move the player; `final` false while a pointer still drags (a player whose seek is costly waits). */
onSeek: (time: number, final: boolean) => void;
/** Play from `time` (the loop's restart, and "Play selection"). */
onPlayFrom: (time: number) => void;
onClose: () => void;
}
const NUDGE_S = 0.1;
// How far short of the item's end a seek stops: a mark on the very end moves the player to its last
// moment, not past it. A player put at its end ENDS, and the item counts as finished — YouTube marks
// it watched and shows its end screen, a Plex position reads as the whole — while a mark there is only
// a place. (The players keep themselves and the clip open at an end while one is marked; this keeps
// the item unfinished.)
const LAST_MOMENT_S = 0.25;
// On the always-dark chrome the ends are white (a light theme's accent is a dark blue there, which
// all but vanished on the bar) and the playhead amber, so an end and the playhead are never confused.
// The media amber (`--media-mark`), not the theme's warn: a light theme's is dark on this bar. Every
// focus ring in the panel is that amber as well (media-scope re-points --focus), as the crop frame's
// is: the theme accent's ring was under 3:1 on the chrome.
const PLAYHEAD = "bg-media-mark";
export default function ClipPanel({
src,
clip,
onChange,
duration,
current,
picture,
crop,
onCrop,
onSeek,
onPlayFrom,
onClose,
}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection).
const [frozen, setFrozen] = useState<RangeWindow | null>(null);
const hours = duration >= 3600;
const tc = (s: number) => formatTimecode(s, hours);
const view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start;
// Opened from a button or a key, the panel takes the focus to its In handle: the button that
// opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without
// scrolling there: the player's card is still fitting itself around the new panel at this point,
// and a card that has to scroll (a short window) scrolled the top of the video out of sight.
useEffect(() => {
rootRef.current
?.querySelector<HTMLElement>('[data-handle="in"]')
?.focus({ preventScroll: true });
}, []);
// The crop turned off from its frame (Escape there): the frame took the focus with it, so it goes to
// the Crop toggle rather than to <body>, where the player's dialog no longer holds it.
const cropButtonRef = useRef<HTMLButtonElement>(null);
const cropOn = crop != null;
useEffect(() => {
if (cropOn) return;
const lost = document.activeElement == null || document.activeElement === document.body;
if (lost) cropButtonRef.current?.focus();
}, [cropOn]);
// "Play selection" loops: past Out, back to In.
const restart = useEffectEvent(() => onPlayFrom(clip.start));
useEffect(() => {
if (looping && current >= clip.end) restart();
}, [looping, current, clip.end]);
const seek = (time: number, final: boolean) =>
onSeek(Math.max(0, Math.min(time, duration - LAST_MOMENT_S)), final);
const moveIn = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
const next = markIn(clip, time, duration);
onChange(next);
seek(next.start, final);
};
const moveOut = (time: number, final: boolean) => {
setFrozen(final ? null : (frozen ?? view));
const next = markOut(clip, time, duration);
onChange(next);
seek(next.end, final);
};
const inField = useTimecodeField(clip.start, hours, (v) => moveIn(v, true));
const outField = useTimecodeField(clip.end, hours, (v) => moveOut(v, true));
const timeErrorId = useId();
const body: ClipRequest = {
source: src.source,
ref: src.ref,
start_s: Math.round(clip.start * 1000) / 1000,
end_s: Math.round(clip.end * 1000) / 1000,
...(src.audioTrack != null ? { audio_track: src.audioTrack } : {}),
...(crop
? {
crop: {
x: round4(crop.rect.x),
y: round4(crop.rect.y),
w: round4(crop.rect.w),
h: round4(crop.rect.h),
aspect: round4(picture),
},
}
: {}),
...(name.trim() ? { display_name: name.trim() } : {}),
};
const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (busy) return;
setBusy(true);
try {
await api.enqueueClip(body);
invalidateDownloads(qc);
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({
level: "success",
message: t("clip.queued"),
action: { label: t("clip.view"), onClick: () => navigateTo("downloads") },
});
onClose();
} catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed"));
setBusy(false);
}
};
return (
<form
ref={rootRef}
onSubmit={submit}
aria-label={t("clip.title")}
data-testid="clip-panel"
className="media-scope flex flex-col gap-2 bg-media-chrome/95 px-3 py-2.5 text-sm"
// The panel's own presses are not the video's (play/pause) nor the backdrop's (close).
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3">
<span className="flex shrink-0 items-center gap-1.5 font-medium">
<Scissors className="h-4 w-4" aria-hidden="true" />
{t("clip.title")}
</span>
<RangeTrack
label={t("clip.overview")}
duration={duration}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={(time, final) => final && seek(time, true)}
className="h-5 flex-1 cursor-pointer rounded-sm bg-white/15"
regions={[
{
key: "window",
start: view.from,
end: view.to,
className: "border-x border-white/50",
},
{ key: "sel", start: clip.start, end: clip.end, className: "bg-accent/70" },
]}
/>
<IconButton variant="overlay" size="sm" onClick={onClose} label={t("clip.close")}>
<X />
</IconButton>
</div>
<RangeTrack
label={t("clip.strip")}
duration={duration}
view={view}
playhead={current}
playheadClassName={PLAYHEAD}
onSeek={seek}
className="h-14 cursor-pointer rounded-md bg-white/10 text-white/45"
background={
<RulerTicks view={view} format={(s) => formatTimecode(s, hours).replace(/\.\d$/, "")} />
}
regions={[
{
key: "sel",
start: clip.start,
end: clip.end,
className: "bg-accent/25 border-y-2 border-accent",
},
]}
handles={[
{
id: "in",
label: t("clip.in"),
time: clip.start,
...bounds.in,
valueText: tc(clip.start),
step: NUDGE_S,
bigStep: 1,
onChange: moveIn,
barClassName: "bg-white",
},
{
id: "out",
label: t("clip.out"),
time: clip.end,
...bounds.out,
valueText: tc(clip.end),
step: NUDGE_S,
bigStep: 1,
onChange: moveOut,
barClassName: "bg-white",
},
]}
/>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<MarkField
label={t("clip.in")}
value={clip.start}
field={inField}
errorId={timeErrorId}
onSet={(v) => moveIn(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.inHere")}
onHere={() => moveIn(current, true)}
/>
<MarkField
label={t("clip.out")}
value={clip.end}
field={outField}
errorId={timeErrorId}
onSet={(v) => moveOut(v, true)}
nudge={NUDGE_S}
hereLabel={t("clip.outHere")}
onHere={() => moveOut(current, true)}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.length", { length: formatTimecode(length) })}
</span>
<Button
variant="overlay"
size="sm"
pressed={looping}
icon={<Repeat />}
onClick={() => {
if (!looping) onPlayFrom(clip.start);
setLooping(!looping);
}}
>
{t("clip.loop")}
</Button>
</div>
{(inField.invalid || outField.invalid) && <TimecodeError id={timeErrorId} hours={hours} />}
<div className="flex flex-wrap items-center gap-2">
<Button
ref={cropButtonRef}
variant="overlay"
size="sm"
pressed={crop != null}
icon={<Crop />}
onClick={() => onCrop(crop ? null : CROP_START)}
>
{t("clip.crop")}
</Button>
{crop && (
<>
<SegmentedToggle
ariaLabel={t("clip.shape")}
value={crop.shape}
onChange={(shape) => {
const next = { ...crop, shape };
const lock = lockOf(next, picture);
onCrop(lock == null ? next : { ...next, rect: fitRatio(crop.rect, lock) });
}}
options={[
{ value: "free", label: t("clip.shapeFree") },
{ value: "16:9", label: "16:9" },
{ value: "9:16", label: "9:16" },
{ value: "1:1", label: "1:1" },
]}
/>
<Button
variant="overlay"
size="sm"
icon={<Maximize />}
onClick={() => onCrop({ rect: WHOLE, shape: "free" })}
>
{t("clip.whole")}
</Button>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
size="sm"
className="min-w-48 flex-1"
value={name}
maxLength={DISPLAY_NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder={t("clip.namePlaceholder")}
aria-label={t("clip.name")}
/>
<Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
{t("clip.cut")}
</Button>
</div>
</form>
);
}
/** One end's field: the time typed (Enter or leaving the field sets it), a tenth back and forward,
* and "to the playhead". */
function MarkField({
label,
value,
field,
errorId,
onSet,
nudge,
hereLabel,
onHere,
}: {
label: string;
value: number;
field: TimecodeDraft;
errorId: string;
onSet: (v: number) => void;
nudge: number;
hereLabel: string;
onHere: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1" role="group" aria-label={label}>
<span className="mr-1 text-muted">{label}</span>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value - nudge)}
label={t("clip.earlier", { label })}
>
<Minus />
</IconButton>
<TimecodeField
size="sm"
className="w-24 text-center tabular-nums"
field={field}
errorId={errorId}
aria-label={label}
/>
<IconButton
variant="overlay"
size="sm"
onClick={() => onSet(value + nudge)}
label={t("clip.later", { label })}
>
<Plus />
</IconButton>
<IconButton variant="overlay" size="sm" onClick={onHere} label={hereLabel}>
<Crosshair />
</IconButton>
</div>
);
}
/** The clip's crop frame, over the player's picture box (the caller positions this layer over it). */
export function ClipCropFrame({
picture,
crop,
onCrop,
}: {
picture: number;
crop: ClipCrop;
onCrop: (c: ClipCrop) => void;
}) {
return (
<CropFrame
aspect={picture}
rect={crop.rect}
ratio={lockOf(crop, picture)}
onChange={(rect) => onCrop({ ...crop, rect })}
className="z-base"
/>
);
}
@@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => {
]);
});
it("a started item continues — its bar shows how far — and its menu can start it over", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 30 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle");
// The media cards' bar along the picture's bottom edge: 30 s of the 60 s item.
expect(rowOf("Clip 2").querySelector<HTMLElement>(".bg-accent")!.style.width).toBe("50%");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
// Starting over opens the player at 0:00, not at the kept point.
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "card.restartTitle")!,
);
const video = await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
return v!;
});
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => 60, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
expect(video.currentTime).toBe(0);
});
it("an item never played offers Play only, with no bar", async () => {
mocks.jobs = [job(2, "done", { position_seconds: 0 })];
mount("done");
await screen.findByText("Clip 2");
expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull();
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).not.toContain("card.restartTitle");
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
+16 -4
View File
@@ -68,7 +68,9 @@ export default function DownloadCenter() {
const [manage, setManage] = useState(false);
const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [playJob, setPlayJob] = useState<DownloadJob | null>(null);
// What the player opens, and whether over the kept point ("Play from the beginning").
const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null);
const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart });
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const [errorJob, setErrorJob] = useState<DownloadJob | null>(null);
@@ -276,7 +278,9 @@ export default function DownloadCenter() {
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
@@ -315,7 +319,9 @@ export default function DownloadCenter() {
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
title={
job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play")
}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
@@ -357,7 +363,13 @@ export default function DownloadCenter() {
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{playJob && <LibraryPlayer job={playJob} onClose={() => setPlayJob(null)} />}
{playing && (
<LibraryPlayer
job={playing.job}
fromStart={playing.fromStart}
onClose={() => setPlaying(null)}
/>
)}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
{errorJob && (
<ErrorModal
@@ -0,0 +1,217 @@
// @vitest-environment jsdom
//
// The feed's optimistic layer: a card shows what was just written to it until the server's own answer
// lands. Which answers count is the rule pinned here: a refetch that LANDS (for what was written
// before it set out), a filter change; never an append of the next page. A mark made while a refetch
// was in flight is newer than its answer, and dropping it put a card marked watched back into an
// Unwatched view. The grid is a stub that shows what Feed hands it.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, 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("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({
items,
onState,
fetchNextPage,
}: {
items: { id: string; status: string }[];
onState: (id: string, status: string) => void;
fetchNextPage: (o: object) => unknown;
}) => (
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
<span>{`${v.id}:${v.status}`}</span>
<button onClick={() => onState(v.id, "watched")}>watch {v.id}</button>
<button onClick={() => onState(v.id, "hidden")}>hide {v.id}</button>
</li>
))}
<button onClick={() => void fetchNextPage({})}>more</button>
</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: "all",
};
const video = (id: string) =>
({
id,
title: id,
channel_id: "UCx",
channel_title: "Channel",
status: "new",
position_seconds: 0,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
// The server never takes the mark in these tests: whatever a list shows as "watched" is the override.
const page = (ids: string[], next: string | null = null) =>
({ items: ids.map(video), next_cursor: next }) as never;
function mount(feed: ReturnType<typeof vi.fn>, setState?: () => Promise<unknown>) {
vi.spyOn(api, "feed").mockImplementation(feed as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
if (setState) vi.spyOn(api, "setState").mockImplementation(setState as never);
else vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const ui = (filters: FeedFilters) => (
<QueryClientProvider client={qc}>
<Feed
filters={filters}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>
);
const r = render(ui(FILTERS));
return { qc, rerender: (f: FeedFilters) => r.rerender(ui(f)) };
}
const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
describe("Feed — optimistic overrides", () => {
it("give way to a refetch that lands after them", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed);
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v1"])));
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
it("keep a mark made while the refetch was in flight", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed);
await screen.findByText("v1:new");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
fireEvent.click(screen.getByText("watch v1"));
await screen.findByText("v1:watched");
// The answer the server gave before it heard of the mark.
await act(async () => answer(page(["v1"])));
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("keep a mark sent before the refetch set out but not answered by then", async () => {
// The server may read the list before it commits the mark: that answer is older than the mark.
let answer: (v: unknown) => void = () => {};
let commit: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"]))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
const { qc } = mount(feed, () => new Promise((r) => (commit = r)));
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v1"])));
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
await act(async () => commit({ ok: true }));
});
it("are taken back by a failed write only while no later write stands", async () => {
// Marked watched, then hidden before the first answer came back: the refused mark must not take
// the hide with it and put the card back in the view.
const writes: { resolve: (v: unknown) => void; reject: (e: unknown) => void }[] = [];
mount(
vi.fn().mockResolvedValue(page(["v1", "v2"])),
() => new Promise((resolve, reject) => writes.push({ resolve, reject })),
);
fireEvent.click(await screen.findByText("watch v1"));
fireEvent.click(await screen.findByText("hide v1"));
await waitFor(() => expect(screen.queryByText(/^v1:/)).toBeNull());
await act(async () => writes[0]?.reject(new Error("refused")));
await settle();
expect(screen.queryByText(/^v1:/)).toBeNull();
await act(async () => writes[1]?.reject(new Error("refused")));
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
it("survive an append of the next page", async () => {
let answer: (v: unknown) => void = () => {};
const feed = vi
.fn()
.mockResolvedValueOnce(page(["v1"], "c1"))
.mockImplementationOnce(() => new Promise((r) => (answer = r)));
mount(feed);
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
fireEvent.click(screen.getByText("more"));
await waitFor(() => expect(feed).toHaveBeenCalledTimes(2));
await settle();
await act(async () => answer(page(["v2"])));
await screen.findByText("v2:new");
await settle();
expect(screen.getByText("v1:watched")).toBeTruthy();
});
it("are dropped by a change of the filters", async () => {
const { rerender } = mount(vi.fn().mockResolvedValue(page(["v1"])));
fireEvent.click(await screen.findByText("watch v1"));
await screen.findByText("v1:watched");
rerender({ ...FILTERS });
await waitFor(() => expect(screen.getByText("v1:new")).toBeTruthy());
});
});
@@ -0,0 +1,253 @@
// @vitest-environment jsdom
//
// A video marked watched in the player (by hand, or by the player at its end) left an Unwatched view
// at once, then came back as "new" when the player closed, until a reload (owner, on a channel page).
// The player's closing position patch writes into the cached feed, which bumped `dataUpdatedAt`; the
// feed took that for fresh server data and dropped its optimistic overrides, and nothing refetched.
// The grid and the player are stubs that show what Feed hands them.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, 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("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
// What the grid was handed for its card menu's channel-filter row.
const grid: { channelFilter?: { ids: string[]; set: (ids: string[]) => void } } = {};
vi.mock("./VirtualFeed", () => ({
default: ({
items,
onOpen,
channelFilter,
}: {
items: { id: string }[];
onOpen: (v: unknown) => void;
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
}) => (
(grid.channelFilter = channelFilter),
(
<ol aria-label="results">
{items.map((v) => (
<li key={v.id}>
{v.id}
<button onClick={() => onOpen(v)}>open {v.id}</button>
</li>
))}
</ol>
)
),
}));
// The player's progress hook marks a clip closed near its end watched from its unmount cleanup:
// `watchOnUnmount` stands in for that, and `flush` for its closing checkpoint, which it tracks.
const player = vi.hoisted(() => ({
watchOnUnmount: false,
flush: null as Promise<unknown> | null,
}));
vi.mock("./PlayerModal", async () => {
const { useEffect } = await import("react");
const { trackProgressFlush } = await import("../lib/progressCache");
return {
default: function Player({
video,
onState,
onClose,
}: {
video: { id: string };
onState: (id: string, status: string) => void;
onClose: () => void;
}) {
useEffect(
() => () => {
if (player.watchOnUnmount) onState(video.id, "watched");
if (player.flush) trackProgressFlush(player.flush);
},
[video.id, onState],
);
return (
<div>
<button onClick={() => onState(video.id, "watched")}>mark</button>
<button onClick={onClose}>close</button>
</div>
);
},
};
});
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, 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;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
player.watchOnUnmount = false;
player.flush = null;
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: ["UCx"],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const video = (status: string, position = 0) =>
({
id: "v1",
title: "v1",
channel_id: "UCx",
channel_title: "Channel",
status,
position_seconds: position,
duration_seconds: 600,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=v1",
}) as unknown as Video;
function mount({
channelScoped = true,
setFilters = () => {},
}: { channelScoped?: boolean; setFilters?: (f: FeedFilters) => void } = {}) {
const feed = vi
.spyOn(api, "feed")
.mockResolvedValueOnce({ items: [video("new")], next_cursor: null } as never)
// The server after the mark: watched, so an Unwatched view leaves it out.
.mockResolvedValue({ items: [], next_cursor: null } as never);
const summary = vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } 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={setFilters}
view="cards"
setView={() => {}}
canRead
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped={channelScoped}
/>
</QueryClientProvider>,
);
return { qc, feed, summary };
}
describe("Feed — a video marked watched in the player", () => {
it("stays out of an Unwatched view when the player writes its position into the cache", async () => {
const { qc } = mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
await waitFor(() => expect(screen.queryByText("open v1")).toBeNull());
// What the player does as it closes: the server's stored position, written into the cached list.
act(() => patchCachedProgress(qc, new Map([["v1", 10]])));
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText("open v1")).toBeNull();
});
it("refetches the list and its count by the view's filters when the player closes", async () => {
const { feed, summary } = mount();
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = { feed: feed.mock.calls.length, summary: summary.mock.calls.length };
fireEvent.click(screen.getByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before.feed));
await waitFor(() => expect(summary.mock.calls.length).toBeGreaterThan(before.summary));
// The refetch is the channel's Unwatched list, and the server's answer keeps the card out.
expect(feed.mock.calls[feed.mock.calls.length - 1]?.[0]).toMatchObject({
channelIds: ["UCx"],
show: "unwatched",
});
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByText("open v1")).toBeNull();
});
it("refetches when the player marks the clip watched as it closes", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
player.watchOnUnmount = true;
const before = feed.mock.calls.length;
fireEvent.click(await screen.findByText("close"));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("waits for the session's writes to land before it refetches", async () => {
const { feed } = mount();
let land: (v: unknown) => void = () => {};
vi.mocked(api.setState).mockReturnValue(new Promise((r) => (land = r)) as never);
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await act(() => new Promise((r) => setTimeout(r, 30)));
// A refetch now would ask a server that has not taken the mark yet.
expect(feed.mock.calls.length).toBe(before);
await act(async () => land({ ok: true }));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("waits for the player's closing checkpoint before it refetches", async () => {
// Refetched before that write landed, the list read the older position and, landing after the
// player's patch, put it back: a reopen resumed early.
const { feed } = mount();
let land: (v: unknown) => void = () => {};
player.flush = new Promise((r) => (land = r));
fireEvent.click(await screen.findByText("open v1"));
fireEvent.click(await screen.findByText("mark"));
const before = feed.mock.calls.length;
fireEvent.click(screen.getByText("close"));
await act(() => new Promise((r) => setTimeout(r, 30)));
expect(feed.mock.calls.length).toBe(before);
await act(async () => land(undefined));
await waitFor(() => expect(feed.mock.calls.length).toBeGreaterThan(before));
});
it("does not refetch on a close with nothing changed", async () => {
const { feed } = mount();
fireEvent.click(await screen.findByText("open v1"));
const before = feed.mock.calls.length;
fireEvent.click(await screen.findByText("close"));
await new Promise((r) => setTimeout(r, 30));
expect(feed.mock.calls.length).toBe(before);
});
});
describe("Feed — the card menu's channel filter", () => {
it("is offered on the feed, as its own channel list, and sets it", async () => {
const setFilters = vi.fn();
mount({ channelScoped: false, setFilters });
await screen.findByText("open v1");
expect(grid.channelFilter?.ids).toEqual(["UCx"]);
grid.channelFilter?.set(["UCx", "UCy"]);
expect(setFilters).toHaveBeenCalledWith({ ...FILTERS, channelIds: ["UCx", "UCy"] });
});
it("is not offered on a channel page, whose view is that channel already", async () => {
mount({ channelScoped: true });
await screen.findByText("open v1");
expect(grid.channelFilter).toBeUndefined();
});
});
+60 -20
View File
@@ -5,7 +5,7 @@
// the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed
// hands it: each video's position, and a button that fires the reset.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -35,6 +35,7 @@ vi.mock("./VirtualFeed", () => ({
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { patchCachedProgress } from "../lib/progressCache";
beforeAll(() => {
class Stub {
@@ -78,30 +79,69 @@ const halfWatched = {
watch_url: "https://www.youtube.com/watch?v=v1",
} as unknown as Video;
function mount(clear: () => Promise<unknown>) {
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
vi.spyOn(api, "clearState").mockImplementation(clear 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=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
return qc;
}
describe("Feed — resetting a half-watched video", () => {
it("clears its resume position at once, not only its status", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
// Never settles: what the card shows before the server answers is the optimistic layer alone.
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) 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=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
mount(() => new Promise(() => {}));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
fireEvent.click(screen.getByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
});
it("shows the position a replay after it saved, so a reopen resumes there", async () => {
// Reset, played to 40 s and closed: the player writes what the server stored into the cache.
// A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint
// wrote over the 40.
const qc = mount(() => Promise.resolve({ ok: true }));
fireEvent.click(await screen.findByText("reset v1"));
await screen.findByText("v1@0");
act(() => patchCachedProgress(qc, new Map([["v1", 40]])));
await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy());
});
it("keeps the position at 0 through a refetch that set out before the reset", async () => {
// That refetch read the row the reset deletes: its answer put the old position back under a
// card the reset had made new, and a reopen resumed there.
let answer: (v: unknown) => void = () => {};
const qc = mount(() => new Promise(() => {}));
await screen.findByText("v1@95");
vi.mocked(api.feed).mockImplementationOnce(() => new Promise((r) => (answer = r)) as never);
act(() => void qc.invalidateQueries({ queryKey: qk.feed() }));
await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2));
await act(() => new Promise((r) => setTimeout(r, 20)));
fireEvent.click(screen.getByText("reset v1"));
await screen.findByText("v1@0");
await act(async () => answer({ items: [halfWatched], next_cursor: null }));
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(screen.getByText("v1@0")).toBeTruthy();
});
it("puts the position back when the server refuses the reset", async () => {
mount(() => Promise.reject(new Error("down")));
fireEvent.click(await screen.findByText("reset v1"));
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
});
});
+125 -48
View File
@@ -27,6 +27,7 @@ import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { patchCachedProgress, progressFlushes } from "../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -102,6 +103,22 @@ function matchesView(status: VideoStatus, show: string): boolean {
}
}
// An optimistic value and the write that made it. A refetch that lands is the server's word only on
// the writes it ANSWERED before that refetch set out: a mark made while it was in flight is newer
// than its answer, and so is one sent just before it whose answer had not come back (the server may
// read the list first). Dropping either put a card marked watched back into an Unwatched view.
type Stamped<T> = { value: T; seq: number };
type Overrides<T> = Record<string, Stamped<T>>;
const unconfirmed = <T,>(o: Overrides<T>, answered: ReadonlySet<number>): Overrides<T> =>
Object.fromEntries(Object.entries(o).filter(([, s]) => !answered.has(s.seq)));
// A failed write takes back only its OWN value: a later write for the same video stands.
const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T> => {
if (o[id]?.seq !== seq) return o;
const next = { ...o };
delete next[id];
return next;
};
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
@@ -146,26 +163,59 @@ export default function Feed({
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// A reset drops the resume position too, which a status override cannot say: without this the card
// kept its progress bar and its Continue button until the next refetch.
const [resetIds, setResetIds] = useState<Record<string, true>>({});
const [overrides, setOverrides] = useState<Overrides<VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Overrides<boolean>>({});
// The write order the stamps above count in, the writes the server has answered, and which of
// them it had answered when the running refetch set out.
const writeSeq = useRef(0);
const answered = useRef(new Set<number>());
const answeredAtRefetch = useRef<ReadonlySet<number>>(new Set());
// A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out
// before the server took the reset puts the old position back: written again on its landing.
const resets = useRef(new Map<string, number>());
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null,
);
const qc = useQueryClient();
const openVideo = useCallback(
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
[],
);
// What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
// the end) a card leaves an Unwatched view through the override at once, but the list, its count
// and the cache stayed the pre-session server's until a reload (owner).
const playerSession = useRef({
open: false,
changed: false,
pending: new Set<Promise<unknown>>(),
});
const openVideo = useCallback((video: Video, startAt: number | null = null) => {
playerSession.current.open = true;
// Its player writes the next position: a reset no longer speaks for it.
resets.current.delete(video.id);
setActiveVideo({ video, startAt });
}, []);
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
// debounced volume write on each pass.
const closePlayer = useCallback(() => setActiveVideo(null), []);
// The session ends once the player has UNMOUNTED, not on the close click: its progress hook's
// cleanup marks a clip closed near its end watched, a write that lands after the click. React runs
// every unmount cleanup before any effect's setup, so here that write is already in the session,
// and so is the player's closing checkpoint (`progressFlushes`): refetched before it lands, the
// list read the older position and, landing after the player's patch, put it back.
const playerOpen = activeVideo !== null;
useEffect(() => {
const session = playerSession.current;
if (playerOpen || !session.open) return;
session.open = false;
if (!session.changed) return;
session.changed = false;
void Promise.allSettled([...session.pending, ...progressFlushes()]).then(() => {
void qc.invalidateQueries({ queryKey: qk.feed() });
void qc.invalidateQueries({ queryKey: qk.feedSummary() });
});
}, [playerOpen, qc]);
const ytActive = !!ytSearch;
// How many live-search results to gather (the count selector replaces a manual "load more";
@@ -244,22 +294,31 @@ export default function Feed({
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
setResetIds({});
}
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
// Only a real refetch (page count doesn't grow) is authoritative.
const pagesLenRef = useRef(0);
const pagesLen = query.data?.pages?.length ?? 0;
// ...but only when a refetch LANDS, the server's own answer. Not on an infinite-scroll append (its
// page 1 rows are unchanged, so a just-hidden card would flash back), and not on a local write into
// the cache: the player's closing position patch bumped `dataUpdatedAt` too, cleared the overrides,
// and put a card marked watched back into an Unwatched view as "new", with nothing to refetch it.
// And only the overrides whose write it had answered when it set out (`Stamped`).
const refetching = query.isFetching && !query.isFetchingNextPage;
const wasRefetching = useRef(false);
useEffect(() => {
const appended = pagesLen > pagesLenRef.current;
pagesLenRef.current = pagesLen;
if (!appended) {
setOverrides({});
setSavedOverrides({});
setResetIds({});
const started = !wasRefetching.current && refetching;
const landed = wasRefetching.current && !refetching && query.status === "success";
wasRefetching.current = refetching;
if (started) answeredAtRefetch.current = new Set(answered.current);
if (!landed) return;
const known = answeredAtRefetch.current;
setOverrides((o) => unconfirmed(o, known));
setSavedOverrides((o) => unconfirmed(o, known));
for (const seq of known) answered.current.delete(seq);
const zero = new Map<string, number>();
for (const [id, seq] of resets.current) {
if (known.has(seq)) resets.current.delete(id);
else zero.set(id, 0);
}
}, [query.dataUpdatedAt, pagesLen]);
patchCachedProgress(qc, zero);
}, [refetching, query.status, qc]);
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
@@ -282,19 +341,28 @@ export default function Feed({
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
const onState = useCallback(
(id: string, status: VideoStatus) => {
setOverrides((o) => ({ ...o, [id]: status }));
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const session = playerSession.current;
if (session.open) {
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
// would falsely report a change that the .catch below is about to roll back (e.g. when
// the API is unreachable).
api
.setState(id, status)
write
.then(() => {
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
// already show the correct state (a hidden/watched card drops out immediately), so refetching
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
@@ -340,11 +408,7 @@ export default function Feed({
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
// a card that silently snaps back reads as a misclick.
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
});
setOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
@@ -354,24 +418,27 @@ export default function Feed({
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
setOverrides((o) => ({ ...o, [id]: "new" }));
setResetIds((r) => ({ ...r, [id]: true }));
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: "new", seq } }));
// The position goes into the cached lists, where the player's own closing patch writes the
// next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first
// checkpoint wrote over the progress just made.
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq);
api
.clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
})
.catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
});
setResetIds((r) => {
const next = { ...r };
delete next[id];
return next;
});
setOverrides((o) => withoutWrite(o, id, seq));
if (resets.current.get(id) !== seq) return;
resets.current.delete(id);
if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]]));
});
},
[qc],
@@ -379,9 +446,11 @@ export default function Feed({
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
setSavedOverrides((o) => ({ ...o, [id]: saved }));
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
@@ -392,7 +461,7 @@ export default function Feed({
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
),
);
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
setSavedOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
@@ -409,12 +478,11 @@ export default function Feed({
list
.map((v) => {
const status = overrides[v.id]; // read once so the narrowing sticks
return status ? { ...v, status } : v;
return status ? { ...v, status: status.value } : v;
})
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
.map((v) => {
const saved = savedOverrides[v.id];
return saved !== undefined ? { ...v, saved } : v;
return saved ? { ...v, saved: saved.value } : v;
});
const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
@@ -878,6 +946,15 @@ export default function Feed({
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
// A channel page's view is the channel already: no row there to narrow it to one.
channelFilter={
channelScoped
? undefined
: {
ids: filters.channelIds,
set: (channelIds) => setFilters({ ...filters, channelIds }),
}
}
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
// re-request it once a round-trip for as long as the end is in view. The row
// below says so and retries on a click.
+15 -2
View File
@@ -663,7 +663,7 @@ describe("Home", () => {
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
fireEvent.click(await screen.findByRole("button", { name: "A Show" }));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ id: "ep-7", type: "play" }));
});
@@ -678,11 +678,24 @@ describe("Home", () => {
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"));
fireEvent.click(await screen.findByRole("button", { name: "A Show" }));
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ replace: true }));
expect(setPage).toHaveBeenCalledWith("plex");
});
it("a show tile's title opens the show page; its picture plays on", async () => {
discover.mockResolvedValue({
budget: "any",
hero: null,
continue_watching: [showCard()],
comfort: [],
});
mount();
fireEvent.click(await screen.findByText("A Show"), { detail: 1 });
expect(setJumpTo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" }));
expect(setJumpTo).not.toHaveBeenCalledWith(expect.objectContaining({ type: "play" }));
});
it("sends the ⓘ affordance to the item's page, not to the player", async () => {
// `onInfo` and `onPlay` differ by one ternary arm, and confusing them is the defect the play
// test above was written for — but only `onPlay` was pinned, so swapping ⓘ's "movie" for "play"
+2 -1
View File
@@ -199,7 +199,7 @@ function useHomeCardMenu({
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.items(target.plex, { play: false, pin: false, anchor: menu.anchor })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
@@ -371,6 +371,7 @@ function Rail({
onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
onPage={() => onInfo(c)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
@@ -5,7 +5,9 @@
// list — so an audio upload with an uncommon/absent container still renders <audio src=/file>. i18n
// is key-passthrough here.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render as rtlRender, cleanup } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn());
vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose }));
import LibraryPlayer from "./LibraryPlayer";
import type { DownloadJob } from "../lib/api";
import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
const baseJob: DownloadJob = {
id: 7,
@@ -75,7 +78,27 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
},
});
afterEach(cleanup);
// The player writes its resume point into the library listings' cache, so it sits under a client.
const render = (ui: ReactElement, client = new QueryClient()) =>
rtlRender(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
/** The rendered <video>, given a length and a clock the test sets (jsdom plays nothing). */
function loaded(duration = 600) {
const video = document.body.querySelector("video")!;
let t = 0;
Object.defineProperties(video, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => duration, configurable: true },
readyState: { get: () => 1, configurable: true },
});
video.dispatchEvent(new Event("loadedmetadata"));
return video;
}
describe("LibraryPlayer", () => {
it("plays a video asset in a <video> pointed at the range-aware /file endpoint", () => {
@@ -130,4 +153,81 @@ describe("LibraryPlayer", () => {
);
expect(document.body.querySelector("audio")).not.toBeNull();
});
it("opens focused on its panel, so Space plays rather than pressing the dialog's ✕", () => {
// jsdom lays nothing out, so every element reads as hidden (`offsetParent` null) and a dialog
// falls back to its panel whatever it asked for; as in a browser, the ✕ is on screen here.
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.parentElement;
});
render(<LibraryPlayer job={withAsset({ width: 1280, height: 720 })} onClose={() => {}} />);
const panel = document.activeElement as HTMLElement;
expect(panel.tagName).toBe("DIV");
expect(panel.contains(document.body.querySelector("video"))).toBe(true);
});
it("opens where the viewer stopped — or from the start when asked", () => {
const job = { ...withAsset({ width: 1280, height: 720 }), position_seconds: 300 };
render(<LibraryPlayer job={job} onClose={() => {}} />);
expect(loaded().currentTime).toBe(300);
cleanup();
render(<LibraryPlayer job={job} fromStart onClose={() => {}} />);
expect(loaded().currentTime).toBe(0);
});
it("keeps where it stopped: on the server, and on the library row at once", () => {
const save = vi.spyOn(api, "saveDownloadProgress").mockResolvedValue({
ok: true,
job_id: 7,
position_seconds: 125,
});
const beacon = vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation(() => {});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [job, { ...job, id: 8 }]);
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 125.4;
video.dispatchEvent(new Event("pause"));
expect(save).toHaveBeenCalledWith(7, 125.4, 600);
const rows = client.getQueryData<DownloadJob[]>(qk.downloads())!;
expect(rows.map((j) => j.position_seconds)).toEqual([125, undefined]);
// Closing the player sends the point the way that survives the page going away.
unmount();
expect(beacon).toHaveBeenCalledWith(7, 125.4, 600, expect.any(Function));
});
it("keeps the closing point over a list fetch that read the server before the point landed", async () => {
// The listing polls. One already out when the player closed answered with the point the server
// held BEFORE the closing beacon, over the row's patch — and Continue then opened there, and its
// first checkpoint wrote that older point back over the right one.
let settled: (() => void) | undefined;
vi.spyOn(api, "saveDownloadProgressBeacon").mockImplementation((_id, _at, _dur, onSettled) => {
settled = onSettled;
});
const client = new QueryClient();
const job = withAsset({ width: 1280, height: 720 });
client.setQueryData(qk.downloads(), [{ ...job, position_seconds: 10 }]);
let answer: (jobs: DownloadJob[]) => void = () => {};
const poll = client.fetchQuery({
queryKey: qk.downloads(),
queryFn: () => new Promise<DownloadJob[]>((r) => (answer = r)),
});
const { unmount } = render(<LibraryPlayer job={job} onClose={() => {}} />, client);
const video = loaded();
video.dispatchEvent(new Event("playing"));
video.currentTime = 300;
unmount();
answer([{ ...job, position_seconds: 10 }]);
await poll;
expect(client.getQueryData<DownloadJob[]>(qk.downloads())![0]!.position_seconds).toBe(300);
// Once the point has landed, the listing asks the server again.
const invalidate = vi.spyOn(client, "invalidateQueries");
settled?.();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.downloads(), exact: true });
});
});
+58 -8
View File
@@ -1,10 +1,15 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { keptPosition, useResume } from "../lib/resume";
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -14,8 +19,21 @@ import { useMediaLogRef } from "../lib/mediaLog";
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
// any codec (best-effort playback).
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
//
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
// controls and the basic keys drive it like the other players.
export default function LibraryPlayer({
job,
fromStart = false,
onClose,
}: {
job: DownloadJob;
fromStart?: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
@@ -32,16 +50,48 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
// the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
const logMedia = useMediaLogRef("library");
const videoRef = useCallback(
(el: HTMLVideoElement | null) => {
exposeVideoToBackgroundPlay(el);
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
const mediaRef = useCallback(
(el: HTMLMediaElement | null) => {
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
logMedia(el);
setMedia(el);
},
[logMedia],
);
const title = jobTitle(job);
useMediaSession(media, {
title,
artist: job.display_uploader ?? asset?.uploader ?? null,
artwork: poster ?? null,
});
useMediaKeys(media);
// The library row's bar follows the point at once — both listings this job may sit in — rather
// than after the next refetch. On the way out a listing poll may already be out, having read the
// server before the closing point lands: it is cancelled, or its answer replaced the row's point
// with the older one, Continue opened there, and its first checkpoint wrote it back over the right
// one. Once the point is on the server, the listings are read again.
const lists = [qk.downloads(), qk.downloadsShared()];
const keepPoint = (position: number, duration: number, leaving: boolean) => {
const at = keptPosition(position, duration);
for (const key of lists) {
if (leaving) void qc.cancelQueries({ queryKey: key, exact: true });
qc.setQueryData<DownloadJob[]>(key, (jobs) =>
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
);
}
if (leaving) {
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
for (const key of lists) void qc.invalidateQueries({ queryKey: key, exact: true });
});
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
};
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
return (
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
{isAudio ? (
<div className="flex flex-col items-center gap-4 py-2">
{poster ? (
@@ -51,13 +101,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
className="w-40 h-40 rounded-xl object-cover border border-border"
/>
) : null}
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
ref={videoRef}
ref={mediaRef}
src={fileUrl}
poster={poster}
controls
@@ -32,6 +32,20 @@ import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
* to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */
function stubNavigation(below: boolean[]) {
const entries = [...below].reverse().map((sameDocument) => ({ sameDocument }));
entries.push({ sameDocument: true });
Object.defineProperty(window, "navigation", {
configurable: true,
value: { currentEntry: { index: entries.length - 1 }, entries: () => entries },
});
}
afterEach(() => {
Reflect.deleteProperty(window, "navigation");
});
// The provider reads the signed-in account (the tab title) from the `me` cache.
let qc = new QueryClient();
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -206,6 +220,31 @@ describe("NavigationProvider · Back", () => {
});
});
describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => {
// A full tab can have dropped the entry under the one the channel page or the search pushed (see
// lib/history's backStaysInDocument): leaving clears it in place instead of stepping out.
it("closes the channel page in place", () => {
const { result } = render();
act(() => result.current.openChannel("UC1", "One"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.closeChannel());
expect(back).not.toHaveBeenCalled();
expect(result.current.channelView).toBeNull();
expect(window.location.search).not.toContain("chan=");
});
it("leaves the YouTube search in place", () => {
const { result } = render();
act(() => result.current.enterYtSearch("cats"));
stubNavigation([false]);
const back = vi.spyOn(window.history, "back").mockImplementation(() => {});
act(() => result.current.exitYtSearch());
expect(back).not.toHaveBeenCalled();
expect(result.current.ytSearch).toBeNull();
});
});
describe("NavigationProvider · never under an open overlay", () => {
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
const { result } = render();
+15 -7
View File
@@ -9,6 +9,7 @@ import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import {
backStaysInDocument,
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
@@ -119,12 +120,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
if (window.history.state?._chan && backStaysInDocument()) window.history.back();
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
// drop the ?chan param from the address bar.
// No owned history entry (e.g. opened straight from a ?chan bookmark), or none of this document
// below it to step back to (a full tab, see backStaysInDocument): clear it in place — the
// entry's channel marks and the ?chan param.
setChannelView(null);
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
const { _chan: _id, _chanName: _name, ...st } = window.history.state ?? {};
window.history.replaceState(st, "", urlWithParams({ chan: null }));
}
}, []);
@@ -143,9 +146,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
// Pop our search entry (so Forward still works); fall back to a clear in place if we don't own
// one, or there is no entry of this document below it (a full tab, see backStaysInDocument).
if (window.history.state?._yt && backStaysInDocument()) window.history.back();
else {
setYtSearch(null);
const { _yt: _q, _ytScrape: _scrape, ...st } = window.history.state ?? {};
window.history.replaceState(st, "");
}
}, []);
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
+3 -2
View File
@@ -28,7 +28,8 @@ function useCap(): string {
// words matched nothing.
/** The key cap at the end of a search field. A button, so the pointer and a screen reader reach it
* too; left out of a phone's narrow header, where there is no keyboard to press it on. */
* too; left out of a phone's narrow header and of every touch screen, where there is (usually) no
* keyboard to press it on — on a tablet it was a 44 px box crowding the field. ⌘K still works. */
export function PaletteKeycap() {
const { t } = useTranslation();
const cap = useCap();
@@ -37,7 +38,7 @@ export function PaletteKeycap() {
onClick={openPalette}
aria-label={`${t("palette.open")} (${cap})`}
title={`${t("palette.open")} (${cap})`}
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg @max-[640px]/appmain:hidden"
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border border-border px-1.5 font-mono text-3xs touch-target text-muted transition hover:border-accent/50 hover:text-fg pointer-coarse:hidden @max-[640px]/appmain:hidden"
>
{cap}
</Pressable>
+468 -27
View File
@@ -50,6 +50,8 @@ beforeAll(() => {
// --- the fake IFrame API ------------------------------------------------------------------------
const clock = { current: 0, duration: 3000 };
const started: { videoId: string; start: number | undefined }[] = [];
// The last player's event handlers, so a test can say "the player is ready" when it wants to.
let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {};
const base = {
getCurrentTime: () => clock.current,
getDuration: () => clock.duration,
@@ -61,8 +63,12 @@ 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 } }) {
Player: function (
_el: unknown,
opts: { videoId: string; playerVars: { start?: number }; events?: typeof playerEvents },
) {
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
playerEvents = opts.events ?? {};
return fakePlayer;
} as never,
};
@@ -331,6 +337,40 @@ describe("PlayerModal — the dialog contract", () => {
}
});
// The card takes the focus back on its own when it may have slipped into the iframe: the player
// turning ready, the pointer leaving the video, the window regaining focus. None of those may take
// it off a control the viewer tabbed to, or their next Tab starts over from the first button.
it.each([
["the player turns ready", () => playerEvents.onReady?.()],
[
"the pointer leaves the video",
() => {
fireEvent.mouseLeave(document.querySelector(".player-stage")!);
},
],
[
"the window regains focus",
() => {
fireEvent.focus(window);
},
],
])("leaves the focus on a control the viewer tabbed to when %s", async (_why, happen) => {
open();
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
const control = within(screen.getByTestId("player-modal")).getByTestId("player-close");
control.focus();
act(happen);
expect(document.activeElement).toBe(control);
});
it("takes the focus back from the iframe when the player turns ready", async () => {
open();
await waitFor(() => expect(playerEvents.onReady).toBeTypeOf("function"));
(document.activeElement as HTMLElement).blur();
act(() => playerEvents.onReady?.());
expect(screen.getByTestId("player-modal").contains(document.activeElement)).toBe(true);
});
it("keeps Tab inside the player", () => {
open();
const dialog = screen.getByTestId("player-modal");
@@ -424,23 +464,103 @@ describe("PlayerModal — the description", () => {
});
});
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
describe("PlayerModal — the side arrows", () => {
it("stand inside the row's gutter", () => {
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
// width (owner): the panel's transport row and Shift+← / → step on their own.
describe("PlayerModal — no side strips", () => {
it("steps from the panel only, and the card keeps no gutter for strips", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
(b) => b.className.includes("w-12"),
)!;
expect(arrow).toBeTruthy();
expect(arrow.className).toMatch(/\bright-0\b/);
expect(arrow.className).not.toMatch(/left-full/);
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.className).not.toMatch(/px-14/);
});
it("closes from one icon-only button", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
const close = screen.getByTestId("player-close");
expect(close.getAttribute("aria-label")).toBe("player.close");
expect(close.textContent).toBe("");
fireEvent.click(close);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// YouTube's embed keeps its controls at fixed px from the iframe's edges: the seek bar across the bottom
// 52–96 px up, the fullscreen button above its right end 80–128 px up, the gear and volume along the
// top. A cutout given only as a share of the height lay over that button on a short player (a small
// window, a browser zoomed to 150 %): the click paused instead.
describe("PlayerModal — the click layer over the video", () => {
const layers = () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
return screen.getAllByTestId("yt-click-layer");
};
const box = (el: HTMLElement) => {
const { left, right, width, top, bottom } = el.style;
return { left, right, width, top, bottom };
};
it("clears the seek bar across the width and the fullscreen button in its right-hand column", () => {
const [picture, column] = layers();
expect(box(picture!)).toEqual({
left: "0px",
right: "88px",
width: "",
top: "max(12%, 56px)",
bottom: "max(22%, 104px)",
});
expect(box(column!)).toEqual({
left: "",
right: "0px",
width: "88px",
top: "max(12%, 56px)",
bottom: "max(22%, 140px)",
});
});
// In the big views our docked bar sits under the iframe; YouTube's px are counted from the iframe's
// bottom, so a box measured from the stage's would start that bar's height too low again.
it.each([
["the windowed card", false, /(^|\s)relative(\s|$)/],
["true fullscreen", true, /(^|\s)bottom-14(\s|$)/],
])("measures those edges against the iframe's own box in %s", (_view, fullscreen, inset) => {
const els = layers();
const stage = document.querySelector(".player-stage")!;
if (fullscreen) {
Object.defineProperty(document, "fullscreenElement", {
configurable: true,
get: () => stage,
});
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
}
try {
for (const el of screen.getAllByTestId("yt-click-layer")) {
const wrapper = el.parentElement!;
expect(wrapper).not.toBe(stage);
expect(wrapper.parentElement).toBe(stage);
expect(wrapper.className).toMatch(inset);
expect(wrapper.className).not.toMatch(/(^|\s)p[byt]?-/);
}
expect(els).toHaveLength(2);
} finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
}
});
});
@@ -457,17 +577,18 @@ describe("PlayerModal — fits the window's height", () => {
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.querySelector(":scope > .player-stage") ? 630 : 0;
return this.querySelector(":scope > * > .player-stage") ? 630 : 0;
});
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe(
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
);
// stage → its wrapper (with the clip panel) → the card → the row
const row =
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
@@ -485,8 +606,19 @@ describe("PlayerModal — the side panel on a wide window", () => {
removeEventListener: () => {},
}) as unknown as MediaQueryList,
);
// jsdom has no matchMedia to spy on: a bare one is put in for these tests and taken out after, or
// every later test's media query would read that bare stub.
let added = false;
beforeEach(() => {
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
if (typeof window.matchMedia === "function") return;
window.matchMedia = (() => ({})) as unknown as typeof window.matchMedia;
added = true;
});
afterEach(() => {
// The spy goes first: restoring it later would put the bare stub back.
vi.restoreAllMocks();
if (added) delete (window as { matchMedia?: unknown }).matchMedia;
added = false;
});
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
@@ -500,7 +632,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
const info = screen.getByTestId("player-info");
// The card is a row: the video, then the panel holding the title and the channel.
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
@@ -510,13 +642,110 @@ describe("PlayerModal — the side panel on a wide window", () => {
// The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
// The row's width: the video at the window's height, plus the panel and the side strips.
expect(card.parentElement!.style.maxWidth).toBe(
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
);
// The row's width: the video at the window's height, plus the panel.
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
expect(name.className).toMatch(/\bmin-w-0\b/);
expect(name.className).toMatch(/\bflex-1\b/);
expect(name.className).not.toMatch(/\bshrink-0\b/);
// The description is cut to three lines, with a More once it is cut.
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
});
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => {
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!
.parentElement!;
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
expect(screen.queryByTestId("player-info")).toBeNull();
const strip = screen.getByTestId("player-strip");
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
expect(document.activeElement).toBe(
within(strip).getByRole("button", { name: "player.expandPanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
// The strip keeps the way out and the stepping.
expect(within(strip).getByTestId("player-close")).toBeTruthy();
// The dialog keeps its name with the title out of sight: it is named by that heading's id.
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
expect(screen.getByRole("dialog", { name: "Video B" })).toBeTruthy();
fireEvent.click(
within(screen.getByTestId("player-strip")).getByRole("button", {
name: "player.expandPanel",
}),
);
expect(screen.getByTestId("player-info")).toBeTruthy();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "player.collapsePanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
});
it("gives the focus to the player when the scissors that opened the clip is not there to take it", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Shut by the user: closing the clip leaves it shut, and its strip has no scissors.
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: "c", bubbles: true, cancelable: true }),
),
);
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
await waitFor(() => expect(document.activeElement).not.toBe(document.body));
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
});
it("shuts while a clip is marked and opens again after it, unless the user opened it meanwhile", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
expect(screen.getByTestId("player-strip")).toBeTruthy();
// The clip panel sits under the video here too: the stacked card's floor, and its scroll past it
// (without them the fit ran the card down to a sliver at 133% with the largest text).
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const cardEl = stage.parentElement!.parentElement!;
expect(cardEl.parentElement!.style.maxWidth).toBe(
"calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 2.75rem)",
);
expect(cardEl.className).toContain("overflow-y-auto");
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
expect(screen.getByTestId("player-info")).toBeTruthy();
expect(cardEl.className).toContain("overflow-hidden");
// Opened again by hand while clipping: it stays open, and closing the clip leaves it so.
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
fireEvent.click(
within(screen.getByTestId("player-strip")).getByRole("button", {
name: "player.expandPanel",
}),
);
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
fireEvent.click(screen.getByRole("button", { name: "clip.close" }));
expect(screen.getByTestId("player-strip")).toBeTruthy();
});
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
@@ -537,11 +766,33 @@ describe("PlayerModal — the side panel on a wide window", () => {
return e.defaultPrevented; // prevented = the wheel went to the volume
};
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
top = 400;
expect(spin(text, 100)).toBe(true); // at its end: the volume
expect(spin(text, -100)).toBe(false);
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
// A panel that fits has nothing to scroll: the wheel is the volume there.
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
expect(spin(text, 100)).toBe(true);
});
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
wideWindow(false);
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<HTMLElement>(".player-stage")!;
const card = stage.parentElement!.parentElement!; // the stage sits in a wrapper (+ clip panel)
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element) => {
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented;
};
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
expect(spin(stage)).toBe(true);
});
// A failed load read "No description." in the always-open panel — an error drawn as empty.
@@ -591,3 +842,193 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(screen.queryByTestId("player-description")).toBeNull();
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`): the docked bar's
// true-fullscreen button did nothing there. The window-filling view is then the biggest one.
describe("PlayerModal — the docked bar's fullscreen", () => {
const maximised = (api_: boolean) => {
if (api_)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
// Brave's stand-in otherwise: a <video>-only requestFullscreen on HTMLElement.prototype.
else
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByLabelText("player.fullscreen"));
return screen.getAllByLabelText("player.fullscreen").length;
};
afterEach(() => {
delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
it("is offered where the browser can fullscreen an element", () => {
expect(maximised(true)).toBe(2);
});
it("is left out where it cannot", () => {
expect(maximised(false)).toBe(1);
});
});
// Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and
// not the player, the scissors gets the focus back; in the maximised view the panel takes the docked
// bar's place; a crop frame lies over the video and Escape turns it off before the panel closes.
describe("PlayerModal — clip mode", () => {
const key = (k: string) =>
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }),
),
);
const open = (onClose = () => {}) => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
};
it("opens under the video on C, and Escape closes the panel, not the player", async () => {
const onClose = vi.fn();
clock.current = 42;
open(onClose);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
const panel = screen.getByTestId("clip-panel");
const stage = document.querySelector(".player-stage")!;
expect(stage.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
"42",
);
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(onClose).not.toHaveBeenCalled();
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
);
});
it("marks in the maximised view without leaving it: the panel takes the docked bar's place", async () => {
clock.current = 10;
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("f");
const stage = document.querySelector(".player-stage--max")!;
expect(stage).toBeTruthy();
key("]");
expect(document.querySelector(".player-stage--max")).toBe(stage);
expect(stage.contains(screen.getByTestId("clip-panel"))).toBe(true);
expect(screen.queryByRole("button", { name: "player.exitMaximize" })).toBeNull();
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
"10",
);
// Escape: the panel first, then the maximised view, then (not here) the player.
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(document.querySelector(".player-stage--max")).toBe(stage);
expect(screen.getByRole("button", { name: "player.exitMaximize" })).toBeTruthy();
});
it("draws a short's frame on its 9:16 picture and tells the cut so", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const S = { ...vid("S", 0), is_short: true } as Video;
renderModal(
<PlayerModal video={S} queue={[S]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("S"));
key("c");
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0].crop?.aspect).toBe(0.5625);
});
it("lays the crop frame over the video, and Escape turns it off before closing the panel", async () => {
// jsdom lays nothing out: the frame's layer measures a 16:9 box.
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1600);
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(900);
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
const frame = screen.getByTestId("crop-frame");
expect(document.querySelector(".player-stage")!.contains(frame)).toBe(true);
act(() => screen.getByRole("group", { name: "common.cropFrame.frame" }).focus());
key("Escape");
expect(screen.queryByTestId("crop-frame")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
// The frame took the focus with it: it is on the Crop toggle, not on <body>.
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.crop" }));
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => {
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("f");
key("c");
key("Escape");
const stage = document.querySelector(".player-stage--max")!;
await waitFor(() =>
expect(document.activeElement?.getAttribute("aria-label")).toBe("clip.open"),
);
// The card's own scissors lies under the maximised stage: a Space there reopened the panel.
expect(stage.contains(document.activeElement)).toBe(true);
});
it("keeps what was typed in the panel when the view is maximised under it", async () => {
open();
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
key("c");
fireEvent.change(screen.getByRole("textbox", { name: "clip.name" }), {
target: { value: "The bit" },
});
const loop = screen.getByRole("button", { name: "clip.loop" });
fireEvent.click(loop);
act(() => loop.focus());
const panel = screen.getByTestId("clip-panel");
key("f");
expect(document.querySelector(".player-stage--max")!.contains(panel)).toBe(true);
expect(screen.getByTestId("clip-panel")).toBe(panel);
expect((screen.getByRole("textbox", { name: "clip.name" }) as HTMLInputElement).value).toBe(
"The bit",
);
expect(loop.getAttribute("aria-pressed")).toBe("true");
expect(document.activeElement).toBe(loop);
// And back: the dock the panel sat in goes, the panel and its focus stay.
key("f");
expect(document.querySelector(".player-stage--max")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBe(panel);
expect(document.activeElement).toBe(loop);
});
it("does not step on to the next video when one ends while a clip is marked", async () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
// Auto-advance on: an ending video steps on to B.
fireEvent.click(screen.getAllByRole("button", { name: /^player\.autoAdvance\.label/ })[0]!);
key("c");
act(() => playerEvents.onStateChange?.({ data: 0 }));
await settle();
expect(lastStart()?.videoId).toBe("A");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -30,7 +30,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
}));
import PlexPlaylistView from "./PlexPlaylistView";
import AddToPlaylist from "./AddToPlaylist";
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import TagManager from "./TagManager";
import { api, HttpError, type PlexCard } from "../lib/api";
@@ -157,6 +157,64 @@ describe("the add-to-playlist popover", () => {
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
});
it("says a failed load of the playlists as one, not as 'no playlists yet'", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
render(
<QueryClientProvider client={client()}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("common.loadError");
expect(screen.queryByText("playlists.noneYet")).toBeNull();
});
it("opened from a card's menu, is a menu named for itself, its lines beside the list", async () => {
vi.spyOn(api, "playlists").mockRejectedValue(new Error("down"));
const anchor = document.createElement("button");
document.body.append(anchor);
render(
<QueryClientProvider client={client()}>
<AddToPlaylistAt videoId="v1" anchor={anchor} onClose={() => {}} />
</QueryClientProvider>,
);
// The menu row that opened it is gone: its own name, not a label pointing at that row. jsdom
// computes no name for the unpositioned surface, so read what names it.
const menu = await waitFor(() => {
const found = document.querySelector(
'[role="menu"][aria-label="playlists.addToPlaylistTitle"]',
);
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.closest("[role=menu]")).toBeNull();
anchor.remove();
});
it("flips the tick as soon as an add lands, so the next tap takes it out again", async () => {
const lists = vi.spyOn(api, "playlists");
lists.mockResolvedValueOnce([{ id: 3, name: "Mix", kind: "user", has_video: false }] as never);
// The refetch after the write never answers: the tick must not wait for it.
lists.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "addToPlaylist").mockResolvedValue({} as never);
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({} as never);
render(
<QueryClientProvider client={client()}>
<AddToPlaylist videoId="v1" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
const row = await screen.findByRole("menuitemcheckbox", { hidden: true });
fireEvent.click(row);
await waitFor(() => expect(row.getAttribute("aria-checked")).toBe("true"));
fireEvent.click(row);
await waitFor(() => expect(remove).toHaveBeenCalledWith(3, "v1"));
expect(add).toHaveBeenCalledTimes(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
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
//
// A show's card in the Browse grid plays (the owner, 2026-10-09): its picture asks the server which
// episode and opens the player on it; a started show's Restart opens it from the start; its title
// opens the show's page. The hook is tested on its own (usePlayShow); this is the grid wiring it.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, 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("./PlexPlayer", () => ({
default: ({ itemId, fromStart }: { itemId: string; fromStart?: boolean }) => (
<div>
player {itemId} {fromStart ? "from the start" : "resumed"}
</div>
),
}));
vi.mock("./plexViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexViews")>()),
PlexShowView: ({ showId }: { showId: string }) => <div>show page {showId}</div>,
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
}));
import PlexBrowse from "./PlexBrowse";
import { api, 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(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const show: PlexCard = {
id: "sh",
type: "show",
title: "Grimm",
thumb: "",
status: "in_progress",
resumable: true,
};
function mount(episodes: PlexCard[] = []) {
vi.spyOn(api, "plexLibrary").mockResolvedValue({
items: [show],
total: 1,
offset: 0,
episodes,
} as never);
const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({
next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" },
});
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexBrowse />
</QueryClientProvider>,
);
return { next };
}
describe("PlexBrowse — a show's card in the grid", () => {
it("plays the episode the server names", async () => {
const { next } = mount();
fireEvent.click(await screen.findByRole("button", { name: "card.continue" }));
expect(await screen.findByText("player e7 resumed")).toBeTruthy();
expect(next).toHaveBeenCalledWith("sh", "en");
});
it("starts that episode over from its Restart", async () => {
mount();
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
expect(await screen.findByText("player e7 from the start")).toBeTruthy();
});
it("opens the show's page from its title, asking nothing", async () => {
const { next } = mount();
fireEvent.click(await screen.findByText("Grimm"), { detail: 1 });
expect(await screen.findByText("show page sh")).toBeTruthy();
expect(next).not.toHaveBeenCalled();
});
// A matched episode's title opens its page, a pointer's door: its menu offers the same to the keyboard.
it("opens a matched episode's page from its menu, as its title does", async () => {
mount([{ id: "ep1", type: "episode", title: "Pilot", thumb: "", show_title: "Grimm" }]);
const section = (await screen.findByText("plex.unified.episodes")).parentElement!;
fireEvent.click(section.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
const rows = await screen.findAllByRole("menuitem", { hidden: true });
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
expect(await screen.findByText("info page ep1")).toBeTruthy();
});
it("opens the show's page from its ⓘ, asking nothing", async () => {
const { next } = mount();
fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" }));
expect(await screen.findByText("show page sh")).toBeTruthy();
expect(next).not.toHaveBeenCalled();
});
});
+37 -3
View File
@@ -26,6 +26,7 @@ import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlayShow } from "./usePlayShow";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
@@ -328,18 +329,32 @@ export default function PlexBrowse() {
return () => io.disconnect();
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
const resolveShow = usePlayShow(
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
(id) => sub.open({ kind: "show", id }),
sub.view,
);
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
function playShow(card: PlexCard, fromStart: boolean) {
scrollRef.current = scroller()?.scrollTop ?? 0;
void resolveShow(card, fromStart);
}
function onCard(card: PlexCard) {
if (card.type === "show") return playShow(card, false);
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
if (card.type === "show") sub.open({ kind: "show", id: card.id });
else sub.open({ kind: "player", id: card.id });
sub.open({ kind: "player", id: card.id });
}
// The started card's Restart (C5): the same trip, from 0 instead of the resume point.
function onRestartCard(card: PlexCard) {
if (card.type === "show") return playShow(card, true);
scrollRef.current = scroller()?.scrollTop ?? 0;
sub.open({ kind: "player", id: card.id, fromStart: true });
}
// A card's page: a show's own page, a movie's or an episode's info page (the card menu's "Details",
// and a card title's link).
function onInfo(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
if (card.type === "show") return void sub.open({ kind: "show", id: card.id });
rememberInfoScroll(0); // fresh info page starts at the top
sub.open({ kind: "info", id: card.id });
}
@@ -377,8 +392,9 @@ export default function PlexBrowse() {
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
menu.open,
);
// Details too: an episode's title opens its page, and the menu is that door for the keyboard.
const epActions = usePlexCardActions(
{ onOpen: onCard, onRestart: onRestartCard, onToggleWatched: toggleWatched },
{ onOpen: onCard, onRestart: onRestartCard, onInfo, onToggleWatched: toggleWatched },
epMenu.open,
);
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
@@ -461,6 +477,20 @@ export default function PlexBrowse() {
onPlayItem={(id, fromStart) => sub.open({ kind: "player", id, fromStart })}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
// Up to the show or season: Back returns to this page where it was (as from a cast row).
onOpenShow={(showId) => {
rememberInfoScroll(scroller()?.scrollTop ?? 0);
sub.open({ kind: "show", id: showId });
}}
onOpenSeason={(showId, seasonId) => {
rememberInfoScroll(scroller()?.scrollTop ?? 0);
sub.open({ kind: "season", showId, seasonId });
}}
// A collection strip's title: another info page, from its top.
onOpenInfo={(id) => {
rememberInfoScroll(0);
sub.open({ kind: "info", id });
}}
/>
);
}
@@ -492,6 +522,7 @@ export default function PlexBrowse() {
}
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
onOpenShow={(id) => sub.open({ kind: "show", id })}
onPlayShow={playShow}
onFilter={applyFilter}
onOpenPerson={onOpenPerson}
/>
@@ -504,6 +535,7 @@ export default function PlexBrowse() {
showId={showId}
seasonId={seasonId}
onBack={sub.back}
onInfoCard={onInfo}
onPlay={(epRk, queue, fromStart) =>
sub.open({ kind: "player", id: epRk, queue, fromStart })
}
@@ -707,6 +739,7 @@ export default function PlexBrowse() {
navIndex={i}
card={c}
onOpen={() => onCard(c)}
onPage={onInfo}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
@@ -730,6 +763,7 @@ export default function PlexBrowse() {
ep={ep}
withShowTitle
onPlay={() => onCard(ep)}
onPage={() => onInfo(ep)}
onRestart={() => onRestartCard(ep)}
onToggleWatched={() => toggleWatched(ep)}
onAdd={() => epActions.add(ep)}
@@ -0,0 +1,139 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, HttpError, type PlexCollection } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { MembershipMenu } from "./ui/MembershipMenu";
import { SURFACE } from "./ui/surfaces";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in
// it, in one step (a collection cannot be empty on Plex). It was a trip to the Collections page, a new
// collection, its name, a search for the title, then another search to add the next one. Its body is
// the same MembershipMenu as the video card's "Add to playlist".
export interface PlexCollectionTarget {
ratingKey: string;
anchor: Element;
}
export default function PlexCollectionAddAt({
target,
onClose,
}: {
target: PlexCollectionTarget;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const pop = useAnchoredPopover({
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
offset: 6,
hostsInput: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(target.anchor), [target.anchor, setPositionReference]);
const [busy, setBusy] = useState(false);
const { ratingKey } = target;
const forKey = qk.plexCollections("for", ratingKey);
const list = useQuery({
queryKey: forKey,
queryFn: () => api.plexCollectionsFor(ratingKey),
});
// The title's own page (a movie's item, a show's show), the collections' pages and the library grid
// all show membership.
function refresh() {
void qc.invalidateQueries({ queryKey: qk.plexCollections() });
void qc.invalidateQueries({ queryKey: qk.plexItem(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexShow(ratingKey) });
void qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// A write that landed flips the tick at once: the row's state comes from the list, and a second tap
// before its refetch returned would otherwise repeat the write instead of undoing it.
async function toggle(c: PlexCollection & { contains: boolean }) {
if (busy) return;
setBusy(true);
try {
if (c.contains) await api.plexCollectionRemoveItem(c.id, ratingKey);
else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) {
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
}
async function create(name: string) {
if (busy) return false;
setBusy(true);
let made = false;
try {
// No library: the server takes the seed title's own.
await api.plexCreateCollection("", name, ratingKey);
made = true;
} catch (e) {
made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed"));
}
refresh();
setBusy(false);
return made;
}
const collections = list.data?.collections ?? [];
return (
<PopoverSurface
popover={pop}
className={`${SURFACE.popover} w-64 p-2`}
ariaLabel={t("plex.collectionAdd.title")}
listInside
>
<MembershipMenu
heading={t("plex.collectionAdd.title")}
rows={collections.map((c) => ({ id: c.id, label: c.title, checked: c.contains }))}
onToggle={(row) => {
const c = collections.find((x) => x.id === row.id);
if (c) void toggle(c);
}}
status={list.isError && !list.data ? "error" : list.isLoading ? "loading" : "ready"}
onRetry={() => void list.refetch()}
emptyText={t("plex.collectionAdd.none")}
errorText={t("plex.collectionAdd.loadFailed")}
busy={busy}
create={{
placeholder: t("plex.collection.newCollection"),
ariaLabel: t("plex.collection.namePlaceholder"),
onCreate: create,
}}
/>
</PopoverSurface>
);
}
/** A create refused only because the read after it failed: the collection is on Plex, with this
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
function madeOnPlex(e: unknown): boolean {
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
return d?.reason === "collection_pending_sync";
}
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
old: T | undefined,
id: string,
): T | undefined {
if (!old) return old;
return {
...old,
collections: old.collections.map((c) => (c.id === id ? { ...c, contains: !c.contains } : c)),
};
}
@@ -431,6 +431,7 @@ export default function PlexCollectionView({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onRestart={() => onRestartCard(c)}
onToggleWatched={toggleWatched}
onAdd={actions.add}
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
}));
import PlexDiscover from "./PlexDiscover";
import { qk } from "../lib/queryKeys";
import type { PlexCard } from "../lib/api";
afterEach(() => {
cleanup();
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
expect.objectContaining({ genres: [] }),
]);
expect(keys).toContainEqual([
"plex-surprise",
"plex-discover",
"surprise",
"both",
"en",
expect.objectContaining({ genres: [] }),
@@ -218,6 +221,103 @@ describe("PlexDiscover", () => {
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
// The first draw asks for anything; the redraw hands over the one on screen to deal it last.
expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] });
expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] });
});
// Back from a card's player or page remounts the surface: the draw it left must still be there, with
// its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals.
it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => {
plexDiscover.mockResolvedValue(fullPayload);
let status: PlexCard["status"] = "new";
plexSurprise.mockImplementation(
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
Promise.resolve({
scope: "both",
items: o.keep?.length
? [{ ...card("sp1", "Random Pick"), status }]
: [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")],
}),
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const ui = (
<QueryClientProvider client={qc}>
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>
</QueryClientProvider>
);
const first = render(ui);
await screen.findByText("Pick");
first.unmount();
render(ui);
await screen.findByText("Random Pick");
// Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
status = "watched";
await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() });
expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] });
// No draw is ever dealt behind the viewer's back: every call after the first reads it again.
for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] });
// Unobserved past the default five minutes (a film watched from the rail) it is still kept.
expect(entry?.options.gcTime).toBe(Infinity);
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
await screen.findByText("Pick");
expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({
exclude: ["sp1"],
});
});
// A watched toggle's resync refreshes every rail, the Surprise draw included. Landing while a ↻
// deal was in flight, it cancelled that fetch and re-ran it as a re-read: the old titles came back
// and the ↻ did nothing. And the ↻ spins for a deal only, never for a re-read of the same titles.
it("a refresh landing mid-deal deals again; only a deal spins the ↻", async () => {
plexDiscover.mockResolvedValue(fullPayload);
const answers: ((v: unknown) => void)[] = [];
const rereads: ((v: unknown) => void)[] = [];
plexSurprise.mockImplementation(
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
plexSurprise.mock.calls.length === 1
? Promise.resolve({ scope: "both", items: [card("sp1", "Old Pick")] })
: new Promise((r) => (o.keep ? rereads : answers).push(r)),
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>
</QueryClientProvider>,
);
await screen.findByText("Old Pick");
const button = () => screen.getByRole("button", { name: "plex.discover.reshuffle" });
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
await waitFor(() => expect(rereads).toHaveLength(1));
expect(button().getAttribute("aria-busy")).toBeNull();
await act(async () => rereads[0]?.({ scope: "both", items: [card("sp1", "Old Pick")] }));
fireEvent.click(button());
expect(button().getAttribute("aria-busy")).toBe("true");
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
await waitFor(() => expect(answers).toHaveLength(2));
const last = plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1];
expect(last?.[4]).toEqual({ exclude: ["sp1"] });
// The cancelled deal's late answer neither lands nor stops the spinner of the one replacing it.
await act(async () => answers[0]?.({ scope: "both", items: [card("sp9", "Stale Pick")] }));
expect(button().getAttribute("aria-busy")).toBe("true");
await act(async () => answers[1]?.({ scope: "both", items: [card("sp2", "New Pick")] }));
await screen.findByText("New Pick");
expect(button().getAttribute("aria-busy")).toBeNull();
});
it("shows the empty message when every rail is empty", async () => {
@@ -244,8 +344,8 @@ describe("PlexDiscover", () => {
});
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.
// The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
// left flipped until that read lands — the sibling of the collection page's fix.
plexDiscover.mockResolvedValue({
...fullPayload,
continue_watching: [],
+46 -8
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
@@ -78,6 +78,7 @@ function CardRail({
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
onPage={onInfoCard}
onRestart={() => onRestartCard(c)}
onToggleWatched={onToggleWatched}
onAdd={actions.add}
@@ -119,11 +120,46 @@ export default function PlexDiscover({
queryFn: () => api.plexDiscover(scope, lang, filters),
});
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
const surpriseKey = qk.plexSurprise(scope, lang, filters);
// The ↻ stands until ITS deal answers, not until the fetch sets out: a refresh of the rails landing
// mid-deal (a watched toggle's resync) cancels that fetch and runs this one again, which read the
// spent flag and handed back the old draw. Only the latest deal ends it, so the cancelled one's late
// answer does not stop the spinner of the deal that replaced it.
const redraw = useRef(false);
const lastDeal = useRef<Promise<unknown> | null>(null);
const [dealing, setDealing] = useState(false);
const surpriseQ = useQuery({
queryKey: qk.plexSurprise(scope, lang, filters),
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
staleTime: 0,
queryKey: surpriseKey,
queryFn: () => {
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
(c) => c.id,
);
if (!redraw.current && shown.length > 0) {
return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
}
const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
lastDeal.current = deal;
const done = () => {
if (lastDeal.current !== deal) return;
redraw.current = false;
setDealing(false);
};
void deal.then(done, done);
return deal;
},
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
// a collected draw is a new one.
gcTime: Infinity,
});
const reshuffle = () => {
redraw.current = true;
setDealing(true);
void surpriseQ.refetch();
};
// 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.
@@ -154,8 +190,8 @@ export default function PlexDiscover({
);
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.
// The resync below re-reads the Surprise draw too, but only once the write has answered: a
// refused toggle is put back at once rather than left flipped until that read lands.
revert: () => setSurpriseStatus(card, card.status),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
@@ -228,8 +264,10 @@ export default function PlexDiscover({
cards={surprise}
action={
<IconButton
onClick={() => surpriseQ.refetch()}
loading={surpriseQ.isFetching}
onClick={reshuffle}
// A deal, not any fetch: a refresh re-reads the same titles, and a spinning ↻ promised a
// new draw that never came.
loading={dealing}
label={t("plex.discover.reshuffle")}
>
<RotateCcw />
@@ -173,6 +173,55 @@ describe("PlexInfo enriched detail (page)", () => {
});
});
describe("an episode page links up to its show and season", () => {
const episode: PlexItemDetail = {
...detail,
id: "e1",
kind: "episode",
title: "Pilot",
show_title: "A Show",
show_id: "s1",
season_id: "se1",
season_number: 1,
episode_number: 25,
tmdb: null,
};
it("the show title opens the show, the episode number its season", () => {
const onOpenShow = vi.fn();
const onOpenSeason = vi.fn();
wrap(
<PlexInfo
onPlayItem={noop}
detail={episode}
variant="page"
onOpenShow={onOpenShow}
onOpenSeason={onOpenSeason}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "A Show" }));
expect(onOpenShow).toHaveBeenCalledWith("s1");
fireEvent.click(screen.getByRole("button", { name: "S1·E25" }));
expect(onOpenSeason).toHaveBeenCalledWith("s1", "se1");
expect(screen.getByText(/— Pilot/)).toBeTruthy();
});
it("over the running player the header stays plain text", () => {
wrap(
<PlexInfo
onPlayItem={noop}
detail={episode}
variant="overlay"
onOpenShow={noop}
onOpenSeason={noop}
/>,
);
expect(screen.queryByRole("button", { name: "A Show" })).toBeNull();
expect(screen.queryByRole("button", { name: "S1·E25" })).toBeNull();
expect(screen.getByText("S1·E25 — Pilot")).toBeTruthy();
});
});
// The owner's layout: a main column (collection strips → crew → cast) beside a sidebar of
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
// The owner's pick: the hero's text stopped at ~55% of the panel. The facts and the key crew are
@@ -362,4 +411,18 @@ describe("a collection strip's card", () => {
fireEvent.click(restart.find((r) => r.textContent === "card.restartTitle")!);
await vi.waitFor(() => expect(onPlayItem).toHaveBeenCalledWith("m2", true));
});
// Its title opens the member's page, a pointer's door: the menu offers the same to the keyboard.
it("opens the member's page from its menu, as its title does", async () => {
const onOpenInfo = vi.fn();
wrap(
<PlexInfo detail={withColl} variant="page" onPlayItem={vi.fn()} onOpenInfo={onOpenInfo} />,
);
const strip = screen.getByRole("heading", { name: "The Collection" }).parentElement!
.parentElement!;
fireEvent.click(strip.querySelector<HTMLButtonElement>("button[aria-haspopup]")!);
const rows = await screen.findAllByRole("menuitem", { hidden: true });
fireEvent.click(rows.find((r) => r.textContent === "plex.info.title")!);
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
});
});
+45 -3
View File
@@ -10,6 +10,9 @@ import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
DETAIL_POSTER,
episodeLine,
episodeName,
episodeNumbers,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
@@ -185,6 +188,11 @@ type Props = {
onPlayItem: (id: string, fromStart?: boolean) => void;
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
library?: string;
// Page variant, an episode: its show title opens the show page, its "S1·E25" the season page.
onOpenShow?: (showId: string) => void;
onOpenSeason?: (showId: string, seasonId: string) => void;
// Page variant: a collection strip's title opens that title's info page (its picture plays it).
onOpenInfo?: (id: string) => void;
};
export default function PlexInfo({
@@ -197,6 +205,9 @@ export default function PlexInfo({
onOpenPerson,
onPlayItem,
library,
onOpenShow,
onOpenSeason,
onOpenInfo,
}: Props) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -274,10 +285,13 @@ export default function PlexInfo({
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
// Details wherever the member's title opens its page (not in the overlay): the menu is that door
// for the keyboard.
const colActions = usePlexCardActions(
{
onOpen: (c) => onPlayItem(c.id),
onRestart: (c) => onPlayItem(c.id, true),
onInfo: variant !== "overlay" && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined,
onToggleWatched: toggleColWatched,
},
colMenu.open,
@@ -298,6 +312,12 @@ export default function PlexInfo({
const asideShown = !overlay && aside.column;
const infoTitle =
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
// Up from an episode: the show's title and the episode's season are links on the page (not over
// the running player, where leaving would stop it).
const showId = detail.kind === "episode" && !overlay ? detail.show_id : null;
const seasonId = detail.season_id;
const numbers = episodeNumbers(detail);
const upLink = "hover:text-accent hover:underline";
return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
@@ -311,11 +331,32 @@ export default function PlexInfo({
{overlay ? (
<h2 className="text-lg font-bold text-white">{infoTitle}</h2>
) : (
<PageHeading className="text-2xl font-bold">{infoTitle}</PageHeading>
<PageHeading className="text-2xl font-bold">
{showId && onOpenShow && detail.show_title ? (
<Pressable selectable className={upLink} onClick={() => onOpenShow(showId)}>
{infoTitle}
</Pressable>
) : (
infoTitle
)}
</PageHeading>
)}
{detail.kind === "episode" && (
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
S{detail.season_number}·E{detail.episode_number} — {detail.title}
{showId && seasonId && onOpenSeason && numbers ? (
<>
<Pressable
selectable
className={upLink}
onClick={() => onOpenSeason(showId, seasonId)}
>
{episodeName(detail)}
</Pressable>
{` — ${detail.title}`}
</>
) : (
episodeLine(detail)
)}
</p>
)}
</div>
@@ -467,7 +508,7 @@ export default function PlexInfo({
{detail.summary && (
<p
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
className={`measure mt-3 whitespace-pre-line text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
@@ -575,6 +616,7 @@ export default function PlexInfo({
shelf
navIndex={i}
onOpen={() => onPlayItem(m.id)}
onPage={!overlay && onOpenInfo ? (c) => onOpenInfo(c.id) : undefined}
onRestart={() => onPlayItem(m.id, true)}
onToggleWatched={toggleColWatched}
onAdd={colActions.add}
+224 -7
View File
@@ -8,7 +8,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PlexCard } from "../lib/api";
import { api, HttpError, type PlexCard, type PlexCollection } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -101,21 +102,27 @@ function Grid({
);
}
function setup(c: PlexCard, opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {}) {
function setup(
c: PlexCard,
opts: Omit<Parameters<typeof Grid>[0], "c" | "handlers"> = {},
{ admin = false }: { admin?: boolean } = {},
) {
const handlers = {
onOpen: vi.fn(),
onInfo: vi.fn(),
onToggleWatched: vi.fn(),
onRestart: vi.fn(),
};
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (admin) qc.setQueryData(qk.me(), { role: "admin" });
render(
<QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={qc}>
<FloatingTree>
<Grid c={c} handlers={handlers} {...opts} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
return { handlers, qc };
}
// The card root holds the menu's trigger props (right-click anywhere on it).
const rightClick = () =>
@@ -154,10 +161,34 @@ describe("a Plex title's menu", () => {
expect(labels()).not.toContain("card.restartTitle");
});
it("a show opens its page and has no watched, add or details rows", () => {
setup(card({ type: "show" }));
// A show plays like any title (the surface resolves the episode); its page is a row of its own.
// The watched toggle and the add belong to an episode, so a show has none.
it("a show plays, opens its page from a row of its own, and has no watched or add rows", async () => {
const { handlers } = setup(card({ type: "show" }));
rightClick();
expect(labels()).toEqual(["plex.openShow", "common.copy.title", "home.pin.add"]);
expect(labels()).toEqual(["plex.play", "plex.openShow", "common.copy.title", "home.pin.add"]);
fireEvent.click(item("plex.play"));
await vi.waitFor(() =>
expect(handlers.onOpen).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
);
rightClick();
fireEvent.click(item("plex.openShow"));
await vi.waitFor(() =>
expect(handlers.onInfo).toHaveBeenCalledWith(expect.objectContaining({ type: "show" })),
);
});
it("a started show continues, and starts its episode over from the next row", () => {
setup(card({ type: "show", status: "in_progress", resumable: true }));
rightClick();
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
});
it("a part-watched show with no episode to resume plays, with nothing to start over", () => {
setup(card({ type: "show", status: "in_progress", resumable: false }));
rightClick();
expect(labels()[0]).toBe("plex.play");
expect(labels()).not.toContain("card.restartTitle");
});
it("the card's '…' opens the same menu, and says so", () => {
@@ -223,3 +254,189 @@ describe("a Plex title's menu", () => {
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("m1"));
});
});
// Adding a title to a collection was a trip to the Collections page, a new collection, its name and a
// search for the title. From the card's menu it is a tick, or a name and Enter.
describe("a Plex title's menu — Add to collection", () => {
const coll = (id: string, title: string, contains: boolean) =>
({ id, title, contains }) as PlexCollection & { contains: boolean };
it("an admin gets the row on a movie and a show; an episode and a viewer do not", () => {
setup(card({}), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "show" }), {}, { admin: true });
rightClick();
expect(labels()).toContain("plex.collectionAdd.row");
cleanup();
setup(card({ type: "episode" }), {}, { admin: true });
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
cleanup();
setup(card({}));
rightClick();
expect(labels()).not.toContain("plex.collectionAdd.row");
});
it("ticks the title into a collection, out of one, and names a new one with it inside", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)],
});
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
const create = vi.spyOn(api, "plexCreateCollection").mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const row = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
fireEvent.click(await row("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
// One write at a time: the next tick waits for this one to settle.
await vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
fireEvent.click(await row("Alpha"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cA", "m1"));
const name = screen.getByLabelText("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: " Rainy days " } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledWith("", "Rainy days", "m1"));
});
const checkRow = async (name: string) =>
vi.waitFor(() => {
const found = screen
.queryAllByRole("menuitemcheckbox", { hidden: true })
.find((b) => b.textContent === name);
expect(found).toBeDefined();
return found!;
});
const idle = () =>
vi.waitFor(() =>
expect(document.querySelector("[aria-busy]")?.getAttribute("aria-busy")).toBe("false"),
);
it("flips the tick as soon as a write lands, so the next tap undoes it before the list refetches", async () => {
const list = vi.spyOn(api, "plexCollectionsFor");
list.mockResolvedValueOnce({ collections: [coll("cB", "Beta", false)] });
// The refetch after the write never answers: the tick must not wait for it.
list.mockImplementation(() => new Promise(() => {}));
const add = vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const remove = vi
.spyOn(api, "plexCollectionRemoveItem")
.mockResolvedValue({} as PlexCollection);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(add).toHaveBeenCalledWith("cB", "m1"));
await idle();
expect((await checkRow("Beta")).getAttribute("aria-checked")).toBe("true");
fireEvent.click(await checkRow("Beta"));
await vi.waitFor(() => expect(remove).toHaveBeenCalledWith("cB", "m1"));
expect(add).toHaveBeenCalledTimes(1);
});
it("refreshes a show's own page too, whose collections come with the show", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({
collections: [coll("cB", "Beta", false)],
});
vi.spyOn(api, "plexCollectionAddItem").mockResolvedValue({} as PlexCollection);
const { qc } = setup(card({ type: "show" }), {}, { admin: true });
const invalidate = vi.spyOn(qc, "invalidateQueries");
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
fireEvent.click(await checkRow("Beta"));
await idle();
expect(invalidate).toHaveBeenCalledWith({ queryKey: qk.plexShow("m1") });
});
it("is a named menu that says it is loading, not that there are no collections", async () => {
vi.spyOn(api, "plexCollectionsFor").mockImplementation(() => new Promise(() => {}));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
// jsdom computes no name for the unpositioned (visibility: hidden) surface: read what names it.
const menu = await vi.waitFor(() => {
const found = document.querySelector('[role="menu"][aria-label="plex.collectionAdd.title"]');
expect(found).not.toBeNull();
return found!;
});
expect(menu.hasAttribute("aria-labelledby")).toBe(false);
expect(screen.getByText("common.loading")).toBeTruthy();
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
});
it("says a failed load as one, with a retry — not as no collections", async () => {
const list = vi.spyOn(api, "plexCollectionsFor").mockRejectedValue(new Error("down"));
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const alert = await screen.findByRole("alert", { hidden: true });
expect(alert.textContent).toContain("plex.collectionAdd.loadFailed");
expect(screen.queryByText("plex.collectionAdd.none")).toBeNull();
// A menu owns its items only: the line about the list sits beside it, not in it.
expect(document.querySelector("[role=menu]")).not.toBeNull();
expect(alert.closest("[role=menu]")).toBeNull();
list.mockResolvedValue({
collections: [coll("cB", "Beta", false), coll("cC", "Gamma", false)],
});
// Retry from the keyboard: the focused button goes as the list loads, and focus must not be left
// on <body> with the menu open — it lands on the first row that came back.
const retry = (await screen.findByText("common.retry")).closest("button")!;
retry.focus();
fireEvent.click(retry);
const beta = await checkRow("Beta");
await vi.waitFor(() => expect(document.activeElement).toBe(beta));
// …and the menu's own arrows carry on from there.
fireEvent.keyDown(beta, { key: "ArrowDown" });
expect(document.activeElement).toBe(await checkRow("Gamma"));
});
it("clears the name when Plex made the collection and only the read after it failed", async () => {
vi.spyOn(api, "plexCollectionsFor").mockResolvedValue({ collections: [] });
const create = vi
.spyOn(api, "plexCreateCollection")
.mockRejectedValue(
new HttpError(422, "pending", { code: "refused", reason: "collection_pending_sync" }),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText<HTMLInputElement>("plex.collection.namePlaceholder");
fireEvent.change(name, { target: { value: "Noir" } });
fireEvent.keyDown(name, { key: "Enter" });
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1));
// It exists now: a second Enter on the kept name could only be refused as taken.
await vi.waitFor(() => expect(name.value).toBe(""));
});
it("steps from the name field into the rows with the arrows — rows that arrived after it opened", async () => {
let answer: (v: { collections: (PlexCollection & { contains: boolean })[] }) => void = () => {};
vi.spyOn(api, "plexCollectionsFor").mockImplementation(
() => new Promise((resolve) => (answer = resolve)),
);
setup(card({}), {}, { admin: true });
rightClick();
fireEvent.click(item("plex.collectionAdd.row"));
const name = await screen.findByLabelText("plex.collection.namePlaceholder");
name.focus();
answer({ collections: [coll("cA", "Alpha", true), coll("cB", "Beta", false)] });
const alpha = await checkRow("Alpha");
fireEvent.keyDown(name, { key: "ArrowDown" });
expect(document.activeElement).toBe(alpha);
name.focus();
fireEvent.keyDown(name, { key: "ArrowUp" });
expect(document.activeElement).toBe(await checkRow("Beta"));
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ export function PlexMenu({
<ContextMenu
menu={menu}
ariaLabel={t("plex.menu")}
items={c ? [...actions.items(c, { pin }), ...(extra?.(c) ?? [])] : []}
items={c ? [...actions.items(c, { pin, anchor: menu.anchor }), ...(extra?.(c) ?? [])] : []}
/>
{actions.panel}
</>
@@ -24,6 +24,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { pushLayer, removeLayer } from "../lib/layerStack";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
@@ -128,6 +129,7 @@ function mount(opts: {
queue?: string[];
first?: string;
fromStart?: boolean;
onClose?: () => void;
}) {
vi.spyOn(api, "plexItem").mockImplementation((id: string) => Promise.resolve(opts.details[id]!));
const session = vi
@@ -139,7 +141,12 @@ function mount(opts: {
const first = opts.first ?? opts.queue?.[0] ?? Object.keys(opts.details)[0]!;
render(
<QueryClientProvider client={qc}>
<PlexPlayer itemId={first} onClose={() => {}} queue={opts.queue} fromStart={opts.fromStart} />
<PlexPlayer
itemId={first}
onClose={opts.onClose ?? (() => {})}
queue={opts.queue}
fromStart={opts.fromStart}
/>
</QueryClientProvider>,
);
return { session };
@@ -358,7 +365,7 @@ describe("keyboard focus in the bands", () => {
it("a Space or Enter on Skip intro, outside the faded bands, stays Skip intro's", async () => {
const intro = {
...item("A"),
markers: [{ type: "intro", start_s: 50, end_s: 200 }],
markers: [{ type: "intro", start_s: 50, end_s: 200, final: false }],
} as unknown as PlexItemDetail;
mount({ details: { A: intro } });
await playingAt("/media/A", 100);
@@ -857,3 +864,237 @@ describe("the seek bar (C3)", () => {
expect(head?.className).toContain("[@media(hover:none)]:opacity-100");
});
});
describe("the time in the row", () => {
it("reads the position and the length, and the clock it ends at — no countdown", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const row = screen.getByText(/1:40 \/ 50:00/).closest("div")!;
expect(row.textContent).not.toMatch(/-\s*48:20/);
expect(row.textContent).toMatch(/\(.+\)/);
});
});
// On an iPad a drag on the video moved the page behind it, and the strip it moved showed the page's
// own background under the player.
describe("the page under the player", () => {
it("is locked, without overscroll, on black, while the player is open — and given back after", async () => {
const root = document.documentElement.style;
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(document.body.style.overflow).toBe("hidden");
expect(root.overscrollBehavior).toBe("none");
expect(root.backgroundColor).toBe("black");
cleanup();
expect(document.body.style.overflow).toBe("");
expect(root.overscrollBehavior).toBe("");
expect(root.backgroundColor).toBe("");
});
it("is covered by a player sized from its fixed box, not from dvh", async () => {
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
const wrap = video().closest<HTMLElement>("[class*='fixed']")!;
expect([wrap.style.width, wrap.style.height]).toEqual(["80%", "80%"]);
});
});
// Brave on an iPad cannot fullscreen an element (it fakes `fullscreenEnabled`), and the button did
// nothing there. It is shown only where the browser has the API on Element.prototype.
describe("the fullscreen button", () => {
const withApi = (on: boolean) => {
if (on)
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
else delete (Element.prototype as { requestFullscreen?: unknown }).requestFullscreen;
};
afterEach(() => withApi(false));
it("is there where the browser can fullscreen an element", async () => {
withApi(true);
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).not.toBeNull();
});
it("is left out where it cannot, whatever Brave's stand-ins claim", async () => {
withApi(false);
// Brave's: a <video>-only requestFullscreen on HTMLElement.prototype, and fullscreenEnabled true.
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
configurable: true,
value: () => true,
});
Object.defineProperty(document, "fullscreenEnabled", { configurable: true, value: true });
mount({ details: { A: item("A") } });
await playingAt("/media/A", 100);
expect(screen.queryByLabelText("plex.player.fullscreen")).toBeNull();
delete (document as { fullscreenEnabled?: unknown }).fullscreenEnabled;
delete (HTMLElement.prototype as { requestFullscreen?: unknown }).requestFullscreen;
});
});
// Clip mode: `[` / `]` mark at the playhead (opening the panel), the panel takes the control row's
// place, Escape closes the panel before it would close the player, and the scissors gets the focus
// back. Cut sends this item and the audio track that is playing.
describe("PlexPlayer — clip mode", () => {
const key = (k: string, init: KeyboardEventInit = {}) =>
act(
() =>
void window.dispatchEvent(
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }),
),
);
it("opens on [ with Start at the playhead, and Escape closes only the panel", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 120);
expect(screen.queryByTestId("clip-panel")).toBeNull();
key("[");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
expect(screen.getByRole("slider", { name: "clip.in" }).getAttribute("aria-valuenow")).toBe(
"120",
);
// The seek bar and the control row make room for it.
expect(screen.queryByLabelText("plex.player.playPause")).toBeNull();
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
expect(screen.getByLabelText("plex.player.playPause")).toBeTruthy();
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByRole("button", { name: "clip.open" })),
);
});
it("marks End on ] typed through AltGr (the HU layout's ])", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 200);
key("]", { ctrlKey: true, altKey: true, modifierAltGraph: true } as KeyboardEventInit);
expect(screen.getByRole("slider", { name: "clip.out" }).getAttribute("aria-valuenow")).toBe(
"200",
);
});
it("cuts this item with the audio track that is playing", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
mount({ details: { a: item("a") } });
await playingAt("/media/a", 60);
fireEvent.click(screen.getByRole("button", { name: "clip.open" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0]).toMatchObject({
source: "plex",
ref: "a",
start_s: 60,
end_s: 90,
audio_track: 0,
});
});
it("crops on the picture's own shape, and Escape turns the crop off before the panel", async () => {
const enqueue = vi.spyOn(api, "enqueueClip").mockResolvedValue({ id: 9 } as never);
// A 720×480 stream: the frame is drawn on a 3:2 picture, and the cut is told so.
const proto = HTMLVideoElement.prototype;
const own = ["videoWidth", "videoHeight"].map(
(k) => [k, Object.getOwnPropertyDescriptor(proto, k)] as const,
);
Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => 720 });
Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => 480 });
try {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 60);
key("c");
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
// The frame's layer is the video's box: both end above the panel, both at the player's top
// (its flex box centred the shortened video, and the frame sat 155 px above the picture).
const video = document.querySelector("video")!;
const layer = screen.getByTestId("crop-frame").parentElement!;
expect(video.style.alignSelf).toBe("flex-start");
expect(layer.className).toContain("top-0");
expect(layer.style.height).toBe(video.style.height);
key("Escape");
expect(screen.queryByTestId("crop-frame")).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "clip.crop" }));
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(enqueue).toHaveBeenCalled());
expect(enqueue.mock.calls[0]![0].crop).toEqual({
x: 0.1,
y: 0.1,
w: 0.8,
h: 0.8,
aspect: 1.5,
});
} finally {
for (const [k, d] of own)
if (d) Object.defineProperty(proto, k, d);
else delete (proto as unknown as Record<string, unknown>)[k];
}
});
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
const full = { ...item("a"), cast: [], genres: [], directors: [], collections: [] };
mount({ details: { a: full as PlexItemDetail } });
await playingAt("/media/a", 120);
key("c");
key("i");
expect(infoOverlay()).toBeTruthy();
key("Escape");
expect(infoOverlay()).toBeNull();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
key("Escape");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("⌫ under a dialog over the player is the dialog's, not the clip's", async () => {
mount({ details: { a: item("a") } });
await playingAt("/media/a", 120);
key("c");
const layer = pushLayer();
try {
key("Backspace");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
} finally {
removeLayer(layer);
}
key("Backspace");
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("opens no range while the item's length is unknown", async () => {
mount({
details: { a: { ...item("a"), duration_seconds: null } as unknown as PlexItemDetail },
});
await playingAt("/media/a", 120);
for (const k of ["c", "[", "]"]) key(k);
expect(screen.queryByTestId("clip-panel")).toBeNull();
});
it("an item played to its end while a clip is marked stays, with the clip", async () => {
const onClose = vi.fn();
mount({ details: { a: item("a") }, onClose });
await playingAt("/media/a", 2990);
key("c");
act(() => void video().dispatchEvent(new Event("ended")));
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
const withCredits = {
...item("a", 0, "b"),
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
} as unknown as PlexItemDetail;
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
await playingAt("/media/a", 2800);
key("c");
await playingAt("/media/a", 2910);
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(lastStart(session)?.[0]).toBe("a");
expect(screen.getByTestId("clip-panel")).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More