Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions internal/api/handlers/playback_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -2004,12 +2004,19 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
plan.Timeline.TimelineOffsetSeconds = seek
windowStart := seek
plan.Timeline.SeekWindowStartSeconds = &windowStart
if durationSeconds > 0 {
windowEnd := durationSeconds
plan.Timeline.SeekWindowEndSeconds = &windowEnd
} else {
plan.Timeline.SeekWindowEndSeconds = nil
}
// A copy remux is served from FFmpeg's live, still-growing playlist
// (see BuildPlaybackManifest), so the seekable extent is whatever has
// been produced so far — a value this plan cannot know and could not
// keep current if it did. Publishing the media runtime here instead
// made the window look *complete*, which clients read as proof that
// any target inside it is locally seekable; they then native-seek past
// the produced head instead of asking for a reanchor. Leaving the end
// open marks the window incomplete, which with can_seek_anywhere=false
// routes every seek back through the server.
//
// The media runtime is published on source.duration_seconds, which is
// a fact about the file rather than a claim about this transport.
plan.Timeline.SeekWindowEndSeconds = nil
plan.Timeline.CanSeekAnywhere = false
plan.Timeline.SeekRestoration = "source_position"
} else {
Expand Down
7 changes: 6 additions & 1 deletion internal/api/handlers/playback_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1195,11 +1195,16 @@ func TestHandleStartPlaybackLegacyBranchPreservesTrailingBodyBehavior(t *testing
}

func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
// A copy remux streams FFmpeg's growing playlist, so its seek window must
// stay open-ended even though the runtime is known. A bounded window reads
// as "complete", which clients treat as proof that any target inside it is
// locally seekable — sending them past the produced head instead of back
// to the server. The runtime belongs on source.duration_seconds.
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
copyPlan.Timeline.SeekWindowEndSeconds == nil || *copyPlan.Timeline.SeekWindowEndSeconds != 600 ||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
copyPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
}
Expand Down
13 changes: 13 additions & 0 deletions internal/playback/capabilities_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,25 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)

// SourceDurationSecondsV3 reports a media file's runtime, or nil when it is
// unknown. models.MediaFile.Duration stores 0 for "probe failed", so a zero
// must never reach a client as a duration. This mirrors the legacy start
// response's fileDurationSeconds so both protocols answer identically.
func SourceDurationSecondsV3(file *models.MediaFile) *float64 {
if file == nil || file.Duration <= 0 {
return nil
}
duration := float64(file.Duration)
return &duration
}

func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDescriptorV3 {
if file == nil {
return SourceDescriptorV3{DVEnhancementLayer: EnhancementUnknownV3}
}
source := SourceDescriptorV3{
MediaFileID: file.ID,
DurationSeconds: SourceDurationSecondsV3(file),
Container: normalizeCodecV3(file.Container),
VideoCodec: normalizeCodecV3(file.CodecVideo),
AudioCodec: normalizeCodecV3(file.CodecAudio),
Expand Down
20 changes: 19 additions & 1 deletion internal/playback/protocol_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const (
FeatureDeviceQuirksV3 = "device_quirks_v1"
FeatureSeekReanchorV3 = "seek_reanchor_v1"
FeatureDirectStreamResumeV3 = "direct_stream_resume_v1"
FeaturePlanSourceDurationV3 = "plan_source_duration_v1"
PlanRecipeVersionV3 = "v3.2"
ClientDV7ToDV81V3 = "client_dv7_to_dv81"
ClientDV7ToHDR10V3 = "client_dv7_to_hdr10"
Expand All @@ -44,6 +45,12 @@ func ServerFeaturesV3() []string {
FeatureDeviceQuirksV3,
FeatureSeekReanchorV3,
FeatureDirectStreamResumeV3,
// Advertised so a client can tell "this server does not populate
// source.duration_seconds" apart from "this server knows the runtime
// is genuinely unknown". Without the distinction both look like an
// absent field, and a client cannot decide whether its own catalog
// fallback is still required.
FeaturePlanSourceDurationV3,
}
}

Expand Down Expand Up @@ -423,7 +430,18 @@ type EffectiveRecipeV3 struct {
}

type SourceDescriptorV3 struct {
MediaFileID int `json:"media_file_id"`
MediaFileID int `json:"media_file_id"`
// DurationSeconds is the full runtime of this source, independent of where
// the delivery's timeline is anchored: never `total - source_start`, and
// never adjusted by timeline_offset_seconds.
//
// Absent means the server does not know the runtime. It is omitted rather
// than sent as null: clients that coerce null to a numeric default would
// read it as zero, which is the value this field exists to stop them
// inventing. A client must not substitute the playback engine's reported
// duration for it — on an HLS copy remux the engine reports the length
// produced so far, not the runtime.
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
Container string `json:"container,omitempty"`
VideoCodec string `json:"video_codec,omitempty"`
VideoProfile string `json:"video_profile,omitempty"`
Expand Down
1 change: 1 addition & 0 deletions internal/playback/protocol_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestServerFeaturesV3ReturnsCompleteIndependentSlices(t *testing.T) {
FeatureDeviceQuirksV3: {},
FeatureSeekReanchorV3: {},
FeatureDirectStreamResumeV3: {},
FeaturePlanSourceDurationV3: {},
}
if len(first) != len(expected) {
t.Fatalf("server features = %v, want %d entries", first, len(expected))
Expand Down
56 changes: 41 additions & 15 deletions internal/scanner/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,20 +272,46 @@ const (
maxReasonableAudioDurationSeconds = 1_000_000
)

// A large video file whose derived duration is only a few seconds is the
// signature of malformed container timestamps (and of the legacy probe that
// divided large durations by one million). The shape is shared with the
// repair triggers in probe_repair.go and scanner.go so the probe parser and
// the repair layers cannot drift apart.
// A video duration is implausible when it is either far too short in absolute
// terms, or when it implies a bitrate no real medium reaches. Both are
// signatures of malformed container timestamps (and of the legacy probe that
// divided large durations by one million).
//
// The absolute rule alone cannot catch a feature film that probed as, say, 61
// seconds — well past the floor, yet still wrong by two orders of magnitude.
// Size and duration together pin an implied bitrate, which separates the two
// cases the absolute rule conflates: a genuine short clip has an ordinary
// bitrate, while 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 and ProRes 4444 XQ at 4K near 500 Mbps — so legitimate content
// cannot trip it. This also makes the rule safer than the absolute floor
// alone, which false-positives on a genuine high-bitrate short.
//
// The shape is shared with the repair triggers in probe_repair.go and
// scanner.go so the probe parser and the repair layers cannot drift apart.
const (
implausiblyShortVideoMaxSeconds = 10
implausiblyShortVideoMinBytes = 100 * 1024 * 1024
implausibleVideoBitrateBps = 1_000_000_000
)

func videoDurationImplausiblyShort(durationSeconds float64, sizeBytes int64, hasVideo bool) bool {
return hasVideo &&
durationSeconds > 0 && durationSeconds <= implausiblyShortVideoMaxSeconds &&
sizeBytes >= implausiblyShortVideoMinBytes
func videoDurationImplausible(durationSeconds float64, sizeBytes int64, hasVideo bool) bool {
if !hasVideo || durationSeconds <= 0 || sizeBytes <= 0 {
return false
}
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 👍 / 👎.

}

// impliedBitrateBps is the bitrate a file's size and duration imply. Callers
// use it as a duration-sanity signal, not as a real bitrate estimate: it
// counts container overhead and every stream, which is precisely what makes it
// a conservative upper bound.
func impliedBitrateBps(sizeBytes int64, durationSeconds float64) float64 {
return float64(sizeBytes) * 8 / durationSeconds
}

func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
Expand All @@ -298,7 +324,7 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
durationIsPositiveFinite(formatDuration) && formatDuration <= maxReasonableAudioDurationSeconds {
return truncatedDuration(formatDuration), true
}
if durationIsReasonable(formatDuration) && !durationLooksImplausiblyShort(raw, formatDuration) {
if durationIsReasonable(formatDuration) && !durationLooksImplausible(raw, formatDuration) {
return truncatedDuration(formatDuration), true
}

Expand All @@ -307,28 +333,28 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
continue
}
streamDuration := parseFloat(stream.Duration)
if durationIsReasonable(streamDuration) && !durationLooksImplausiblyShort(raw, streamDuration) {
if durationIsReasonable(streamDuration) && !durationLooksImplausible(raw, streamDuration) {
return truncatedDuration(streamDuration), true
}
duration := durationAfterStart(streamDuration, parseFloat(stream.StartTime))
if duration > 0 && !durationLooksImplausiblyShort(raw, duration) {
if duration > 0 && !durationLooksImplausible(raw, duration) {
return truncatedDuration(duration), true
}
}

duration := durationAfterStart(formatDuration, parseFloat(raw.Format.StartTime))
if duration > 0 && !durationLooksImplausiblyShort(raw, duration) {
if duration > 0 && !durationLooksImplausible(raw, duration) {
return truncatedDuration(duration), true
}
return 0, false
}

func durationLooksImplausiblyShort(raw *ffprobeOutput, duration float64) bool {
func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool {
if raw == nil {
return false
}
size := int64(parseFloat(raw.Format.Size))
return videoDurationImplausiblyShort(duration, size, hasVideoStream(raw.Streams))
return videoDurationImplausible(duration, size, hasVideoStream(raw.Streams))
}

func durationAfterStart(end, start float64) float64 {
Expand Down
77 changes: 77 additions & 0 deletions internal/scanner/probe_duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,80 @@ func TestDurationFromProbeMetadataRejectsCollapsedTimestampSpanForLargeVideo(t *
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}

// A feature film that probes far short of its real runtime clears the absolute
// floor but implies an impossible bitrate. This is the case that reached
// clients as a 90-minute movie displayed as ~1 minute.
func TestDurationFromProbeMetadataRejectsImpossibleImpliedBitrate(t *testing.T) {
t.Parallel()

raw := &ffprobeOutput{
Format: ffprobeFormat{
Duration: "61.000000",
Size: "107374182400", // 100 GiB => ~14 Gbps at 61s
},
Streams: []ffprobeStream{{
CodecType: "video",
AvgFrameRate: "24/1",
}},
}

got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}

// The implied-bitrate rule must not reject genuinely short clips. A 30-second
// 4K clip at 100 MiB implies ~28 Mbps, which is ordinary.
func TestDurationFromProbeMetadataKeepsGenuineShortHighBitrateClip(t *testing.T) {
t.Parallel()

raw := &ffprobeOutput{
Format: ffprobeFormat{
Duration: "30.000000",
Size: "104857600",
},
Streams: []ffprobeStream{{
CodecType: "video",
AvgFrameRate: "60/1",
}},
}

got, ok := durationFromProbeMetadata(raw)
if !ok || got != 30 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 30, true", got, ok)
}
}

func TestVideoDurationImplausible(t *testing.T) {
t.Parallel()

const gib = int64(1024 * 1024 * 1024)
tests := []struct {
name string
duration float64
size int64
hasVideo bool
want bool
}{
{name: "feature film probed as one minute", duration: 61, size: 100 * gib, want: true, hasVideo: true},
{name: "legacy microsecond collapse", duration: 3, size: 2 * gib, want: true, hasVideo: true},
{name: "genuine short clip", duration: 30, size: 100 * 1024 * 1024, want: false, hasVideo: true},
{name: "ordinary feature film", duration: 5400, size: 8 * gib, want: false, hasVideo: true},
{name: "uhd remux at full runtime", duration: 7200, size: 80 * gib, want: false, hasVideo: true},
{name: "audio only is never flagged", duration: 1, size: 100 * gib, want: false, hasVideo: false},
{name: "unknown size cannot be judged", duration: 61, size: 0, want: false, hasVideo: true},
{name: "unknown duration is not this rule's job", duration: 0, size: 100 * gib, want: false, hasVideo: true},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := videoDurationImplausible(tc.duration, tc.size, tc.hasVideo); got != tc.want {
t.Fatalf("videoDurationImplausible(%v, %d, %v) = %v; want %v",
tc.duration, tc.size, tc.hasVideo, got, tc.want)
}
})
}
}
23 changes: 14 additions & 9 deletions internal/scanner/probe_repair.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,21 @@ func reprobeMayScanPackets(file *models.MediaFile) bool {
return false
}
return file.Duration <= 0 ||
videoDurationImplausiblyShort(float64(file.Duration), file.FileSize, true)
videoDurationImplausible(float64(file.Duration), file.FileSize, true)
}

// legacyProbeDurationFixTime is when the probe duration parser stopped
// treating large ffprobe durations as microseconds. Rows probed before this
// may carry the collapsed durations that conversion produced. Rows probed
// after it are authoritative: a still-short duration was re-derived from
// packet timestamps, and re-flagging it would reprobe genuinely short clips
// on every playback decision forever. Adjust if this fix ships later.
var legacyProbeDurationFixTime = time.Date(2026, time.July, 18, 0, 0, 0, 0, time.UTC)
// legacyProbeDurationFixTime marks the revision of the duration-validity rule
// in probe.go. Rows probed before it were judged by an older, weaker rule and
// are re-checked once under the current one. Rows probed after it are
// authoritative: their duration already passed the current rule, and
// re-flagging them would reprobe genuinely short clips on every playback
// decision forever.
//
// Bump this whenever videoDurationImplausible changes, or existing rows never
// 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 👍 / 👎.


func needsLegacyDurationRepair(file *models.MediaFile) bool {
if file == nil {
Expand All @@ -144,7 +149,7 @@ func needsLegacyDurationRepair(file *models.MediaFile) bool {
}

func legacyDurationRepairNeeded(duration int, sizeBytes int64, hasVideo bool, probeUpdatedAt *time.Time) bool {
if !videoDurationImplausiblyShort(float64(duration), sizeBytes, hasVideo) {
if !videoDurationImplausible(float64(duration), sizeBytes, hasVideo) {
return false
}
return probeUpdatedAt == nil || probeUpdatedAt.Before(legacyProbeDurationFixTime)
Expand Down
10 changes: 3 additions & 7 deletions web/src/player/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import type {
SeriesContext,
SubtitleMode,
} from "../types";
import { toMediaTime, toPlayerTime } from "../utils/mediaTimeline";
import { mediaDurationSeconds, toMediaTime, toPlayerTime } from "../utils/mediaTimeline";
import {
copyWatchTogetherInvite,
endWatchTogetherRoom,
Expand Down Expand Up @@ -744,12 +744,8 @@ export function VideoPlayer({
const buildExitState = useCallback((): PlaybackExitState => {
const video = videoRef.current;
const positionSeconds = toMediaTime(video?.currentTime ?? currentTime, streamOriginRef.current);
const durationSeconds =
duration > 0
? duration
: backendDurationRef.current > 0
? backendDurationRef.current
: undefined;
// positionSeconds is media time, so the runtime paired with it must be too.
const durationSeconds = mediaDurationSeconds(backendDurationRef.current, duration);

return {
positionSeconds,
Expand Down
Loading