Files
silo-server/internal/scanner/probe_duration_test.go
T
2fb5e10de4 fix(scanner): recover malformed video durations (#416)
* fix(scanner): recover malformed video durations

* fix(scanner): preserve probe failure semantics

* fix(scanner): harden malformed-duration recovery after review

Remediates the deep-review findings on the duration recovery machinery:

- Ignore attached_pic cover-art streams when deciding whether a file is
  video: audiobooks/music with embedded artwork no longer fail import or
  persist 1-second durations, and never trigger the packet scan.
- Cap the audio-only duration path (raised ceiling, not removed) so
  malformed audio containers cannot persist multi-year durations.
- Keep the parsed probe when the packet fallback fails instead of
  discarding codecs/tracks/resolution with a hard ProbeFile error.
- Apply the implausibly-short guard to the end-minus-start fallbacks so
  collapsed absolute-timestamp spans reach the packet scan too.
- Make the legacy-duration repair one-shot: rows reprobed by the fixed
  parser are authoritative, ending the infinite reprobe loop for
  genuinely short large clips.
- Teach the library-scan repair predicate (needsCriticalProbeRepairScanState)
  the same legacy signature so scans repair collapsed durations instead
  of deferring to request-time repair.
- Grant the packet-scan timeout to any reprobe likely to hit the
  fallback (Duration<=0 video files), not just the legacy shape.
- Share the implausibly-short thresholds between probe and repair layers
  (closes the 100-500MiB repair gap) and deduplicate frame-rate parsing
  within the scanner package.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:23:05 -04:00

240 lines
6.5 KiB
Go

package scanner
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDurationFromProbeMetadataUsesReasonableVideoDuration(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: "1745764.949333"},
Streams: []ffprobeStream{
{CodecType: "video", Duration: "4077.708452"},
{CodecType: "audio", Duration: "1745764.949333"},
},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 4077 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 4077, true", got, ok)
}
}
func TestDurationFromProbeMetadataRemovesAbsoluteTimestampOffset(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "1514891.405000",
Duration: "1520667.605000",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "1514891.405000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 5776 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 5776, true", got, ok)
}
}
func TestDurationFromProbeMetadataRejectsAbsurdSubtitleTimeline(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: "4298357.248000"},
Streams: []ffprobeStream{
{CodecType: "video", AvgFrameRate: "30000/1001"},
{CodecType: "subtitle", Duration: "4298357.248000"},
},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}
func TestDurationFromProbeMetadataRejectsImplausiblyShortLargeVideo(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
Duration: "3.022000",
Size: "2022705152",
},
Streams: []ffprobeStream{{
CodecType: "video",
AvgFrameRate: "30/1",
}},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}
func TestDurationFromProbeMetadataKeepsLongAudioDurationInSeconds(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: "108000.000000"},
Streams: []ffprobeStream{{CodecType: "audio"}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 108000 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 108000, true", got, ok)
}
}
func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
t.Parallel()
packets := strings.NewReader("1514891.405000\n1515000.000000\n1520667.605000\n")
got := estimateVideoPacketDuration(packets, "30000/1001")
if got != 5776 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 5776", got)
}
}
func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) {
t.Parallel()
var packets strings.Builder
for i := 0; i < 300; i++ {
packets.WriteString("3.022000\n")
}
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "30/1")
if got != 10 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 10", got)
}
}
func TestProbeFileFallsBackToVideoPacketsForInvalidMetadata(t *testing.T) {
t.Parallel()
ffprobePath := filepath.Join(t.TempDir(), "ffprobe")
script := `#!/bin/sh
case " $* " in
*" -show_format "*)
printf '%s\n' '{"format":{"duration":"4298357.248000","size":"1258000000"},"streams":[{"codec_type":"video","avg_frame_rate":"30/1"},{"codec_type":"subtitle","duration":"4298357.248000"}]}'
;;
*)
i=0
while [ "$i" -lt 300 ]; do
printf '%s\n' '3.022000'
i=$((i + 1))
done
;;
esac
`
if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil {
t.Fatalf("writing fake ffprobe: %v", err)
}
probe, err := ProbeFile(context.Background(), ffprobePath, "broken.mkv")
if err != nil {
t.Fatalf("ProbeFile() returned error: %v", err)
}
if probe.Duration != 10 {
t.Fatalf("ProbeFile() duration = %d, want 10", probe.Duration)
}
}
// A failed packet fallback must not discard the codec/track metadata that
// already parsed successfully; the file imports with an unknown duration and
// the repair layer retries later.
func TestProbeFileKeepsMetadataWhenPacketFallbackFails(t *testing.T) {
t.Parallel()
ffprobePath := filepath.Join(t.TempDir(), "ffprobe")
script := `#!/bin/sh
case " $* " in
*" -show_format "*)
printf '%s\n' '{"format":{"duration":"4298357.248000","size":"1258000000"},"streams":[{"codec_type":"video","codec_name":"h264","avg_frame_rate":"30/1"}]}'
;;
*)
exit 1
;;
esac
`
if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil {
t.Fatalf("writing fake ffprobe: %v", err)
}
probe, err := ProbeFile(context.Background(), ffprobePath, "broken.mkv")
if err != nil {
t.Fatalf("ProbeFile() returned error: %v", err)
}
if probe.Duration != 0 {
t.Fatalf("ProbeFile() duration = %d, want 0 (unknown)", probe.Duration)
}
if probe.CodecVideo != "h264" {
t.Fatalf("ProbeFile() codec = %q, want parsed metadata to survive the failed fallback", probe.CodecVideo)
}
}
// Embedded cover art is reported by ffprobe as a video stream; it must not
// route audiobooks and music through the video duration gauntlet.
func TestDurationFromProbeMetadataIgnoresCoverArtVideoStream(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: "108000.000000"},
Streams: []ffprobeStream{
{CodecType: "audio"},
{CodecType: "video", CodecName: "mjpeg", Disposition: ffprobeDisp{AttachedPic: 1}},
},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 108000 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 108000, true", got, ok)
}
}
func TestDurationFromProbeMetadataRejectsAbsurdAudioDuration(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: "4298357.248000"},
Streams: []ffprobeStream{{CodecType: "audio"}},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}
// The end-minus-start fallbacks must apply the same plausibility guard as the
// direct duration paths: a multi-GB video whose absolute timestamps collapse
// to a few seconds needs the packet fallback, not a persisted 3s duration.
func TestDurationFromProbeMetadataRejectsCollapsedTimestampSpanForLargeVideo(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Size: "2022705152"},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "1514891.405000",
Duration: "1514894.405000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}