Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions frontend/src/components/config/StreamingConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,81 @@ export function StreamingConfigSection({
</div>
</div>
</div>

{/* Failure Masking */}
<div className="space-y-6 rounded-2xl border-2 border-base-300/80 bg-base-200/60 p-6">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h4 className="font-bold text-base-content text-sm">Playback Failure Masking</h4>
<p className="mt-1 break-words text-[11px] text-base-content/50 leading-relaxed">
Debounce transient streaming errors (like propagation delays) by requiring multiple consecutive playback failures before triggering a redownload.
</p>
</div>
<input
type="checkbox"
className="toggle toggle-primary shrink-0"
checked={streamingData.failure_masking?.enabled === true}
disabled={isReadOnly}
onChange={(e) => {
const newData = {
...streamingData,
failure_masking: {
...streamingData.failure_masking,
enabled: e.target.checked,
},
};
setStreamingData(newData);
checkChanges(newData, cacheData);
}}
/>
</div>

{streamingData.failure_masking?.enabled === true && (
<div className="fade-in slide-in-from-top-2 animate-in space-y-4 border-base-300 border-t pt-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h5 className="font-bold text-base-content text-xs">Failure Threshold</h5>
<p className="mt-1 break-words text-[10px] text-base-content/50 leading-relaxed">
Number of consecutive failures required before declaring a file corrupted and requesting repair.
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="font-black font-mono text-primary text-lg">
{streamingData.failure_masking?.threshold ?? 3}
</span>
<span className="font-bold text-base-content/60 text-[10px] uppercase">failures</span>
</div>
</div>
<input
type="range"
min="1"
max="10"
value={streamingData.failure_masking?.threshold ?? 3}
step="1"
className="range range-primary range-xs w-full"
disabled={isReadOnly}
onChange={(e) => {
const newData = {
...streamingData,
failure_masking: {
...streamingData.failure_masking,
threshold: Number.parseInt(e.target.value, 10),
},
};
setStreamingData(newData);
checkChanges(newData, cacheData);
}}
/>
<div className="flex justify-between px-1 font-black text-base-content/50 text-[10px]">
<span>1</span>
<span>3</span>
<span>5</span>
<span>7</span>
<span>10</span>
</div>
</div>
)}
</div>
</div>

{/* Segment Cache */}
Expand Down
68 changes: 67 additions & 1 deletion internal/nzbfilesystem/health_disabled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}

49 changes: 31 additions & 18 deletions internal/nzbfilesystem/metadata_remote_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading