Skip to content
Merged
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
1 change: 1 addition & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand Down
27 changes: 24 additions & 3 deletions frontend/src/components/config/HealthConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,28 @@ export function HealthConfigSection({

{/* Repair & Back-off Configuration */}
<div className="rounded-2xl border-2 border-base-300/80 bg-base-200/60 p-6">
<div className="flex items-start justify-between gap-4">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold">On Corruption</legend>
<select
className="select select-bordered w-full bg-base-100 text-sm"
value={formData.corruption_action ?? "repair"}
disabled={isReadOnly}
onChange={(e) =>
handleInputChange("corruption_action", e.target.value as "repair" | "delete")
}
>
<option value="repair">Trigger repair</option>
<option value="delete">Delete file</option>
</select>
<p className="label break-words text-[10px] text-base-content/50">
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.
</p>
</fieldset>

<div className="mt-6 flex items-start justify-between gap-4 border-base-300/50 border-t pt-6">
<div className="min-w-0 flex-1">
<h4 className="break-words font-bold text-base-content text-sm">Repair Engine</h4>
<p className="mt-1 break-words text-[11px] text-base-content/50 leading-relaxed">
Expand All @@ -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)}
/>
</div>

{formData.repair?.enabled && (
{formData.repair?.enabled && formData.corruption_action !== "delete" && (
<div className="fade-in slide-in-from-top-2 mt-6 animate-in border-base-300/50 border-t pt-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<fieldset className="fieldset">
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -395,6 +399,7 @@ export interface HealthUpdateRequest {
verify_data?: boolean;
acceptable_missing_segments_percentage?: number;
repair?: Partial<RepairConfig>;
corruption_action?: "repair" | "delete";
}

// RClone update request
Expand Down
6 changes: 6 additions & 0 deletions internal/config/accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions internal/health/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
147 changes: 147 additions & 0 deletions internal/health/worker_delete_corrupted_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
21 changes: 21 additions & 0 deletions internal/metadata/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
66 changes: 66 additions & 0 deletions internal/nzbfilesystem/delete_on_corruption_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading