From 37b34bf8ee9694dbf57555cfca51d45ebe9a822b Mon Sep 17 00:00:00 2001 From: mprachar Date: Tue, 24 Mar 2026 15:56:39 +0000 Subject: [PATCH 1/3] Fix cross-platform CUE split path resolution and ManualImport When unpackerr runs on Linux but Starr apps run on Windows (or in Docker with different mount paths), several issues prevent CUE splitting from working end-to-end: 1. getDownloadPath: When the torrent title differs from the actual folder name (very common), the configured paths + title lookup fails. The fallback uses the raw outputPath from the Starr API, which is a UNC/Windows path unusable on Linux. Fix: extract the folder name from outputPath and try it against configured paths before falling back. 2. importSplitFlacTracks: The ManualImport API folder parameter was always item.Path (the local extraction path). On cross-platform setups, Lidarr can't access Linux paths. Fix: store the original outputPath from the Starr API on the Extract struct and use it for ManualImport, since Lidarr already knows that path. 3. filterManualImportToSplitTracks: Compared full file paths between Linux (NewFiles) and Windows (Lidarr ManualImport response). On Linux, filepath.Base doesn't split on backslashes, so Windows UNC paths returned the entire path instead of the filename. Fix: add crossPlatformBase() that normalizes backslashes before extracting the basename. 4. NewFiles tracking: After MoveFiles moves split tracks from the temp _unpackerred folder back to the original path, resp.NewFiles is often empty (especially for multi-CD albums with subdirectories). This caused extractionHasFlacFiles to return false, preventing importSplitFlacTracks from firing. Fix: trigger on resp.Size > 0 instead. Add filterManualImportToNumberedTracks as a fallback that matches the CUE-split naming pattern (NN - Title.flac) when NewFiles is unavailable. All fixes are platform-agnostic and benefit any cross-platform setup including Docker deployments with different mount paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/unpackerr/handlers.go | 23 +++++++++++-- pkg/unpackerr/lidarr.go | 70 +++++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/pkg/unpackerr/handlers.go b/pkg/unpackerr/handlers.go index c00ed9b3..46acf024 100644 --- a/pkg/unpackerr/handlers.go +++ b/pkg/unpackerr/handlers.go @@ -18,7 +18,8 @@ type Extract struct { Syncthing bool SplitFlac bool Retries uint - Path string + Path string // Local path (resolved for extraction on this host). + OutputPath string // Original path from Starr app (may be UNC/remote — used for ManualImport). App starr.App URL string Updated time.Time @@ -237,8 +238,7 @@ func (u *Unpackerr) handleXtractrCallback(resp *xtractr.Response) { u.Debugf("Extraction Finished: %d files in path: %s", len(files), files) u.updateQueueStatus(&newStatus{Name: resp.X.Name, Status: EXTRACTED, Resp: resp}, now, true) - if item != nil && item.App == starr.Lidarr && item.SplitFlac && - extractionHasFlacFiles(resp.NewFiles) { + if item != nil && item.App == starr.Lidarr && item.SplitFlac && resp.Size > 0 { go u.importSplitFlacTracks(item, u.lidarrServerByURL(item.URL)) } } @@ -264,8 +264,25 @@ func (u *Unpackerr) getDownloadPath(outputPath string, app starr.App, title stri // Print the errors for each user-provided path. u.Debugf("%s: Errors encountered looking for %s path: %q", app, title, errs) + // The title often differs from the actual folder name (e.g. torrent names include genre tags). + // Try the folder name from outputPath against configured paths — the folder name is the real + // directory on disk. This also handles cross-platform setups where outputPath is a UNC/Windows + // path but the configured paths are local Linux mounts of the same share. if outputPath != "" { + outputFolder := filepath.Base(filepath.FromSlash(strings.ReplaceAll(outputPath, `\`, `/`))) + if outputFolder != "" && outputFolder != "." && outputFolder != title { + for _, path := range paths { + candidate := filepath.Join(path, outputFolder) + + if _, err := os.Stat(candidate); err == nil { + u.Debugf("%s: Resolved via outputPath folder name: %s -> %s", app, outputPath, candidate) + return candidate + } + } + } + u.Debugf("%s: Configured paths do not exist; trying 'outputPath': %s", app, outputPath) + return outputPath } diff --git a/pkg/unpackerr/lidarr.go b/pkg/unpackerr/lidarr.go index 4e532426..443fab3a 100644 --- a/pkg/unpackerr/lidarr.go +++ b/pkg/unpackerr/lidarr.go @@ -3,6 +3,7 @@ package unpackerr import ( "errors" "path/filepath" + "regexp" "strings" "time" @@ -10,6 +11,9 @@ import ( "golift.io/starr/lidarr" ) +// numberedTrackPattern matches filenames generated by CUE splitting: "NN - Title.flac". +var numberedTrackPattern = regexp.MustCompile(`^\d{2,3} - .+\.flac$`) + // LidarrConfig represents the input data for a Lidarr server. type LidarrConfig struct { StarrConfig @@ -102,6 +106,7 @@ func (u *Unpackerr) checkLidarrQueue(now time.Time) { Syncthing: server.Syncthing, SplitFlac: server.SplitFlac, Path: u.getDownloadPath(record.OutputPath, starr.Lidarr, record.Title, server.Paths), + OutputPath: record.OutputPath, IDs: map[string]any{ "title": record.Title, "artistId": record.ArtistID, @@ -162,24 +167,50 @@ func extractionHasFlacFiles(files []string) bool { return false } -// filterManualImportToSplitTracks returns only outputs whose path is in the extract's NewFiles -// (the split track files). This excludes the original FLAC file we split from, which Lidarr -// would otherwise try and fail to import. +// crossPlatformBase returns the filename from a path that may use either forward slashes +// or backslashes as separators. On Linux, filepath.Base does not split on backslashes, +// so Windows/UNC paths from Starr apps would return the entire path instead of the filename. +func crossPlatformBase(path string) string { + return filepath.Base(filepath.FromSlash(strings.ReplaceAll(path, `\`, `/`))) +} + +// filterManualImportToSplitTracks returns only outputs whose filename matches a split track file. +// This excludes the original audio file we split from, which Lidarr would otherwise try and fail +// to import. Comparison uses basenames (filenames only) so it works across platforms — the extraction +// host (e.g. Linux) may use different paths than the Starr app (e.g. Windows). func filterManualImportToSplitTracks(outputs []*lidarr.ManualImportOutput, item *Extract) []*lidarr.ManualImportOutput { if item == nil || item.Resp == nil || len(item.Resp.NewFiles) == 0 { return outputs } - splitPaths := make(map[string]struct{}, len(item.Resp.NewFiles)) + splitFiles := make(map[string]struct{}, len(item.Resp.NewFiles)) for _, p := range item.Resp.NewFiles { - splitPaths[filepath.Clean(p)] = struct{}{} + splitFiles[strings.ToLower(crossPlatformBase(p))] = struct{}{} } filtered := outputs[:0] // reusable memory for _, out := range outputs { if out != nil && out.Path != "" { - if _, ok := splitPaths[filepath.Clean(out.Path)]; ok { + if _, ok := splitFiles[strings.ToLower(crossPlatformBase(out.Path))]; ok { + filtered = append(filtered, out) + } + } + } + + return filtered +} + +// filterManualImportToNumberedTracks returns outputs whose filename matches the CUE-split +// naming pattern (NN - Title.flac). This is a fallback when NewFiles tracking is unavailable +// (e.g. after MoveFiles moves tracks back to the original folder). +func filterManualImportToNumberedTracks(outputs []*lidarr.ManualImportOutput) []*lidarr.ManualImportOutput { + filtered := make([]*lidarr.ManualImportOutput, 0, len(outputs)) + + for _, out := range outputs { + if out != nil && out.Path != "" { + base := crossPlatformBase(out.Path) + if numberedTrackPattern.MatchString(base) { filtered = append(filtered, out) } } @@ -200,8 +231,15 @@ func (u *Unpackerr) importSplitFlacTracks(item *Extract, server *LidarrConfig) { downloadID, _ := item.IDs["downloadId"].(string) artistID, _ := item.IDs["artistId"].(int64) + // Use OutputPath (the Starr app's view of the path) for ManualImport when available. + // The extraction host may use a different mount/path than the Starr app (e.g., Linux vs Windows UNC). + importFolder := item.Path + if item.OutputPath != "" { + importFolder = item.OutputPath + } + params := &lidarr.ManualImportParams{ - Folder: item.Path, + Folder: importFolder, DownloadID: downloadID, ArtistID: artistID, FilterExistingFiles: false, @@ -219,12 +257,22 @@ func (u *Unpackerr) importSplitFlacTracks(item *Extract, server *LidarrConfig) { return } - // Exclude the original FLAC we split from; only import the split track files (item.Resp.NewFiles). - // Lidarr returns every file in the folder including the source file, which will never import. - outputs = filterManualImportToSplitTracks(outputs, item) + // Filter to split track files only, excluding the original unsplit audio file. + // When NewFiles is populated (tracked by xtractr), use basename matching. + // When NewFiles is empty (common after MoveFiles moves tracks back to the original folder), + // filter to numbered track files (NN - Title.flac pattern) which are generated by CUE splitting. + allOutputs := make([]*lidarr.ManualImportOutput, len(outputs)) + copy(allOutputs, outputs) + + outputs = filterManualImportToSplitTracks(allOutputs, item) + + if len(outputs) == 0 { + u.Printf("[Lidarr] No split track files matched via NewFiles for %s; trying numbered track pattern", item.Path) + outputs = filterManualImportToNumberedTracks(allOutputs) + } if len(outputs) == 0 { - u.Printf("[Lidarr] No split track files to import (folder: %s); original FLAC excluded", item.Path) + u.Printf("[Lidarr] No split track files to import (folder: %s)", item.Path) return } From 9c1dc5eef168918736ed8ba942301502056f0d1c Mon Sep 17 00:00:00 2001 From: mprachar Date: Tue, 24 Mar 2026 16:18:11 +0000 Subject: [PATCH 2/3] fix: remove unused function, extract filter to satisfy funlen lint - Remove extractionHasFlacFiles (unused after resp.Size trigger change) - Extract filterSplitOutputs helper from importSplitFlacTracks to bring function length under the 60-line funlen limit Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/unpackerr/lidarr.go | 56 +++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/pkg/unpackerr/lidarr.go b/pkg/unpackerr/lidarr.go index 443fab3a..4632a656 100644 --- a/pkg/unpackerr/lidarr.go +++ b/pkg/unpackerr/lidarr.go @@ -155,17 +155,6 @@ func (u *Unpackerr) lidarrServerByURL(url string) *LidarrConfig { return nil } -// extractionHasFlacFiles returns true if any path in files has a .flac extension. -// Used to only trigger manual import after a FLAC+CUE split, not for e.g. zip-of-mp3s. -func extractionHasFlacFiles(files []string) bool { - for _, p := range files { - if strings.HasSuffix(strings.ToLower(p), ".flac") { - return true - } - } - - return false -} // crossPlatformBase returns the filename from a path that may use either forward slashes // or backslashes as separators. On Linux, filepath.Base does not split on backslashes, @@ -219,6 +208,23 @@ func filterManualImportToNumberedTracks(outputs []*lidarr.ManualImportOutput) [] return filtered } +// filterSplitOutputs filters ManualImport outputs to only include split track files. +// First tries matching against NewFiles (basename comparison), then falls back to the +// CUE-split naming pattern (NN - Title.flac) when NewFiles is unavailable. +func (u *Unpackerr) filterSplitOutputs(outputs []*lidarr.ManualImportOutput, item *Extract) []*lidarr.ManualImportOutput { + allOutputs := make([]*lidarr.ManualImportOutput, len(outputs)) + copy(allOutputs, outputs) + + filtered := filterManualImportToSplitTracks(allOutputs, item) + if len(filtered) > 0 { + return filtered + } + + u.Printf("[Lidarr] No split track files matched via NewFiles for %s; trying numbered track pattern", item.Path) + + return filterManualImportToNumberedTracks(allOutputs) +} + // importSplitFlacTracks runs in a goroutine after a Lidarr FLAC+CUE split extraction completes. // It asks Lidarr for the manual import list for the extract folder and sends the ManualImport command // so Lidarr imports the split track files. @@ -238,15 +244,13 @@ func (u *Unpackerr) importSplitFlacTracks(item *Extract, server *LidarrConfig) { importFolder = item.OutputPath } - params := &lidarr.ManualImportParams{ + outputs, err := server.ManualImport(&lidarr.ManualImportParams{ Folder: importFolder, DownloadID: downloadID, ArtistID: artistID, FilterExistingFiles: false, ReplaceExistingFiles: true, - } - - outputs, err := server.ManualImport(params) + }) if err != nil { u.Errorf("[Lidarr] Manual import list failed for %s: %v", item.Path, err) return @@ -257,20 +261,7 @@ func (u *Unpackerr) importSplitFlacTracks(item *Extract, server *LidarrConfig) { return } - // Filter to split track files only, excluding the original unsplit audio file. - // When NewFiles is populated (tracked by xtractr), use basename matching. - // When NewFiles is empty (common after MoveFiles moves tracks back to the original folder), - // filter to numbered track files (NN - Title.flac pattern) which are generated by CUE splitting. - allOutputs := make([]*lidarr.ManualImportOutput, len(outputs)) - copy(allOutputs, outputs) - - outputs = filterManualImportToSplitTracks(allOutputs, item) - - if len(outputs) == 0 { - u.Printf("[Lidarr] No split track files matched via NewFiles for %s; trying numbered track pattern", item.Path) - outputs = filterManualImportToNumberedTracks(allOutputs) - } - + outputs = u.filterSplitOutputs(outputs, item) if len(outputs) == 0 { u.Printf("[Lidarr] No split track files to import (folder: %s)", item.Path) return @@ -282,11 +273,10 @@ func (u *Unpackerr) importSplitFlacTracks(item *Extract, server *LidarrConfig) { return } - u.Debugf("[Lidarr] Sending manual import command: replaceExisting=%v, importMode=%q, files=%d: %v", - cmd.ReplaceExistingFiles, cmd.ImportMode, len(cmd.Files), cmd.Files) + u.Debugf("[Lidarr] Sending manual import command: replaceExisting=%v, importMode=%q, files=%d", + cmd.ReplaceExistingFiles, cmd.ImportMode, len(cmd.Files)) - _, err = server.SendManualImportCommand(cmd) - if err != nil { + if _, err = server.SendManualImportCommand(cmd); err != nil { u.Errorf("[Lidarr] Manual import command failed for %s: %v", item.Path, err) return } From 161eda888b94edaeff6ded3842f01fac6ef7fa93 Mon Sep 17 00:00:00 2001 From: mprachar Date: Tue, 24 Mar 2026 17:00:47 +0000 Subject: [PATCH 3/3] fix: remove extra blank line, wrap long function signature for lll lint Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/unpackerr/lidarr.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/unpackerr/lidarr.go b/pkg/unpackerr/lidarr.go index 4632a656..b3d5fa14 100644 --- a/pkg/unpackerr/lidarr.go +++ b/pkg/unpackerr/lidarr.go @@ -155,7 +155,6 @@ func (u *Unpackerr) lidarrServerByURL(url string) *LidarrConfig { return nil } - // crossPlatformBase returns the filename from a path that may use either forward slashes // or backslashes as separators. On Linux, filepath.Base does not split on backslashes, // so Windows/UNC paths from Starr apps would return the entire path instead of the filename. @@ -211,7 +210,9 @@ func filterManualImportToNumberedTracks(outputs []*lidarr.ManualImportOutput) [] // filterSplitOutputs filters ManualImport outputs to only include split track files. // First tries matching against NewFiles (basename comparison), then falls back to the // CUE-split naming pattern (NN - Title.flac) when NewFiles is unavailable. -func (u *Unpackerr) filterSplitOutputs(outputs []*lidarr.ManualImportOutput, item *Extract) []*lidarr.ManualImportOutput { +func (u *Unpackerr) filterSplitOutputs( + outputs []*lidarr.ManualImportOutput, item *Extract, +) []*lidarr.ManualImportOutput { allOutputs := make([]*lidarr.ManualImportOutput, len(outputs)) copy(allOutputs, outputs)