Skip to content

fix(playback): trust the server's media runtime end to end - #482

Merged
Quick104 merged 3 commits into
mainfrom
fix/playback-duration-trust
Jul 26, 2026
Merged

fix(playback): trust the server's media runtime end to end#482
Quick104 merged 3 commits into
mainfrom
fix/playback-duration-trust

Conversation

@Quick104

@Quick104 Quick104 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

An Android user reported a 90-minute movie playing back as 0:37 / 1:01. Tracing it surfaced one root cause and two independent defects along the same path.

A wrong duration persists in the database. The scanner's plausibility rule only rejected video durations of 10 seconds or less (probe.go), so a feature film that probed as 61 seconds passed as "reasonable" and was stored. The repair layer reuses the same predicate, so such a row was never re-probed.

The v3 plan never carried a runtime. TimelineV3 describes where playback sits but never how long the media is, so clients fell back to the playback engine. On an HLS copy remux the server intentionally serves FFmpeg's still-growing playlist (BuildPlaybackManifest), so the engine reports the length produced so far. Android's grow-only ratchet is a correct defense, but with a wrong catalog value it had no floor to hold. The legacy protocol already answered this correctly via fileDurationSeconds — v3 regressed on it.

Two further defects found while verifying, both independent of the display bug:

  • Copy-mode seeking. The plan published seek_window_end_seconds as the media runtime. That makes the window look complete, which clients read as proof that any target inside it is locally seekable — so they native-seek past the produced head of a growing playlist instead of requesting a reanchor. Legacy published no window here and reanchored correctly; v3 added the bound that defeats the guard.
  • Web resume points. The player's exit state converts its position to media time but took the duration from the video element, which is player-local. Resuming a movie 50 minutes in gives a position of ~3060s against an element duration of ~120s, so position >= duration marked the item completed, latched the watched badge, and — because completion clears the resume point — reset position to 0. Exiting a resumed movie destroyed the resume point and claimed it had been watched.

Approach

Fix the data first. Size and duration together pin an implied bitrate, which separates the two cases the absolute floor conflates: a genuine short clip has an ordinary bitrate, a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling sits far above any real medium (UHD Blu-ray peaks near 150 Mbps), so legitimate content cannot trip it — and unlike the absolute floor it does not false-positive on a genuine high-bitrate short. The repair-rule revision marker is bumped so rows judged by the previous rule re-converge; without that an improved rule never reaches the rows it was written for.

Then make the contract self-sufficient. source.duration_seconds is added to the v3 plan — a fact about the media file, not a claim about the transport. It lands on SourceDescriptorV3 rather than TimelineV3 for two reasons: SourceDescriptorFromFileV3 is the single site every delivery already flows through (timeline finalization is scattered across four sites, and original_http has none — which is exactly why seek_window_end_seconds is populated on 1 of 4 deliveries today), and the descriptor carries media_file_id, so the value is self-labelling when the server resolves a different effective file.

It is omitted, not null, when unknown. Android's SiloJson sets coerceInputValues, so an explicit null against a non-nullable field becomes 0.0 — the exact value this field exists to stop clients inventing.

Considered and rejected: a manifest_completeness enum. It is time-varying data in an immutable document — FFmpeg writes EXT-X-ENDLIST on exit, and decisionResponseFromAttemptV3 replays the stored plan verbatim on idempotent retry, so a frozen "growing" becomes a lie. It is also derivable from delivery + can_seek_anywhere. Once the runtime is authoritative the client never trusts the engine's duration, so it never needs to branch.

Also deliberately not done: making duration genuinely "unknown" client-side. All three clients collapse unknown to 0, which would kill D-pad scrubbing on Android TV, shrink the phone seek bar to a 1-second slider, and make a web seek-bar click jump to the start. Clients keep treating 0 as unknown internally.

Scope

Part of #431.

Server-side only. The client halves are separate PRs in silo-android and silo-apple (branch fix/playback-source-duration in both), each consuming source.duration_seconds. This PR is independently correct and safe to merge first — it is purely additive on the wire, and older clients ignore the new field.

