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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ postie-cli
# ClaudPoint checkpoint system
.checkpoints/
example/
# CocoIndex Code (ccc)
/.cocoindex_code/
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/javi11/nntppool/v4 v4.11.1
github.com/javi11/nxg v0.1.0
github.com/javi11/nzbparser v0.5.4
github.com/javi11/par2go v0.0.7
github.com/javi11/par2go v0.0.8
github.com/klauspost/compress v1.18.2
github.com/mattn/go-sqlite3 v1.14.32
github.com/mnightingale/rapidyenc v0.0.0-20251128204712-7aafef1eaf1c
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -345,16 +345,14 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
github.com/javi11/nntppool/v4 v4.10.1 h1:NHoRniTnCRgpt/niljsWjA3gP5pYtNwxaqbLdr0Bcew=
github.com/javi11/nntppool/v4 v4.10.1/go.mod h1:+UtisJLDFLXBSkW9R6uCRgdp3lS/+6pLAk7L+Wt6LMw=
github.com/javi11/nntppool/v4 v4.11.1 h1:581fZSPv+RyIKY2hI0GB0eZrm1rCbAp+HRP5nIp7kd0=
github.com/javi11/nntppool/v4 v4.11.1/go.mod h1:+UtisJLDFLXBSkW9R6uCRgdp3lS/+6pLAk7L+Wt6LMw=
github.com/javi11/nxg v0.1.0 h1:CTThldYlaVIPIhpkrMw0HcTD0NLrW1uYMoDILjjEOtM=
github.com/javi11/nxg v0.1.0/go.mod h1:+GvYpp+y1oq+qBOWxFMvfTjtin/0zCeomWfjiPkiu8A=
github.com/javi11/nzbparser v0.5.4 h1:0aYyORZipp7iX8eNpT/efnzCeVO+9C0sE2HWCGc/JaI=
github.com/javi11/nzbparser v0.5.4/go.mod h1:ikF7WI3BUGs5IHQJmKzmtTkX29NZW5nvUdo6ZWFZgL4=
github.com/javi11/par2go v0.0.7 h1:30KMAvFHCBheMkg6b+D1Pi+xx5EvGeXZdfKeoLDv3vc=
github.com/javi11/par2go v0.0.7/go.mod h1:GXuKNiRmZGv4rGia0FR23016IbcrsTMvLHihytsSnMU=
github.com/javi11/par2go v0.0.8 h1:dnEaRIHIxJZ9dQg2AZ6h/mD9L11xFpdAfaA+vWg2pAI=
github.com/javi11/par2go v0.0.8/go.mod h1:GXuKNiRmZGv4rGia0FR23016IbcrsTMvLHihytsSnMU=
github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
Expand Down
154 changes: 154 additions & 0 deletions internal/par2/binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,160 @@ func (b *BinaryExecutor) Create(ctx context.Context, files []fileinfo.FileInfo)
return b.CreateInDirectory(ctx, files, "")
}

// CreateSet bundles all input files into a single par2 set named setName,
// using parpar's --filepath-format=common so each FileDesc carries the
// folderDir as --filepath-base so parpar records each file's path relative
// to folderDir in the FileDesc packet, letting SABnzbd / NZBGet recreate
// the folder tree inside the job directory on disk.
func (b *BinaryExecutor) CreateSet(ctx context.Context, files []fileinfo.FileInfo, outputDir, setName, folderDir string) ([]string, error) {
if len(files) == 0 {
return nil, fmt.Errorf("par2: no input files for set %q", setName)
}
if setName == "" {
return nil, fmt.Errorf("par2: empty set name")
}

inputs := make([]fileinfo.FileInfo, 0, len(files))
for _, f := range files {
if filepath.Ext(f.Path) == ".par2" {
continue
}
inputs = append(inputs, f)
}
if len(inputs) == 0 {
return nil, fmt.Errorf("par2: no non-par2 input files for set %q", setName)
}

dirPath := outputDir
if dirPath == "" {
if b.cfg.TempDir != "" {
dirPath = b.cfg.TempDir
} else {
dirPath = filepath.Dir(inputs[0].Path)
}
}
if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, fmt.Errorf("par2: create output dir %s: %w", dirPath, err)
}

if existing, ok := checkExistingPar2SetInPath(ctx, setName, dirPath); ok {
return existing, nil
}

totalSize := uint64(0)
maxFileSize := uint64(0)
for _, f := range inputs {
totalSize += f.Size
if f.Size > maxFileSize {
maxFileSize = f.Size
}
}
blockSize := calculateParBlockSize(totalSize, b.articleSize)
if maxFileSize > 0 && blockSize > maxFileSize {
blockSize = alignDown(maxFileSize, 4)
}
if blockSize < 4 {
slog.WarnContext(ctx, "Block size too small for PAR2 set creation, skipping",
"setName", setName, "totalSize", totalSize)
return nil, nil
}
totalSlices := 0
for _, f := range inputs {
n := int(math.Ceil(float64(f.Size) / float64(blockSize)))
if n == 0 {
n = 1
}
totalSlices += n
}
redundancyPct := parseRedundancyPercentage(b.cfg.Redundancy, totalSize, blockSize)
numRecovery := max(int(math.Ceil(float64(totalSlices)*redundancyPct/100.0)), 1)

outputBase := filepath.Join(dirPath, setName)

// Use folderDir as --filepath-base so parpar records each file's path
// relative to the folder root (e.g. "extras/bonus.mkv"). This is the
// explicit folder root passed by the caller, which is always correct
// regardless of whether all input files happen to share a subdirectory.
args := []string{
"-s", fmt.Sprintf("%db", blockSize),
"-r", fmt.Sprintf("%d", numRecovery),
"-o", outputBase,
"--filepath-base", folderDir,
"--filepath-format", "outrel",
}
args = append(args, b.cfg.ParparExtraArgs...)
for _, f := range inputs {
args = append(args, f.Path)
}

progressID := uuid.New()
progressName := fmt.Sprintf("PAR2: %s", setName)
var pg progress.Progress
if b.jobProgress != nil {
pg = b.jobProgress.AddProgress(progressID, progressName, progress.ProgressTypePar2Generation, 100)
}

slog.InfoContext(ctx, "Invoking parpar binary for set",
"binary", b.cfg.ParparBinaryPath, "setName", setName, "files", len(inputs),
"outputBase", outputBase, "filepathBase", folderDir,
"blockSize", blockSize, "recoverySlices", numRecovery)

cmd := exec.CommandContext(ctx, b.cfg.ParparBinaryPath, args...)

stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("parpar stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("parpar start for set %s: %w", setName, err)
}

var stdoutBuf bytes.Buffer
var lastPct int64
scanner := bufio.NewScanner(io.TeeReader(stdoutPipe, &stdoutBuf))
scanner.Split(splitOnCROrLF)
for scanner.Scan() {
if pg == nil {
continue
}
if m := parparProgressRe.FindStringSubmatch(scanner.Text()); len(m) > 1 {
if pct, parseErr := strconv.ParseFloat(m[1], 64); parseErr == nil {
newPct := int64(pct)
if newPct > lastPct {
pg.UpdateProgress(newPct - lastPct)
lastPct = newPct
}
}
}
}

if err := cmd.Wait(); err != nil {
if ctx.Err() != nil {
slog.InfoContext(ctx, "Parpar set cancelled", "setName", setName)
return nil, ctx.Err()
}
combined := strings.TrimSpace(stdoutBuf.String())
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
if combined != "" {
combined += "\n"
}
combined += s
}
return nil, fmt.Errorf("parpar failed for set %s: %w\noutput: %s", setName, err, combined)
}

if pg != nil && b.jobProgress != nil {
if lastPct < 100 {
pg.UpdateProgress(100 - lastPct)
}
b.jobProgress.FinishProgress(progressID)
}

return collectPar2SetFiles(ctx, dirPath, setName, outputBase+".par2"), nil
}

// CreateInDirectory creates PAR2 files in the specified output directory using the parpar binary.
func (b *BinaryExecutor) CreateInDirectory(ctx context.Context, files []fileinfo.FileInfo, outputDir string) ([]string, error) {
var all []string
Expand Down
Loading
Loading