Skip to content
Closed
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
9 changes: 6 additions & 3 deletions internal/importer/archive/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,18 @@ func ValidateSegmentIntegrity(ctx context.Context, content Content) error {
}
}
} else {
// For standard files, validate total segment coverage against PackedSize (if available)
// For standard files, validate total segment coverage against Size (uncompressed).
// Size comes from the first RAR volume header and is the correct full file size.
// PackedSize is the sum of actual parts found and can be truncated when volumes
// are missing, so we must validate against Size to detect incomplete archives.
var totalCovered int64
for _, seg := range content.Segments {
totalCovered += (seg.EndOffset - seg.StartOffset + 1)
}

expectedSize := content.PackedSize
expectedSize := content.Size
if expectedSize <= 0 {
expectedSize = content.Size
expectedSize = content.PackedSize
}

if expectedSize > 0 {
Expand Down
73 changes: 58 additions & 15 deletions internal/importer/archive/rar/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,23 @@ func ProcessArchive(ctx context.Context, opts ProcessArchiveOptions) error {
}
}

// Compute total archive size and a critical-file threshold.
// Validation failures on files below the threshold are logged and skipped
// (e.g. .nfo, .srt, .jpg sidecars in a missing last volume) so that the
// import does not fail when only a tiny sidecar is incomplete.
// Validation failures on files at or above the threshold fail the archive.
var totalArchiveSize int64
for _, c := range rarContents {
if !c.IsDirectory {
totalArchiveSize += c.Size
}
}
const minCriticalSizeAbsolute = 1 * 1024 * 1024 // 1 MB floor
minCriticalSize := totalArchiveSize / 100
if minCriticalSize < minCriticalSizeAbsolute {
minCriticalSize = minCriticalSizeAbsolute
}

// Pre-pass: resolve paths, apply renames, and pre-compute per-file segment offsets so
// each goroutine can build its own OffsetTracker without any sequential shared state.
type fileToProcess struct {
Expand Down Expand Up @@ -377,12 +394,18 @@ func ProcessArchive(ctx context.Context, opts ProcessArchiveOptions) error {
"file", item.baseFilename,
"size", item.content.Size)
} else {
if err := validateSegmentIntegrity(ctx, item.content); err != nil {
slog.ErrorContext(ctx, "Skipping RAR file due to segment integrity failure (missing segments in NZB)",
if err := validateSegmentIntegrity(ctx, item.content); err != nil {
if isSidecarFile(item.baseFilename) {
slog.WarnContext(ctx, "Skipping sidecar file with segment integrity failure",
"file", item.baseFilename,
"error", err)
return nil
}
slog.ErrorContext(ctx, "RAR file failed segment integrity validation",
"file", item.baseFilename,
"error", err)
return err
}

var offsetTracker *progress.OffsetTracker
if validationProgressTracker != nil && totalSegmentsToValidate > 0 {
Expand Down Expand Up @@ -411,21 +434,29 @@ func ProcessArchive(ctx context.Context, opts ProcessArchiveOptions) error {
}
}

if err := validation.ValidateSegmentsForFile(
ctx,
item.baseFilename,
validationSize,
validationSegments,
metapb.Encryption_NONE,
poolManager,
maxValidationGoroutines,
segmentSamplePercentage,
offsetTracker,
timeout,
); err != nil {
slog.WarnContext(ctx, "Skipping RAR file due to validation error", "error", err, "file", item.baseFilename)
if err := validation.ValidateSegmentsForFile(
ctx,
item.baseFilename,
validationSize,
validationSegments,
metapb.Encryption_NONE,
poolManager,
maxValidationGoroutines,
segmentSamplePercentage,
offsetTracker,
timeout,
); err != nil {
if isSidecarFile(item.baseFilename) {
slog.WarnContext(ctx, "Skipping sidecar file with segment availability failure",
"file", item.baseFilename,
"error", err)
return nil
}
slog.ErrorContext(ctx, "RAR file failed segment availability validation",
"file", item.baseFilename,
"error", err)
return err
}
}

fileMeta := rarProcessor.CreateFileMetadataFromRarContent(item.content, nzbPath, releaseDate, item.content.NzbdavID)
Expand Down Expand Up @@ -549,6 +580,18 @@ func expandISOContents(
return result, nil
}

// isSidecarFile returns true for subtitle, cover-art, and info files that can be
// safely skipped if their segments are missing, without failing the entire archive.
func isSidecarFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".srt", ".sub", ".idx", ".vtt", ".ass", ".ssa",
".jpg", ".jpeg", ".png", ".nfo", ".tbn":
return true
}
return false
}

// GroupArchivesByBaseName groups ParsedFiles by their RAR base name (case-insensitive).
// Returns groups in deterministic order (sorted by base name) for testability.
func GroupArchivesByBaseName(files []parser.ParsedFile) [][]parser.ParsedFile {
Expand Down
6 changes: 3 additions & 3 deletions internal/importer/archive/rar/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,9 @@ func patchMissingSegment(segments []*metapb.SegmentData, expectedSize, coveredSi
lastSeg := segments[len(segments)-1]
patchSeg := &metapb.SegmentData{
Id: lastSeg.Id,
StartOffset: lastSeg.StartOffset,
EndOffset: lastSeg.StartOffset + shortfall - 1,
SegmentSize: lastSeg.SegmentSize,
StartOffset: lastSeg.EndOffset + 1,
EndOffset: lastSeg.EndOffset + shortfall,
SegmentSize: shortfall,
}

patchedSegments := append(segments, patchSeg)
Expand Down