mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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.
503 lines
15 KiB
Go
503 lines
15 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
|
|
}
|
|
|
|
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
|
|
existing := make(map[string]int, len(found))
|
|
for idx := range found {
|
|
// Key format: "libraryID:path" (normalized)
|
|
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, normalizePathForComparison(found[idx].Path))
|
|
existing[key] = idx
|
|
}
|
|
for _, path := range resolvedPaths {
|
|
// Parse the library-qualified path
|
|
parts := strings.SplitN(path, ":", 2)
|
|
// Path is already qualified: "libraryID:path"
|
|
normalizedPath := parts[0] + ":" + normalizePathForComparison(parts[1])
|
|
|
|
idx, ok := existing[normalizedPath]
|
|
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
|
|
}
|
|
|
|
// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
|
|
// for consistent comparison. This fixes Unicode normalization issues on macOS where
|
|
// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
|
|
func normalizePathForComparison(path string) string {
|
|
return strings.ToLower(norm.NFC.String(path))
|
|
}
|
|
|
|
// 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.
|
|
func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
|
|
if folder != nil && !filepath.IsAbs(line) {
|
|
return r.resolveRelativePath(line, folder)
|
|
}
|
|
return r.resolveAbsolutePath(line)
|
|
}
|
|
|
|
// resolveRelativePath handles relative paths by converting them to absolute paths
|
|
// and finding their library location. This enables cross-library playlist references.
|
|
func (r *pathResolver) resolveRelativePath(line string, folder *model.Folder) pathResolution {
|
|
// Step 1: Resolve relative path to absolute path based on playlist location
|
|
// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
|
|
// resolves to /music/songs/abc.mp3
|
|
absolutePath := filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
|
|
|
|
// Step 2: Determine which library this absolute path belongs to
|
|
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
|
|
if libID == 0 {
|
|
// Path not found in any library - this should not happen as the playlist's
|
|
// own library should have been matched above
|
|
return pathResolution{valid: false}
|
|
}
|
|
return pathResolution{
|
|
absolutePath: absolutePath,
|
|
libraryPath: libPath,
|
|
libraryID: libID,
|
|
valid: true,
|
|
}
|
|
}
|
|
|
|
// resolveAbsolutePath handles absolute paths by matching them against library paths.
|
|
func (r *pathResolver) resolveAbsolutePath(line string) pathResolution {
|
|
cleanPath := filepath.Clean(line)
|
|
libID, libPath := r.matcher.findLibraryForPath(cleanPath)
|
|
|
|
if libID == 0 {
|
|
return pathResolution{valid: false}
|
|
}
|
|
return pathResolution{
|
|
absolutePath: cleanPath,
|
|
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)
|
|
}
|