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