diff --git a/pkg/unpackerr/handlers.go b/pkg/unpackerr/handlers.go index c00ed9b..46acf02 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 4e53242..b3d5fa1 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, @@ -150,36 +155,32 @@ 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, +// 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 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. +// 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) } } @@ -188,6 +189,43 @@ func filterManualImportToSplitTracks(outputs []*lidarr.ManualImportOutput, item 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) + } + } + } + + 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. @@ -200,15 +238,20 @@ func (u *Unpackerr) importSplitFlacTracks(item *Extract, server *LidarrConfig) { downloadID, _ := item.IDs["downloadId"].(string) artistID, _ := item.IDs["artistId"].(int64) - params := &lidarr.ManualImportParams{ - Folder: item.Path, + // 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 + } + + 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 @@ -219,12 +262,9 @@ 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) - + outputs = u.filterSplitOutputs(outputs, item) 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 } @@ -234,11 +274,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 }