mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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>
491 lines
15 KiB
Go
491 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.
|
|
// 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)
|
|
}
|