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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user