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
17 changes: 17 additions & 0 deletions frontend/src/components/config/HealthConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,23 @@ export function HealthConfigSection({
validation of all segments (slower).
</p>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend">Hybrid Data Verification</legend>
<label className="label cursor-pointer">
<span className="label-text">Enable data verification</span>
<input
type="checkbox"
className="checkbox"
checked={formData.data_verification ?? false}
disabled={isReadOnly}
onChange={(e) => handleInputChange("data_verification", e.target.checked)}
/>
</label>
<p className="label text-sm">
When enabled, verify a subset of segments by downloading them to ensure data integrity.
This helps detect "ghost" files that exist on the provider but are corrupted.
</p>
</fieldset>
{formData.segment_sample_percentage !== undefined && !formData.check_all_segments && (
<fieldset className="fieldset">
<legend className="fieldset-legend">Segment Sample Percentage</legend>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface HealthConfig {
library_sync_interval_minutes?: number; // Library sync interval in minutes (optional)
check_all_segments?: boolean; // Whether to check all segments or use sampling
resolve_repair_on_import?: boolean; // Automatically resolve pending repairs in the same directory when a new file is imported
data_verification?: boolean; // Whether to perform actual data download verification (hybrid approach)
}

// Library sync types
Expand Down Expand Up @@ -296,6 +297,7 @@ export interface HealthUpdateRequest {
library_sync_interval_minutes?: number; // Library sync interval in minutes (optional)
check_all_segments?: boolean; // Whether to check all segments or use sampling
resolve_repair_on_import?: boolean;
data_verification?: boolean;
}

// RClone update request
Expand Down
8 changes: 8 additions & 0 deletions internal/config/accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ func (c *Config) GetSegmentSamplePercentage() int {
return c.Health.SegmentSamplePercentage
}

// GetDataVerification returns whether data verification is enabled with a default fallback.
func (c *Config) GetDataVerification() bool {
if c.Health.DataVerification == nil {
return false // Default: false
}
return *c.Health.DataVerification
}

// GetLibrarySyncInterval returns the library sync interval with a default fallback.
func (c *Config) GetLibrarySyncInterval() time.Duration {
if c.Health.LibrarySyncIntervalMinutes <= 0 {
Expand Down
3 changes: 3 additions & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ type HealthConfig struct {
LibrarySyncIntervalMinutes int `yaml:"library_sync_interval_minutes" mapstructure:"library_sync_interval_minutes" json:"library_sync_interval_minutes,omitempty"`
LibrarySyncConcurrency int `yaml:"library_sync_concurrency" mapstructure:"library_sync_concurrency" json:"library_sync_concurrency,omitempty"`
ResolveRepairOnImport *bool `yaml:"resolve_repair_on_import" mapstructure:"resolve_repair_on_import" json:"resolve_repair_on_import,omitempty"`
DataVerification *bool `yaml:"data_verification" mapstructure:"data_verification" json:"data_verification,omitempty"`
}

// GenerateProviderID creates a unique ID based on host, port, and username
Expand Down Expand Up @@ -856,6 +857,7 @@ func DefaultConfig(configDir ...string) *Config {
skipHealthCheck := true
watchIntervalSeconds := 10 // Default watch interval
cleanupAutomaticImportFailure := false
dataVerification := false

// Set paths based on whether we're running in Docker or have a specific config directory
var dbPath, metadataPath, logPath, rclonePath, cachePath string
Expand Down Expand Up @@ -982,6 +984,7 @@ func DefaultConfig(configDir ...string) *Config {
SegmentSamplePercentage: 5, // Default: 5% segment sampling
LibrarySyncIntervalMinutes: 360, // Default: sync every 6 hours
ResolveRepairOnImport: &resolveRepairOnImport, // Enabled by default
DataVerification: &dataVerification, // Disabled by default
},
SABnzbd: SABnzbdConfig{
Enabled: &sabnzbdEnabled,
Expand Down
1 change: 1 addition & 0 deletions internal/health/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func (hc *HealthChecker) checkSingleFile(ctx context.Context, filePath string, f
hc.poolManager,
cfg.GetMaxConnectionsForHealthChecks(),
samplePercentage,
cfg.GetDataVerification(),
nil, // No progress callback for health checks
30*time.Second,
)
Expand Down
2 changes: 1 addition & 1 deletion internal/importer/validation/segments.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func ValidateSegmentsForFile(
}

// Validate segment availability using shared validation logic
if err := usenet.ValidateSegmentAvailability(ctx, segments, poolManager, maxGoroutines, samplePercentage, progressTracker, timeout); err != nil {
if err := usenet.ValidateSegmentAvailability(ctx, segments, poolManager, maxGoroutines, samplePercentage, false, progressTracker, timeout); err != nil {
return err
}

Expand Down
85 changes: 83 additions & 2 deletions internal/usenet/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package usenet
import (
"context"
"fmt"
"io"
"math/rand"
"sync/atomic"
"time"
Expand Down Expand Up @@ -33,6 +34,7 @@ func ValidateSegmentAvailability(
poolManager pool.Manager,
maxConnections int,
samplePercentage int,
verifyData bool,
progressTracker progress.ProgressTracker,
timeout time.Duration,
) error {
Expand All @@ -57,6 +59,37 @@ func ValidateSegmentAvailability(
// Atomic counter for progress tracking (thread-safe for concurrent validation)
var validatedCount int32

// Determine which segments need FULL download verification if verifyData is enabled
segmentsToDownload := make(map[string]bool)
if verifyData {
// First segment
if len(segments) > 0 {
segmentsToDownload[segments[0].Id] = true
}
// Last segment
if len(segments) > 1 {
segmentsToDownload[segments[len(segments)-1].Id] = true
}
// Random sample of ~18 others from the SELECTED segments
candidates := make([]string, 0, len(segmentsToValidate))
for _, s := range segmentsToValidate {
if !segmentsToDownload[s.Id] {
candidates = append(candidates, s.Id)
}
}

if len(candidates) > 0 {
perm := rand.Perm(len(candidates))
count := 18
if count > len(candidates) {
count = len(candidates)
}
for i := 0; i < count; i++ {
segmentsToDownload[candidates[perm[i]]] = true
}
}
}

// Validate segments concurrently with connection limit
pl := concpool.New().WithErrors().WithFirstError().WithMaxGoroutines(maxConnections)
for _, segment := range segmentsToValidate {
Expand All @@ -65,7 +98,15 @@ func ValidateSegmentAvailability(
checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

_, err := usenetPool.Stat(checkCtx, seg.Id, []string{})
var err error
if verifyData && segmentsToDownload[seg.Id] {
// Download check (Body)
_, err = usenetPool.Body(checkCtx, seg.Id, io.Discard, []string{})
} else {
// Stat check
_, err = usenetPool.Stat(checkCtx, seg.Id, []string{})
}

if err != nil {
return fmt.Errorf("segment with ID %s unreachable: %w", seg.Id, err)
}
Expand Down Expand Up @@ -102,6 +143,7 @@ func ValidateSegmentAvailabilityDetailed(
poolManager pool.Manager,
maxConnections int,
samplePercentage int,
verifyData bool,
progressTracker progress.ProgressTracker,
timeout time.Duration,
) (ValidationResult, error) {
Expand Down Expand Up @@ -135,6 +177,37 @@ func ValidateSegmentAvailabilityDetailed(
// We use a channel to collect missing IDs to avoid locking
missingChan := make(chan string, len(segmentsToValidate))

// Determine which segments need FULL download verification if verifyData is enabled
segmentsToDownload := make(map[string]bool)
if verifyData {
// First segment
if len(segments) > 0 {
segmentsToDownload[segments[0].Id] = true
}
// Last segment
if len(segments) > 1 {
segmentsToDownload[segments[len(segments)-1].Id] = true
}
// Random sample of ~18 others from the SELECTED segments
candidates := make([]string, 0, len(segmentsToValidate))
for _, s := range segmentsToValidate {
if !segmentsToDownload[s.Id] {
candidates = append(candidates, s.Id)
}
}

if len(candidates) > 0 {
perm := rand.Perm(len(candidates))
count := 18
if count > len(candidates) {
count = len(candidates)
}
for i := 0; i < count; i++ {
segmentsToDownload[candidates[perm[i]]] = true
}
}
}

// Validate segments concurrently with connection limit
// We don't use WithFirstError because we want to check all selected segments
pl := concpool.New().WithErrors().WithMaxGoroutines(maxConnections)
Expand All @@ -144,7 +217,15 @@ func ValidateSegmentAvailabilityDetailed(
checkCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

_, err := usenetPool.Stat(checkCtx, seg.Id, []string{})
var err error
if verifyData && segmentsToDownload[seg.Id] {
// Download check (Body)
_, err = usenetPool.Body(checkCtx, seg.Id, io.Discard, []string{})
} else {
// Stat check
_, err = usenetPool.Stat(checkCtx, seg.Id, []string{})
}

if err != nil {
atomic.AddInt32(&missingCount, 1)
missingChan <- seg.Id
Expand Down
Loading