fix(quick-19): multi-root path resolution for playlist M3U8 tracks

- Replace getLibraryRoot() with getAllLibraryRoots() returning all library paths
- Add resolveM3UPath() for multi-root resolution with knownPaths lookup
- Add toRelativePathMultiRoot() to save relative paths using correct root
- Update removeM3UEntries, replaceM3UEntryPaths, findM3UEntry for []string roots
- Update all 8 call sites in playlist.go for multi-root resolution
- Update existing test signatures for new []string parameter types
- Fix pre-existing golines formatting in scan_queue.go
This commit is contained in:
2026-03-16 01:07:45 -04:00
parent 08216752b2
commit 9144dedc27
4 changed files with 224 additions and 73 deletions
+3 -1
View File
@@ -111,7 +111,9 @@ func (l *Library) SoftScanAllLibraries() error {
// dynamic library_id unsupported by sqlc. No user input. // dynamic library_id unsupported by sqlc. No user input.
for _, lib := range libs { for _, lib := range libs {
result, claimErr := l.db.ExecContext( 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+"/", lib.ID, lib.Path+"/",
) )
if claimErr != nil { if claimErr != nil {
+85 -13
View File
@@ -370,6 +370,65 @@ func toRelativePath(absolutePath, libraryRoot string) string {
return rel 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 // isValidM3UExtension checks whether a file extension is a
// recognized M3U variant. // recognized M3U variant.
func isValidM3UExtension(ext string) bool { func isValidM3UExtension(ext string) bool {
@@ -414,17 +473,18 @@ func extractPlaylistID(filePath string) int64 {
} }
// removeM3UEntries removes entries from a slice whose resolved // 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( func removeM3UEntries(
entries []m3uEntry, entries []m3uEntry,
targetAbsPaths map[string]struct{}, targetAbsPaths map[string]struct{},
libraryRoot string, libraryRoots []string,
) []m3uEntry { ) []m3uEntry {
result := make([]m3uEntry, 0, len(entries)) result := make([]m3uEntry, 0, len(entries))
for _, e := range entries { for _, e := range entries {
absPath := toAbsolutePath( absPath := resolveM3UPath(
e.RelativePath, libraryRoot, e.RelativePath, libraryRoots, targetAbsPaths,
) )
if _, remove := targetAbsPaths[absPath]; remove { if _, remove := targetAbsPaths[absPath]; remove {
continue continue
@@ -438,19 +498,26 @@ func removeM3UEntries(
// replaceM3UEntryPaths replaces the relative paths of entries // replaceM3UEntryPaths replaces the relative paths of entries
// whose resolved absolute paths match keys in the replacements // 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( func replaceM3UEntryPaths(
entries []m3uEntry, entries []m3uEntry,
replacements map[string]string, replacements map[string]string,
libraryRoot string, libraryRoots []string,
) []m3uEntry { ) []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)) result := make([]m3uEntry, len(entries))
for i, e := range entries { for i, e := range entries {
result[i] = e result[i] = e
absPath := toAbsolutePath( absPath := resolveM3UPath(
e.RelativePath, libraryRoot, e.RelativePath, libraryRoots, keySet,
) )
if newRel, ok := replacements[absPath]; ok { if newRel, ok := replacements[absPath]; ok {
@@ -462,16 +529,21 @@ func replaceM3UEntryPaths(
} }
// findM3UEntry finds the M3U entry whose resolved absolute path // findM3UEntry finds the M3U entry whose resolved absolute path
// matches the given target path. Returns the entry and its index, // matches the given target path. Each entry is resolved against
// or -1 if not found. // all library roots. Returns the entry and its index, or -1 if
// not found.
func findM3UEntry( func findM3UEntry(
entries []m3uEntry, entries []m3uEntry,
targetAbsPath string, targetAbsPath string,
libraryRoot string, libraryRoots []string,
) (m3uEntry, int) { ) (m3uEntry, int) {
targetSet := map[string]struct{}{
targetAbsPath: {},
}
for i, e := range entries { for i, e := range entries {
absPath := toAbsolutePath( absPath := resolveM3UPath(
e.RelativePath, libraryRoot, e.RelativePath, libraryRoots, targetSet,
) )
if absPath == targetAbsPath { if absPath == targetAbsPath {
return e, i return e, i
+11 -5
View File
@@ -731,7 +731,9 @@ func TestRemoveM3UEntries(t *testing.T) {
"/music/Artist/Song2.flac": {}, "/music/Artist/Song2.flac": {},
} }
result := removeM3UEntries(entries, targets, "/music") result := removeM3UEntries(
entries, targets, []string{"/music"},
)
if len(result) != 2 { if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result)) t.Fatalf("expected 2 entries, got %d", len(result))
@@ -765,7 +767,9 @@ func TestRemoveM3UEntriesAll(t *testing.T) {
"/music/Song.flac": {}, "/music/Song.flac": {},
} }
result := removeM3UEntries(entries, targets, "/music") result := removeM3UEntries(
entries, targets, []string{"/music"},
)
if len(result) != 0 { if len(result) != 0 {
t.Errorf("expected 0 entries, got %d", len(result)) t.Errorf("expected 0 entries, got %d", len(result))
@@ -793,7 +797,7 @@ func TestReplaceM3UEntryPaths(t *testing.T) {
} }
result := replaceM3UEntryPaths( result := replaceM3UEntryPaths(
entries, replacements, "/music", entries, replacements, []string{"/music"},
) )
if len(result) != 2 { if len(result) != 2 {
@@ -836,7 +840,8 @@ func TestFindM3UEntry(t *testing.T) {
} }
entry, idx := findM3UEntry( entry, idx := findM3UEntry(
entries, "/music/Artist/Song2.flac", "/music", entries, "/music/Artist/Song2.flac",
[]string{"/music"},
) )
if idx != 1 { if idx != 1 {
@@ -853,7 +858,8 @@ func TestFindM3UEntry(t *testing.T) {
// Not found. // Not found.
_, idx = findM3UEntry( _, idx = findM3UEntry(
entries, "/music/Artist/Missing.flac", "/music", entries, "/music/Artist/Missing.flac",
[]string{"/music"},
) )
if idx != -1 { if idx != -1 {
+125 -54
View File
@@ -367,12 +367,21 @@ func (s *Service) mergeTracksForPlaylist(
return dbTracksToSlice(dbTracks) 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)) tracks := make([]Track, 0, len(parsed.Entries))
for i, entry := range parsed.Entries { for i, entry := range parsed.Entries {
absPath := toAbsolutePath( absPath := resolveM3UPath(
entry.RelativePath, libraryRoot, entry.RelativePath, libraryRoots, knownPaths,
) )
if dbTrack, ok := dbTracks[absPath]; ok { if dbTrack, ok := dbTracks[absPath]; ok {
@@ -841,7 +850,7 @@ func (s *Service) ImportPlaylist(
) )
} }
libraryRoot := s.getLibraryRoot() libraryRoots := s.getAllLibraryRoots()
var ( var (
resolved int resolved int
@@ -850,14 +859,37 @@ func (s *Service) ImportPlaylist(
) )
for _, entry := range parsed.Entries { for _, entry := range parsed.Entries {
absPath := toAbsolutePath( var audioFile sqlcgen.AudioFile
entry.RelativePath, libraryRoot,
)
audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( found := false
s.db.Ctx, absPath,
) if filepath.IsAbs(entry.RelativePath) {
if lookupErr != nil { 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. // Track not in library — will appear as phantom.
unresolved++ unresolved++
@@ -876,7 +908,7 @@ func (s *Service) ImportPlaylist(
s.logger.Warn( s.logger.Warn(
"Could not add imported track", "Could not add imported track",
"playlistId", created.ID, "playlistId", created.ID,
"path", absPath, "path", audioFile.FilePath,
"err", addErr, "err", addErr,
) )
@@ -890,7 +922,7 @@ func (s *Service) ImportPlaylist(
// Save the M3U8 file with entries (preserves unresolved // Save the M3U8 file with entries (preserves unresolved
// paths for phantom display). // paths for phantom display).
s.saveImportedPlaylistFile( s.saveImportedPlaylistFile(
created.ID, playlistName, parsed.Entries, libraryRoot, created.ID, playlistName, parsed.Entries, libraryRoots,
) )
s.logger.Info( s.logger.Info(
@@ -981,7 +1013,7 @@ func (s *Service) RestoreAllPlaylists() {
return return
} }
libraryRoot := s.getLibraryRoot() libraryRoots := s.getAllLibraryRoots()
var totalRestored, totalUnresolved int var totalRestored, totalUnresolved int
@@ -997,7 +1029,7 @@ func (s *Service) RestoreAllPlaylists() {
} }
restored, unresolved := s.restoreSinglePlaylist( restored, unresolved := s.restoreSinglePlaylist(
playlistID, file, libraryRoot, playlistID, file, libraryRoots,
) )
totalRestored += restored totalRestored += restored
@@ -1014,11 +1046,12 @@ func (s *Service) RestoreAllPlaylists() {
} }
// restoreSinglePlaylist restores tracks for a single playlist // 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( func (s *Service) restoreSinglePlaylist(
playlistID int64, playlistID int64,
m3uPath string, m3uPath string,
libraryRoot string, libraryRoots []string,
) (restored, unresolved int) { ) (restored, unresolved int) {
parsed, err := parseM3U8(m3uPath) parsed, err := parseM3U8(m3uPath)
if err != nil { if err != nil {
@@ -1049,14 +1082,37 @@ func (s *Service) restoreSinglePlaylist(
var position int var position int
for _, entry := range parsed.Entries { for _, entry := range parsed.Entries {
absPath := toAbsolutePath( var audioFile sqlcgen.AudioFile
entry.RelativePath, libraryRoot,
)
audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( found := false
s.db.Ctx, absPath,
) if filepath.IsAbs(entry.RelativePath) {
if lookupErr != nil { 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++ unresolved++
continue continue
@@ -1074,7 +1130,7 @@ func (s *Service) restoreSinglePlaylist(
s.logger.Warn( s.logger.Warn(
"Could not restore track", "Could not restore track",
"playlistId", playlistID, "playlistId", playlistID,
"path", absPath, "path", audioFile.FilePath,
"err", addErr, "err", addErr,
) )
@@ -1169,24 +1225,29 @@ func (s *Service) playlistsDir() (string, error) {
return dir, nil return dir, nil
} }
// getLibraryRoot returns the library root directory path. // getAllLibraryRoots returns the root directory paths of all
// It first checks the legacy config DirectoryPath; if that is // configured libraries. It first checks the legacy config
// empty (removed during multi-library migration) it falls back // DirectoryPath; if that is set, it returns a single-element
// to the first library's path from the database. // slice. Otherwise it queries all libraries from the database.
func (s *Service) getLibraryRoot() string { func (s *Service) getAllLibraryRoots() []string {
// Legacy config fallback.
if s.libraryDir != nil { if s.libraryDir != nil {
if dir := s.libraryDir.GetLibraryDirectory(); dir != "" { 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) libs, err := s.db.Queries.GetAllLibraries(s.db.Ctx)
if err != nil || len(libs) == 0 { 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 // savePlaylistFile saves the current state of a playlist to its
@@ -1243,7 +1304,7 @@ func (s *Service) saveImportedPlaylistFile(
playlistID int64, playlistID int64,
name string, name string,
entries []m3uEntry, entries []m3uEntry,
libraryRoot string, libraryRoots []string,
) { ) {
dir, err := s.playlistsDir() dir, err := s.playlistsDir()
if err != nil { if err != nil {
@@ -1255,16 +1316,22 @@ func (s *Service) saveImportedPlaylistFile(
return 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)) converted := make([]m3uEntry, len(entries))
for i, entry := range 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{ converted[i] = m3uEntry{
RelativePath: toRelativePath( RelativePath: toRelativePathMultiRoot(
toAbsolutePath( absPath, libraryRoots,
entry.RelativePath, libraryRoot,
),
libraryRoot,
), ),
DurationSec: entry.DurationSec, DurationSec: entry.DurationSec,
DisplayTitle: entry.DisplayTitle, DisplayTitle: entry.DisplayTitle,
@@ -1301,7 +1368,7 @@ func (s *Service) buildM3UEntries(
return nil return nil
} }
libraryRoot := s.getLibraryRoot() libraryRoots := s.getAllLibraryRoots()
entries := make([]m3uEntry, 0, len(rows)) entries := make([]m3uEntry, 0, len(rows))
for _, row := range rows { for _, row := range rows {
@@ -1310,8 +1377,8 @@ func (s *Service) buildM3UEntries(
) )
entries = append(entries, m3uEntry{ entries = append(entries, m3uEntry{
RelativePath: toRelativePath( RelativePath: toRelativePathMultiRoot(
row.FilePath, libraryRoot, row.FilePath, libraryRoots,
), ),
DurationSec: durationSec, DurationSec: durationSec,
DisplayTitle: displayTitle( DisplayTitle: displayTitle(
@@ -1445,7 +1512,7 @@ func (s *Service) FindPhantomMatches(
) )
} }
libraryRoot := s.getLibraryRoot() libraryRoots := s.getAllLibraryRoots()
// Load M3U8 entries for display title / duration data. // Load M3U8 entries for display title / duration data.
m3uPath, err := findPlaylistFile(dir, playlistID) m3uPath, err := findPlaylistFile(dir, playlistID)
@@ -1465,11 +1532,13 @@ func (s *Service) FindPhantomMatches(
} }
// Build a lookup from absolute path to M3U entry. // 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)) entryByPath := make(map[string]m3uEntry, len(entries))
for _, e := range entries { for _, e := range entries {
absPath := toAbsolutePath( absPath := resolveM3UPath(
e.RelativePath, libraryRoot, e.RelativePath, libraryRoots, nil,
) )
entryByPath[absPath] = e entryByPath[absPath] = e
} }
@@ -1533,7 +1602,7 @@ func (s *Service) GetPhantomCandidates(
) )
} }
libraryRoot := s.getLibraryRoot() libraryRoots := s.getAllLibraryRoots()
// Find the M3U entry for this phantom. // Find the M3U entry for this phantom.
m3uPath, err := findPlaylistFile(dir, playlistID) m3uPath, err := findPlaylistFile(dir, playlistID)
@@ -1549,7 +1618,7 @@ func (s *Service) GetPhantomCandidates(
parsed, parseErr := parseM3U8(m3uPath) parsed, parseErr := parseM3U8(m3uPath)
if parseErr == nil { if parseErr == nil {
entry, _ = findM3UEntry( 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) m3uPath, err := findPlaylistFile(dir, playlistID)
if err != nil || m3uPath == "" { if err != nil || m3uPath == "" {
@@ -1681,7 +1750,9 @@ func (s *Service) ResolvePhantomTracks(
continue continue
} }
newRel := toRelativePath(resolvedAbs, libraryRoot) newRel := toRelativePathMultiRoot(
resolvedAbs, libraryRoots,
)
pathReplacements[phantomAbs] = newRel pathReplacements[phantomAbs] = newRel
resolved++ resolved++
} }
@@ -1689,7 +1760,7 @@ func (s *Service) ResolvePhantomTracks(
// Rewrite the M3U8 with updated paths. // Rewrite the M3U8 with updated paths.
if resolved > 0 { if resolved > 0 {
updated := replaceM3UEntryPaths( updated := replaceM3UEntryPaths(
parsed.Entries, pathReplacements, libraryRoot, parsed.Entries, pathReplacements, libraryRoots,
) )
playlist, nameErr := s.db.Queries.GetPlaylist( 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) m3uPath, err := findPlaylistFile(dir, playlistID)
if err != nil || m3uPath == "" { if err != nil || m3uPath == "" {
@@ -1766,7 +1837,7 @@ func (s *Service) RemovePhantomTracks(
} }
updated := removeM3UEntries( updated := removeM3UEntries(
parsed.Entries, targetSet, libraryRoot, parsed.Entries, targetSet, libraryRoots,
) )
playlist, err := s.db.Queries.GetPlaylist( playlist, err := s.db.Queries.GetPlaylist(