acd69f6a4f
* feat(playlist): add migration for playlist image field rename and external URL * refactor(playlist): rename ImageFile to UploadedImage and ArtworkPath to UploadedImagePath Rename playlist model fields and methods for clarity in preparation for adding external image URL and sidecar image support. Add the new ExternalImageURL field to the Playlist model. * feat(playlist): parse #EXTALBUMARTURL directive in M3U imports * feat(playlist): always sync ExternalImageURL on re-scan, preserve UploadedImage * feat(artwork): add sidecar image discovery and cache invalidation for playlists Add playlist sidecar image support to the artwork reader fallback chain. A sidecar image (e.g., MyPlaylist.jpg next to MyPlaylist.m3u) is discovered via case-insensitive base name matching using model.IsImageFile(). Cache invalidation uses max(playlist.UpdatedAt, imageFile.ModTime()) to bust stale artwork when sidecar or ExternalImageURL local files change. * feat(artwork): add external image URL source to playlist artwork reader Add fromPlaylistExternalImage source function that resolves playlist cover art from ExternalImageURL, supporting both HTTP(S) URLs (via the existing fromURL helper) and local file paths (via os.Open). Insert it in the Reader() fallback chain between sidecar and tiled cover. * refactor(artwork): simplify playlist artwork source functions Extract shared fromLocalFile helper, use url.Parse for scheme check, and collapse sidecar directory scan conditions. * test(artwork): remove redundant fromPlaylistSidecar tests These tests duplicated scenarios already covered by findPlaylistSidecarPath tests combined with fromLocalFile (tested via fromPlaylistExternalImage). After refactoring fromPlaylistSidecar to a one-liner composing those two functions, the wrapper tests add no value. * fix(playlist): address security review comments from PR #5131: - Use url.PathUnescape instead of url.QueryUnescape for file:// URLs so that '+' in filenames is preserved (not decoded as space). - Validate all local image paths (file://, absolute, relative) against known library boundaries via libraryMatcher, rejecting paths outside any configured library. - Harden #EXTALBUMARTURL against path traversal and SSRF by adding EnableM3UExternalAlbumArt config flag (default false, also disabled by EnableExternalServices=false) to gate HTTP(S) URL storage at parse time and fetching at read time (defense in depth). - Log a warning when os.ReadDir fails in findPlaylistSidecarPath for diagnosability. - Extract resolveLocalPath helper to simplify resolveImageURL. Signed-off-by: Deluan <deluan@navidrome.org> * feat(playlist): implement human-friendly filename generation for uploaded playlist cover images Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package playlists
|
||
|
||
import (
|
||
"cmp"
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"path/filepath"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/navidrome/navidrome/conf"
|
||
"github.com/navidrome/navidrome/log"
|
||
"github.com/navidrome/navidrome/model"
|
||
"github.com/navidrome/navidrome/utils/slice"
|
||
"golang.org/x/text/unicode/norm"
|
||
)
|
||
|
||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||
resolver, err := newPathResolver(ctx, s.ds)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var mfs model.MediaFiles
|
||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
|
||
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
|
||
}
|
||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
|
||
continue
|
||
}
|
||
// Skip empty lines and extended info
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
if after, ok := strings.CutPrefix(line, "file://"); ok {
|
||
line = after
|
||
line, _ = url.PathUnescape(line)
|
||
}
|
||
if !model.IsAudioFile(line) {
|
||
continue
|
||
}
|
||
filteredLines = append(filteredLines, line)
|
||
}
|
||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
||
if err != nil {
|
||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||
continue
|
||
}
|
||
|
||
// SQLite comparisons do not perform Unicode normalization, and filesystem normalization
|
||
// differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC).
|
||
// Generate lookup candidates for both forms so playlist entries match DB paths regardless
|
||
// of the original normalization. See https://github.com/navidrome/navidrome/issues/4884
|
||
//
|
||
// We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE
|
||
// only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters
|
||
// (e.g., ABCD vs abcd) are not matched case-insensitively by NOCASE.
|
||
lookupCandidates := make([]string, 0, len(resolvedPaths)*4)
|
||
seen := make(map[string]struct{}, len(resolvedPaths)*4)
|
||
for _, path := range resolvedPaths {
|
||
// Add original paths first (for exact matching of non-ASCII characters)
|
||
nfcRaw := norm.NFC.String(path)
|
||
if _, ok := seen[nfcRaw]; !ok {
|
||
seen[nfcRaw] = struct{}{}
|
||
lookupCandidates = append(lookupCandidates, nfcRaw)
|
||
}
|
||
nfdRaw := norm.NFD.String(path)
|
||
if _, ok := seen[nfdRaw]; !ok {
|
||
seen[nfdRaw] = struct{}{}
|
||
lookupCandidates = append(lookupCandidates, nfdRaw)
|
||
}
|
||
|
||
// Add lowercased paths (for ASCII case-insensitive matching via NOCASE)
|
||
nfc := strings.ToLower(nfcRaw)
|
||
if _, ok := seen[nfc]; !ok {
|
||
seen[nfc] = struct{}{}
|
||
lookupCandidates = append(lookupCandidates, nfc)
|
||
}
|
||
nfd := strings.ToLower(nfdRaw)
|
||
if _, ok := seen[nfd]; !ok {
|
||
seen[nfd] = struct{}{}
|
||
lookupCandidates = append(lookupCandidates, nfd)
|
||
}
|
||
}
|
||
|
||
found, err := mediaFileRepository.FindByPaths(lookupCandidates)
|
||
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.
|
||
// Canonicalize to NFC so NFD/NFC become comparable.
|
||
existing := make(map[string]int, len(found))
|
||
for idx := range found {
|
||
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path)))
|
||
existing[key] = idx
|
||
}
|
||
|
||
// Find media files in the order of the resolved paths, to keep playlist order.
|
||
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
|
||
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
|
||
for _, path := range resolvedPaths {
|
||
key := strings.ToLower(norm.NFC.String(path))
|
||
idx, ok := existing[key]
|
||
if ok {
|
||
mfs = append(mfs, found[idx])
|
||
} else {
|
||
// Prefer logging a composed representation when possible to avoid confusing output
|
||
// with decomposed combining marks.
|
||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(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 (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||
results := make([]string, 0, len(lines))
|
||
for idx, line := range lines {
|
||
resolution := r.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
|
||
}
|
||
|
||
// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
|
||
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
|
||
// Local paths (file://, absolute, or relative) are resolved to an absolute path
|
||
// and validated against known library boundaries via matcher.
|
||
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
|
||
// HTTP(S) URLs — store as-is, but only if external album art is enabled
|
||
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
// Resolve to local absolute path
|
||
localPath, ok := resolveLocalPath(value, folder)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
|
||
// Validate path is within a known library
|
||
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
|
||
return ""
|
||
}
|
||
return localPath
|
||
}
|
||
|
||
// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path.
|
||
// Returns ("", false) if the path cannot be resolved.
|
||
func resolveLocalPath(value string, folder *model.Folder) (string, bool) {
|
||
if after, ok := strings.CutPrefix(value, "file://"); ok {
|
||
decoded, err := url.PathUnescape(after)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
return filepath.Clean(decoded), true
|
||
}
|
||
if filepath.IsAbs(value) {
|
||
return filepath.Clean(value), true
|
||
}
|
||
if folder == nil {
|
||
return "", false
|
||
}
|
||
return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
|
||
}
|