Skip to content

feat: a queue entry can name a section — start.position_ms and end.position_ms - #302

Merged
Sawtaytoes merged 7 commits into
mainfrom
feat/section-fields
Sep 2, 2026
Merged

feat: a queue entry can name a section — start.position_ms and end.position_ms#302
Sawtaytoes merged 7 commits into
mainfrom
feat/section-fields

Conversation

@Sawtaytoes

Copy link
Copy Markdown
Owner

Adds the two fields that let a queue entry name a section of a playable unit —
start.position_ms and end.position_ms. Implements
2026-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.ts
gains type declarations and the shelf skeleton spells one more null. There is no
control, no editor and no tag in this diff.

What lands

  • Types. Start.position_ms, a new End, EntryExtras.end.
  • Normalise + write. normalizeStart() carries the position, normalizeEnd() and
    setEnd() mirror their start siblings, and both follow the sparse rule every other
    per-entry override follows: an absent, blank, negative or non-numeric value drops the
    key, a start left with none of its three fields is null rather than {}, and an
    end with no position is null.
  • Routes. PATCH /queues/:set/items/:key/end, the house shape exactly. end joins
    the bulk reset arm.
  • The wire. endOf beside startOf, one line in the queueTile literal.
  • The descriptor. describe() carries end on the raw type, on EntryDescriptor
    and in all three return arms.
  • Provider capability. Provider.playsSections (true only on Plex), PLAYS_SECTIONS
    keyed by kind beside DELIVERY, and plays_sections on ProviderPublicView.
  • The debt from feat(server): an entry can carry an id, so one queue can hold the same file twice #300. e2e/entry-id-test.ts loses its as EntryValue cast.

Where the cross-field check lives, and why

On the writers — setStart and setEnd — inside the YAML lock, not on the route.

end must be strictly after start. 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 set
start: 120s, and each request on its own looks fine. A route-level check reads one side
off 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 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
comes 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 trap

Its first line was if (!hasSeries && src.episode == null) return null. That was right
while 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 null with nothing logged. It reads all three fields now, and both
server/src/sectionWindow.test.ts and e2e/yaml-roundtrip-test.ts pin the movie case.

hasSection was real, but disagreed with the sparse rule

hasSection() landed in #300, ahead of the fields, and it is what decides an add is
deliberate — it mints an entry id and walks the add past the duplicate guard. It was not a
placeholder, but its hasPositionMs() helper tested Number(side.position_ms) directly,
and Number(null) is 0. So {start: {position_ms: null}} answered true. null is
this file format's spelling of "no value" ({start: null} is how a start is cleared), so a
cleared 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 and hasPositionMs() calls it, so the
predicate 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 unusable
    spelling 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 an
    unrelated 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 refused
    write 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 the
    migration, then come back through the engine's describe().
  • e2e/api-v2-test.ts: the route lands, clears, drops a junk value and refuses an
    equal end by name; the bulk reset clears both ends; the capability reads 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.

Gates

Run on this branch rebased onto aa56f74.

yarn install --immutable                        ok
yarn workspace queuepilot-web run build         exit 0
yarn workspace queuepilot-server run build      server/dist/index.js — 4011.9 KB
yarn typecheck                                  exit 0, no output
yarn workspace queuepilot-web run lint:biome    exit 0, 75 warnings (baseline: 75)
yarn test                                       web 322/322, server 371/371 (baseline 322 + 355)

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 the
image's own /opt/pw-browsers, which holds chromium-1234 and matches e2e/package.json's
pin, so no PLAYWRIGHT_BROWSERS_PATH override was needed.

One finding, not touched by this PR

e2e/topup-test.ts fails 16 checks in an agent sandbox, and it fails identically on
origin/main (aa56f74).
Verified by checking out origin/main and running it there, so
it 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 it
is /, / is a directory, so the store lands at
/nonexistent-so-loadEntries-is-never-consulted.queuepilot.sqlite — the filesystem root. The
open fails with unable to open database file for any process that cannot write to /, and
every routing read then reports the fixture unreadable. It passes wherever the runner can
write to the root. The harness calls itself hermetic; it is hermetic in everything except
this. Fixing it is a one-line STORE_PATH in that file, and it belongs in its own change.

…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.
@Sawtaytoes

Copy link
Copy Markdown
Owner Author

Design: #299. Entry identity this builds on: #300.

@Sawtaytoes
Sawtaytoes merged commit 380f418 into main Sep 2, 2026
1 check passed
@Sawtaytoes
Sawtaytoes deleted the feat/section-fields branch September 2, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant