diff --git a/config/config.yaml b/config/config.yaml index 857f66898..275e24cee 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -23,6 +23,7 @@ health: segment_sample_percentage: 5 library_sync_interval_minutes: 360 library_sync_concurrency: 0 + corruption_action: repair rclone: path: /config password: '' diff --git a/frontend/src/components/config/HealthConfigSection.tsx b/frontend/src/components/config/HealthConfigSection.tsx index 302659085..fa34c679f 100644 --- a/frontend/src/components/config/HealthConfigSection.tsx +++ b/frontend/src/components/config/HealthConfigSection.tsx @@ -172,7 +172,28 @@ export function HealthConfigSection({ {/* Repair & Back-off Configuration */}
-
+
+ On Corruption + +

+ What to do when a confirmed (non-degraded) corruption is detected, whether from a + scheduled health check or a live streaming failure. "Delete file" removes the file's + metadata, health record, and any now-empty parent directories instead of asking an ARR + to redownload it. +

+
+ +

Repair Engine

@@ -183,12 +204,12 @@ export function HealthConfigSection({ type="checkbox" className="toggle toggle-info mt-1 shrink-0" checked={formData.repair?.enabled ?? true} - disabled={isReadOnly} + disabled={isReadOnly || formData.corruption_action === "delete"} onChange={(e) => handleRepairChange("enabled", e.target.checked)} />

- {formData.repair?.enabled && ( + {formData.repair?.enabled && formData.corruption_action !== "delete" && (
diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index f4c61bf11..96e1a99be 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -119,6 +119,10 @@ export interface HealthConfig { read_timeout_seconds?: number; // Timeout for data verification acceptable_missing_segments_percentage?: number; repair: RepairConfig; + // What happens when the health checker or a streaming read confirms real + // (non-degraded) corruption: "repair" (default) triggers an Arr rescan; + // "delete" removes the file and cleans up now-empty parent directories instead. + corruption_action?: "repair" | "delete"; } export interface RepairConfig { @@ -395,6 +399,7 @@ export interface HealthUpdateRequest { verify_data?: boolean; acceptable_missing_segments_percentage?: number; repair?: Partial; + corruption_action?: "repair" | "delete"; } // RClone update request diff --git a/internal/config/accessors.go b/internal/config/accessors.go index 994a944cd..133a9770b 100644 --- a/internal/config/accessors.go +++ b/internal/config/accessors.go @@ -206,6 +206,12 @@ func (c *Config) GetHealthEnabled() bool { return *c.Health.Enabled } +// GetHealthDeleteOnCorruption reports whether confirmed corruption should delete the +// file instead of triggering an Arr repair (health.corruption_action == "delete"). +func (c *Config) GetHealthDeleteOnCorruption() bool { + return c.Health.CorruptionAction == "delete" +} + // GetRepairEnabled returns whether automatic repair is enabled (defaults to true) func (c *Config) GetRepairEnabled() bool { if c.Health.Repair.Enabled == nil { diff --git a/internal/config/manager.go b/internal/config/manager.go index e0082740e..5aaaeda93 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -362,6 +362,11 @@ type HealthConfig struct { ReadTimeoutSeconds int `yaml:"read_timeout_seconds" mapstructure:"read_timeout_seconds" json:"read_timeout_seconds,omitempty"` AcceptableMissingSegmentsPercentage float64 `yaml:"acceptable_missing_segments_percentage" mapstructure:"acceptable_missing_segments_percentage" json:"acceptable_missing_segments_percentage"` Repair RepairConfig `yaml:"repair" mapstructure:"repair" json:"repair"` + // CorruptionAction controls what happens when the health checker or a streaming read + // confirms real (non-degraded) corruption: "repair" (default) triggers an Arr rescan; + // "delete" removes the file's metadata/NZB/health record and cleans up now-empty + // parent directories instead. Degraded files are never affected either way. + CorruptionAction string `yaml:"corruption_action" mapstructure:"corruption_action" json:"corruption_action,omitempty"` } // Path validation functions have been moved to internal/utils/path.go diff --git a/internal/health/worker.go b/internal/health/worker.go index 304f3c8ec..fdd4392fd 100644 --- a/internal/health/worker.go +++ b/internal/health/worker.go @@ -463,6 +463,13 @@ func (hw *HealthWorker) prepareUpdateForResult(ctx context.Context, fh *database // that has exhausted its health-check retries (or is already in repair_triggered from a // time when repair was enabled) is finalized as corrupted for visibility, with no // metadata safety-folder move. Regular health-check retries below are left intact. + // When delete-on-corruption is enabled, confirmed corruption is handled by removing + // the file entirely instead of ever entering the repair flow below (this covers both + // freshly-exhausted checks and files already sitting in repair_triggered). + if hw.configGetter().GetHealthDeleteOnCorruption() { + return hw.deleteCorruptedFile(ctx, fh) + } + repairEnabled := hw.configGetter().GetRepairEnabled() markCorruptedNoRepair := func() (*database.HealthStatusUpdate, func() error) { update.Type = database.UpdateTypeCorrupted @@ -597,10 +604,42 @@ func (hw *HealthWorker) markCorruptedRepairExhausted(ctx context.Context, fh *da } } +// deleteCorruptedFile removes a confirmed-corrupted file's metadata (and optional source +// NZB), cleans up now-empty parent directories in both the metadata store and the physical +// library tree, and deletes the health record — used instead of triggering an Arr repair +// when health.corruption_action is set to "delete". +func (hw *HealthWorker) deleteCorruptedFile(ctx context.Context, fh *database.FileHealth) (*database.HealthStatusUpdate, func() error) { + update := &database.HealthStatusUpdate{FilePath: fh.FilePath, Skip: true} + return update, func() error { + cfg := hw.configGetter() + var physicalPath, rootPath string + if fh.LibraryPath != nil { + physicalPath = *fh.LibraryPath + rootPath = cfg.MountPath + if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { + rootPath = *cfg.Health.LibraryDir + } + } + if err := hw.metadataService.DeleteCorruptedFile(ctx, fh.FilePath, cfg.Metadata.ShouldDeleteSourceNzb(), physicalPath, rootPath); err != nil { + slog.ErrorContext(ctx, "Failed to delete corrupted file", "file_path", fh.FilePath, "error", err) + return err + } + if err := hw.healthRepo.DeleteHealthRecord(ctx, fh.FilePath); err != nil { + slog.ErrorContext(ctx, "Failed to delete health record for deleted corrupted file", "file_path", fh.FilePath, "error", err) + } + slog.WarnContext(ctx, "Deleted corrupted file instead of triggering repair", "file_path", fh.FilePath) + return nil + } +} + // prepareRepairNotificationUpdate builds the update and side effect for a file already in // repair_triggered state. It re-triggers ARR directly without calling CheckFile, since the // metadata has already been moved to the corrupted folder. func (hw *HealthWorker) prepareRepairNotificationUpdate(ctx context.Context, fh *database.FileHealth) (*database.HealthStatusUpdate, func() error) { + if hw.configGetter().GetHealthDeleteOnCorruption() { + return hw.deleteCorruptedFile(ctx, fh) + } + // Default to the existing diagnosis so a successful re-trigger (which never // sets these itself) doesn't null out the reason the file was flagged in // the first place; applyRepairOutcome combines onto this if repair fails. diff --git a/internal/health/worker_delete_corrupted_test.go b/internal/health/worker_delete_corrupted_test.go new file mode 100644 index 000000000..fcc96a7a0 --- /dev/null +++ b/internal/health/worker_delete_corrupted_test.go @@ -0,0 +1,147 @@ +package health + +import ( + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/javi11/altmount/internal/config" + "github.com/javi11/altmount/internal/database" + "github.com/javi11/altmount/internal/holes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestE2E_DeleteOnCorruption_HealthCheckExhausted verifies that when +// health.corruption_action is "delete", a health-check cycle that exhausts retries +// deletes the file's metadata, health record, and physical library file (plus any +// now-empty parent directory) instead of triggering an Arr repair. +func TestE2E_DeleteOnCorruption_HealthCheckExhausted(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks not supported on Windows") + } + tempDir := t.TempDir() + libraryRoot := t.TempDir() + env := newRepairTestEnv(t, tempDir, nil, func(c *config.Config) { + c.Health.CorruptionAction = "delete" + c.Health.LibraryDir = &libraryRoot + }) + + ctx := context.Background() + filePath := "series/show.s01e01.mkv" + libraryDir := filepath.Join(libraryRoot, "series", "show") + libraryPath := filepath.Join(libraryDir, "show.s01e01.mkv") + maxRetries := 3 + + require.NoError(t, os.MkdirAll(libraryDir, 0o755)) + require.NoError(t, os.WriteFile(libraryPath, []byte("dummy"), 0o644)) + + meta := validSegmentMeta(env.metadataService, 1024) + require.NoError(t, env.metadataService.WriteFileMetadata(filePath, meta)) + + // File already at its last health retry: a single failing cycle would normally + // trigger a repair, but corruption_action is "delete". + insertFileHealth(t, env.db, filePath, libraryPath, maxRetries-1, maxRetries) + + require.NoError(t, env.hw.runHealthCheckCycle(ctx)) + + env.mockARRs.mu.Lock() + callCount := len(env.mockARRs.calls) + env.mockARRs.mu.Unlock() + assert.Equal(t, 0, callCount, "delete mode must not trigger an ARR rescan") + + fh, err := env.healthRepo.GetFileHealth(ctx, filePath) + require.NoError(t, err) + assert.Nil(t, fh, "health record should be deleted") + + deletedMeta, err := env.metadataService.ReadFileMetadata(filePath) + require.NoError(t, err) + assert.Nil(t, deletedMeta, "metadata should be deleted") + + _, statErr := os.Stat(libraryPath) + assert.True(t, os.IsNotExist(statErr), "physical library file should be deleted") + + _, dirErr := os.Stat(libraryDir) + assert.True(t, os.IsNotExist(dirErr), "now-empty library directory should be removed") + + _, rootErr := os.Stat(libraryRoot) + assert.NoError(t, rootErr, "library root itself must not be removed") +} + +// TestE2E_DeleteOnCorruption_AlreadyRepairTriggered verifies that a file already sitting +// in repair_triggered (e.g. from before corruption_action was switched to "delete") gets +// deleted on the next repair-retry sweep instead of being re-notified to the ARR. +func TestE2E_DeleteOnCorruption_AlreadyRepairTriggered(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks not supported on Windows") + } + tempDir := t.TempDir() + env := newRepairTestEnv(t, tempDir, nil, func(c *config.Config) { + c.Health.CorruptionAction = "delete" + }) + + ctx := context.Background() + filePath := "series/show.s01e02.mkv" + + meta := validSegmentMeta(env.metadataService, 1024) + require.NoError(t, env.metadataService.WriteFileMetadata(filePath, meta)) + + _, err := env.db.Exec(` + INSERT INTO file_health + (file_path, library_path, status, retry_count, max_retries, + repair_retry_count, max_repair_retries, scheduled_check_at) + VALUES (?, NULL, 'repair_triggered', 3, 3, 0, 3, datetime('now', '-1 second')) + `, filePath) + require.NoError(t, err) + + require.NoError(t, env.hw.runHealthCheckCycle(ctx)) + + env.mockARRs.mu.Lock() + callCount := len(env.mockARRs.calls) + env.mockARRs.mu.Unlock() + assert.Equal(t, 0, callCount, "delete mode must not re-notify the ARR") + + fh, err := env.healthRepo.GetFileHealth(ctx, filePath) + require.NoError(t, err) + assert.Nil(t, fh, "health record should be deleted") +} + +// TestPrepareUpdateForResultDeleteOnCorruption verifies that a degraded verdict still wins +// over delete-on-corruption: a file that is playable despite missing segments must never +// be deleted just because corruption_action is set to "delete". +func TestPrepareUpdateForResultDeleteOnCorruption(t *testing.T) { + tempDir := t.TempDir() + env := newRepairTestEnv(t, tempDir, nil, func(c *config.Config) { + c.Health.CorruptionAction = "delete" + }) + + filePath := "/movies/movie.mp4" + meta := validSegmentMeta(env.metadataService, 1024) + require.NoError(t, env.metadataService.WriteFileMetadata(filePath, meta)) + + fh := database.FileHealth{ + FilePath: filePath, + Status: database.HealthStatusPending, + RetryCount: 99, + } + event := HealthEvent{ + Type: EventTypeFileCorrupted, + FilePath: filePath, + Status: database.HealthStatusCorrupted, + Classification: &holes.Impact{ + Verdict: holes.VerdictDegraded, + TotalMissing: 2, + LongestRun: 1, + }, + } + + update, sideEffect := env.hw.prepareUpdateForResult(context.Background(), &fh, event) + assert.Equal(t, database.UpdateTypeDegraded, update.Type, "degraded must win over delete-on-corruption") + require.NoError(t, sideEffect()) + + got, err := env.metadataService.ReadFileMetadata(filePath) + require.NoError(t, err) + assert.NotNil(t, got, "degraded file must not be deleted") +} diff --git a/internal/metadata/service.go b/internal/metadata/service.go index a835da97e..464e06337 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -738,6 +738,27 @@ func (ms *MetadataService) DeleteFileMetadataWithSourceNzb(ctx context.Context, return nil } +// DeleteCorruptedFile removes a file's metadata (and optionally its source NZB), then +// removes the physical library file (if any) and cleans up now-empty parent directories +// in the physical library tree. Metadata-tree cleanup is already handled by +// DeleteFileMetadataWithSourceNzb; the physical-path removal is error-tolerant since +// physicalPath is often just a view into the same mount and may already be gone. +func (ms *MetadataService) DeleteCorruptedFile(ctx context.Context, virtualPath string, deleteSourceNzb bool, physicalPath string, physicalRoot string) error { + if err := ms.DeleteFileMetadataWithSourceNzb(ctx, virtualPath, deleteSourceNzb); err != nil { + return err + } + if physicalPath == "" { + return nil + } + if err := os.Remove(physicalPath); err != nil && !os.IsNotExist(err) { + slog.WarnContext(ctx, "Failed to delete physical library file", "path", physicalPath, "error", err) + } + if physicalRoot != "" { + utils.RemoveEmptyDirs(physicalRoot, filepath.Dir(physicalPath)) + } + return nil +} + // DeleteDirectory deletes a metadata directory and all its contents func (ms *MetadataService) DeleteDirectory(virtualPath string) error { ctx := context.Background() diff --git a/internal/nzbfilesystem/delete_on_corruption_test.go b/internal/nzbfilesystem/delete_on_corruption_test.go new file mode 100644 index 000000000..d91f46264 --- /dev/null +++ b/internal/nzbfilesystem/delete_on_corruption_test.go @@ -0,0 +1,66 @@ +package nzbfilesystem + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/javi11/altmount/internal/config" + "github.com/javi11/altmount/internal/usenet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestUpdateFileHealthOnError_DeleteOnCorruption_DeletesFile verifies that when +// health.corruption_action is "delete", a mid-stream DataCorruptionError deletes the +// file's metadata, health record, and physical library file (plus any now-empty parent +// directory) instead of triggering an Arr repair. +func TestUpdateFileHealthOnError_DeleteOnCorruption_DeletesFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks not supported on Windows") + } + repo, db, ms := setupStreamHealthEnv(t) + ctx := context.Background() + + libraryRoot := t.TempDir() + filePath := "series/stream.s01e03.mkv" + libraryDir := filepath.Join(libraryRoot, "series", "stream") + libraryPath := filepath.Join(libraryDir, "stream.s01e03.mkv") + seg := writeStreamMeta(t, ms, filePath) + + require.NoError(t, os.MkdirAll(libraryDir, 0o755)) + require.NoError(t, os.WriteFile(libraryPath, []byte("dummy"), 0o644)) + + _, err := db.Exec( + `INSERT INTO file_health (file_path, library_path, status, scheduled_check_at) VALUES (?, ?, 'healthy', datetime('now'))`, + filePath, libraryPath, + ) + require.NoError(t, err) + + enabled := true + cfg := config.DefaultConfig() + cfg.Health.Enabled = &enabled + cfg.Health.CorruptionAction = "delete" + cfg.Health.LibraryDir = &libraryRoot + cfg.MountPath = "" + + mvf := newStreamFailureMVF(ctx, filePath, repo, ms, seg, cfg) + mvf.updateFileHealthOnError(&usenet.DataCorruptionError{UnderlyingErr: errors.New("article not found"), NoRetry: true}, true) + + fh, err := repo.GetFileHealth(ctx, filePath) + require.NoError(t, err) + assert.Nil(t, fh, "health record should be deleted") + + deletedMeta, err := ms.ReadFileMetadata(filePath) + require.NoError(t, err) + assert.Nil(t, deletedMeta, "metadata should be deleted") + + _, statErr := os.Stat(libraryPath) + assert.True(t, os.IsNotExist(statErr), "physical library file should be deleted") + + _, dirErr := os.Stat(libraryDir) + assert.True(t, os.IsNotExist(dirErr), "now-empty library directory should be removed") +} diff --git a/internal/nzbfilesystem/metadata_remote_file.go b/internal/nzbfilesystem/metadata_remote_file.go index 9d70dd82c..4617ce6b3 100644 --- a/internal/nzbfilesystem/metadata_remote_file.go +++ b/internal/nzbfilesystem/metadata_remote_file.go @@ -2137,6 +2137,25 @@ func (mvf *MetadataVirtualFile) updateFileHealthOnError(dataCorruptionErr *usene "total_missing", classification.TotalMissing, "longest_run", classification.LongestRun) dbStatus = database.HealthStatusDegraded + } else if healthEnabled && cfg.GetHealthDeleteOnCorruption() { + // Delete-on-corruption is enabled: remove the file instead of triggering a repair. + slog.WarnContext(ctx, "Streaming failure detected, deleting corrupted file instead of triggering repair", "file", mvf.name) + + var physicalPath, rootPath string + if health, err := mvf.healthRepository.GetFileHealth(ctx, mvf.name); err == nil && health != nil && health.LibraryPath != nil { + physicalPath = *health.LibraryPath + rootPath = cfg.MountPath + if cfg.Health.LibraryDir != nil && *cfg.Health.LibraryDir != "" { + rootPath = *cfg.Health.LibraryDir + } + } + + if err := mvf.metadataService.DeleteCorruptedFile(ctx, mvf.name, cfg.Metadata.ShouldDeleteSourceNzb(), physicalPath, rootPath); err != nil { + slog.ErrorContext(ctx, "Failed to delete corrupted file after streaming failure", "file", mvf.name, "error", err) + } else if err := mvf.healthRepository.DeleteHealthRecord(ctx, mvf.name); err != nil { + slog.ErrorContext(ctx, "Failed to delete health record after deleting corrupted file", "file", mvf.name, "error", err) + } + return } else if healthEnabled { // 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.