mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 15:08:04 -05:00
* fix: handle cross-library relative paths in playlists Playlists can now reference songs in other libraries using relative paths. Previously, relative paths like '../Songs/abc.mp3' would not resolve correctly when pointing to files in a different library than the playlist file. The fix resolves relative paths to absolute paths first, then checks which library they belong to using the library regex. This allows playlists to reference files across library boundaries while maintaining backward compatibility with existing single-library relative paths. Fixes #4617 * fix: enhance playlist path normalization for cross-library support Signed-off-by: Deluan <deluan@navidrome.org> * refactor: improve handling of relative paths in playlists for cross-library compatibility Signed-off-by: Deluan <deluan@navidrome.org> * fix: ensure longest library path matches first to resolve prefix conflicts in playlists Signed-off-by: Deluan <deluan@navidrome.org> * test: refactor tests isolation Signed-off-by: Deluan <deluan@navidrome.org> * fix: enhance handling of library-qualified paths and improve cross-library playlist support Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify mocks Signed-off-by: Deluan <deluan@navidrome.org> * fix: lint Signed-off-by: Deluan <deluan@navidrome.org> * fix: improve path resolution for cross-library playlists and enhance error handling Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * refactor: remove unnecessary path validation fallback Remove validatePathInLibrary function and its fallback logic in resolveRelativePath. The library matcher should always find the correct library, including the playlist's own library. If this fails, we now return an invalid resolution instead of attempting a fallback validation. This simplifies the code by removing redundant validation logic that was masking test setup issues. Also fixes test mock configuration to properly set up library paths that match folder LibraryPath values. * refactor: consolidate path resolution logic Collapse resolveRelativePath and resolveAbsolutePath into a unified resolvePath function, extracting common library matching logic into a new findInLibraries helper method. This eliminates duplicate code (~20 lines) while maintaining clear separation of concerns: resolvePath handles path normalization (relative vs absolute), and findInLibraries handles library matching. Update tests to call resolvePath directly with appropriate parameters, maintaining full test coverage for both absolute and relative path scenarios. Signed-off-by: Deluan <deluan@navidrome.org> * docs: add FindByPaths comment Signed-off-by: Deluan <deluan@navidrome.org> * fix: enhance Unicode normalization for path comparisons in playlists. Fixes 4663 Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
488 lines
14 KiB
Go
488 lines
14 KiB
Go
package core
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/RaveNoX/go-jsoncommentstrip"
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils/ioutils"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
type Playlists interface {
|
|
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
|
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
|
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
|
}
|
|
|
|
type playlists struct {
|
|
ds model.DataStore
|
|
}
|
|
|
|
func NewPlaylists(ds model.DataStore) Playlists {
|
|
return &playlists{ds: ds}
|
|
}
|
|
|
|
func InPlaylistsPath(folder model.Folder) bool {
|
|
if conf.Server.PlaylistsPath == "" {
|
|
return true
|
|
}
|
|
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
|
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
|
if match, _ := doublestar.Match(path, rel); match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
|
pls, err := s.parsePlaylist(ctx, filename, folder)
|
|
if err != nil {
|
|
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
|
return nil, err
|
|
}
|
|
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
|
err = s.updatePlaylist(ctx, pls)
|
|
if err != nil {
|
|
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
|
}
|
|
return pls, err
|
|
}
|
|
|
|
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
|
owner, _ := request.UserFrom(ctx)
|
|
pls := &model.Playlist{
|
|
OwnerID: owner.ID,
|
|
Public: false,
|
|
Sync: false,
|
|
}
|
|
err := s.parseM3U(ctx, pls, nil, reader)
|
|
if err != nil {
|
|
log.Error(ctx, "Error parsing playlist", err)
|
|
return nil, err
|
|
}
|
|
err = s.ds.Playlist(ctx).Put(pls)
|
|
if err != nil {
|
|
log.Error(ctx, "Error saving playlist", err)
|
|
return nil, err
|
|
}
|
|
return pls, nil
|
|
}
|
|
|
|
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
|
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
file, err := os.Open(pls.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
reader := ioutils.UTF8Reader(file)
|
|
extension := strings.ToLower(filepath.Ext(playlistFile))
|
|
switch extension {
|
|
case ".nsp":
|
|
err = s.parseNSP(ctx, pls, reader)
|
|
default:
|
|
err = s.parseM3U(ctx, pls, folder, reader)
|
|
}
|
|
return pls, err
|
|
}
|
|
|
|
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
|
playlistPath := filepath.Join(baseDir, playlistFile)
|
|
info, err := os.Stat(playlistPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var extension = filepath.Ext(playlistFile)
|
|
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
|
|
|
pls := &model.Playlist{
|
|
Name: name,
|
|
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
|
Public: false,
|
|
Path: playlistPath,
|
|
Sync: true,
|
|
UpdatedAt: info.ModTime(),
|
|
}
|
|
return pls, nil
|
|
}
|
|
|
|
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
|
line = 1
|
|
for _, b := range data[:offset] {
|
|
if b == '\n' {
|
|
line++
|
|
column = 1
|
|
} else {
|
|
column++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
|
nsp := &nspFile{}
|
|
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
|
reader = jsoncommentstrip.NewReader(reader)
|
|
input, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
|
}
|
|
err = json.Unmarshal(input, nsp)
|
|
if err != nil {
|
|
var syntaxErr *json.SyntaxError
|
|
if errors.As(err, &syntaxErr) {
|
|
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
|
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
|
}
|
|
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
|
}
|
|
pls.Rules = &nsp.Criteria
|
|
if nsp.Name != "" {
|
|
pls.Name = nsp.Name
|
|
}
|
|
if nsp.Comment != "" {
|
|
pls.Comment = nsp.Comment
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
|
mediaFileRepository := s.ds.MediaFile(ctx)
|
|
var mfs model.MediaFiles
|
|
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
|
|
filteredLines := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
line := strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "#PLAYLIST:") {
|
|
pls.Name = line[len("#PLAYLIST:"):]
|
|
continue
|
|
}
|
|
// Skip empty lines and extended info
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "file://") {
|
|
line = strings.TrimPrefix(line, "file://")
|
|
line, _ = url.QueryUnescape(line)
|
|
}
|
|
if !model.IsAudioFile(line) {
|
|
continue
|
|
}
|
|
filteredLines = append(filteredLines, line)
|
|
}
|
|
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
|
if err != nil {
|
|
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
|
continue
|
|
}
|
|
|
|
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
|
|
// See https://github.com/navidrome/navidrome/issues/4663
|
|
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
|
|
return strings.ToLower(norm.NFD.String(path))
|
|
})
|
|
|
|
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
|
|
if err != nil {
|
|
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
|
continue
|
|
}
|
|
// Build lookup map with library-qualified keys, normalized for comparison
|
|
existing := make(map[string]int, len(found))
|
|
for idx := range found {
|
|
// Normalize to lowercase for case-insensitive comparison
|
|
// Key format: "libraryID:path"
|
|
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
|
|
existing[key] = idx
|
|
}
|
|
|
|
// Find media files in the order of the resolved paths, to keep playlist order
|
|
for _, path := range resolvedPaths {
|
|
idx, ok := existing[path]
|
|
if ok {
|
|
mfs = append(mfs, found[idx])
|
|
} else {
|
|
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
|
|
}
|
|
}
|
|
}
|
|
if pls.Name == "" {
|
|
pls.Name = time.Now().Format(time.RFC3339)
|
|
}
|
|
pls.Tracks = nil
|
|
pls.AddMediaFiles(mfs)
|
|
|
|
return nil
|
|
}
|
|
|
|
// pathResolution holds the result of resolving a playlist path to a library-relative path.
|
|
type pathResolution struct {
|
|
absolutePath string
|
|
libraryPath string
|
|
libraryID int
|
|
valid bool
|
|
}
|
|
|
|
// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
|
|
// Format: "libraryID:relativePath" with forward slashes for path separators.
|
|
func (r pathResolution) ToQualifiedString() (string, error) {
|
|
if !r.valid {
|
|
return "", fmt.Errorf("invalid path resolution")
|
|
}
|
|
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Convert path separators to forward slashes
|
|
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
|
|
}
|
|
|
|
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
|
|
type libraryMatcher struct {
|
|
libraries model.Libraries
|
|
cleanedPaths []string
|
|
}
|
|
|
|
// findLibraryForPath finds which library contains the given absolute path.
|
|
// Returns library ID and path, or 0 and empty string if not found.
|
|
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
|
|
// Check sorted libraries (longest path first) to find the best match
|
|
for i, cleanLibPath := range lm.cleanedPaths {
|
|
// Check if absolutePath is under this library path
|
|
if strings.HasPrefix(absolutePath, cleanLibPath) {
|
|
// Ensure it's a proper path boundary (not just a prefix)
|
|
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
|
|
return lm.libraries[i].ID, cleanLibPath
|
|
}
|
|
}
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
|
|
// This ensures correct matching when library paths are prefixes of each other.
|
|
// Example: /music-classical must be checked before /music
|
|
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
|
|
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
|
|
// Sort libraries by path length (descending) to ensure longest paths match first.
|
|
slices.SortFunc(libs, func(i, j model.Library) int {
|
|
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
|
|
})
|
|
|
|
// Pre-clean all library paths once for efficient matching
|
|
cleanedPaths := make([]string, len(libs))
|
|
for i, lib := range libs {
|
|
cleanedPaths[i] = filepath.Clean(lib.Path)
|
|
}
|
|
return &libraryMatcher{
|
|
libraries: libs,
|
|
cleanedPaths: cleanedPaths,
|
|
}
|
|
}
|
|
|
|
// pathResolver handles path resolution logic for playlist imports.
|
|
type pathResolver struct {
|
|
matcher *libraryMatcher
|
|
}
|
|
|
|
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
|
func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
|
|
libs, err := ds.Library(ctx).GetAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
matcher := newLibraryMatcher(libs)
|
|
return &pathResolver{matcher: matcher}, nil
|
|
}
|
|
|
|
// resolvePath determines the absolute path and library path for a playlist entry.
|
|
// For absolute paths, it uses them directly.
|
|
// For relative paths, it resolves them relative to the playlist's folder location.
|
|
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
|
|
//
|
|
// resolves to /music/songs/abc.mp3
|
|
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
|
|
var absolutePath string
|
|
if folder != nil && !filepath.IsAbs(line) {
|
|
// Resolve relative path to absolute path based on playlist location
|
|
absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
|
|
} else {
|
|
// Use absolute path directly after cleaning
|
|
absolutePath = filepath.Clean(line)
|
|
}
|
|
|
|
return r.findInLibraries(absolutePath)
|
|
}
|
|
|
|
// findInLibraries matches an absolute path against all known libraries and returns
|
|
// a pathResolution with the library information. Returns an invalid resolution if
|
|
// the path is not found in any library.
|
|
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
|
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
|
|
if libID == 0 {
|
|
return pathResolution{valid: false}
|
|
}
|
|
return pathResolution{
|
|
absolutePath: absolutePath,
|
|
libraryPath: libPath,
|
|
libraryID: libID,
|
|
valid: true,
|
|
}
|
|
}
|
|
|
|
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
|
// For relative paths, it resolves them to absolute paths first, then determines which
|
|
// library they belong to. This allows playlists to reference files across library boundaries.
|
|
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
|
resolver, err := newPathResolver(ctx, s.ds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results := make([]string, 0, len(lines))
|
|
for idx, line := range lines {
|
|
resolution := resolver.resolvePath(line, folder)
|
|
|
|
if !resolution.valid {
|
|
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
|
continue
|
|
}
|
|
|
|
qualifiedPath, err := resolution.ToQualifiedString()
|
|
if err != nil {
|
|
log.Debug(ctx, "Error getting library-qualified path", "path", line,
|
|
"libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
|
|
continue
|
|
}
|
|
|
|
results = append(results, qualifiedPath)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
|
owner, _ := request.UserFrom(ctx)
|
|
|
|
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
return err
|
|
}
|
|
if err == nil && !pls.Sync {
|
|
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
|
return nil
|
|
}
|
|
|
|
if err == nil {
|
|
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
|
newPls.ID = pls.ID
|
|
newPls.Name = pls.Name
|
|
newPls.Comment = pls.Comment
|
|
newPls.OwnerID = pls.OwnerID
|
|
newPls.Public = pls.Public
|
|
newPls.EvaluatedAt = &time.Time{}
|
|
} else {
|
|
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
|
newPls.OwnerID = owner.ID
|
|
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
|
}
|
|
return s.ds.Playlist(ctx).Put(newPls)
|
|
}
|
|
|
|
func (s *playlists) Update(ctx context.Context, playlistID string,
|
|
name *string, comment *string, public *bool,
|
|
idsToAdd []string, idxToRemove []int) error {
|
|
needsInfoUpdate := name != nil || comment != nil || public != nil
|
|
needsTrackRefresh := len(idxToRemove) > 0
|
|
|
|
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
var pls *model.Playlist
|
|
var err error
|
|
repo := tx.Playlist(ctx)
|
|
tracks := repo.Tracks(playlistID, true)
|
|
if tracks == nil {
|
|
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
|
}
|
|
if needsTrackRefresh {
|
|
pls, err = repo.GetWithTracks(playlistID, true, false)
|
|
pls.RemoveTracks(idxToRemove)
|
|
pls.AddMediaFilesByID(idsToAdd)
|
|
} else {
|
|
if len(idsToAdd) > 0 {
|
|
_, err = tracks.Add(idsToAdd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if needsInfoUpdate {
|
|
pls, err = repo.Get(playlistID)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !needsTrackRefresh && !needsInfoUpdate {
|
|
return nil
|
|
}
|
|
|
|
if name != nil {
|
|
pls.Name = *name
|
|
}
|
|
if comment != nil {
|
|
pls.Comment = *comment
|
|
}
|
|
if public != nil {
|
|
pls.Public = *public
|
|
}
|
|
// Special case: The playlist is now empty
|
|
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
|
if err = tracks.DeleteAll(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return repo.Put(pls)
|
|
})
|
|
}
|
|
|
|
type nspFile struct {
|
|
criteria.Criteria
|
|
Name string `json:"name"`
|
|
Comment string `json:"comment"`
|
|
}
|
|
|
|
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
|
m := map[string]interface{}{}
|
|
err := json.Unmarshal(data, &m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
i.Name, _ = m["name"].(string)
|
|
i.Comment, _ = m["comment"].(string)
|
|
return json.Unmarshal(data, &i.Criteria)
|
|
}
|