feat: a queue entry can name a section — start.position_ms and end.position_ms - #302
Merged
Conversation
…agree
`start.position_ms` says where inside the first played unit playback begins;
`end` is its mirror and is a nested mapping so that a later "stop after season 2
episode 6" is an addition to that key rather than a third one. Both are
independently optional and all four combinations are valid.
`entryFormat.toPositionMs()` is the ONE coercion, and `hasSection()` now calls it
instead of testing `Number(...)` itself. That is a fix, not a tidy-up:
`Number(null)` is 0, so `{start: {position_ms: null}}` read as a section at offset
zero. `null` is this file format's spelling of "no value", so the guard would have
minted an entry id and walked a duplicate add through while the writer correctly
dropped the key. A blank string did the same.
Zero itself is a real position, so every test is `!= null`, never truthiness.
Provider.playsSections joins the interface beside stampsQueuedAt.
Decision: docs/decisions/2026-09-01-a-start-point-carries-a-position-and-end-is-its-mirror.md
…erted one
normalizeStart() carries `position_ms`, and its "no start" guard now reads all
three fields. It used to read two — `!hasSeries && src.episode == null` — which
was right while a start could only pick a UNIT, and which discarded every FILM
section silently: a movie has neither a series nor an episode, only a position.
normalizeEnd() and setEnd() mirror their start siblings, including the sparse
rule: an absent, blank, negative or non-numeric value drops the key, a start left
with none of its three fields is null rather than an empty mapping, and an end
with no position is null.
THE PAIR RULE LIVES ON THE WRITERS. `end` must be strictly after `start`; equal is
refused by name and never swapped, because a swap hides the typo that made it.
Both setStart and setEnd ask, because either can be the second of two
individually-valid writes that together invert the window — set `end: 90s`, then
set `start: 120s`. A route-level check reads one side off the request and the
other off a file it has not locked, so it cannot close that.
rewriteEntry's mutator may return `false` to refuse the write, which leaves the
file exactly as it was found. Every other mutator returns void and is unaffected.
A refusal is `{ok: false, error}`; a plain `{ok: false}` still means "no such
entry", and the two stay distinguishable.
…descriptor PATCH /queues/:set/items/:key/end mirrors the start route at the same file, and follows the house shape exactly: isQueueSet guard, decodeURIComponent on the key, readBody, and no validation at the route because the writer coerces — and because the writer is the only thing that can see the other side of the window under the same YAML lock. `endOf` sits beside `startOf` and adds one line to the queueTile literal. `start` already flowed through as a whole mapping, so `position_ms` needed no plumbing of its own. The bulk reset arm clears `end` beside `start`, stop first, so the entry is legal at every instant in between. describe() carries `end` on the raw entry type, on EntryDescriptor and in all three return arms.
…lex alone A section is a seek plus a stop on a timeline QueuePilot can command. The other four providers are delivery: 'pull' — a URL or an artifact goes out and control goes with it — so they offer no section control rather than accepting one and playing the item in full. Follows stampsQueuedAt exactly: a boolean on the instance (true only on Plex), plus PLAYS_SECTIONS keyed by KIND beside DELIVERY so the API can report it without instantiating a provider. That is what lets the web app hide the control for a reading queue whose token was never configured. Surfaced as `plays_sections` on ProviderPublicView, and as playsSectionsForKind() for the callers that would otherwise grow a second hand-maintained answer.
StartPoint gains position_ms, EndPoint joins it, QueueItem carries `end`, and ProviderInfo carries plays_sections. The shelf skeleton spells `end: null` the way it already spells `start: null` — it knows neither, and the resolved payload overwrites both. Types only. No control, no editor, no tags: the section UI is its own change.
… capability server/src/sectionWindow.test.ts covers the two normalizers: every unusable spelling of a position, the movie case that has a position and neither a series nor an episode, the four optionality states from the decision record, and a cross-check that hasSection() answers true exactly when a normalizer keeps a position. e2e/yaml-roundtrip-test.ts asks each field the same four questions every other sparse override answers there — the value is written, it survives an unrelated later edit, the sparse case drops the key, junk drops the key — each wrapped with assertCommentsSurvive. It also pins the pair rule from BOTH directions, including that a refused write changed nothing on disk, and that an unknown entry is not-found rather than a refusal. e2e/entry-objects-test.ts carries a film section and an episode section across the migration, then reads both back through the engine's describe(). e2e/api-v2-test.ts drives the route: it lands, it clears, a junk end drops the key, an equal end is refused by name, the bulk reset clears both ends, and the capability is reported true for Plex and false for a pull provider — off a providers.yaml of the suite's own, with neither provider configured, which is the point of the kind-keyed map. e2e/entry-id-test.ts loses its `as EntryValue` cast. The fields it ran ahead of are declared now, so that literal is checked.
Names the trap the first implementation hit (a movie section has no series and no episode, and the old two-field guard discarded it silently), says why the pair rule lives on the writers rather than the route, records that hasSection() has to agree with the sparse rule and used to read a cleared window as a section at zero, and points at the provider capability.
Owner
Author
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the two fields that let a queue entry name a section of a playable unit —
start.position_msandend.position_ms. Implements2026-09-01-a-start-point-carries-a-position-and-end-is-its-mirror;design in
docs/clip-playback-design.md.Follows #299 (the design) and #300 (the entry id that lets one file hold two lines).
Scope
The DATA path only. The fields are stored, written, served and described. Playback
and the editor control are the next two changes, so this one stays reviewable.
No UI change, so no screenshots. Nothing renders differently:
web/src/lib/types.tsgains type declarations and the shelf skeleton spells one more
null. There is nocontrol, no editor and no tag in this diff.
What lands
Start.position_ms, a newEnd,EntryExtras.end.normalizeStart()carries the position,normalizeEnd()andsetEnd()mirror their start siblings, and both follow the sparse rule every otherper-entry override follows: an absent, blank, negative or non-numeric value drops the
key, a
startleft with none of its three fields isnullrather than{}, and anendwith no position isnull.PATCH /queues/:set/items/:key/end, the house shape exactly.endjoinsthe bulk reset arm.
endOfbesidestartOf, one line in thequeueTileliteral.describe()carriesendon the raw type, onEntryDescriptorand in all three return arms.
Provider.playsSections(true only on Plex),PLAYS_SECTIONSkeyed by kind beside
DELIVERY, andplays_sectionsonProviderPublicView.e2e/entry-id-test.tsloses itsas EntryValuecast.Where the cross-field check lives, and why
On the writers —
setStartandsetEnd— inside the YAML lock, not on the route.endmust be strictly afterstart. Equal is refused by name and never swapped,because a zero-length section plays nothing and a swap would hide the typo that made it.
When only one side is set there is nothing to compare, so the write is accepted.
Both writers ask, and that is the point. Either one can be the second of two
individually-valid writes that together invert the window: set
end: 90s, then setstart: 120s, and each request on its own looks fine. A route-level check reads one sideoff the request body and the other off a file it has not locked, so it cannot close that
door. The writers are the only place both sides of one entry are visible at once.
rewriteEntry's mutator may now returnfalseto refuse the write, which leaves the fileexactly as it was found. Every other mutator returns
voidand is unaffected. A refusalcomes back
{ok: false, error}with both offsets named; a plain{ok: false}still means"no such entry", so the two stay distinguishable.
normalizeStart's early return was the predicted trapIts first line was
if (!hasSeries && src.episode == null) return null. That was rightwhile a start could only pick a unit. A film section has neither a series nor an
episode — only a position — so the guard would have discarded every one of them and
returned
nullwith nothing logged. It reads all three fields now, and bothserver/src/sectionWindow.test.tsande2e/yaml-roundtrip-test.tspin the movie case.hasSectionwas real, but disagreed with the sparse rulehasSection()landed in #300, ahead of the fields, and it is what decides an add isdeliberate — it mints an entry id and walks the add past the duplicate guard. It was not a
placeholder, but its
hasPositionMs()helper testedNumber(side.position_ms)directly,and
Number(null)is0. So{start: {position_ms: null}}answeredtrue.nullisthis file format's spelling of "no value" (
{start: null}is how a start is cleared), so acleared window read as a section at offset zero: an id minted and a duplicate add let
through, while the writer correctly dropped the key.
''did the same.entryFormat.toPositionMs()is now the one coercion andhasPositionMs()calls it, so thepredicate and the two writers cannot drift. Zero itself is a real position — it is the
other half of an end-only window — so every test is
!= null, never truthiness.Tests
Extended in place rather than as a parallel suite.
server/src/sectionWindow.test.ts(new): the two normalizers, every unusablespelling of a position, the movie case, the four optionality states, and a cross-check
that
hasSection()answers true exactly when a normalizer keeps a position.e2e/yaml-roundtrip-test.ts: for both fields — the value is written, it survives anunrelated later edit, the sparse case drops the key, junk drops the key — each wrapped
with
assertCommentsSurvive. Plus the pair rule from both directions, that a refusedwrite changed nothing on disk, and that an unknown entry is not-found rather than a
refusal.
e2e/entry-objects-test.ts: a film section and an episode section cross themigration, then come back through the engine's
describe().e2e/api-v2-test.ts: the route lands, clears, drops a junk value and refuses anequal end by name; the bulk reset clears both ends; the capability reads
truefor Plexand
falsefor a pull provider, off aproviders.yamlof the suite's own with neitherprovider configured — which is the point of the kind-keyed map.
Gates
Run on this branch rebased onto
aa56f74.33 offline e2e harnesses pass:
yaml-roundtrip,entry-objects,entry-id,api-v2,store-backend-parity,play-one-entry,batch-stops-at,collection-start,keep-completed,kind-normalize,per-entry-shuffle-write,pending,priority-lane,pick-contract,skipped-items,collection-reorder,rewatch-members,provider-cache,provider-seam,people,tonight-routing,queue-people,roster-editor,weights,ttl-sweep,history-persist,on-complete,lineup-knobs,queue-name,default-lane-change,host-config,resume-in-queue,resume-on-advance.Three browser gates pass as well —
shelf-remove,tile-lane,pending-dismiss— off theimage's own
/opt/pw-browsers, which holdschromium-1234and matchese2e/package.json'spin, so no
PLAYWRIGHT_BROWSERS_PATHoverride was needed.One finding, not touched by this PR
e2e/topup-test.tsfails 16 checks in an agent sandbox, and it fails identically onorigin/main(aa56f74). Verified by checking outorigin/mainand running it there, soit is not a regression from this change and no fixture was edited to hide it.
The cause is the store path, not top-up. The harness sets
QUEUES_PATH = '/nonexistent-so-loadEntries-is-never-consulted.yaml'as a sentinel.config.ts defaultStorePath()derives the SQLite path from that candidate,dirname()of itis
/,/is a directory, so the store lands at/nonexistent-so-loadEntries-is-never-consulted.queuepilot.sqlite— the filesystem root. Theopen fails with
unable to open database filefor any process that cannot write to/, andevery
routingread then reports the fixture unreadable. It passes wherever the runner canwrite to the root. The harness calls itself hermetic; it is hermetic in everything except
this. Fixing it is a one-line
STORE_PATHin that file, and it belongs in its own change.