diff --git a/frontend/src/components/config/StreamingConfigSection.tsx b/frontend/src/components/config/StreamingConfigSection.tsx
index 506f5d5d0..502f713de 100644
--- a/frontend/src/components/config/StreamingConfigSection.tsx
+++ b/frontend/src/components/config/StreamingConfigSection.tsx
@@ -124,6 +124,81 @@ export function StreamingConfigSection({
+
+ {/* Failure Masking */}
+
+
+
+
Playback Failure Masking
+
+ Debounce transient streaming errors (like propagation delays) by requiring multiple consecutive playback failures before triggering a redownload.
+
+
+
{
+ const newData = {
+ ...streamingData,
+ failure_masking: {
+ ...streamingData.failure_masking,
+ enabled: e.target.checked,
+ },
+ };
+ setStreamingData(newData);
+ checkChanges(newData, cacheData);
+ }}
+ />
+
+
+ {streamingData.failure_masking?.enabled === true && (
+
+
+
+
Failure Threshold
+
+ Number of consecutive failures required before declaring a file corrupted and requesting repair.
+
+
+
+
+ {streamingData.failure_masking?.threshold ?? 3}
+
+ failures
+
+
+
{
+ const newData = {
+ ...streamingData,
+ failure_masking: {
+ ...streamingData.failure_masking,
+ threshold: Number.parseInt(e.target.value, 10),
+ },
+ };
+ setStreamingData(newData);
+ checkChanges(newData, cacheData);
+ }}
+ />
+
+ 1
+ 3
+ 5
+ 7
+ 10
+
+
+ )}
+
{/* Segment Cache */}
diff --git a/internal/nzbfilesystem/health_disabled_test.go b/internal/nzbfilesystem/health_disabled_test.go
index 7727e3013..b901afb2c 100644
--- a/internal/nzbfilesystem/health_disabled_test.go
+++ b/internal/nzbfilesystem/health_disabled_test.go
@@ -49,7 +49,8 @@ func setupStreamHealthEnv(t *testing.T) (*database.HealthRepository, *sql.DB, *m
priority INTEGER DEFAULT 0,
streaming_failure_count INTEGER DEFAULT 0,
is_masked BOOLEAN DEFAULT FALSE,
- indexer TEXT DEFAULT NULL
+ indexer TEXT DEFAULT NULL,
+ download_id TEXT DEFAULT NULL
);
`)
require.NoError(t, err)
@@ -159,3 +160,68 @@ func TestUpdateFileHealthOnError_HealthEnabled_TriggersRepair(t *testing.T) {
require.NoError(t, readErr)
assert.Nil(t, original, "health enabled must move metadata to the corrupted folder")
}
+
+// TestUpdateFileHealthOnError_FailureMasking_MasksRepair verifies that when failure masking
+// is enabled, a streaming failure below the threshold does not immediately trigger a repair
+// or move the metadata, but increments the failure count instead.
+func TestUpdateFileHealthOnError_FailureMasking_MasksRepair(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlinks not supported on Windows")
+ }
+ repo, db, ms := setupStreamHealthEnv(t)
+ ctx := context.Background()
+
+ filePath := "series/stream.s01e03.mkv"
+ libraryPath := "/media/library/stream.s01e03.mkv"
+ seg := writeStreamMeta(t, ms, filePath)
+
+ _, err := db.Exec(
+ `INSERT INTO file_health (file_path, library_path, status, scheduled_check_at, streaming_failure_count) VALUES (?, ?, 'healthy', datetime('now'), 0)`,
+ filePath, libraryPath,
+ )
+ require.NoError(t, err)
+
+ healthEnabled := true
+ maskingEnabled := true
+ cfg := config.DefaultConfig()
+ cfg.Health.Enabled = &healthEnabled
+ cfg.Streaming.FailureMasking.Enabled = &maskingEnabled
+ cfg.Streaming.FailureMasking.Threshold = 3
+ cfg.MountPath = ""
+
+ mvf := newStreamFailureMVF(ctx, filePath, repo, ms, seg, cfg)
+
+ // First failure: below threshold of 3
+ mvf.updateFileHealthOnError(&usenet.DataCorruptionError{UnderlyingErr: errors.New("article not found"), NoRetry: true}, true)
+
+ fh, err := repo.GetFileHealth(ctx, filePath)
+ require.NoError(t, err)
+ require.NotNil(t, fh)
+ assert.Equal(t, database.HealthStatusPending, fh.Status, "should be pending, not repair_triggered yet")
+ assert.Equal(t, 1, fh.StreamingFailureCount, "failure count should be incremented to 1")
+
+ original, readErr := ms.ReadFileMetadata(filePath)
+ require.NoError(t, readErr)
+ assert.NotNil(t, original, "should NOT move metadata to corrupted folder yet")
+
+ // Second failure: still below threshold
+ mvf.updateFileHealthOnError(&usenet.DataCorruptionError{UnderlyingErr: errors.New("article not found"), NoRetry: true}, true)
+
+ fh, err = repo.GetFileHealth(ctx, filePath)
+ require.NoError(t, err)
+ assert.Equal(t, 2, fh.StreamingFailureCount, "failure count should be 2")
+ original, _ = ms.ReadFileMetadata(filePath)
+ assert.NotNil(t, original, "should still not move metadata")
+
+ // Third failure: meets threshold (3) -> triggers repair
+ mvf.updateFileHealthOnError(&usenet.DataCorruptionError{UnderlyingErr: errors.New("article not found"), NoRetry: true}, true)
+
+ fh, err = repo.GetFileHealth(ctx, filePath)
+ require.NoError(t, err)
+ assert.Equal(t, database.HealthStatusRepairTriggered, fh.Status, "should trigger repair now")
+ assert.Equal(t, 3, fh.StreamingFailureCount, "failure count should be 3")
+
+ original, _ = ms.ReadFileMetadata(filePath)
+ assert.Nil(t, original, "metadata should be moved to corrupted folder now")
+}
+
diff --git a/internal/nzbfilesystem/metadata_remote_file.go b/internal/nzbfilesystem/metadata_remote_file.go
index 4617ce6b3..d41170313 100644
--- a/internal/nzbfilesystem/metadata_remote_file.go
+++ b/internal/nzbfilesystem/metadata_remote_file.go
@@ -2094,13 +2094,34 @@ func (mvf *MetadataVirtualFile) updateFileHealthOnError(dataCorruptionErr *usene
isDegraded := healthEnabled && classification != nil &&
classification.Verdict == holes.VerdictDegraded
+ // Increment failure count for tracking/masking if explicitly enabled with a valid
+ // threshold. Masking must be opt-in: Enabled == nil means disabled (not on-by-default),
+ // and Threshold <= 0 would make every file immediately masked (count+1 >= 0 is always
+ // true), suppressing all repairs. Degraded files are also exempt: masking a still-
+ // playable file defeats the point of keeping it available.
+ shouldRepair := true
+ isMasked := false
+ if !isDegraded && cfg.Streaming.FailureMasking.Enabled != nil && *cfg.Streaming.FailureMasking.Enabled && cfg.Streaming.FailureMasking.Threshold > 0 {
+ var err error
+ isMasked, shouldRepair, err = mvf.healthRepository.IncrementStreamingFailureCount(ctx, mvf.name, cfg.Streaming.FailureMasking.Threshold)
+ if err != nil {
+ slog.WarnContext(ctx, "Failed to update streaming failure count, proceeding with repair", "file", mvf.name, "error", err)
+ shouldRepair = true
+ } else if isMasked && !shouldRepair {
+ slog.InfoContext(ctx, "File masked due to streaming failure (threshold not reached)", "file", mvf.name)
+ }
+ }
+
// Any file with missing segments or corruption is marked as corrupted in metadata
// and DB so it stays visible on the Health page, regardless of whether repair runs.
// Degraded files instead keep a status that leaves them visible AND streamable
// (FILE_STATUS_CORRUPTED hides the file from listings and blocks opens).
+ // Masked files keep their healthy metadata status so they remain openable.
metadataStatus := metapb.FileStatus_FILE_STATUS_CORRUPTED
if isDegraded {
metadataStatus = metapb.FileStatus_FILE_STATUS_DEGRADED
+ } else if isMasked && !shouldRepair {
+ metadataStatus = metapb.FileStatus_FILE_STATUS_HEALTHY
}
// Update metadata status (blocking with timeout)
@@ -2156,7 +2177,7 @@ func (mvf *MetadataVirtualFile) updateFileHealthOnError(dataCorruptionErr *usene
slog.ErrorContext(ctx, "Failed to delete health record after deleting corrupted file", "file", mvf.name, "error", err)
}
return
- } else if healthEnabled {
+ } else if healthEnabled && shouldRepair {
// Mark as repair_triggered with high priority to trigger the replacement immediately.
// We skip the re-verification phase because a streaming failure is a definitive indicator of corruption.
slog.InfoContext(ctx, "Streaming failure detected, triggering immediate ARR repair", "file", mvf.name)
@@ -2185,14 +2206,18 @@ func (mvf *MetadataVirtualFile) updateFileHealthOnError(dataCorruptionErr *usene
}
}
} else {
- // Health system disabled: record the corruption for visibility only and skip
- // every repair action (no repair_triggered status, no metadata move, no Arr redownload).
- slog.InfoContext(ctx, "Streaming failure detected but health system disabled, marking corrupted without triggering repair", "file", mvf.name)
- dbStatus = database.HealthStatusCorrupted
+ // Health system disabled or failure count has not reached the threshold:
+ // record the corruption for visibility only and skip every repair action.
+ if healthEnabled && !shouldRepair {
+ slog.InfoContext(ctx, "Streaming failure detected but masked (threshold not reached), skipping repair", "file", mvf.name)
+ dbStatus = database.HealthStatusPending // Re-schedule it for verification later
+ } else {
+ slog.InfoContext(ctx, "Streaming failure detected but health system disabled, marking corrupted without triggering repair", "file", mvf.name)
+ dbStatus = database.HealthStatusCorrupted
+ }
}
// Update database health tracking. scheduleImmediately (noRetry) forces immediate
- // pick-up by the HealthWorker, which is only desired when a repair was actually triggered.
if err := mvf.healthRepository.UpdateFileHealthScheduled(ctx,
mvf.name,
dbStatus,
@@ -2204,18 +2229,6 @@ func (mvf *MetadataVirtualFile) updateFileHealthOnError(dataCorruptionErr *usene
); err != nil {
slog.WarnContext(ctx, "Failed to update health database for streaming failure", "file", mvf.name, "error", err)
}
-
- // Increment failure count for tracking/masking if enabled. Degraded files
- // are exempt: masking would hide a file that still plays, defeating the
- // point of keeping it available.
- if !isDegraded && (cfg.Streaming.FailureMasking.Enabled == nil || *cfg.Streaming.FailureMasking.Enabled) {
- isMasked, _, err := mvf.healthRepository.IncrementStreamingFailureCount(ctx, mvf.name, cfg.Streaming.FailureMasking.Threshold)
- if err != nil {
- slog.WarnContext(ctx, "Failed to update streaming failure count", "file", mvf.name, "error", err)
- } else if isMasked {
- slog.InfoContext(ctx, "File masked due to streaming failure", "file", mvf.name)
- }
- }
}
// readFullContext reads exactly len(buf) bytes from r, but returns early