Skip to content

feat(playback): finalize platform-neutral protocol v3 - #567

Merged
Quick104 merged 37 commits into
mainfrom
codex/playback-v3-neutral-server
Aug 10, 2026
Merged

feat(playback): finalize platform-neutral protocol v3#567
Quick104 merged 37 commits into
mainfrom
codex/playback-v3-neutral-server

Conversation

@Quick104

@Quick104 Quick104 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Part of #135
Fixes #574

Playback protocol v3 still exposed client-engine and platform details, let clients derive attempt identities, and split intent changes across legacy routes. That made cross-platform behavior drift-prone and left failure recovery, subtitle selection, terminal reporting, and hardware-transcode startup inconsistent in real Shield playback.

Approach

  • Make neutral playback v3 the sole first-party playback contract before the v1 lock, with the removal recorded in the pre-lock table.
  • Replace engine selection with server-owned delivery plans, evidence-tiered capabilities, opaque server-minted attempt keys, typed output context, and one replan transaction for recovery, seek, track, and quality intent.
  • Move the web video and audiobook players to the same v3 lifecycle and extract their wire adapter from stateful lifecycle code.
  • Preserve selected subtitle identity and complete inventories across replans/adapted routes; make terminal starts addressable for route events.
  • Correct PGS sidecar semantics and High10 AVC software-decode/QSV startup, including CPU scaling before upload and a bounded readiness terminal.
  • Publish JSON Schemas plus a generated, typed conformance matrix. Raw-wire tests ensure client fixtures contain schema-valid arrays, server-owned identities, and no synthetic failures on intent/seek replans.

Coordinated client PRs:

Risks and follow-up

  • This deliberately removes draft-v3 fields before the v1 lock; server and first-party clients should land as one coordinated release train.
  • The generated corpus is authoritative in one direction: clients vendor and echo server output rather than re-deriving attempt keys.
  • Physical Shield validation covered PGS playback and DV7 to DV8.1/TrueHD behavior on shared dev. The final commits after that run are contract-fixture and wire-shape corrections.
  • No visual design change; browser validation covered the player route and production build, while signed-in end-to-end browser playback remains a release-train smoke check.

Testing

  • go test ./... on dev-builder: all Go packages passed for the final Go tree.
  • make test-web on dev-builder: 267 files / 1,831 tests passed. The final source-only sync initially omitted the gitignored vendored Foliate package; after restoring it, the four affected files passed 67/67 and the other 263 files had passed 1,764/1,764.
  • pnpm exec vitest run src/player/hooks/usePlaybackSession.test.ts: 19/19 passed at final head.
  • pnpm run build: passed at final head.
  • golangci-lint run --new-from-merge-base=origin/main ./...: 0 issues.
  • pnpm run lint: 0 errors (157 unchanged baseline warnings).
  • pnpm run format:check: passed.
  • make verify-playback-fixtures, make verify-local-paths, and git diff --check: passed.
  • Isolated dev-builder production frontend/backend build and doctor passed API, frontend, database, proxy, and transcode-node health before the final serialization-only corrections.

AI Disclosure

  • Tool(s): OpenAI Codex; Claude Code
  • Model(s): gpt-5; claude-fable-5 (xhigh review attempt produced no result)
  • Involvement: AI-assisted
  • Adversarial review: Independent Standards and Spec reviews found and drove fixes for stale disabled schemas, undocumented pre-lock removals, untyped conformance expectations, an oversized web lifecycle adapter, invalid null arrays in canonical request vectors, synthetic empty replan failures in Go/native/web clients, and fixture-generation drift. The final exact-head review found no remaining blocker. Claude/Fable was attempted twice and timeboxed without output, so no Claude approval is claimed.

Checklist

  • I ran an adversarial AI review of the diff and summarized findings above.
  • I ran the relevant repository verify commands; changed-line Go lint is clean, web lint/format are clean, and full Linux Go/web tests pass as documented above.

Summary by CodeRabbit

  • New Features

    • Introduced Playback Protocol v3 with server-managed plans, quality options, recovery, seeking, and progress persistence.
    • Added audio-only playback, improved HDR and codec handling, and richer subtitle inventories with burn-in support.
    • Added playback diagnostics and clearer terminal and transport error messages.
  • Bug Fixes

    • Improved audiobook seeking and embedded cover-art handling.
    • Fixed subtitle streaming, caching, and format compatibility issues.
  • Documentation

    • Added Playback Protocol v3 specifications, schemas, and conformance fixtures.

Quick104 and others added 24 commits August 8, 2026 17:18
Supersedes the wire-contract sections of the 2026-07-12 v3 plan: server-owned
attempt keys, delivery-keyed negotiation without Media3 engine names, tiered
capability evidence, neutral device/output context, track/quality replan
operations, audio-only planning, and coordinated no-back-compat rollout
across server, Android, Apple, and web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… with deliveries

Contract core of the platform-neutral v3 finalization (plan sections 3.1
and 3.2), breaking on purpose — v3 is dark and all clients move together:

- Every PlanV3 now carries plan_attempt_key, an opaque server-computed
  token clients store and echo in attempted_plan_keys; ReplanRequestV3
  gains bounded local_mutations that the replan handler folds into the
  failed plan's key. Clients never hash anything.
- KotlinName() is deleted from DeliveryV3, StreamProtocolV3 and
  SubtitleModeV3; the attempt-key canonical string now uses lowercase
  wire tokens, and PlanRecipeVersionV3 bumps to v3.3 so no key or plan
  ID computed under the old canonicalization can collide.
- EngineV3 leaves the wire: ClientPlaybackContextV3.Engines (media3_*)
  becomes Deliveries keyed original_http|progressive|hls, with
  EngineCapabilityV3 renamed DeliveryCapabilityV3. PlanV3.Engine is
  removed; the planner, subtitle policy and quirk registry re-key on
  delivery class, and the media3_only feature token is deleted.
- Validated-claim strings drop the prefix: media3_h264_decode ->
  h264_decode, media3_audio_decode -> audio_decode.
- Golden fixtures in testdata/protocol_v3 are regenerated by Go and are
  now the cross-repo source of truth.

