Skip to content
Open
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
23 changes: 20 additions & 3 deletions pkg/unpackerr/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
Expand All @@ -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
}

Expand Down
99 changes: 69 additions & 30 deletions pkg/unpackerr/lidarr.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ package unpackerr
import (
"errors"
"path/filepath"
"regexp"
"strings"
"time"

"golift.io/starr"
"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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
}

Expand All @@ -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
}
Expand Down