Risks

  • seek_window_end_seconds becomes nil for copy remuxes. Any client using it as a duration source would lose that value — none do; Android consumes it only in decideSeek. This is why the runtime field ships in the same change.
  • The bitrate ceiling is deliberately generous (1 Gbps). It catches the reported class with ~13x margin but will not catch a small file with a moderately wrong duration. Strictly better than today, and conservative by design.
  • Bumping the repair marker re-probes rows probed since 2026-07-18 once. One-time cost; the marker then protects them again.

Verification

$ make verify-local-paths
scripts/check-local-path-leaks.sh
$ echo $?
0
$ golangci-lint run | grep -E "scanner/probe|playback/protocol_v3|playback/capabilities_v3|handlers/playback_v3\.go"
internal/api/handlers/playback_v3.go:1752:11: ST1005: error strings should not end with punctuation or newlines (staticcheck)
internal/api/handlers/playback_v3.go:1775:11: ST1005: error strings should not end with punctuation or newlines (staticcheck)
internal/api/handlers/playback_v3.go:1781:10: ST1005: error strings should not end with punctuation or newlines (staticcheck)

All three reproduce at 10394b0a (main) at identical line numbers and are untouched by this diff. Zero findings attributable to these changes.

$ cd web && pnpm run lint
✖ 158 problems (0 errors, 158 warnings)

$ pnpm run format:check
Checking formatting...
All matched files use Prettier code style!

$ pnpm exec tsc --noEmit -p tsconfig.app.json
(no output)

$ pnpm exec vitest run
 Test Files  2 failed | 224 passed (226)
      Tests  6 failed | 1338 passed (1344)

The 6 web failures are in SeasonContent.test.tsx and ServerStorageStep.test.tsx and reproduce identically with this branch stashed. Unrelated to the player.

$ go test -count=1 ./internal/scanner/... ./internal/playback/... ./internal/api/handlers/...
ok      github.com/Silo-Server/silo-server/internal/scanner      0.204s
--- FAIL: TestServeDirectPlayChangedEntityRejectsOldIfRange (0.00s)
FAIL    github.com/Silo-Server/silo-server/internal/playback     5.839s
ok      github.com/Silo-Server/silo-server/internal/playback/planstore   0.012s
--- FAIL: TestRemoveJellyfinCompatWebDisablesWebSetting (0.00s)
--- FAIL: TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion (0.00s)
FAIL    github.com/Silo-Server/silo-server/internal/api/handlers 23.347s

Failures were checked against a baseline captured before these changes; no new failures. All three are pre-existing:

Test Status
TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion Fails at unmodified main. Consistent, pre-existing.
TestServeDirectPlayChangedEntityRejectsOldIfRange Flaky at unmodified main (1 failure in 4 runs). Rewrites a same-size file and relies on coarse-grained Linux ctime changing between two writes in the same tick.
TestRemoveJellyfinCompatWebDisablesWebSetting Flaky: 1 of 3 full-suite runs, 3/3 pass in isolation.

New tests added: 3 scanner cases (impossible implied bitrate, genuine short clip preserved, table-driven predicate), 1 updated HLS timeline assertion, 6 web mediaTimeline cases.

AI Disclosure

  • Tool(s): Claude Code
  • Model(s): claude-opus-5[1m]
  • Involvement: fully AI-generated
  • Adversarial review: Three independent reviews were run against the proposed design (two Claude subagents, one Codex pass) before any code was written. They changed the design materially in four ways. (1) Field placement moved from TimelineV3 to SourceDescriptorV3 — the timeline's fields are all positions in named clocks, so an unqualified duration there has no defined clock, and timeline finalization is scattered across four sites while the descriptor has one. (2) The proposed manifest_completeness enum was dropped as time-varying data frozen into a replayed immutable document, and redundant with delivery + can_seek_anywhere. (3) The proposed client rule "represent unknown duration" was withdrawn after review showed all three clients collapse unknown to 0, which would break Android TV scrubbing and make a web seek-bar click jump to the start — worse than the bug being fixed. (4) Review surfaced the copy-mode seek-window bug and the web resume-point bug, neither of which was in the original report; both are fixed here. One review claim was checked and corrected: it cited the legacy canSeekAnywhere at playback.go:529 for a v3 conclusion; the v3 equivalent is playback_v3.go:2021, same conclusion. A single test failure that appeared once in final verification was traced rather than dismissed — the predicate returns false for that fixture's exact values (2,341 bps against a 1,000,000,000 bps ceiling), proving the change is behaviourally identical there.