Part of the playback protocol v3 neutral-contract train (steps 2-3 of
docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement plan sections 3.3 and 3.4 of the v3 neutral-contract pass:

- ClientCodecCapabilitiesV3 gains required video_evidence and
  audio_evidence closed enums (exact | platform_attested | declared).
  Planner strictness follows the tier: exact keeps the strict decode-entry
  validation, platform_attested validates codec/resolution/bit-depth/
  frame-rate but skips profile/level matching, declared grants copy routes
  from the flat codec lists. Only exact audio evidence earns passthrough
  claims. The detailed_decode_capabilities feature token is deleted
  (subsumed by video_evidence=exact), and evidence-blocked direct routes
  carry the new evidence_insufficient_for_direct reason/warning.

- DeviceContextV3 is now platform/os_version/manufacturer/model plus a
  bounded platform_details map (<=16 entries, <=128 chars); the Android
  Build dump fields are gone. Fire TV quirks keep matching on
  manufacturer/model (brand fallback removed with the field).

- output_route_generation (int64, dual-location) becomes an optional
  opaque output_context_id string on the output context; the dual-location
  consistency validation is deleted. Attempt keys, plan invalidation,
  route events, and the planstore column follow (new Goose migration).

- Feature advertisement collapses to the top-level client_features list
  only; ClientPlaybackContextV3.Features is deleted and ReplanRequestV3
  gains an optional client_features refresh.

- PlanRecipeVersionV3 bumped v3.3 -> v3.4; fixtures re-keyed.

Part of the playback protocol v3 neutral-contract finalization plan
(docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…outes

Protocol v3 could only replan after a failure, so changing the audio track
or the quality still required the legacy audio PATCH and the client-recipe
transcode start — the two endpoints v3 is meant to replace. Clients also had
to own a resolution ladder to render a quality menu, and a source with no
video track was terminaled by the video/HDR gates, keeping audiobooks on the
legacy path.

Add track_change and quality_change replan operations. They carry no failure
classification and route through the existing replan transaction, so they
inherit its idempotency, capacity reservation, and staged-successor commit
for free. Because nothing failed, the previous route stays eligible: neither
the attempted-key history nor the failed-plan exclusion applies to them.

Publish the server ladder on the plan as available_qualities so the quality
menu is server-owned; the rungs come from the same resolutionLabelV3 and
ladderBitrateKbpsV3 helpers the planner itself uses, not a parallel table.

Plan audio-only sources through their own reduced route family: original_http
when the client decodes the codec, otherwise a progressive AAC conversion.
The plan advertises audio/mp4 for that remux and the transport now serves the
same value, because a declared-tier client probes the advertised MIME with
isTypeSupported before attaching a source buffer, and "video/mp4" on a stream
with no video track is exactly the mismatch that makes the probe lie.

Name the protocol's string vocabulary (dynamic ranges, transformations,
executors, validated claims, terminal reasons) as constants while touching
these lines, so the wire values have one definition.

Part of #135
…inals

Protocol v3 exists only as Go code today, so the Android and Apple ports have
no authority to implement against other than reading this repository. Publish
the contract as a normative document, machine-checkable schemas, and generated
golden fixtures, and fix the one place where the server's own wire output
disagreed with the ordinal space it publishes.

- docs/architecture/playback-protocol-v3.md is self-contained enough for a
  third-party client: endpoints and status codes, evidence tiers and their
  bound-matching rules, delivery classes, the timeline model, replan
  semantics, registries, track identity, plan identity, quality, and
  transformations.
- docs/design/schemas/playback-v3/ carries JSON Schemas for the five wire
  shapes plus valid and invalid fixtures, following the client-diagnostics
  layout. internal/playback/contract validates every fixture against its
  schema, so a schema that drifts from the Go types fails the Go suite.
- cmd/playbackfixtures generates internal/playback/testdata/protocol_v3 from
  the production planner. `make playback-fixtures` writes them and
  `make verify-playback-fixtures` (wired into CI) fails when they are stale.
  These files are what the client ports consume, so drift would otherwise
  surface as a playback bug on three platforms at once.

The subtitle fix: combined ordinals are one dense space over externals, then
embedded tracks, then downloaded ones, but the legacy URL builder skipped
burn-in-only tracks while assigning indices, so every track after a DVD/DVB
track was numbered one too low and resolved to its neighbour. Ordinal
assignment now lives in playback.BuildSubtitleInventoryV3 and both the plan
inventory and the legacy `subtitle_urls` shape project from it; the legacy
shape still filters burn-in-only entries but keeps each track's real index.

Part of #135
The web player was the last client still speaking the legacy start
protocol: it picked its own file version from a codec probe, posted an
ffmpeg recipe to start a transcode, PATCHed an endpoint to change audio
tracks, and derived its own quality ladder. None of that survives a
server-owned plan, and none of it produced telemetry the apps could be
compared against.

Video player: starts with a v3 request that advertises `declared`
evidence from `isTypeSupported` probes and the three delivery classes,
then consumes the returned plan for its URL, timeline, tracks and
warnings. Quality and track changes become replans (`quality_change`,
`track_change`), the quality menu renders `available_qualities` instead
of computing rungs, and playback failures emit `route-events` so web
failures land in the same diagnostics as Android and Apple. The
duration comes from `source.duration_seconds` rather than the playback
engine, and the "how was this delivered" overlay reads the plan's
delivery and server transformations instead of comparing codec strings.

Audiobook player: starts against the audio-only planner path with a
single `original` rung, and takes its seek anchor from
`timeline.player_start_seconds` so the progressive-remux route (which
anchors the stream and restarts the player clock at zero) does not seek
twice.

Server side, `disable_progress_persistence` left the wire, so the rule
it encoded is now derived. Resume state is keyed on the item, but every
part of a multipart presentation shares that key while carrying its own
file-local clock — persisting part 4's position would store "12 minutes
in" as the book's resume point. `PresentationPartTotal > 1` expresses
that directly and generalizes to multipart movies and split episodes,
and a client can no longer forget to ask or lie about it.

`useTranscodeQuality` and the legacy response types are deleted, and
`WEBTEST_KNOWN_FAILURES` loses the audiobook entry along with its fix.

Part of #135
Protocol v3 shipped behind a flag, alongside the legacy start path it was
designed to replace. Running both meant every planner change had to be made
twice, in two shapes that disagree about who decides the route: the legacy
body carried a decision the client had already made, while v3 asks the server
to make it. This deletes the legacy half.

Removed:

- `handleStartPlaybackLegacy` and its request/response bodies. The
  `POST /playback/start` route stays, but the protocol-version dispatch
  envelope is now a strict v3 decode — a body that does not declare
  `protocol_version: 3` gets `426 client_upgrade_required` so an outdated app
  can render a clear "update required" state instead of misreading a plan.
  Deliberately not a `400`: the request may be well-formed for the protocol it
  was written against.
- `POST /playback/transcode/start`, superseded by the `quality_change` replan
  operation, and `PATCH /playback/{session_id}/audio`, superseded by
  `track_change`. Both mutated a session without re-planning.
- The shadow planner and both rollout settings rows. With v3 the only
  protocol, `playback.protocol_v3_enabled` would mean "no playback at all";
  `playback.protocol_v3_shadow_enabled` gated a comparison against a path that
  no longer exists. `409 protocol_disabled` on route-events goes with them, and
  capability `enabled` is now constant `true` (the field stays — clients
  feature-detect against it).
- Version-selection helpers in `internal/playback/resolver.go` that only legacy
  start reached. `Resolve`/`ClientCapabilities`/`PlayDecision` stay: downloads
  consumes them. `internal/jellycompat` has its own resolution surface and is
  untouched.

Behaviour the legacy handlers owned and v3 now owns explicitly: series version
and audio-track preferences are persisted on start and on a `track_change`
replan (not on failure recovery, whose forced route is not a user choice); an
omitted `start_position` resolves to the profile's saved resume point; and an
omitted audio track resolves through the series preference, the profile audio
language, then the library override. Both are settled before planning, because
the plan's timeline is cut at the start position. Spec §2.2 documents this as
"omission is a request, not a default".

The encode-target clamp that lived in the deleted transcode handler is already
enforced in the planner, twice — `availableQualitiesV3` omits rungs at or above
the source height, and the encode path clamps `targetHeight` to it.

Unchanged: progress, stop, HLS manifest and segment delivery, the realtime
control socket, stream tokens and restart reconstruction, watch together,
downloads, jellycompat.

Every removal is recorded in the pre-lock removals table in
docs/architecture/v1-scope.md.

Part of #135
ffprobe reports embedded cover art as a video stream carrying
disposition.attached_pic. convertProbeData appended every "video" stream
to VideoTracks without consulting isMainVideoStream, the predicate that
already existed for duration decisions, so the picture was persisted as a
playable track. That misreports the file twice:

  - An audio file with a cover picks up a video track, so it no longer
    satisfies MediaFile.IsAudioOnly and the v3 planner routes an
    audiobook through the video path instead of planAudioOnlyV3.
  - When the picture is ordered ahead of the real stream, the flat
    codec_video/resolution/hdr columns describe the poster: a 954x720
    h264 episode was stored as mjpeg 480x480.

Filter attached_pic streams out of the track loop. The guard is the
disposition flag, not the codec name, so a genuine MJPEG video is still
probed as video — the library has one.

Already-probed rows self-heal on the next playback: NeedsCriticalProbeRepair
already reprobes tracks missing color_range, which covers 21 of the 23
affected rows, and applyProbeData overwrites VideoTracks wholesale. The
remaining two need a rescan; nothing persisted records attached_pic, and
keying repair off still-image codec names would reprobe the genuine MJPEG
file on every playback forever.

Part of the playback v3 neutral-contract work: it is what lets Android
drop AUDIOBOOK_COVER_ART_CODECS, which fabricated decode support the
client cannot honestly claim under video_evidence: "exact".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ubtitles off

The v3 plan's subtitle inventory is the authoritative track list a client builds
its subtitle menu from, but the handler only rewrote it with session-scoped URLs
when a track was actually selected. A start or replan that resolved to
`subtitle.mode: "off"` therefore returned the planner's URL-less inventory, so a
client whose picker reads the inventory had a menu it could not fetch anything
from. The Cast path hits this every time: it starts with subtitles off and needs
the receiver's text tracks up front.

attachSubtitleArtifactV3 now scopes and publishes the inventory unconditionally
and gates only the artifact stamping on the selection. Spec §8 records that the
`url` on a sidecar entry does not depend on the current selection.

Part of the v3 neutral-contract finalization.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Playback Protocol v3 is finalized across server and web playback. The change adds neutral contracts, delivery-based capability negotiation, server-owned plans and attempt keys, durable replanning, subtitle inventories, audio-only playback, software-decoding support, generated fixtures, migrations, and CI verification.

Changes

Playback Protocol v3

Layer / File(s) Summary
Protocol contract and conformance
docs/architecture/playback-protocol-v3.md, docs/design/schemas/playback-v3/..., internal/playback/protocol_v3.go, internal/playback/contract/...
Defines and validates the v3 wire contract, schemas, fixtures, identities, deliveries, replans, subtitles, quality options, and terminal outcomes.
Deterministic fixture generation
cmd/playbackfixtures/main.go, Makefile, .github/workflows/ci.yml
Generates protocol fixtures from production code and verifies that committed fixtures remain current in CI.
Server protocol and planning
internal/playback/protocol_v3.go, internal/playback/capabilities_v3.go, internal/playback/plan_v3.go
Replaces engine-specific negotiation with delivery classes, capability evidence tiers, opaque output contexts, server-owned attempt keys, audio-only planning, quality ladders, and typed transformations.
Playback handlers and durable decisions
internal/api/handlers/playback_v3.go, internal/playback/protocol_store_v3.go, internal/playback/planstore/postgres.go
Adds durable start and replan responses, idempotent replay, explicit track and quality replans, terminal route events, sessionless terminal attempts, and nullable session persistence.
Transcode, media, and subtitle streaming
internal/playback/transcode.go, internal/playback/remux.go, internal/api/handlers/stream.go, internal/models/media.go, internal/scanner/...
Supports software decoding for selected hardware encoders, audio-only remuxing, attached-picture filtering, subtitle format validation, cached subtitle HEAD requests, and stable subtitle identities.
Web playback migration
web/src/player/..., web/src/pages/audiobooks/player/...
Moves web playback to server-owned plans and replans. Playback controls, seeking, quality, subtitles, errors, route events, and audiobook timelines now use protocol-v3 data.
Database and integration wiring
migrations/sql/..., internal/api/router.go, internal/playback/..._test.go, web/src/player/..._test.ts
Updates persistence columns, removes rollout flags, purges pre-neutral attempts, wires subtitle inventory resolution, adds subtitle HEAD routing, and expands integration coverage.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WebPlayer
  participant PlaybackAPI
  participant PlaybackPlanner
  participant PlanStore
  participant StreamService
  WebPlayer->>PlaybackAPI: POST /playback/start with capabilities and context
  PlaybackAPI->>PlaybackPlanner: resolve delivery and create PlanV3
  PlaybackPlanner->>PlanStore: persist plan and start response
  PlaybackAPI-->>WebPlayer: return plan URL and playback metadata
  WebPlayer->>PlaybackAPI: POST /playback/replan for quality, track, seek, or recovery
  PlaybackAPI->>PlaybackPlanner: create replacement plan
  PlaybackPlanner->>PlanStore: update durable start response
  PlaybackAPI-->>WebPlayer: return replacement plan
  WebPlayer->>StreamService: request server-provided stream URL
Loading

Possibly related issues

Possibly related PRs

Suggested labels: v1

Suggested reviewers: rhainland

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: finalizing the platform-neutral Playback Protocol v3.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/playback-v3-neutral-server

Comment @coderabbitai help to get the list of available commands.

@Quick104

Quick104 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Code review — protocol v3 finalization (head 63247a3)

Deep review of the full diff: 9 finder passes (~40 candidates), each surviving candidate independently verified against the head tree. 16 correctness findings confirmed; the 10 most severe are below, with 6 lower-severity confirmed items after them. Contract discipline is strong — the pre-lock removals table is complete, migrations follow repo rules, and the conventions pass found nothing. The bugs cluster in the web player's new replan effects, migration/deploy seams, and stream-origin handling on remuxed/resumed playback.


Confirmed, most severe first

1. Burn-in subtitle selection triggers an endless replan loopVideoPlayer.tsx:1771
The request effect dedupes on `${planRevision}:${index}`, but adoptDecision bumps planRevision on every adopted plan (usePlaybackSession.ts:351), and changeSubtitleTrack has no settle check against the plan's current subtitle selection (unlike switchAudioTrack, line 651). Selecting a burn-in-only track replans forever; each cycle restarts the server transcode and reloads the <video> element. Also: a start plan arriving with subtitle.mode: "burn_in" before activeSubtitleIndex is seeded fires changeSubtitleTrack(null), cancelling the server's own burn-in decision.

2. Stale cover-art probe rows silently break existing audiobook librariesprobe.go:197
The attached-picture skip only applies to new probes. Pre-existing MP3/M4B rows keep VideoTracks=[mjpeg]/codec_video set, so IsAudioOnly() is false, and NeedsCriticalProbeRepair passes for them (cover maps to a valid resolution) — nothing ever re-probes. The planner takes the video route (mjpeg matches no client codec) and emits an HLS plan; the newly v3-migrated audiobook hook drops the m3u8 into <audio> with no hls.js attach — Chrome/Firefox fail silently (console.error only). The pre-PR hook direct-played these files. Needs a backfill/re-probe path.

3. Track change on a 4K-fallback session returns a fatal terminalplayback_v3.go:1356
intentChange resets start.FileID = record.RequestedMediaFileID, re-mounting the requested (4K) file on a session that fell back to a 1080p alternate at start; the planner re-emits non-retryable no_alternate_version, and replanAllowsAlternateFileV3 (lines 2001–2010) excludes track_change from alternate fallback — so an audio switch kills working playback. The comment at 2005 says track changes "stay pinned to the mounted source"; the intentChange path un-pins them. The legacy audio PATCH kept the mounted alternate.

4. Replans omitting client_features 400 for transformation-advertising clientsprotocol_v3.go:810
ReplanRequestV3.Validate runs before the handler's start-time feature merge (playback_v3.go:1145 vs 1370–1374) and sees only the replan body's own client_features; the pre-PR fallback to context features was deleted. A client echoing client-executor DV transformations without resending client_features — the documented omission affordance, and the Android replan model has no such field — fails "client transformation capability is not enabled" on every replan, so a single decoder hiccup ends playback. Validate after merging stored start-time features, or require the field on replans.

5. Audio-only remux still maps the embedded cover-art streamremux.go:159
buildRemuxArgs emits -map 0:v:0? + -c copy; ffmpeg's lowercase v matches attached pictures. A freshly-scanned FLAC/OGG with a PNG/WebP cover hits the remux route and the MP4 muxer dies ("could not find tag for codec png") after the 200/audio/mp4 headers are written — silently truncated stream. An MJPEG cover muxes a video track into the promised audio-only fMP4, defeating the exact isTypeSupported("audio/mp4") gate the plan_v3.go:560 comment cites. Use -map 0:V:0? or -vn on the audio-only route.

6. First seek on pre-deploy sessions fails for 24h after upgradeplayback_v3.go:1458
Seek replans seed start := record.NormalizedRequest and deliberately don't accept the fresh request's capability payload (comment at 1317–1320), then NormalizeAndValidate hard-requires the evidence fields pre-PR rows can't have → non-retryable invalid_replan terminal. v3 is already live on main, rows carry MaxTokenTTL = 24h, and no migration purges or backfills playback_v3_attempts. A purge migration or evidence defaulting on load closes it.

7. Audiobook player crashes on plain-HTTP originsuseAudiobookPlayback.ts:340
Bare crypto.randomUUID() — undefined in insecure contexts — thrown synchronously in the effect body, before the async IIFE's .catch. usePlaybackSession uses the @/lib/uuid helper that exists precisely for this (see PR #458). Audiobooks never start on plain-HTTP self-hosts.

8. Non-HLS seeks skip the stream-origin mappingVideoPlayer.tsx:656
streamOriginSeconds now comes from the plan for all deliveries (the forced 0 for non-transcode and useRemuxSeeking's offset machinery were removed). On an anchored server_remux_progressive plan (resume start → stream_origin_seconds = resume, player_start_seconds = 0), display maps through toMediaTime but performPlayerSeek writes media-absolute seconds raw into video.currentTime — every seek lands origin seconds late or clamps at the end, and the can_seek_anywhere=false reanchor path is unreachable for progressive streams.

9. Audiobook hook ignores the stream-origin anchor after initial loaduseAudiobookPlayback.ts:446
For anchored converted-part plans (the hook's own comment at 399 describes them), absoluteFromAudio = activePart.start + audio.currentTime under-reports by the anchor: the scrubber snaps to the part boundary on first timeupdate and the progress reporter overwrites the saved resume point with it; a same-part seekTo (line 580) lands anchor seconds past the target. Direct-play audiobooks are unaffected — which is why M4B testing misses it.

10. Subtitle refresh replans at the start position for every viewerusePlaybackSession.ts:714
refreshSubtitles sends positionSeconds: initialPosition (player-local plan start — 0 for progressive remux), not the current position. subtitle_ready/subtitle_translation_completed broadcast to every session on the file and legitimately omit track when the notifier can't resolve it (subtitle_ready_notifier.go:107–127) — in which case every concurrent viewer's client replans, the server cuts a new plan at ~the start, and the planRevision bump reloads the stream: everyone gets yanked back.


Also confirmed (below the severity cut)

  • Stale idempotent replay after replanplanstore/postgres.go CompleteReplan never updates the new start_response column (memory store equivalent via updated := *record), and decisionResponseFromAttemptV3 now prefers it: a byte-identical start retry after a replan returns the superseded plan → next replan 409s stale_playback_plan. Regression vs main, which always rebuilt from current_plan.
  • Null arrays on pre-migration row replay — the fallback branch in decisionResponseFromAttemptV3 normalizes applied_quirks/runtime_corrections but not the new required-[] fields available_qualities and subtitle.inventory; pre-migration rows replay them as null for up to 24h.
  • pgssub alias splitisClientRenderableBitmapSubtitleV3 knows ffmpeg's short aliases but the same path calls IsPGS/NeedsBurnIn, which don't: an alias-spelled row (the v3 file's own comment at subtitle_policy_v3.go:165 says older rows carry them) skips the sidecar route and lands in the libass text filter, which cannot render bitmap subs. Canonicalize the codec before consulting the legacy maps.
  • Subtitle notifier matches by language/label, not row IDresolveTrack reverse-scans for a language/label match though both call sites hold the exact subtitleID and inventory items carry downloadedSubtitleID in-package; two same-language tracks make the event's SubtitleID and its Track block name different rows.
  • Stale-error burn-in rollback — the rollback effect (VideoPlayer.tsx:1791) keys on session.error, which only clears on successful adoption; the first burn-in selection after any earlier failed replan is silently reverted in the same commit.
  • Draft-v3 clients get a generic 400, not the 426 — the upgrade gate checks only protocol_version; a shipped draft-v3 body declares 3, passes, and dies at evidence validation with bad_request — no upgrade prompt, which is the exact outcome the 426 path exists to prevent. A draft-marker check (missing evidence fields) mapping to client_upgrade_required closes it.

Adversarially verified review: 5 additional candidates were refuted during verification (session-error conflation, attempt-key delimiter injection, transcode-node skew, manifest-wait-under-lock, and the passthrough narrowing — the last two being deliberate documented design). Review by Claude Code (claude-fable-5), 9 finder passes + per-finding verification against head 63247a3.

@Quick104

Quick104 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all 16 confirmed findings from the review at 63247a3 in 30ddc0d09255816e44cd9874ff79a785d8a6fd8e.

Resolution summary:

  • Web player replans now settle burn-in selections, preserve/retry rollback state, refresh subtitles at the live media position, and map progressive seeks through the plan stream origin.
  • Audiobooks now use the insecure-origin-safe UUID helper, account for stream anchors in time/buffer/seek math, reanchor non-global seeks, and explicitly declare client-owned durable progress.
  • Server planning now normalizes legacy audiobook/podcast cover-art video rows before route selection and schedules repair probes; audio-only remux excludes attached pictures with 0:V:0?.
  • Track changes stay pinned to the mounted alternate, omitted replan features merge from the durable start request, completed replans atomically advance idempotent start replay state, and replayed required arrays normalize to [].
  • Subtitle codec aliases share one canonical bitmap policy and realtime notifications match the exact downloaded subtitle row.
  • Draft-v3 requests now receive the documented 426, and a new Goose migration purges incompatible pre-neutral ephemeral attempts without altering the previously deployed migration.

The finalized neutral additions are progress_persistence: "server" | "client" (omission defaults to server; client requires explicit start_position, including zero) and server feature neutral_playback_v3_contract_v1. The generated corpus and schemas include both plus a typed draft-v3 426 vector; native clients should revendor all nine files under internal/playback/testdata/protocol_v3/ from this SHA.

Validation:

  • dev-builder exact-source production frontend + Go build
  • make test: all Go packages; 267 web test files / 1,837 tests
  • go test -race ./internal/playback/... ./internal/api/handlers
  • CI-pinned changed-line golangci-lint: 0 findings
  • web lint: 0 errors / 157 existing warnings; Prettier clean
  • fixtures/schema, Goose validate/apply/status, settings bindings, local-path hygiene, gofmt/vet all clean
  • isolated three-process sandbox doctor: API, frontend, database, proxy node, and transcode node healthy

PR remains draft as requested.

… audio planning

Server:
- The deferred replan lease release is bounded by a 3s timeout so a
  saturated pool or DB outage cannot wedge a handler goroutine that
  holds the per-session store lock on an uncancellable context.
- planAudioOnlyV3 honors the request bandwidth cap: an over-cap source
  skips the original_http direct route and converts to AAC with the
  same bandwidth_cap_applied warning and decision reason the video
  ladder uses. Unknown source bitrate never triggers the cap.
- A copy-audio progressive plan rejected only by a per-delivery
  audio_decode_codecs subset retries as an AAC conversion instead of
  returning adaptation_unavailable, and the AAC recipe respects the
  delivery's max_channels.

Web:
- failure_recovery replans issued while another replan is in flight
  queue (superseding a pending seek reanchor) instead of being
  silently dropped with the fatal overlay already suppressed.
- A terminal response to a fresh non-preserving start clears the
  previous plan and stops its session, so episode navigation cannot
  keep rendering the prior item under the new title.
- A refused recovery replan for a transport-dead plan surfaces the
  error and re-arms the plan failure key, so transient recovery
  failures no longer strand an endless spinner; the audiobook player
  gets the same guard reset.
- A track-less subtitle_translation_completed hands off to the
  refreshed persisted track once the inventory settles, clearing the
  live overlay, instead of pinning the synthetic live track forever.

Part of #135.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Quick104

Copy link
Copy Markdown
Contributor Author

Review-bot comment disposition (41 items)

All 39 inline comments plus the 2 outside-diff review-body items were verified against the PR head. Summary:

31 already addressed — the five commits 13c87af2267bf001 resolved essentially every earlier server finding (planner-derived decision_response.json fixture, pinned attempt-key expectation, subtitle-resolver error propagation, audio-only positive evidence, lease release/completion on all replan exits, jellycompat source facts, doc/behavior alignment on replay + omitted quality, per-delivery plan enforcement, audio-only remux video map, resume-position error propagation, subtitle HEAD fast paths, audio-preference error propagation, terminal lease completion, retryable-terminal persistence, lease owner tokens + migration, shared audio-only probe predicate, library-pref error propagation), and the earlier remediation commits covered the web WatchPage/quality/playback_session_not_found items.

9 valid → fixed in 63782d75:

  • Server: the deferred ReleaseReplan is now bounded by a 3s timeout (an unbounded uncancellable DB call could wedge a handler holding the session lock during a PG outage); planAudioOnlyV3 honors the bandwidth cap (over-cap sources convert to AAC with the standard bandwidth_cap_applied warning instead of direct-playing); a copy-audio progressive plan rejected only by a per-delivery codec subset retries as AAC conversion (respecting max_channels) instead of returning adaptation_unavailable.
  • Web: failure_recovery replans queue behind an in-flight replan (superseding a pending seek) instead of being silently dropped; a terminal on a fresh non-preserving start clears the previous plan and stops its session (episode navigation no longer renders the prior item under the new title); a refused recovery for a transport-dead plan surfaces the error and re-arms the failure guard (video + audiobook players); the track-less translation completion hands off to the refreshed persisted track instead of pinning the live overlay.

1 refuted — the NOT VALID suggestion on 20260808204902_allow_terminal_playback_v3_attempts.sql: the same ALTER TABLE drops a NOT NULL and adds a defaulted column, which takes ACCESS EXCLUSIVE regardless, the table holds only 24h-TTL ephemeral rows, and the next migration in the same deploy deletes every row the validation scan would touch. NOT VALID would change nothing here.

1 deferred — routing client-side sidecar subtitle swaps through track_change (VideoPlayer.tsx): the current behavior is deliberate protocol design (client-side swaps avoid a replan round trip) and changing it is a cross-client protocol decision affecting Apple/Android parity, not a this-PR fix.

Verification for 63782d75: go build/go vet, playback + handlers + planstore suites green, golangci-lint --new-from-merge-base 0 issues, fixtures current, gofmt clean; web 267 tests green, lint 0 errors, format check, production build. (The TestFFmpegSupportsNVENC* pair flakes under parallel load and passes in isolation — pre-existing behavior.)


🤖 Triage and verification by Claude (Fable 5); fixes drafted by OpenAI Codex (gpt-5.6-sol), reviewed and verified by Claude before commit. Generated with Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63782d7513

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/playback/plan_v3.go
Comment thread internal/api/handlers/stream.go Outdated
Comment thread internal/playback/plan_v3.go Outdated
Comment thread web/src/player/components/VideoPlayer.tsx Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

const nextError = describePlaybackSessionError(err, initialErrorMessage);
setState((current) => ({
...current,
loading: false,
replacing: false,

P1 Badge Clear the previous session after replacement start failures

When requestKey changes while a session is active and the new start throws—such as during a network outage or HTTP 5xx—allowPreserveExistingSessionOnError is false, but this catch path only sets an error and leaves the previous plan, stream URL, and session refs active. The page can therefore continue playing the previous item under the new item's metadata, and the newly assigned playback-attempt ID is now paired with the old plan, breaking subsequent recovery requests; stop and clear the previous session here as the terminal-decision path does.

AGENTS.md reference: AGENTS.md:L12-L14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/playback/plan_v3.go
Comment thread docs/architecture/playback-protocol-v3.md
Comment thread internal/api/handlers/playback.go
Comment thread internal/api/handlers/playback_v3.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

setState((current) => ({
...current,
loading: false,
replacing: false,
errorTitle: nextError.title,

P1 Badge Clear the old session after a failed replacement start

The terminal-decision path now clears the previous session, but when requestKey changes and the new start throws because of a network failure, 5xx, or malformed response, this catch only updates the error fields. Since allowPreserveExistingSessionOnError is false for that path, the old plan, streamUrl, and sessionId remain active while the page renders the newly selected item's metadata, and playbackAttemptIdRef already identifies the failed new attempt. Apply the same stop-and-clear behavior used for a non-adopted terminal response.

AGENTS.md reference: AGENTS.md:L12-L14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/api/handlers/playback_v3.go Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Quick104

Copy link
Copy Markdown
Contributor Author

Post-review closeout — 0bf85b9

All 48 inline review threads now have dispositions and are resolved.

This follow-up:

  • carries planned AAC channel and bitrate constraints through session state, executable recipes, recipe cards, tokens, local/remote transcode execution, proxy reconstruction, and progressive remux;
  • prevents original HTTP from claiming non-default audio selections, clamps HLS AAC to delivery max_channels, and enforces audio-only bandwidth caps in FFmpeg;
  • makes sidecar subtitle selection durable, preserves requested position when copy-HLS transport is reused, applies native-HLS player_start_seconds, and clears retired sessions after both terminal and thrown replacement starts;
  • restores provider start scrobbles, chapter-thumbnail scheduling, and lazy marker analysis after durable v3 start commit;
  • advertises header_refresh none for the stable session URLs, regenerates the authoritative decision fixture, and brings the normative transformation/terminal registry documentation back in sync.

Two review dispositions did not require source changes:

  • Exact video evidence with omitted profile/level bounds remains intentionally unconstrained. Concrete bounds still reject missing or ffprobe-unknown values; the normative text and regression now state both cases explicitly. This preserves the VP9 repair in 13c87af.
  • The lease-owner migration remains immutable. Its Down path assumes application rollback precedes schema rollback, and splitting the same add-column operation would not remove its schema-lock boundary on this short-lived lease table.

Validation:

  • changed-lines golangci-lint: 0 issues;
  • go vet ./... and go build ./...: pass;
  • focused Go playback/handler/transcode/proxy suites: pass;
  • focused web playback suites: 41/41 pass;
  • web lint: 0 errors; Prettier: pass;
  • generated playback fixtures and local-path hygiene: pass.
  • Full-suite comparison found only existing failures reproduced on untouched main: two environment-sensitive jellycompat process-lock tests locally and the stale NotificationsAdminSettings copy assertion locally/CI.

The body-only review items for reconstruction durability and failed replacement starts are included in 0bf85b9. The two migration nitpicks are covered by the disposition above.

@Quick104

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Head commit changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Quick104

Copy link
Copy Markdown
Contributor Author

Follow-up on the exact head 79e3e76:

  • CI caught and the follow-up fixed the native-HLS test fixture contract plus the retired plan-attempt ref type.
  • Local pnpm run build, focused player tests 41/41, and Prettier all pass after that correction.
  • GitHub Go, Docs hygiene, and CodeRabbit are green.
  • Web lint, format, typecheck/build, and 1,862/1,863 tests pass. Its sole failure is the unchanged NotificationsAdminSettings copy assertion at line 119, reproduced on untouched main.
  • Review threads remain 0 unresolved; the worktree and remote head match.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Silo v1 scope - auto-adds to the Silo v1 project

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Dev playback matrix: valid test files report missing and VP9 lacks route metadata

1 participant