fix(engine): cross-backend integrity guard with retry-then-damaged
A truncated debrid download (in-memory byte counter hit 100% while the
NFS write-back silently dropped most of the bytes) was marked completed.
The 1.1.6 fsync fix closed the debrid-specific hole; this generalizes the
guarantee so "completed" never means a corrupt file on ANY backend.
- IntegrityError + bounded retry: on a corrupt/short result the manager
re-downloads the same source up to 3x (clean start), then surfaces the
task as damaged ("corrupt download:" prefix) instead of completing it.
- verify (size mismatch / empty), debrid (incomplete / post-write / flush),
torrent (BytesMissing), usenet (par2 unrepairable / repair-failed) all
classify integrity failures so they route through the retry/damaged path.
- scanner: a file ffprobe can't read is emitted as a damaged library_item
(reason "unreadable") instead of being silently dropped from the sync.
- tests: manager retry-then-success + retry-exhausted-then-damaged,
verifying->resolving transition, damaged sync item.
This commit is contained in:
parent
271413e0f9
commit
a5f3f0914a
13 changed files with 400 additions and 91 deletions
|
|
@ -89,6 +89,29 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem {
|
|||
items := make([]agent.LibrarySyncItem, 0, len(cache.Items))
|
||||
for _, item := range cache.Items {
|
||||
if item.ScanError != "" {
|
||||
// A file ffprobe can't read is almost always a truncated/corrupt
|
||||
// download (2026-06-15 NFS write-back truncation). Previously these were
|
||||
// silently dropped — the file vanished from the library with no trace.
|
||||
// Emit a minimal DAMAGED row instead so the web flags it (badge +
|
||||
// blocked playback + re-download) rather than hiding it. All fields below
|
||||
// are populated before ffprobe runs, so they're valid even on scan error.
|
||||
// The scanner re-probes damaged items every scan, so a clean re-download
|
||||
// to the same path self-heals the verdict.
|
||||
items = append(items, agent.LibrarySyncItem{
|
||||
FilePath: item.FilePath,
|
||||
FileName: item.FileName,
|
||||
FileSize: item.FileSize,
|
||||
Title: item.Title,
|
||||
Year: item.Year,
|
||||
ContentType: DeriveContentType(item),
|
||||
Season: item.Season,
|
||||
Episode: item.Episode,
|
||||
Fingerprint: item.Fingerprint,
|
||||
RelPath: relToRoot(cache.Path, item.FilePath),
|
||||
LibraryRootKey: "library",
|
||||
Integrity: "damaged",
|
||||
IntegrityReason: "unreadable",
|
||||
})
|
||||
continue
|
||||
}
|
||||
si := agent.LibrarySyncItem{
|
||||
|
|
|
|||
|
|
@ -52,8 +52,10 @@ func TestBuildSyncItems(t *testing.T) {
|
|||
|
||||
items := BuildSyncItems(cache)
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items (1 skipped), got %d", len(items))
|
||||
// 3 items: the movie, the show, and the scan-error file surfaced as DAMAGED
|
||||
// (no longer silently dropped — the web flags it for re-download).
|
||||
if len(items) != 3 {
|
||||
t.Fatalf("expected 3 items (1 damaged), got %d", len(items))
|
||||
}
|
||||
|
||||
// First item: movie with full media info
|
||||
|
|
@ -97,6 +99,18 @@ func TestBuildSyncItems(t *testing.T) {
|
|||
if show.Resolution != "" {
|
||||
t.Errorf("resolution should be empty, got %q", show.Resolution)
|
||||
}
|
||||
|
||||
// Third item: scan-error file surfaced as damaged (unreadable), not skipped.
|
||||
damaged := items[2]
|
||||
if damaged.FilePath != "/media/bad.mkv" {
|
||||
t.Errorf("damaged filePath = %q, want /media/bad.mkv", damaged.FilePath)
|
||||
}
|
||||
if damaged.Integrity != "damaged" {
|
||||
t.Errorf("integrity = %q, want damaged", damaged.Integrity)
|
||||
}
|
||||
if damaged.IntegrityReason != "unreadable" {
|
||||
t.Errorf("integrityReason = %q, want unreadable", damaged.IntegrityReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSyncItemsEmpty(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue