feat: a section plays — start at a mark, stop at a mark, then advance - #304
Merged
Conversation
…after it `sectionOf(desc)` reads the entry's `start.position_ms` and `end.position_ms` and returns them, rather than applying them, so neither play-list builder can quietly stamp a second item. `nextQueue` stamps `items[0]` of what the entry resolved to; `buildReel` stamps the first item that entry pushed, in both its collection arm and its title arm. An entry contributing three episodes per visit takes the offsets on episode one; two and three play in full. That is not a simplification of the rule — it is the only reading that serves both asks the feature came from. "Start season 2 episode 4 at 12:30" means THAT episode, and a film section is one unit by construction. An entry wanting three separately-windowed sections is three entries, which is what the `id:` key from #300 exists to allow. `buildReel` matters more than it looks: the Theater Demo Reel is the case the whole feature came from, and every line of it is a pre-clipped file that exists only because a section could not be written down. One behaviour change rides along, in `leadsInProgress`. An entry with a section is never "in the middle of" anything — it begins at its own start mark every sitting — so a resume marker left behind by the last one says nothing about it. Without this, an entry that stops at 40% by design reads as half-watched and hoists itself to the front of the random pool every night, forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`resume.ts` cannot carry a section, and the reasons are structural rather than
a matter of tuning:
* its plan is `Map<ratingKey, ms>`, so a second section of the same file
OVERWRITES the first;
* its `seen: Set<string>` considers each ratingKey ONCE, so the second
occurrence is answered `already considered` and never seeks;
* `RESUME_MIN_MS` (30 s) would drop a section starting at 0:12,
`RESUME_MAX_FRACTION` (0.95) would drop a closing-gag section, and
`viewCount >= 1` would drop a section of a film already watched.
Every one of those three is CORRECT for a resume marker, which is inferred
data, and wrong for a section, which is authored. Since #300 one queue can hold
the same file twice, so the first two are reachable in production rather than
theoretical.
So the plan is keyed by playQueue INDEX. `readPlayQueue().selectedOffset` is
the only signal that says which occurrence is playing, and an index is the only
key that can hold two windows for one file. The index is READ and CHECKED
against the live playQueue rather than assumed — Plex reorders, drops what a
token cannot see, and a top-up inserts mid-queue — and an ambiguous reading
declines instead of guessing, because a section seek on the wrong occurrence is
worse than no section at all. The read is paid only when two pending windows
name the same file, which is the only case an index can settle.
`headStartOffsetMs()` is the head's free path and the precedence rule in one
function: Companion `playMedia` already takes an offset that applies to the
item it starts on, and an AUTHORED section start outranks an INFERRED resume
marker — including the private ledger's position on a `watch_history: queue`
entry. An `end` with no `start` reads as "from the beginning of the unit", so
it returns 0 rather than a resume point that would honour neither key. The
`playsSections` capability is checked here, so a provider that cannot serve a
section never has one reach its playback path.
A window is dropped against a known duration: a `start` at or past the runtime
has nothing to seek to, and an `end` past it can never be reached by a position
and would hold the watcher open until `maxMs`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d advances
The section plan and the resume plan are answers to the SAME event — "the
player is at position P on item R" — so they share one read and one loop. Two
watchers would double the reads of one endpoint and, worse, let two decisions
race over one item. With one, the precedence is a single `if`, and it is the
rule the feature turns on: an authored section outranks an inferred resume
marker. When a section names the item on screen, the resume plan is not
consulted for it at all — an entry whose window says only "stop at 1:06:00"
means "from the beginning of the unit", and letting a resume marker start it
half an hour in would honour neither key.
The stop is Companion `skipNext` through `playback.transport('next')`, NOT
`advanceSession()` — that rebuilds the whole playQueue and restarts playback,
which `topup.ts` already names as the thing to avoid, and it would be a hiccup
on screen where a section wants a cut.
A fourth watch trigger joins push/retry/poll: `mark`. The last read says "the
end is 87 s away at this position", so the next one is BOOKED for then instead
of grinding through fifty-eight polls that all answer "not yet". It is clamped
to the ordinary cadence at the top, so a long section still gets its regular
sanity reads and a viewer who paused is noticed, and to the retry delay at the
bottom, so a mark already upon us cannot spin the loop. A pause simply makes
the booked read early, and it re-books.
`session.ts` arms both plans and starts the watcher when either is non-empty —
`RESUME_ON_ADVANCE=0` now empties the resume plan rather than disabling the
section path, which is a different feature. The head's start goes out as
playMedia's `offset`, which costs nothing at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everywhere else in `finished.ts`, "where playback stopped" is the evidence: a
play that ends at 40% ended because somebody walked away. A section entry ends
at 40% BY DESIGN, and the two are indistinguishable from the position alone.
Left to the ordinary path, a two-minute clip of a two-hour film would be filed
as an abandonment — the queue's ledger would keep a 40% resume position,
`leadsInProgress` would hoist that entry to the front of the pool every
sitting, and the entry sheet would offer to resume something nobody stopped.
So the fact that decides it is not the position. It is WHO stopped it. The
watcher records a boundary only when IT issued the `skipNext`, and this claims
that record — once, with a two-minute TTL, so a later ordinary play of the same
file goes down the ordinary path.
What it decides, and why each half is the way it is:
* `watch_history: queue` — the entry's own ledger records the item COMPLETED.
The window played to the end of what the entry asked for, so the line is
finished. This is the ledger that exists precisely because Plex has nowhere
to put a second position for one file.
* `watch_history: provider` — NOTHING is written, here or anywhere. That queue
asked Plex to be the judge, and Plex judges a 40% play as unwatched, so the
entry stays. That is the queue's call and not this feature's, per the
decision record; force-marking a `viewCount` would be this feature inventing
a rule the owner did not ask for. The consequence is real and deliberate: a
windowed entry on provider history replays every sitting, from its section
start, until the queue is switched to `watch_history: queue`.
Two more things the boundary record fixes, both silent:
* the boundary's entry key is the authority for WHICH line just played. With
two sections of one file in one lineup, `SESSION.queue.find()` returns the
first of them for both.
* a WINDOWED item saves no live position. That writer matches by ratingKey
too, so while the second section played it addressed the FIRST section's
ledger row — and `savePosition` clears `is_completed`, undoing the
completion that section had just earned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st the mark `section-playback-test.ts` is built around the case the old ratingKey-keyed design could not represent at all: the same film twice in one lineup, at two different places in it. It drives the REAL `startWatch()` with both plans armed — through `nextQueue` for the lineup, so the first-unit rule and the two distinct windows are asserted on real resolver output rather than on a hand-built map. What it pins beyond the headline: the head's start outranking a resume marker AND the queue-owned ledger position; an `end` with no `start` playing from the beginning; the capability guard on both the offset and the plan; the three resume filters that must NOT apply (a 0:12 start, a closing-gag section, a film already watched); a window past the item's runtime at either end; refusing to guess when two windows name one file and no index settles it; and the boundary ledger's once-only claim and its TTL. `resume-latency-test.ts` gains the second number this feature owes, in the same harness rather than a second one — the two share every modelled cost, and splitting them would let the assumptions drift. Sweeping the phase of the end mark against the booked read: | Stopping at the end mark | Mean | Worst | | the read is BOOKED for the mark | 475 ms | 900 ms | | the same stop on a plain 1500 ms poll| 925 ms | 1400 ms | | booked, with an exact position | 50 ms | 50 ms | So the scheduling is not what is left: 50 ms is the two round trips, and the other ~425 ms is Plex's ~1 s position grain, which no cadence can shorten. It never fires EARLY — the position a decision is made on is at or behind the truth — and a player paused short of the mark re-books rather than advancing, which is pinned separately. `resume-on-advance-test.ts` gains the boundary between the two plans, driven in both directions: an authored start wins over a marker for the same item, a SPENT window keeps its item away from the resume plan for the rest of the sitting, and an unwindowed item resumes exactly as it always did. `play-one-entry-test.ts` gains the windowed one-entry start, on the path where the first-unit rule is easiest to get wrong. Two small production changes ride along because the harnesses are what found them: `SectionDecision.isSpent`, which keeps a section's ownership of its item alive for the whole sitting rather than one read, and the `isWindowed()` read `finished.ts` uses to keep a live position off a windowed item. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The completion rule gets its own decision record, because it is the one place this feature had to choose rather than implement: a section entry stops at 40% BY DESIGN, and from the position alone that is indistinguishable from somebody walking out. The fact that decides it is WHO stopped it. The design doc gains the measured overshoot table beside the seek-latency one it already carries, and its implementation map now says which rows shipped. `AGENTS.md` gains the five things that bite in this path — the index key, the one-watcher rule, the three resume filters that must not be applied, the advance verb, and the completion rule — and `section-playback-test.ts` joins both gate lists, `ci.yml`'s and this file's. A gate this file does not name is a gate nobody re-runs by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Playing a section, end to end: the head starts at
start.position_ms, every other unit isseeked once the player advances to it, the item stops at
end.position_ms, and the lineupmoves on. #302 stored, wrote and served the two keys; nothing played them.
Builds on #299 (the design), #300 (an entry can carry an
id), #301 (the push-feed wake-up)and #302 (the two keys). Server only — the editor control is a separate change, and no file
under
web/is touched. No UI change, so no screenshots.The head's start is free
Companion
playMediaalready takes anoffsetthat applies to the item it starts on, andPlexArtifact.offsetalready carries it. So the head's section start IS that offset: no poll,no seek, no delay.
headStartOffsetMs()is that path and the precedence rule in one function. An AUTHOREDsection start outranks an INFERRED resume marker — the provider's
viewOffsetand thequeue's own ledger position alike, because the question is not which ledger is better but which
KIND of fact wins. An
endwith nostartreturns 0 rather than the marker, because thedecision record's third row reads "from the beginning of the unit" and a resume point would
honour neither key.
How the two plans coexist, and why
One watcher, two plans.
section.tsholds the windows;resume.ts's existing watcherconsults it first and its own plan second.
Two watchers was the alternative, and it is the wrong one for three reasons:
/status/sessionsread answers both; two would double the request rate against one endpoint,which is the failure the perf(playback): cut the seek latency from four seconds to a fifth of one #301 design was built to avoid.
moment, and the later write would win by timing.
if, in one place, and it is testable. With two itwould be an emergent property of two schedules.
The section owns an item for the whole sitting, not for one read.
SectionDecision.isSpentis what makes that hold: without it, the read after a start seek finds no live row, falls
through to the resume plan, and a resume marker drags the viewer straight back out of the
section that was just set up.
e2e/resume-on-advance-test.tsdrives that in both directions.The plan is keyed by playQueue INDEX
resume.ts's plan structurally cannot carry a section, and both reasons are reachable inproduction since #300:
Map<ratingKey, ms>, so a second section of the same file overwrites the first;seen: Set<string>considers each ratingKey once, so the second occurrence isanswered
already consideredand never seeks.readPlayQueue().selectedOffsetis the only signal that says which occurrence is playing, soan index is the key. It is read and checked against the live playQueue rather than assumed
— Plex reorders, drops what a token cannot see, and a top-up inserts mid-queue — and an
ambiguous reading declines rather than guessing, because a section seek on the wrong
occurrence is worse than no section at all. The read is paid only when two pending windows name
the same file, which is the only case an index can settle.
The resume filters are deliberately not applied.
RESUME_MIN_MS(30 s) would drop asection starting at 0:12,
RESUME_MAX_FRACTION(0.95) would drop a closing-gag section, andviewCount >= 1would drop a section of any film already watched. All three are correct for aninferred marker and wrong for an authored one. Each is pinned.
Stopping at the mark, and the measured overshoot
The advance is Companion
skipNextviaplayback.transport('next'). NotadvanceSession(), which rebuilds the whole playQueue and restarts playback —topup.tsalready names that as the thing to avoid, and it would be a hiccup on screen where a section
wants a cut.
The boundary is booked, not polled for: the read before it says "the end is 87 s away at
this position", so the next read is scheduled for then — a fourth watch trigger,
mark,beside push/retry/poll. Measured in
e2e/resume-latency-test.ts, extended rather thanduplicated so both numbers share the same modelled costs:
The scheduling is not what is left. With an exact position source the overshoot is 50 ms —
the
/status/sessionsGET plus theskipNext, and nothing else. The other ~425 ms is Plex's~1 s position grain, which no cadence can shorten; the lever for that is PMS's own
/:/websockets/notificationsfeed, the same lever the seek latency has.It never fires early — the position a decision is made on is at or behind the truth — and a
player paused short of the mark re-books rather than advancing. Both are pinned.
What I decided about completion, and why
This is the subtlest part of the change, and it has its own decision record:
docs/decisions/2026-09-02-a-stop-the-section-asked-for-is-not-a-stop-the-viewer-made.md.finished.tsdecides an item's outcome from where playback stopped, and that has always beensound: a play that ends at 40% ended because somebody walked away. A section entry ends at
40% by design, and from the position alone the two are identical. Left to the ordinary path,
a two-minute clip of a two-hour film would be filed as an abandonment, and three things follow,
none of them visible as an error: the queue's ledger keeps a 40% resume position;
leadsInProgressreads that as half-watched and hoists the entry to the front of the Randompool every sitting, forever; and the entry sheet offers to resume something nobody stopped.
So the fact that decides it is not the position — it is who stopped it, recorded at the
moment the
skipNextgoes out rather than inferred afterwards.section.tswrites a boundaryonly when the watcher itself issued the command;
finished.tsclaims it once, with atwo-minute TTL so a later ordinary play of the same file takes the ordinary path.
The outcome follows the queue's setting, which is what the data-model record already decided:
watch_history: queue— the entry's own ledger records the item completed. The windowplayed to the end of what the entry asked for, so the line is finished. That ledger exists
precisely because Plex holds one position per item and a queue that curates sections needs one
per entry.
watch_history: provider— nothing is written, here or anywhere. That queue asked Plexto be the judge, and Plex judges a 40% play as unwatched. Force-marking a
viewCountwould bethis feature inventing a rule the owner never asked for, and would write into history every
account sharing that library sees.
watch_history: providernever completes, so it replays its section every sitting until thequeue is switched to
watch_history: queue. That is the queue's own setting speaking, per the2026-09-01 record — "whether playing ninety seconds counts as watching the film is the queue's
call, not this feature's" — but it is the surprise a reader hits first, so it is in
AGENTS.mdbeside the rule.
Two silent bugs fall out of the same fact and are fixed here:
SESSION.queue.find()matches by ratingKey, so with two sections of one file it returns the first of them for both.
second section played it addressed the first section's ledger row — and
savePositionclearsis_completed, undoing the completion that section had just earned.And the read-side twin: an entry with a section no longer counts as in-progress in
leadsInProgress, so it stops camping the front of a Random pool.The other interactions
sectionOf()returns the fields ratherthan applying them, so neither play-list builder can stamp a second item. An entry
contributing three episodes takes the offsets on episode one.
buildReelstamps too — theTheater Demo Reel is the case the whole feature came from.
provider.playsSectionsis checked insession.tsfor both thehead offset and the plan, so a section on a non-Plex provider reaches neither. Pinned for all
five kinds.
independent progress rows. That is the case the old design could not represent at all, and
e2e/section-playback-test.tsis built around it.startat or past the runtime has nothing toseek to and is dropped; an
endpast it can never be reached by a position, so the unitplays to its natural end rather than holding the watcher open until
maxMs. A window theplayer has moved past is retired for the same reason.
a transition is real — and given up on after eight reads rather than fought forever.
Gates
Every command below was run and the output read. Full list in the task report.
yarn install --immutable,yarn workspace queuepilot-web run build,yarn workspace queuepilot-server run build— all clean.yarn typecheck— exit 0.yarn workspace queuepilot-web run lint:biome— exit 0, 75 warnings, byte-identical tomain(noweb/file is touched).yarn test— web 22 files / 322 tests, server 29 files / 371 tests, all passing.session and entries.
e2e/tile-lane-test.tsalso runs green with noPLAYWRIGHT_BROWSERS_PATHoverride (
/opt/pw-browsersholds chromium-1234, the repo pins 1.62.1 → 1234).Pre-existing failures, confirmed identical on unmodified
mainand not touched here:maine2e/topup-test.tsunable to open database filee2e/session-profile-gate-test.tse2e/rotation-length-test.tse2e/sse-test.tse2e/binding-token-test.tsFollows #299, #300, #301, #302.
🤖 Generated with Claude Code