mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* feat(model): add per-library PID config columns * refactor(metadata): pass PID config to ToMediaFile and add spec validation * feat(scanner): rescan only libraries whose PID config changed * feat(server): validate library PID config and rescan on change * feat(ui): edit per-library PID config * fix(ui): label the PID mode selects * fix: tighten per-library PID rescan edge cases An interrupted PID rescan no longer upgrades every library to a full scan, a save that loses the race for the scanner logs at debug, the confirm dialog only shows when the effective PID spec changes, and it now gets translation keys. * refactor(metadata): pass the library to ToMediaFile ToMediaFile and core.Inspect took the library ID and its PID config as separate arguments, so a caller could mix values from two libraries. They now take the model.Library and resolve the effective PID config from it. * chore: tidy per-library PID comments, PropTypes and migration Trim comments that restated the code, add PropTypes to the new UI components, and recreate the migration with make migration-sql. * fix(ui): show the PID spec help under its input * feat(cmd): make inspect use the file's library PID config inspect always used the global PID config, so it showed different IDs than the scanner for files in a library with an override. It now finds the file's library in the DB and uses its effective config, falling back to the global config when there is no DB or the file is outside every library. It never creates a DB. The library path matcher moves from core/playlists to model so both can use it. * refactor: simplify per-library PID code Share the DB-file check between CLI commands, move ErrAlreadyScanning to model so core no longer imports scanner, read the libraries once for insights, and let ValidatePIDSpec accept an empty spec and look tags up directly. In the scanner, use FullScanInProgress instead of a second flag, and skip recomputing album IDs when the album spec did not change. In the UI, share the PID inputs between Create and Edit, and use docsUrl. * feat(ui): add section titles to Library Create and pre-fill Custom PID specs Custom now starts from the global spec, so admins edit a working spec instead of typing one from scratch. * fix(inspect): map files with the library-relative path the scanner uses Inspect gave metadata the file's directory as typed, so folder-based PIDs never matched the DB. It now uses the path relative to the library root, through the scanner's helper, which moves to model. * fix(scanner): say when a PID rescan only covers target folders * fix: reject tag aliases in album PID specs and match root libraries Tags are stored under canonical names, so an alias in a spec always reads as empty. In an album spec that gives every album the same ID, so album specs now require the tag name. Track specs keep accepting aliases, since the default one uses them. LibraryMatcher now matches paths under a library at the filesystem root. * refactor(model): move the tag alias lookup to tag_mappings.go * test: run the library matcher and inspect tests on Windows Build test paths with filepath instead of Unix literals, so they use the OS separator like filepath.Abs output, and drop the Windows skips. * feat(ui): add pt-BR translations for per-library PID settings
283 lines
9.7 KiB
Go
283 lines
9.7 KiB
Go
package playlists
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/navidrome/navidrome/conf"
|
||
"github.com/navidrome/navidrome/log"
|
||
"github.com/navidrome/navidrome/model"
|
||
"github.com/navidrome/navidrome/model/request"
|
||
"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()
|
||
resolver, err := newPathResolver(ctx, s.ds)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var mfs model.MediaFiles
|
||
// Chunked so a huge playlist is not held in memory at once. Each line yields up to
|
||
// 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables.
|
||
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
|
||
filteredLines := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
line := strings.TrimSpace(line)
|
||
if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok {
|
||
pls.Name = after
|
||
continue
|
||
}
|
||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||
owner, _ := request.UserFrom(ctx)
|
||
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner)
|
||
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(ctx, 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
|
||
}
|
||
|
||
// pathResolver handles path resolution logic for playlist imports.
|
||
type pathResolver struct {
|
||
matcher *model.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().GetAll(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
matcher := model.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 {
|
||
lib, ok := r.matcher.FindLibrary(absolutePath)
|
||
if !ok {
|
||
return pathResolution{valid: false}
|
||
}
|
||
return pathResolution{
|
||
absolutePath: absolutePath,
|
||
libraryPath: filepath.Clean(lib.Path),
|
||
libraryID: lib.ID,
|
||
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 *model.LibraryMatcher, owner model.User) 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 || !model.IsImageFile(localPath) {
|
||
return ""
|
||
}
|
||
|
||
lib, ok := matcher.FindLibrary(localPath)
|
||
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
|
||
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
|
||
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
|
||
}
|