Follow-ups (not in this PR)

  • Both native clients write duration: 0 with force_overwrite: true on stop, and the server unconditionally clobbers a good stored duration with it (progress.go:97pgstore/progress.go:127). Live watch-state corruption path, independent of this work.
  • The web completion rule uses 100% where the server uses 90% (userstore/threshold.go).
  • The three flaky/failing tests above.
  • timing_origin_seconds is computed and sent by the server but read by no client; on a re-anchored copy stream every sidecar subtitle cue is offset by the anchor.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added source runtime information to playback metadata when available.
    • Improved player exit state duration reporting for more reliable resume behavior.
    • Advertised support for source duration metadata in the playback protocol.
  • Bug Fixes

    • Kept copy-mode HLS seek windows open-ended when the endpoint is unknown.
    • Improved detection and repair of invalid media durations, including suspicious bitrate reports.

Quick104 and others added 3 commits July 26, 2026 01:54
The duration-plausibility rule only rejected videos of 10 seconds or less,
so a feature film that probed as 61 seconds passed untouched and persisted.
Clients then had nothing trustworthy to anchor on: Android's grow-only
duration ratchet has no floor to hold when the catalog value is wrong, so
the playback engine's growing-HLS-window duration won and a 90-minute movie
displayed as ~1 minute.

Size and duration together pin an implied bitrate, which separates the two
cases the absolute floor conflates. A genuine short clip has an ordinary
bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling
sits far above any real medium, so legitimate content cannot trip it — and
unlike the absolute floor, it does not false-positive on a genuine
high-bitrate short.

Also bump the repair-rule revision marker so rows judged by the previous,
weaker rule are re-checked once under this one. Without that bump an
improved rule never reaches the rows it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… copy seek window

Two defects with one root: a v3 plan described where playback sits without
ever stating how long the media is.

Add source.duration_seconds. It is the file's full runtime, never
`total - source_start` and never adjusted by timeline_offset_seconds, and it
is omitted rather than null when unknown — clients that coerce null to a
numeric default would read it as zero, the exact value this field exists to
stop them inventing. It is set in SourceDescriptorFromFileV3, the single
place every delivery already flows through, so direct play, progressive
remux, HLS remux and HLS transcode all carry it.

Until now the v3 plan omitted duration entirely, so clients fell back to the
playback engine. On an HLS copy remux the server intentionally serves
FFmpeg's still-growing playlist, so the engine reports the length produced
so far. With no server-supplied runtime to anchor on, a feature film played
back as a couple of minutes. The legacy protocol already answered this
correctly via fileDurationSeconds; this restores parity.

Separately, the copy branch published seek_window_end_seconds as the media
runtime. That made the window look *complete*, which clients read as proof
that any target inside it is locally seekable, so they native-seek past the
produced head of a growing playlist instead of asking for a reanchor. Leave
the end open: an incomplete window plus can_seek_anywhere=false routes every
seek through the server, which is what legacy did before v3 added the bound.

Advertise plan_source_duration_v1 so a client can distinguish "this server
does not populate the field" from "this server knows the runtime is
genuinely unknown" — without it, both look like an absent field and a client
cannot tell whether its own catalog fallback is still required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent duration

The player's exit state converts its position to media time but took the
duration from the video element, which is player-local. On a remux or
transcode stream the element only covers the window produced so far, so the
two values live in different coordinate systems.

Resuming a movie 50 minutes in makes that concrete: the exit position is
~3060s of media time while the element reports ~120s. The progress cache
then evaluates `position >= duration`, marks the item completed, latches the
watched badge, and — because completion clears the resume point — resets
position to 0. Exiting a resumed movie destroyed the resume point and
claimed it had been watched.

