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:
Deivid Soto 2026-06-17 12:51:47 +02:00
parent 271413e0f9
commit a5f3f0914a
13 changed files with 400 additions and 91 deletions

View file

@ -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{