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
+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(