The server's runtime is authoritative and already expressed in media time,
so prefer it and fall back to the element only when no server value exists.
The rule moves into mediaTimeline.ts next to the coordinate conversions it
depends on, which is also what makes it testable — VideoPlayer itself has no
test harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Playback v3 now exposes validated source runtimes, keeps copy-remux seek windows open-ended, and selects authoritative player exit durations. Probe duration validation adds bitrate-based plausibility checks and applies them to legacy repair decisions.

Changes

Playback duration semantics

Layer / File(s) Summary
Protocol source duration contract
internal/playback/protocol_v3.go, internal/playback/capabilities_v3.go, internal/playback/protocol_v3_test.go
Protocol v3 advertises plan_source_duration_v1 and optionally serializes positive media-file runtimes as source.duration_seconds.
Seek-window and player duration semantics
internal/api/handlers/playback_v3.go, internal/api/handlers/playback_v3_test.go, web/src/player/utils/mediaTimeline.ts, web/src/player/utils/mediaTimeline.test.ts, web/src/player/components/VideoPlayer.tsx
Copy remux plans leave the seek-window end unset, while player exit state prefers backend runtime duration and falls back to valid element duration.

Probe duration validation

Layer / File(s) Summary
Bitrate-aware duration plausibility
internal/scanner/probe.go
Duration validation now rejects short video durations when file size implies an excessive bitrate, while retaining short-duration and minimum-size checks.
Repair integration and coverage
internal/scanner/probe_repair.go, internal/scanner/probe_duration_test.go
Legacy repair uses the revised plausibility predicate and tests cover impossible probes, genuine short clips, and supported input combinations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: v1

Suggested reviewers: rxwatcher

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: using the server-provided media runtime throughout playback and resume handling.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/playback-duration-trust

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

@coderabbitai coderabbitai Bot added the v1 Silo v1 scope - auto-adds to the Silo v1 project label Jul 26, 2026

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
internal/scanner/probe_duration_test.go (1)

290-305: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a boundary case for the short/min-bytes threshold.

No case tests duration == 10 (i.e., implausiblyShortVideoMaxSeconds) combined with size == 100MiB exactly, to pin down the inclusive <=/>= boundary behavior of the new rule.

✅ Suggested boundary case
 		{name: "unknown duration is not this rule's job", duration: 0, size: 100 * gib, want: false, hasVideo: true},
+		{name: "exact short/min-bytes boundary", duration: 10, size: 100 * 1024 * 1024, want: true, hasVideo: true},
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/scanner/probe_duration_test.go` around lines 290 - 305, Add a
boundary test case to the duration probe table in the relevant test function,
using duration 10 seconds and size exactly 100MiB, and set its expected result
to match the rule’s inclusive <= and >= thresholds. Keep the case as a video
input and preserve the existing scenarios.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/scanner/probe_duration_test.go`:
- Around line 290-305: Add a boundary test case to the duration probe table in
the relevant test function, using duration 10 seconds and size exactly 100MiB,
and set its expected result to match the rule’s inclusive <= and >= thresholds.
Keep the case as a video input and preserve the existing scenarios.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4fea1df4-353d-4614-8557-2efa170290d9

📥 Commits

Reviewing files that changed from the base of the PR and between 10394b0 and ebec414.

📒 Files selected for processing (11)
  • internal/api/handlers/playback_v3.go
  • internal/api/handlers/playback_v3_test.go
  • internal/playback/capabilities_v3.go
  • internal/playback/protocol_v3.go
  • internal/playback/protocol_v3_test.go
  • internal/scanner/probe.go
  • internal/scanner/probe_duration_test.go
  • internal/scanner/probe_repair.go
  • web/src/player/components/VideoPlayer.tsx
  • web/src/player/utils/mediaTimeline.test.ts
  • web/src/player/utils/mediaTimeline.ts

@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: ebec4141a8

ℹ️ 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".

// re-converge on the improved rule. Last bumped when the implied-bitrate
// ceiling was added, which catches durations the absolute floor missed —
// a feature film probing as 61 seconds passed the old rule untouched.
var legacyProbeDurationFixTime = time.Date(2026, time.July, 26, 0, 0, 0, 0, time.UTC)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Replace the pre-deployment repair cutoff

