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.
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 {
+85 -13
View File
@@ -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
+11 -5
View File
@@ -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 {
+125 -54
View File
@@ -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(