diff --git a/backend/library/scan_queue.go b/backend/library/scan_queue.go index 8d0b50a..fc46018 100644 --- a/backend/library/scan_queue.go +++ b/backend/library/scan_queue.go @@ -111,7 +111,9 @@ func (l *Library) SoftScanAllLibraries() error { // dynamic library_id unsupported by sqlc. No user input. for _, lib := range libs { result, claimErr := l.db.ExecContext( - `UPDATE audio_files SET library_id = ? WHERE library_id = 0 AND file_path LIKE ? || '%'`, + `UPDATE audio_files SET library_id = ?`+ + ` WHERE library_id = 0`+ + ` AND file_path LIKE ? || '%'`, lib.ID, lib.Path+"/", ) if claimErr != nil { diff --git a/backend/playlist/m3u.go b/backend/playlist/m3u.go index 52b66ac..fb027e2 100644 --- a/backend/playlist/m3u.go +++ b/backend/playlist/m3u.go @@ -370,6 +370,65 @@ func toRelativePath(absolutePath, libraryRoot string) string { return rel } +// resolveM3UPath resolves a relative M3U path against multiple +// library roots, returning the first absolute path that exists in +// the knownPaths set. If the path is already absolute and known, +// it is returned as-is. Falls back to the first root if no match +// is found, preserving current behavior for phantom tracks. +func resolveM3UPath( + relativePath string, + libraryRoots []string, + knownPaths map[string]struct{}, +) string { + if filepath.IsAbs(relativePath) { + if _, ok := knownPaths[relativePath]; ok { + return relativePath + } + + return relativePath + } + + for _, root := range libraryRoots { + absPath := filepath.Join(root, relativePath) + if _, ok := knownPaths[absPath]; ok { + return absPath + } + } + + // Fallback: use first root (preserves current behavior + // for phantom tracks). + if len(libraryRoots) > 0 { + return filepath.Join(libraryRoots[0], relativePath) + } + + return relativePath +} + +// toRelativePathMultiRoot converts an absolute path to a relative +// path using the first library root that contains the path. +// If no root matches, the absolute path is returned unchanged. +func toRelativePathMultiRoot( + absolutePath string, + libraryRoots []string, +) string { + for _, root := range libraryRoots { + if root == "" { + continue + } + + rel, err := filepath.Rel(root, absolutePath) + if err != nil { + continue + } + + if !strings.HasPrefix(rel, "..") { + return rel + } + } + + return absolutePath +} + // isValidM3UExtension checks whether a file extension is a // recognized M3U variant. func isValidM3UExtension(ext string) bool { @@ -414,17 +473,18 @@ func extractPlaylistID(filePath string) int64 { } // removeM3UEntries removes entries from a slice whose resolved -// absolute paths appear in the target set. +// absolute paths appear in the target set. Each entry is resolved +// against all library roots. func removeM3UEntries( entries []m3uEntry, targetAbsPaths map[string]struct{}, - libraryRoot string, + libraryRoots []string, ) []m3uEntry { result := make([]m3uEntry, 0, len(entries)) for _, e := range entries { - absPath := toAbsolutePath( - e.RelativePath, libraryRoot, + absPath := resolveM3UPath( + e.RelativePath, libraryRoots, targetAbsPaths, ) if _, remove := targetAbsPaths[absPath]; remove { continue @@ -438,19 +498,26 @@ func removeM3UEntries( // replaceM3UEntryPaths replaces the relative paths of entries // whose resolved absolute paths match keys in the replacements -// map. Values are new relative paths. +// map. Values are new relative paths. Each entry is resolved +// against all library roots. func replaceM3UEntryPaths( entries []m3uEntry, replacements map[string]string, - libraryRoot string, + libraryRoots []string, ) []m3uEntry { + // Build a set of replacement keys for resolveM3UPath lookup. + keySet := make(map[string]struct{}, len(replacements)) + for k := range replacements { + keySet[k] = struct{}{} + } + result := make([]m3uEntry, len(entries)) for i, e := range entries { result[i] = e - absPath := toAbsolutePath( - e.RelativePath, libraryRoot, + absPath := resolveM3UPath( + e.RelativePath, libraryRoots, keySet, ) if newRel, ok := replacements[absPath]; ok { @@ -462,16 +529,21 @@ func replaceM3UEntryPaths( } // findM3UEntry finds the M3U entry whose resolved absolute path -// matches the given target path. Returns the entry and its index, -// or -1 if not found. +// matches the given target path. Each entry is resolved against +// all library roots. Returns the entry and its index, or -1 if +// not found. func findM3UEntry( entries []m3uEntry, targetAbsPath string, - libraryRoot string, + libraryRoots []string, ) (m3uEntry, int) { + targetSet := map[string]struct{}{ + targetAbsPath: {}, + } + for i, e := range entries { - absPath := toAbsolutePath( - e.RelativePath, libraryRoot, + absPath := resolveM3UPath( + e.RelativePath, libraryRoots, targetSet, ) if absPath == targetAbsPath { return e, i diff --git a/backend/playlist/m3u_test.go b/backend/playlist/m3u_test.go index f9c10f6..585e155 100644 --- a/backend/playlist/m3u_test.go +++ b/backend/playlist/m3u_test.go @@ -731,7 +731,9 @@ func TestRemoveM3UEntries(t *testing.T) { "/music/Artist/Song2.flac": {}, } - result := removeM3UEntries(entries, targets, "/music") + result := removeM3UEntries( + entries, targets, []string{"/music"}, + ) if len(result) != 2 { t.Fatalf("expected 2 entries, got %d", len(result)) @@ -765,7 +767,9 @@ func TestRemoveM3UEntriesAll(t *testing.T) { "/music/Song.flac": {}, } - result := removeM3UEntries(entries, targets, "/music") + result := removeM3UEntries( + entries, targets, []string{"/music"}, + ) if len(result) != 0 { t.Errorf("expected 0 entries, got %d", len(result)) @@ -793,7 +797,7 @@ func TestReplaceM3UEntryPaths(t *testing.T) { } result := replaceM3UEntryPaths( - entries, replacements, "/music", + entries, replacements, []string{"/music"}, ) if len(result) != 2 { @@ -836,7 +840,8 @@ func TestFindM3UEntry(t *testing.T) { } entry, idx := findM3UEntry( - entries, "/music/Artist/Song2.flac", "/music", + entries, "/music/Artist/Song2.flac", + []string{"/music"}, ) if idx != 1 { @@ -853,7 +858,8 @@ func TestFindM3UEntry(t *testing.T) { // Not found. _, idx = findM3UEntry( - entries, "/music/Artist/Missing.flac", "/music", + entries, "/music/Artist/Missing.flac", + []string{"/music"}, ) if idx != -1 { diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 13fd7de..2d02118 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -367,12 +367,21 @@ func (s *Service) mergeTracksForPlaylist( return dbTracksToSlice(dbTracks) } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() + + // Build a set of known DB paths for multi-root resolution. + knownPaths := make( + map[string]struct{}, len(dbTracks), + ) + for k := range dbTracks { + knownPaths[k] = struct{}{} + } + tracks := make([]Track, 0, len(parsed.Entries)) for i, entry := range parsed.Entries { - absPath := toAbsolutePath( - entry.RelativePath, libraryRoot, + absPath := resolveM3UPath( + entry.RelativePath, libraryRoots, knownPaths, ) if dbTrack, ok := dbTracks[absPath]; ok { @@ -841,7 +850,7 @@ func (s *Service) ImportPlaylist( ) } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() var ( resolved int @@ -850,14 +859,37 @@ func (s *Service) ImportPlaylist( ) for _, entry := range parsed.Entries { - absPath := toAbsolutePath( - entry.RelativePath, libraryRoot, - ) + var audioFile sqlcgen.AudioFile - audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( - s.db.Ctx, absPath, - ) - if lookupErr != nil { + found := false + + if filepath.IsAbs(entry.RelativePath) { + af, err := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, entry.RelativePath, + ) + if err == nil { + audioFile = af + found = true + } + } else { + for _, root := range libraryRoots { + absPath := toAbsolutePath( + entry.RelativePath, root, + ) + + af, err := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, absPath, + ) + if err == nil { + audioFile = af + found = true + + break + } + } + } + + if !found { // Track not in library — will appear as phantom. unresolved++ @@ -876,7 +908,7 @@ func (s *Service) ImportPlaylist( s.logger.Warn( "Could not add imported track", "playlistId", created.ID, - "path", absPath, + "path", audioFile.FilePath, "err", addErr, ) @@ -890,7 +922,7 @@ func (s *Service) ImportPlaylist( // Save the M3U8 file with entries (preserves unresolved // paths for phantom display). s.saveImportedPlaylistFile( - created.ID, playlistName, parsed.Entries, libraryRoot, + created.ID, playlistName, parsed.Entries, libraryRoots, ) s.logger.Info( @@ -981,7 +1013,7 @@ func (s *Service) RestoreAllPlaylists() { return } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() var totalRestored, totalUnresolved int @@ -997,7 +1029,7 @@ func (s *Service) RestoreAllPlaylists() { } restored, unresolved := s.restoreSinglePlaylist( - playlistID, file, libraryRoot, + playlistID, file, libraryRoots, ) totalRestored += restored @@ -1014,11 +1046,12 @@ func (s *Service) RestoreAllPlaylists() { } // restoreSinglePlaylist restores tracks for a single playlist -// from its M3U8 file. +// from its M3U8 file. Each M3U8 entry is resolved against all +// library roots. func (s *Service) restoreSinglePlaylist( playlistID int64, m3uPath string, - libraryRoot string, + libraryRoots []string, ) (restored, unresolved int) { parsed, err := parseM3U8(m3uPath) if err != nil { @@ -1049,14 +1082,37 @@ func (s *Service) restoreSinglePlaylist( var position int for _, entry := range parsed.Entries { - absPath := toAbsolutePath( - entry.RelativePath, libraryRoot, - ) + var audioFile sqlcgen.AudioFile - audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( - s.db.Ctx, absPath, - ) - if lookupErr != nil { + found := false + + if filepath.IsAbs(entry.RelativePath) { + af, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, entry.RelativePath, + ) + if lookupErr == nil { + audioFile = af + found = true + } + } else { + for _, root := range libraryRoots { + absPath := toAbsolutePath( + entry.RelativePath, root, + ) + + af, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, absPath, + ) + if lookupErr == nil { + audioFile = af + found = true + + break + } + } + } + + if !found { unresolved++ continue @@ -1074,7 +1130,7 @@ func (s *Service) restoreSinglePlaylist( s.logger.Warn( "Could not restore track", "playlistId", playlistID, - "path", absPath, + "path", audioFile.FilePath, "err", addErr, ) @@ -1169,24 +1225,29 @@ func (s *Service) playlistsDir() (string, error) { return dir, nil } -// getLibraryRoot returns the library root directory path. -// It first checks the legacy config DirectoryPath; if that is -// empty (removed during multi-library migration) it falls back -// to the first library's path from the database. -func (s *Service) getLibraryRoot() string { +// getAllLibraryRoots returns the root directory paths of all +// configured libraries. It first checks the legacy config +// DirectoryPath; if that is set, it returns a single-element +// slice. Otherwise it queries all libraries from the database. +func (s *Service) getAllLibraryRoots() []string { + // Legacy config fallback. if s.libraryDir != nil { if dir := s.libraryDir.GetLibraryDirectory(); dir != "" { - return dir + return []string{dir} } } - // Fallback: query the first library from the database. libs, err := s.db.Queries.GetAllLibraries(s.db.Ctx) if err != nil || len(libs) == 0 { - return "" + return nil } - return libs[0].Path + roots := make([]string, len(libs)) + for i, lib := range libs { + roots[i] = lib.Path + } + + return roots } // savePlaylistFile saves the current state of a playlist to its @@ -1243,7 +1304,7 @@ func (s *Service) saveImportedPlaylistFile( playlistID int64, name string, entries []m3uEntry, - libraryRoot string, + libraryRoots []string, ) { dir, err := s.playlistsDir() if err != nil { @@ -1255,16 +1316,22 @@ func (s *Service) saveImportedPlaylistFile( return } - // Convert any absolute paths in entries to relative. + // Convert any absolute paths in entries to relative + // using the correct library root for each track. converted := make([]m3uEntry, len(entries)) for i, entry := range entries { + // Resolve against all roots to get the absolute path, + // then convert back to relative using the matching root. + absPath := resolveM3UPath( + entry.RelativePath, + libraryRoots, + nil, + ) + converted[i] = m3uEntry{ - RelativePath: toRelativePath( - toAbsolutePath( - entry.RelativePath, libraryRoot, - ), - libraryRoot, + RelativePath: toRelativePathMultiRoot( + absPath, libraryRoots, ), DurationSec: entry.DurationSec, DisplayTitle: entry.DisplayTitle, @@ -1301,7 +1368,7 @@ func (s *Service) buildM3UEntries( return nil } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() entries := make([]m3uEntry, 0, len(rows)) for _, row := range rows { @@ -1310,8 +1377,8 @@ func (s *Service) buildM3UEntries( ) entries = append(entries, m3uEntry{ - RelativePath: toRelativePath( - row.FilePath, libraryRoot, + RelativePath: toRelativePathMultiRoot( + row.FilePath, libraryRoots, ), DurationSec: durationSec, DisplayTitle: displayTitle( @@ -1445,7 +1512,7 @@ func (s *Service) FindPhantomMatches( ) } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() // Load M3U8 entries for display title / duration data. m3uPath, err := findPlaylistFile(dir, playlistID) @@ -1465,11 +1532,13 @@ func (s *Service) FindPhantomMatches( } // Build a lookup from absolute path to M3U entry. + // Use resolveM3UPath with a nil knownPaths to get the + // first-root fallback for each entry. entryByPath := make(map[string]m3uEntry, len(entries)) for _, e := range entries { - absPath := toAbsolutePath( - e.RelativePath, libraryRoot, + absPath := resolveM3UPath( + e.RelativePath, libraryRoots, nil, ) entryByPath[absPath] = e } @@ -1533,7 +1602,7 @@ func (s *Service) GetPhantomCandidates( ) } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() // Find the M3U entry for this phantom. m3uPath, err := findPlaylistFile(dir, playlistID) @@ -1549,7 +1618,7 @@ func (s *Service) GetPhantomCandidates( parsed, parseErr := parseM3U8(m3uPath) if parseErr == nil { entry, _ = findM3UEntry( - parsed.Entries, phantomPath, libraryRoot, + parsed.Entries, phantomPath, libraryRoots, ) } } @@ -1613,7 +1682,7 @@ func (s *Service) ResolvePhantomTracks( ) } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil || m3uPath == "" { @@ -1681,7 +1750,9 @@ func (s *Service) ResolvePhantomTracks( continue } - newRel := toRelativePath(resolvedAbs, libraryRoot) + newRel := toRelativePathMultiRoot( + resolvedAbs, libraryRoots, + ) pathReplacements[phantomAbs] = newRel resolved++ } @@ -1689,7 +1760,7 @@ func (s *Service) ResolvePhantomTracks( // Rewrite the M3U8 with updated paths. if resolved > 0 { updated := replaceM3UEntryPaths( - parsed.Entries, pathReplacements, libraryRoot, + parsed.Entries, pathReplacements, libraryRoots, ) playlist, nameErr := s.db.Queries.GetPlaylist( @@ -1740,7 +1811,7 @@ func (s *Service) RemovePhantomTracks( ) } - libraryRoot := s.getLibraryRoot() + libraryRoots := s.getAllLibraryRoots() m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil || m3uPath == "" { @@ -1766,7 +1837,7 @@ func (s *Service) RemovePhantomTracks( } updated := removeM3UEntries( - parsed.Entries, targetSet, libraryRoot, + parsed.Entries, targetSet, libraryRoots, ) playlist, err := s.db.Queries.GetPlaylist(