Any server running the previous binary after 2026-07-26T00:00:00Z can persist a duration that passes the old rule but fails the new implied-bitrate rule; after upgrading, its ProbeUpdatedAt is not before this cutoff, so legacyDurationRepairNeeded returns false and the stable-file scanner never repairs it. Since this commit itself was created after the cutoff and deployments may happen much later or roll gradually, use a persisted probe-rule revision (or another deployment-safe marker) rather than the start of the authoring day.

Useful? React with 👍 / 👎.

Comment thread internal/scanner/probe.go
if durationSeconds <= implausiblyShortVideoMaxSeconds && sizeBytes >= implausiblyShortVideoMinBytes {
return true
}
return impliedBitrateBps(sizeBytes, durationSeconds) > implausibleVideoBitrateBps

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow valid media above the bitrate heuristic

For legitimate sources whose aggregate bitrate exceeds 1 Gbps, this rejects an otherwise correct ffprobe duration; an 8K/4320p ProRes 4444 XQ source can exceed this threshold even though the repository explicitly handles 4320p media. ProbeFile consequently falls back to enumerating every video packet in what can be a hundreds-of-gigabytes file, blocking normal scans and potentially exhausting the playback repair's one-minute timeout. Use a codec/resolution-aware bound or a narrower malformed-timestamp signature rather than treating every source above this fixed ceiling as impossible.

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

Useful? React with 👍 / 👎.

@Quick104
Quick104 merged commit 02203d9 into main Jul 26, 2026
5 checks passed
@Quick104
Quick104 deleted the fix/playback-duration-trust branch July 26, 2026 04:12
@github-project-automation github-project-automation Bot moved this to Done in Silo v1 Jul 26, 2026
Pukabyte pushed a commit to Pukabyte/silo-server that referenced this pull request Jul 26, 2026
…er#482)

* fix(scanner): reject durations that imply an impossible bitrate

The duration-plausibility rule only rejected videos of 10 seconds or less,
so a feature film that probed as 61 seconds passed untouched and persisted.
Clients then had nothing trustworthy to anchor on: Android's grow-only
duration ratchet has no floor to hold when the catalog value is wrong, so
the playback engine's growing-HLS-window duration won and a 90-minute movie
displayed as ~1 minute.

Size and duration together pin an implied bitrate, which separates the two
cases the absolute floor conflates. A genuine short clip has an ordinary
bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling
sits far above any real medium, so legitimate content cannot trip it — and
unlike the absolute floor, it does not false-positive on a genuine
high-bitrate short.

Also bump the repair-rule revision marker so rows judged by the previous,
weaker rule are re-checked once under this one. Without that bump an
improved rule never reaches the rows it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(playback): publish source runtime in v3 plans and stop faking the copy seek window

Two defects with one root: a v3 plan described where playback sits without
ever stating how long the media is.

Add source.duration_seconds. It is the file's full runtime, never
`total - source_start` and never adjusted by timeline_offset_seconds, and it
is omitted rather than null when unknown — clients that coerce null to a
numeric default would read it as zero, the exact value this field exists to
stop them inventing. It is set in SourceDescriptorFromFileV3, the single
place every delivery already flows through, so direct play, progressive
remux, HLS remux and HLS transcode all carry it.

Until now the v3 plan omitted duration entirely, so clients fell back to the
playback engine. On an HLS copy remux the server intentionally serves
FFmpeg's still-growing playlist, so the engine reports the length produced
so far. With no server-supplied runtime to anchor on, a feature film played
back as a couple of minutes. The legacy protocol already answered this
correctly via fileDurationSeconds; this restores parity.

Separately, the copy branch published seek_window_end_seconds as the media
runtime. That made the window look *complete*, which clients read as proof
that any target inside it is locally seekable, so they native-seek past the
produced head of a growing playlist instead of asking for a reanchor. Leave
the end open: an incomplete window plus can_seek_anywhere=false routes every
seek through the server, which is what legacy did before v3 added the bound.

Advertise plan_source_duration_v1 so a client can distinguish "this server
does not populate the field" from "this server knows the runtime is
genuinely unknown" — without it, both look like an absent field and a client
cannot tell whether its own catalog fallback is still required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(web): pair the exit position with the media runtime, not the element duration

