Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
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)
|
|
}
|