Skip to content

feat: a section plays — start at a mark, stop at a mark, then advance - #304

Merged
Sawtaytoes merged 6 commits into
mainfrom
feat/section-playback
Sep 2, 2026
Merged

feat: a section plays — start at a mark, stop at a mark, then advance#304
Sawtaytoes merged 6 commits into
mainfrom
feat/section-playback

Conversation

@Sawtaytoes

@Sawtaytoes Sawtaytoes commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Playing a section, end to end: the head starts at start.position_ms, every other unit is
seeked once the player advances to it, the item stops at end.position_ms, and the lineup
moves 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 playMedia already takes an offset that applies to the item it starts on, and
PlexArtifact.offset already 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 AUTHORED
section start outranks an INFERRED resume marker
— the provider's viewOffset and the
queue's own ledger position alike, because the question is not which ledger is better but which
KIND of fact wins. An end with no start returns 0 rather than the marker, because the
decision 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.ts holds the windows; resume.ts's existing watcher
consults it first and its own plan second.

Two watchers was the alternative, and it is the wrong one for three reasons:

  1. Both plans are answers to the same event — "the player is at position P on item R". One
    /status/sessions read 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.
  2. Two loops could both decide to seek the same item from two different reads of the same
    moment, and the later write would win by timing.
  3. With one loop the precedence is a single if, in one place, and it is testable. With two it
    would be an emergent property of two schedules.

The section owns an item for the whole sitting, not for one read. SectionDecision.isSpent
is 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.ts drives 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 in
production since #300:

  • it 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.

readPlayQueue().selectedOffset is the only signal that says which occurrence is playing, so
an 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 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 any film already watched. All three are correct for an
inferred marker and wrong for an authored one. Each is pinned.

Stopping at the mark, and the measured overshoot

The advance is Companion skipNext via playback.transport('next'). Not
advanceSession(), which rebuilds the whole playQueue and restarts playback — topup.ts
already 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 than
duplicated so both numbers share the same modelled costs:

Stopping at the end mark Mean Worst case
the read is BOOKED for the mark (shipped) 475 ms 900 ms
the same stop on a plain 1 500 ms poll 925 ms 1 400 ms
booked, with an exact position source 50 ms 50 ms

The scheduling is not what is left. With an exact position source the overshoot is 50 ms —
the /status/sessions GET plus the skipNext, 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/notifications feed, 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.ts decides an item's outcome from where playback stopped, and that has always been
sound: 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;
leadsInProgress reads that as half-watched and hoists the entry to the front of the Random
pool 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 skipNext goes out rather than inferred afterwards. section.ts writes a boundary
only when the watcher itself issued the command; finished.ts claims it once, with a
two-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 window
    played 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: providernothing is written, here or anywhere. That queue asked Plex
    to be the judge, and Plex judges a 40% play as unwatched. Force-marking a viewCount would be
    this feature inventing a rule the owner never asked for, and would write into history every
    account sharing that library sees.

⚠️ The deliberate consequence, named rather than buried: a windowed entry on
watch_history: provider never completes, so it replays its section every sitting until the
queue is switched to watch_history: queue. That is the queue's own setting speaking, per the
2026-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.md
beside the rule.

Two silent bugs fall out of the same fact and are fixed here:

  • the boundary's entry key is the authority for which line just played. SESSION.queue.find()
    matches by ratingKey, so with two sections of one file it 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.

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

  • The window applies to the FIRST played unit only. sectionOf() returns the fields rather
    than applying them, so neither play-list builder can stamp a second item. An entry
    contributing three episodes takes the offsets on episode one. buildReel stamps too — the
    Theater Demo Reel is the case the whole feature came from.
  • The capability guard. provider.playsSections is checked in session.ts for both the
    head offset and the plan, so a section on a non-Plex provider reaches neither. Pinned for all
    five kinds.
  • Two sections of one file in one lineup get independent starts, independent stops and
    independent progress rows. That is the case the old design could not represent at all, and
    e2e/section-playback-test.ts is built around it.
  • A window past the item's real duration: a start at or past the runtime has nothing to
    seek to and is dropped; an end past it can never be reached by a position, so the unit
    plays to its natural end rather than holding the watcher open until maxMs. A window the
    player has moved past is retired for the same reason.
  • A viewer already past the start is declined provisionally — the stale-position artifact at
    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 to
    main (no web/ file is touched).
  • yarn test — web 22 files / 322 tests, server 29 files / 371 tests, all passing.
  • 68 of 70 offline e2e harnesses pass, including every one touching resume, playback,
    session and entries. e2e/tile-lane-test.ts also runs green with no PLAYWRIGHT_BROWSERS_PATH
    override (/opt/pw-browsers holds chromium-1234, the repo pins 1.62.1 → 1234).

Pre-existing failures, confirmed identical on unmodified main and not touched here:

Gate On main On this branch In CI?
e2e/topup-test.ts 16 checks fail, unable to open database file identical no
e2e/session-profile-gate-test.ts hangs (exit 124) identical yes
e2e/rotation-length-test.ts exit 1, same SQLite path cause identical no
e2e/sse-test.ts exit 1 identical no
e2e/binding-token-test.ts checks pass, then hangs on the live broker identical yes

Follows #299, #300, #301, #302.

🤖 Generated with Claude Code

Sawtaytoes and others added 6 commits September 2, 2026 01:31
…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>
@Sawtaytoes
Sawtaytoes merged commit c56f808 into main Sep 2, 2026
2 checks passed
@Sawtaytoes
Sawtaytoes deleted the feat/section-playback branch September 2, 2026 06:41
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