The player's exit state converts its position to media time but took the
duration from the video element, which is player-local. On a remux or
transcode stream the element only covers the window produced so far, so the
two values live in different coordinate systems.

Resuming a movie 50 minutes in makes that concrete: the exit position is
~3060s of media time while the element reports ~120s. The progress cache
then evaluates `position >= duration`, marks the item completed, latches the
watched badge, and — because completion clears the resume point — resets
position to 0. Exiting a resumed movie destroyed the resume point and
claimed it had been watched.

The server's runtime is authoritative and already expressed in media time,
so prefer it and fall back to the element only when no server value exists.
The rule moves into mediaTimeline.ts next to the coordinate conversions it
depends on, which is also what makes it testable — VideoPlayer itself has no
test harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cursor Bot pushed a commit to Prairie-Server/prairie-server that referenced this pull request Jul 26, 2026
…er#482)

* fix(scanner): reject durations that imply an impossible bitrate

The duration-plausibility rule only rejected videos of 10 seconds or less,
so a feature film that probed as 61 seconds passed untouched and persisted.
Clients then had nothing trustworthy to anchor on: Android's grow-only
duration ratchet has no floor to hold when the catalog value is wrong, so
the playback engine's growing-HLS-window duration won and a 90-minute movie
displayed as ~1 minute.

Size and duration together pin an implied bitrate, which separates the two
cases the absolute floor conflates. A genuine short clip has an ordinary
bitrate; a 100 GB file claiming 61 seconds implies ~13 Gbps. The ceiling
sits far above any real medium, so legitimate content cannot trip it — and
unlike the absolute floor, it does not false-positive on a genuine
high-bitrate short.

Also bump the repair-rule revision marker so rows judged by the previous,
weaker rule are re-checked once under this one. Without that bump an
improved rule never reaches the rows it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(playback): publish source runtime in v3 plans and stop faking the copy seek window

Two defects with one root: a v3 plan described where playback sits without
ever stating how long the media is.

Add source.duration_seconds. It is the file's full runtime, never
`total - source_start` and never adjusted by timeline_offset_seconds, and it
is omitted rather than null when unknown — clients that coerce null to a
numeric default would read it as zero, the exact value this field exists to
stop them inventing. It is set in SourceDescriptorFromFileV3, the single
place every delivery already flows through, so direct play, progressive
remux, HLS remux and HLS transcode all carry it.

Until now the v3 plan omitted duration entirely, so clients fell back to the
playback engine. On an HLS copy remux the server intentionally serves
FFmpeg's still-growing playlist, so the engine reports the length produced
so far. With no server-supplied runtime to anchor on, a feature film played
back as a couple of minutes. The legacy protocol already answered this
correctly via fileDurationSeconds; this restores parity.

Separately, the copy branch published seek_window_end_seconds as the media
runtime. That made the window look *complete*, which clients read as proof
that any target inside it is locally seekable, so they native-seek past the
produced head of a growing playlist instead of asking for a reanchor. Leave
the end open: an incomplete window plus can_seek_anywhere=false routes every
seek through the server, which is what legacy did before v3 added the bound.

Advertise plan_source_duration_v1 so a client can distinguish "this server
does not populate the field" from "this server knows the runtime is
genuinely unknown" — without it, both look like an absent field and a client
cannot tell whether its own catalog fallback is still required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(web): pair the exit position with the media runtime, not the element duration

The player's exit state converts its position to media time but took the
duration from the video element, which is player-local. On a remux or
transcode stream the element only covers the window produced so far, so the
two values live in different coordinate systems.

Resuming a movie 50 minutes in makes that concrete: the exit position is
~3060s of media time while the element reports ~120s. The progress cache
then evaluates `position >= duration`, marks the item completed, latches the
watched badge, and — because completion clears the resume point — resets
position to 0. Exiting a resumed movie destroyed the resume point and
claimed it had been watched.

The server's runtime is authoritative and already expressed in media time,
so prefer it and fall back to the element only when no server value exists.
The rule moves into mediaTimeline.ts next to the coordinate conversions it
depends on, which is also what makes it testable — VideoPlayer itself has no
test harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant