feat(library): detect corrupt/incomplete files during scan

ffprobe already runs on every scanned file; now we capture its stderr and
assess integrity from it. assessIntegrity flags a file "damaged" on the
markers that mean the container/bitstream is unusable: invalid_data,
ebml_corrupt, moov_missing, bitstream_corrupt, plus no_duration (a video
stream with non-positive duration = a truncated/incomplete download).

The verdict rides on MediaInfo.Integrity (IntegrityInfo{Damaged,Reason}),
maps onto LibrarySyncItem.{Integrity,IntegrityReason}, and syncs to the web
so a damaged file can be surfaced at rest instead of only blowing up at
playback.

Bumps the scan cache version (1 → 2) so existing entries re-probe once, and
the scanner re-probes any cached entry that has no integrity verdict yet.
This commit is contained in:
Deivid Soto 2026-06-02 19:42:00 +02:00
parent c86e50245e
commit f0ac905fdb
7 changed files with 122 additions and 4 deletions

View file

@ -428,3 +428,42 @@ func TestParseFFprobeOutput_FrameRateNoSlash(t *testing.T) {
t.Errorf("frameRate = %v, want 0 (no slash)", mi.Video.FrameRate)
}
}
func TestAssessIntegrity(t *testing.T) {
healthy := &MediaInfo{Video: &VideoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 5477}}
// Healthy file with no stderr → nil (not damaged).
if got := assessIntegrity("", healthy); got != nil {
t.Errorf("healthy file flagged damaged: %+v", got)
}
// MKV EBML corruption (the real "In the Grey" case): ffprobe exits 0 but
// logs EBML errors → damaged with the ebml_corrupt code.
ebml := "[matroska,webm @ 0x60e7] 0x00 at pos 2144995 invalid as first byte of an EBML number\n"
got := assessIntegrity(ebml, healthy)
if got == nil || !got.Damaged || got.Reason != "ebml_corrupt" {
t.Errorf("EBML corruption not flagged correctly: %+v", got)
}
// Truncated MP4.
if got := assessIntegrity("moov atom not found\n", healthy); got == nil || got.Reason != "moov_missing" {
t.Errorf("moov-missing not flagged: %+v", got)
}
// Invalid data.
if got := assessIntegrity("Invalid data found when processing input\n", healthy); got == nil || got.Reason != "invalid_data" {
t.Errorf("invalid-data not flagged: %+v", got)
}
// No duration on a video stream → truncated.
noDur := &MediaInfo{Video: &VideoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 0}}
if got := assessIntegrity("", noDur); got == nil || got.Reason != "no_duration" {
t.Errorf("no-duration not flagged: %+v", got)
}
// Audio-only file with no duration is NOT flagged (legitimately omits it).
audioOnly := &MediaInfo{Audio: []AudioTrack{{Lang: "en", Codec: "aac"}}}
if got := assessIntegrity("", audioOnly); got != nil {
t.Errorf("audio-only file wrongly flagged: %+v", got)
}
}