Compare commits

..

14 Commits

Author SHA1 Message Date
Deluan
b4b1830513 refactor: remove outdated comments regarding NATURALSORT collation behavior
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 11:35:36 -05:00
Deluan Quintão
eca4c5acf0 Merge branch 'master' into custom-collation-function 2026-02-17 11:27:15 -05:00
Deluan
e766a5d780 refactor: extend NATURALSORT collation to playlist and radio indexes
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:42:05 -05:00
Deluan
90d6cd5f47 refactor: update collation handling for natural sorting in SQL queries
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 10:37:08 -05:00
Deluan
24ab04581a fix format
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:45:34 -05:00
Deluan
8e647a0e41 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:31:37 -05:00
Deluan Quintão
86c326bd4a fix doc
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-17 09:30:18 -05:00
Deluan
5fa8356b31 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:28:42 -05:00
Deluan
929e7193b4 refactor: use natural.Compare directly instead of wrapper 2026-02-17 09:00:23 -05:00
Deluan
9bcefea0ca refactor: use maruel/natural for NATURALSORT collation instead of custom impl
Replace the hand-rolled natural sort comparison with a thin wrapper
around github.com/maruel/natural.Compare, which is already a dependency.
The wrapper just lowercases both inputs for case-insensitive comparison.
2026-02-17 09:00:20 -05:00
Deluan
b0cb40b029 feat: add custom NATURALSORT collation for natural number ordering
Register a custom SQLite collation function (NATURALSORT) that compares
strings using natural sort ordering, where embedded numeric sequences are
compared as numbers rather than lexicographically. This fixes the issue
where albums like "Bravo Hits 1-132" sort as 1, 10, 100... instead of
1, 2, 3... 10, 11...

Closes navidrome/navidrome#4891
2026-02-17 09:00:15 -05:00
Deluan Quintão
cad9cdc53e fix(scanner): preserve created_at when moving songs between libraries (#5055)
* fix: preserve created_at when moving songs between libraries (#5050)

When songs are moved between libraries, their creation date was being
reset to the current time, causing them to incorrectly appear in
"Recently Added". Three changes fix this:

1. Add hash:"ignore" to AlbumID in MediaFile struct so that Equals()
   works for cross-library moves (AlbumID includes library prefix,
   making hashes always differ between libraries)

2. Preserve album created_at in moveMatched() via CopyAttributes,
   matching the pattern already used in persistAlbum() for
   within-library album ID changes

3. Only set CreatedAt in Put() when it's zero (new files), and
   explicitly copy missing.CreatedAt to the target in moveMatched()
   as defense-in-depth for the INSERT code path

* test: add regression tests for created_at preservation (#5050)

Add tests covering the three aspects of the fix:
- Scanner: moveMatched preserves missing track's created_at
- Scanner: CopyAttributes called for album created_at on album change
- Scanner: CopyAttributes not called when album ID stays the same
- Persistence: Put sets CreatedAt to now for new files with zero value
- Persistence: Put preserves non-zero CreatedAt on insert
- Persistence: Put does not reset CreatedAt on update

Also adds CopyAttributes to MockAlbumRepo for test support.

* test: verify album created_at is updated in cross-library move test (#5050)

Added end-to-end assertion in the cross-library move test to verify that
the new album's CreatedAt field is actually set to the original value after
CopyAttributes runs, not just that the method was called. This strengthens
the test by confirming the mock correctly propagates the timestamp.
2026-02-17 08:37:05 -05:00
Deluan
b774133cd1 chore(deps): update go-sqlite3 to v1.14.34 and pocketbase/dbx to v1.12.0
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 08:35:02 -05:00
Alanna
a20d56c137 fix(ui): prevent "Play Next" restarting play at top of queue (#5049)
Set playIndex when rebuilding the queue in reducePlayNext so the music
player library knows which track is currently playing. Without this, the
library's loadNewAudioLists defaults playIndex to 0, causing playback to
restart from the top of the queue on rapid "Play Next" actions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:34:24 -05:00
72 changed files with 1076 additions and 2208 deletions

View File

@@ -20,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.9.0
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")

View File

@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
}
type authResponse struct {
JWT string `json:"jwt"`
JWT string `json:"jwt"` //nolint:gosec
}
var result authResponse

View File

@@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
return
}

View File

@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
}
type listenBrainzRequest struct {
ApiKey string
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}

View File

@@ -8,7 +8,7 @@ import (
"os"
"strings"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -74,7 +74,7 @@ func runScanner(ctx context.Context) {
sqlDB := db.Db()
defer db.Db().Close()
ds := persistence.New(sqlDB)
pls := playlists.NewPlaylists(ds)
pls := core.NewPlaylists(ds)
// Parse targets from command line or file
var scanTargets []model.ScanTarget

View File

@@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@@ -62,7 +61,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
playlists := core.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
@@ -73,12 +72,12 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
user := core.NewUser(dataStore, manager)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager)
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
return router
}
@@ -99,11 +98,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
players := core.NewPlayers(dataStore)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -166,8 +165,8 @@ func CreateScanner(ctx context.Context) model.Scanner {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
return modelScanner
}
@@ -183,8 +182,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
playlistsPlaylists := playlists.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playlists := core.NewPlaylists(dataStore)
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}

View File

@@ -172,8 +172,8 @@ type TagConf struct {
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
ApiKey string //nolint:gosec
Secret string //nolint:gosec
Language string
ScrobbleFirstArtistOnly bool
@@ -183,7 +183,7 @@ type lastfmOptions struct {
type spotifyOptions struct {
ID string
Secret string
Secret string //nolint:gosec
}
type deezerOptions struct {
@@ -208,7 +208,7 @@ type httpHeaderOptions struct {
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
Password string //nolint:gosec
}
type AudioDeviceDefinition []string
@@ -748,7 +748,7 @@ func getConfigFile(cfgFile string) string {
}
cfgFile = os.Getenv("ND_CONFIGFILE")
if cfgFile != "" {
if _, err := os.Stat(cfgFile); err == nil {
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
return cfgFile
}
}

View File

@@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
hc := http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
req.Header.Set("User-Agent", consts.HTTPUserAgent)
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}

View File

@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return

View File

@@ -1,28 +1,183 @@
package playlists
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.SplitSeq(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
}
if nsp.Public != nil {
pls.Public = *nsp.Public
} else {
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return nil
}
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.
@@ -47,7 +202,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
@@ -103,9 +258,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
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).
// Find media files in the order of the resolved paths, to keep playlist order
for _, path := range resolvedPaths {
key := strings.ToLower(norm.NFC.String(path))
idx, ok := existing[key]
@@ -245,10 +398,15 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
// 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) {
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 := r.resolvePath(line, folder)
resolution := resolver.resolvePath(line, folder)
if !resolution.valid {
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
@@ -267,3 +425,123 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
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
// For NSP files, Public may already be set from the file; for M3U, use server default
if !newPls.IsSmartPlaylist() {
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"`
Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
if public, ok := m["public"].(bool); ok {
i.Public = &public
}
return json.Unmarshal(data, &i.Criteria)
}

View File

@@ -1,119 +0,0 @@
package playlists
import (
"context"
"errors"
"io"
"os"
"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/ioutils"
"golang.org/x/text/unicode/norm"
)
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(ctx, "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) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
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
// For NSP files, Public may already be set from the file; for M3U, use server default
if !newPls.IsSmartPlaylist() {
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
}
return s.ds.Playlist(ctx).Put(newPls)
}

View File

@@ -1,103 +0,0 @@
package playlists
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/RaveNoX/go-jsoncommentstrip"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
)
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
}
if nsp.Public != nil {
pls.Public = *nsp.Public
} else {
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
}
return nil
}
type nspFile struct {
criteria.Criteria
Name string `json:"name"`
Comment string `json:"comment"`
Public *bool `json:"public"`
}
func (i *nspFile) UnmarshalJSON(data []byte) error {
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err
}
i.Name, _ = m["name"].(string)
i.Comment, _ = m["comment"].(string)
if public, ok := m["public"].(bool); ok {
i.Public = &public
}
return json.Unmarshal(data, &i.Criteria)
}

View File

@@ -1,213 +0,0 @@
package playlists
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parseNSP", func() {
var s *playlists
ctx := context.Background()
BeforeEach(func() {
s = &playlists{}
})
It("parses a well-formed NSP with all fields", func() {
nsp := `{
"name": "My Smart Playlist",
"comment": "A test playlist",
"public": true,
"all": [{"is": {"loved": true}}],
"sort": "title",
"order": "asc",
"limit": 50
}`
pls := &model.Playlist{Name: "default-name"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Smart Playlist"))
Expect(pls.Comment).To(Equal("A test playlist"))
Expect(pls.Public).To(BeTrue())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.Sort).To(Equal("title"))
Expect(pls.Rules.Order).To(Equal("asc"))
Expect(pls.Rules.Limit).To(Equal(50))
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
})
It("keeps existing name when NSP has no name field", func() {
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original Name"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Original Name"))
})
It("keeps existing comment when NSP has no comment field", func() {
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Comment: "Original Comment"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Comment).To(Equal("Original Comment"))
})
It("strips JSON comments before parsing", func() {
nsp := `{
// Line comment
"name": "Commented Playlist",
/* Block comment */
"all": [{"is": {"loved": true}}]
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("Commented Playlist"))
})
It("uses server default when public field is absent", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
nsp := `{"all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Public).To(BeTrue())
})
It("honors explicit public: false over server default", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultPlaylistPublicVisibility = true
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Public).To(BeFalse())
})
It("returns a syntax error with line and column info", func() {
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
})
It("returns a parsing error for completely invalid JSON", func() {
nsp := `not json at all`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
})
It("gracefully handles non-string name field", func() {
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
pls := &model.Playlist{Name: "Original"}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
// Type assertion in UnmarshalJSON fails silently; name stays as original
Expect(pls.Name).To(Equal("Original"))
})
It("parses criteria with multiple rules", func() {
nsp := `{
"all": [
{"is": {"loved": true}},
{"contains": {"title": "rock"}}
],
"sort": "lastPlayed",
"order": "desc",
"limit": 100
}`
pls := &model.Playlist{}
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
Expect(err).ToNot(HaveOccurred())
Expect(pls.Rules).ToNot(BeNil())
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
Expect(pls.Rules.Order).To(Equal("desc"))
Expect(pls.Rules.Limit).To(Equal(100))
})
})
var _ = Describe("getPositionFromOffset", func() {
It("returns correct position on first line", func() {
data := []byte("hello world")
line, col := getPositionFromOffset(data, 5)
Expect(line).To(Equal(1))
Expect(col).To(Equal(5))
})
It("returns correct position after newlines", func() {
data := []byte("line1\nline2\nline3")
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
line, col := getPositionFromOffset(data, 8)
Expect(line).To(Equal(2))
Expect(col).To(Equal(3))
})
It("returns correct position at start of new line", func() {
data := []byte("line1\nline2")
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
line, col := getPositionFromOffset(data, 6)
Expect(line).To(Equal(2))
Expect(col).To(Equal(1))
})
It("handles multiple newlines", func() {
data := []byte("a\nb\nc\nd")
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
line, col := getPositionFromOffset(data, 6)
Expect(line).To(Equal(4))
Expect(col).To(Equal(1))
})
})
var _ = Describe("newSyncedPlaylist", func() {
var s *playlists
BeforeEach(func() {
s = &playlists{}
})
It("creates a synced playlist with correct attributes", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("test"))
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
Expect(pls.Public).To(BeFalse())
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
Expect(pls.Sync).To(BeTrue())
Expect(pls.UpdatedAt).ToNot(BeZero())
})
It("strips extension from filename to derive name", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(pls.Name).To(Equal("My Favorites"))
})
It("returns error for non-existent file", func() {
tmpDir := GinkgoT().TempDir()
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
Expect(err).To(HaveOccurred())
})
})

View File

@@ -1,265 +0,0 @@
package playlists
import (
"context"
"io"
"path/filepath"
"strconv"
"strings"
"github.com/bmatcuk/doublestar/v4"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
type Playlists interface {
// Reads
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
Get(ctx context.Context, id string) (*model.Playlist, error)
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
// Mutations
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
Delete(ctx context.Context, id string) error
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
// Import
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
// REST adapters (follows Share/Library pattern)
NewRepository(ctx context.Context) rest.Repository
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
}
type playlists struct {
ds model.DataStore
}
func NewPlaylists(ds model.DataStore) Playlists {
return &playlists{ds: ds}
}
func InPath(folder model.Folder) bool {
if conf.Server.PlaylistsPath == "" {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true
}
}
return false
}
// --- Read operations ---
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetAll(options...)
}
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).Get(id)
}
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
}
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
}
// --- Mutation operations ---
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
usr, _ := request.UserFrom(ctx)
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var pls *model.Playlist
var err error
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if pls.IsSmartPlaylist() {
return model.ErrNotAuthorized
}
if !usr.IsAdmin && pls.OwnerID != usr.ID {
return model.ErrNotAuthorized
}
} else {
pls = &model.Playlist{Name: name}
pls.OwnerID = usr.ID
}
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID
return err
})
return playlistId, err
}
func (s *playlists) Delete(ctx context.Context, id string) error {
if _, err := s.checkWritable(ctx, id); err != nil {
return err
}
return s.ds.Playlist(ctx).Delete(id)
}
func (s *playlists) Update(ctx context.Context, playlistID string,
name *string, comment *string, public *bool,
idsToAdd []string, idxToRemove []int) error {
var pls *model.Playlist
var err error
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
if hasTrackChanges {
pls, err = s.checkTracksEditable(ctx, playlistID)
} else {
pls, err = s.checkWritable(ctx, playlistID)
}
if err != nil {
return err
}
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
repo := tx.Playlist(ctx)
if len(idxToRemove) > 0 {
tracksRepo := repo.Tracks(playlistID, false)
// Convert 0-based indices to 1-based position IDs and delete them directly,
// avoiding the need to load all tracks into memory.
positions := make([]string, len(idxToRemove))
for i, idx := range idxToRemove {
positions[i] = strconv.Itoa(idx + 1)
}
if err := tracksRepo.Delete(positions...); err != nil {
return err
}
if len(idsToAdd) > 0 {
if _, err := tracksRepo.Add(idsToAdd); err != nil {
return err
}
}
return s.updateMetadata(ctx, tx, pls, name, comment, public)
}
if len(idsToAdd) > 0 {
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
return err
}
}
if name == nil && comment == nil && public == nil {
return nil
}
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
return s.updateMetadata(ctx, tx, pls, name, comment, public)
})
}
// --- Permission helpers ---
// checkWritable fetches the playlist and verifies the current user can modify it.
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
pls, err := s.ds.Playlist(ctx).Get(id)
if err != nil {
return nil, err
}
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && pls.OwnerID != usr.ID {
return nil, model.ErrNotAuthorized
}
return pls, nil
}
// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist).
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
pls, err := s.checkWritable(ctx, playlistID)
if err != nil {
return nil, err
}
if pls.IsSmartPlaylist() {
return nil, model.ErrNotAuthorized
}
return pls, nil
}
// updateMetadata applies optional metadata changes to a playlist and persists it.
// Accepts a DataStore parameter so it can be used inside transactions.
// The caller is responsible for permission checks.
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
if name != nil {
pls.Name = *name
}
if comment != nil {
pls.Comment = *comment
}
if public != nil {
pls.Public = *public
}
return ds.Playlist(ctx).Put(pls)
}
// --- Track management operations ---
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
}
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
}
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
}
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
})
}
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}

View File

@@ -1,17 +0,0 @@
package playlists_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPlaylists(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Playlists Suite")
}

View File

@@ -1,297 +0,0 @@
package playlists_test
import (
"context"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: &tests.MockLibraryRepo{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("Delete", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to delete their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("allows admin to delete any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
err := ps.Delete(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner, non-admin from deleting", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.Delete(ctx, "pls-1")
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockPlsRepo.Deleted).To(BeEmpty())
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Delete(ctx, "nonexistent")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("Create", func() {
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
ps = playlists.NewPlaylists(ds)
})
It("creates a new playlist with owner set from context", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
})
It("replaces tracks on existing playlist when owner matches", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
Expect(err).ToNot(HaveOccurred())
Expect(id).To(Equal("pls-1"))
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
})
It("allows admin to replace tracks on any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
Expect(err).ToNot(HaveOccurred())
Expect(id).To(Equal("pls-2"))
})
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when existing playlistId not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
Expect(err).To(Equal(model.ErrNotFound))
})
It("denies replacing tracks on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("Update", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
newName := "Updated Name"
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin from updating", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Name"
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
Expect(err).To(Equal(model.ErrNotFound))
})
It("denies adding tracks to a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies removing tracks from a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("allows metadata updates on a smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
newName := "Updated Smart"
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("AddTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to add tracks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
})
It("allows admin to add tracks to any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"})
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("RemoveTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to remove tracks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
Expect(err).ToNot(HaveOccurred())
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
})
It("denies on smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds)
})
It("allows owner to reorder", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
Expect(err).ToNot(HaveOccurred())
Expect(mockTracks.Reordered).To(BeTrue())
})
It("denies on smart playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
})
})

View File

@@ -1,95 +0,0 @@
package playlists
import (
"context"
"errors"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
// --- REST adapter (follows Share/Library pattern) ---
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
return &playlistRepositoryWrapper{
ctx: ctx,
PlaylistRepository: s.ds.Playlist(ctx),
service: s,
}
}
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
// and rest.Persistable by delegating to service methods for all mutations.
type playlistRepositoryWrapper struct {
model.PlaylistRepository
ctx context.Context
service *playlists
}
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
}
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
err := r.service.Delete(r.ctx, id)
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
repo := s.ds.Playlist(ctx)
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
if tracks == nil {
return nil
}
return tracks.(rest.Repository)
}
// savePlaylist creates a new playlist, assigning the owner from context.
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
usr, _ := request.UserFrom(ctx)
pls.OwnerID = usr.ID
pls.ID = "" // Force new creation
err := s.ds.Playlist(ctx).Put(pls)
if err != nil {
return "", err
}
return pls.ID, nil
}
// updatePlaylistEntity updates playlist metadata with permission checks.
// Used by the REST API wrapper.
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
usr, _ := request.UserFrom(ctx)
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
return rest.ErrPermissionDenied
}
// Apply ownership change (admin only)
if entity.OwnerID != "" {
current.OwnerID = entity.OwnerID
}
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
}

View File

@@ -1,120 +0,0 @@
package playlists_test
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("REST Adapter", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedLibrary: &tests.MockLibraryRepo{},
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
Describe("NewRepository", func() {
var repo rest.Persistable
BeforeEach(func() {
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
}
ps = playlists.NewPlaylists(ds)
})
Describe("Save", func() {
It("sets the owner from the context user", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "New Playlist"}
id, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
Expect(id).ToNot(BeEmpty())
Expect(pls.OwnerID).To(Equal("user-1"))
})
It("forces a new creation by clearing ID", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
_, err := repo.Save(pls)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
})
})
Describe("Update", func() {
It("allows owner to update their playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).ToNot(HaveOccurred())
})
It("allows admin to update any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("pls-1", pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("denies regular user from changing ownership", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
err := repo.Update("pls-1", pls)
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
pls := &model.Playlist{Name: "Updated"}
err := repo.Update("nonexistent", pls)
Expect(err).To(Equal(rest.ErrNotFound))
})
})
Describe("Delete", func() {
It("delegates to service Delete with permission checks", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
err := repo.Delete("pls-1")
Expect(err).To(Equal(rest.ErrPermissionDenied))
})
})
})
})

View File

@@ -1,4 +1,4 @@
package playlists
package core
import (
"context"
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
})
Describe("resolvePath", func() {
Context("basic", func() {
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
It("resolves absolute paths", func() {
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.libraryPath).To(Equal("/music"))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
Context("cross-library", func() {
It("resolves relative paths when folder is provided", func() {
folder := &model.Folder{
Path: "playlists",
LibraryPath: "/music",
LibraryID: 1,
}
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
Expect(resolution.valid).To(BeTrue())
Expect(resolution.libraryID).To(Equal(1))
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
})
It("returns invalid resolution for paths outside any library", func() {
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
Expect(resolution.valid).To(BeFalse())
})
})
Describe("resolvePath", func() {
Context("With absolute paths", func() {
It("resolves path within a library", func() {
resolution := resolver.resolvePath("/music/track.mp3", nil)

View File

@@ -1,4 +1,4 @@
package playlists_test
package core_test
import (
"context"
@@ -9,7 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -19,18 +19,18 @@ import (
"golang.org/x/text/unicode/norm"
)
var _ = Describe("Playlists - Import", func() {
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
var ps playlists.Playlists
var mockPlsRepo *tests.MockPlaylistRepo
var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
mockPlsRepo = tests.CreateMockPlaylistRepo()
mockPlsRepo = mockedPlaylistRepo{}
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedPlaylist: mockPlsRepo,
MockedPlaylist: &mockPlsRepo,
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
@@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() {
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
ps = playlists.NewPlaylists(ds)
ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
// Set up library with the actual library path that matches the folder
@@ -61,7 +61,7 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(mockPlsRepo.last).To(Equal(pls))
})
It("parses playlists using LF ending", func() {
@@ -99,7 +99,7 @@ var _ = Describe("Playlists - Import", func() {
It("parses well-formed playlists", func() {
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(mockPlsRepo.last).To(Equal(pls))
Expect(pls.OwnerID).To(Equal("123"))
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Comment).To(Equal("Recently played tracks"))
@@ -149,7 +149,7 @@ var _ = Describe("Playlists - Import", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
ps = playlists.NewPlaylists(ds)
ps = core.NewPlaylists(ds)
// Create the playlist file on disk with the filesystem's normalization form
plsFile := tmpDir + "/" + filesystemName + ".m3u"
@@ -163,7 +163,7 @@ var _ = Describe("Playlists - Import", func() {
Path: storedPath,
Sync: true,
}
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
// Import using the filesystem's normalization form
plsFolder := &model.Folder{
@@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() {
"def.mp3", // This is playlists/def.mp3 relative to plsDir
},
}
ps = playlists.NewPlaylists(ds)
ps = core.NewPlaylists(ds)
})
It("handles relative paths that reference files in other libraries", func() {
@@ -365,7 +365,7 @@ var _ = Describe("Playlists - Import", func() {
},
}
// Recreate playlists service to pick up new mock
ps = playlists.NewPlaylists(ds)
ps = core.NewPlaylists(ds)
// Create playlist in music library that references both tracks
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
@@ -408,7 +408,7 @@ var _ = Describe("Playlists - Import", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
ps = playlists.NewPlaylists(ds)
ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -439,7 +439,7 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
Expect(mockPlsRepo.Last).To(Equal(pls))
Expect(mockPlsRepo.last).To(Equal(pls))
})
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
@@ -460,7 +460,7 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Tracks).To(HaveLen(2))
})
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
repo.data = []string{
"album1/test1.mp3",
"album2/test2.mp3",
@@ -570,7 +570,7 @@ var _ = Describe("Playlists - Import", func() {
})
Describe("InPath", func() {
Describe("InPlaylistsPath", func() {
var folder model.Folder
BeforeEach(func() {
@@ -584,27 +584,27 @@ var _ = Describe("Playlists - Import", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
Expect(playlists.InPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
Expect(playlists.InPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
Expect(playlists.InPath(folder)).To(BeTrue())
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
Expect(playlists.InPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
Expect(playlists.InPath(folder)).To(BeFalse())
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -612,7 +612,7 @@ var _ = Describe("Playlists - Import", func() {
Name: ".",
}
Expect(playlists.InPath(folder2)).To(BeTrue())
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
@@ -693,3 +693,23 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
}
return mfs, nil
}
type mockedPlaylistRepo struct {
last *model.Playlist
data map[string]*model.Playlist // keyed by path
model.PlaylistRepository
}
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
if r.data != nil {
if pls, ok := r.data[path]; ok {
return pls, nil
}
}
return nil, model.ErrNotFound
}
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
r.last = pls
return nil
}

View File

@@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage {
func (s *localStorage) FS() (storage.MusicFS, error) {
path := s.u.Path
if _, err := os.Stat(path); err != nil {
if _, err := os.Stat(path); err != nil { //nolint:gosec
return nil, fmt.Errorf("%w: %s", err, path)
}
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil

View File

@@ -7,7 +7,6 @@ import (
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
)
@@ -17,7 +16,7 @@ var Set = wire.NewSet(
NewArchiver,
NewPlayers,
NewShare,
playlists.NewPlaylists,
NewPlaylists,
NewLibrary,
NewUser,
NewMaintenance,

View File

@@ -6,7 +6,9 @@ import (
"embed"
"fmt"
"runtime"
"strings"
"github.com/maruel/natural"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
_ "github.com/navidrome/navidrome/db/migrations"
@@ -31,7 +33,12 @@ func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
return err
}
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
})
},
})
Path = conf.Server.DbPath

View File

@@ -0,0 +1,152 @@
-- +goose Up
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
-- This way bare ORDER BY on these columns automatically uses natural sorting,
-- without needing explicit COLLATE NATURALSORT in every query.
PRAGMA writable_schema = ON;
UPDATE sqlite_master
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
PRAGMA writable_schema = OFF;
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
-- Artist indexes
drop index if exists artist_order_artist_name;
create index artist_order_artist_name
on artist (order_artist_name collate NATURALSORT);
drop index if exists artist_sort_name;
create index artist_sort_name
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
-- Album indexes
drop index if exists album_order_album_name;
create index album_order_album_name
on album (order_album_name collate NATURALSORT);
drop index if exists album_order_album_artist_name;
create index album_order_album_artist_name
on album (order_album_artist_name collate NATURALSORT);
drop index if exists album_alphabetical_by_artist;
create index album_alphabetical_by_artist
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
drop index if exists album_sort_name;
create index album_sort_name
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
drop index if exists album_sort_album_artist_name;
create index album_sort_album_artist_name
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
-- Media file indexes
drop index if exists media_file_order_title;
create index media_file_order_title
on media_file (order_title collate NATURALSORT);
drop index if exists media_file_order_album_name;
create index media_file_order_album_name
on media_file (order_album_name collate NATURALSORT);
drop index if exists media_file_order_artist_name;
create index media_file_order_artist_name
on media_file (order_artist_name collate NATURALSORT);
drop index if exists media_file_sort_title;
create index media_file_sort_title
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
drop index if exists media_file_sort_artist_name;
create index media_file_sort_artist_name
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
drop index if exists media_file_sort_album_name;
create index media_file_sort_album_name
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
drop index if exists playlist_name;
create index playlist_name
on playlist (name collate NATURALSORT);
drop index if exists radio_name;
create index radio_name
on radio (name collate NATURALSORT);
-- +goose Down
-- Restore NOCASE column collation
PRAGMA writable_schema = ON;
UPDATE sqlite_master
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
PRAGMA writable_schema = OFF;
-- Restore NOCASE collation indexes
-- Artist indexes
drop index if exists artist_order_artist_name;
create index artist_order_artist_name
on artist (order_artist_name);
drop index if exists artist_sort_name;
create index artist_sort_name
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
-- Album indexes
drop index if exists album_order_album_name;
create index album_order_album_name
on album (order_album_name);
drop index if exists album_order_album_artist_name;
create index album_order_album_artist_name
on album (order_album_artist_name);
drop index if exists album_alphabetical_by_artist;
create index album_alphabetical_by_artist
on album (compilation, order_album_artist_name, order_album_name);
drop index if exists album_sort_name;
create index album_sort_name
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
drop index if exists album_sort_album_artist_name;
create index album_sort_album_artist_name
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
-- Media file indexes
drop index if exists media_file_order_title;
create index media_file_order_title
on media_file (order_title);
drop index if exists media_file_order_album_name;
create index media_file_order_album_name
on media_file (order_album_name);
drop index if exists media_file_order_artist_name;
create index media_file_order_artist_name
on media_file (order_artist_name);
drop index if exists media_file_sort_title;
create index media_file_sort_title
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
drop index if exists media_file_sort_artist_name;
create index media_file_sort_artist_name
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
drop index if exists media_file_sort_album_name;
create index media_file_sort_album_name
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
-- Restore playlist and radio indexes
drop index if exists playlist_name;
create index playlist_name
on playlist (name);
drop index if exists radio_name;
create index radio_name
on radio (name);

4
go.mod
View File

@@ -46,13 +46,13 @@ require (
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/maruel/natural v1.3.0
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/mattn/go-sqlite3 v1.14.34
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.26.0
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3

8
go.sum
View File

@@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=

View File

@@ -38,7 +38,7 @@ type MediaFile struct {
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
// AlbumArtist is the display name used for the album artist.
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
AlbumID string `structs:"album_id" json:"albumId"`
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
TrackNumber int `structs:"track_number" json:"trackNumber"`
DiscNumber int `structs:"disc_number" json:"discNumber"`

View File

@@ -22,7 +22,7 @@ type User struct {
Password string `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
// It is received from the UI with the name "password"
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
}

View File

@@ -138,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
})
r.setSortMappings(map[string]string{
r.setSortMappings(map[string]string{ //nolint:gosec
"name": "order_artist_name",
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",

View File

@@ -17,45 +17,45 @@ import (
var _ = Describe("Collation", func() {
conn := db.Db()
DescribeTable("Column collation",
func(table, column string) {
Expect(checkCollation(conn, table, column)).To(Succeed())
func(table, column, expectedCollation string) {
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
},
Entry("artist.order_artist_name", "artist", "order_artist_name"),
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
Entry("album.order_album_name", "album", "order_album_name"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
Entry("album.sort_album_name", "album", "sort_album_name"),
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
Entry("media_file.order_title", "media_file", "order_title"),
Entry("media_file.order_album_name", "media_file", "order_album_name"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
Entry("media_file.sort_title", "media_file", "sort_title"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
Entry("playlist.name", "playlist", "name"),
Entry("radio.name", "radio", "name"),
Entry("user.name", "user", "name"),
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
Entry("radio.name", "radio", "name", "NATURALSORT"),
Entry("user.name", "user", "name", "NOCASE"),
)
DescribeTable("Index collation",
func(table, column string) {
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
},
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
Entry("media_file.path", "media_file", "path collate nocase"),
Entry("playlist.name", "playlist", "name collate nocase"),
Entry("radio.name", "radio", "name collate nocase"),
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
Entry("radio.name", "radio", "name collate NATURALSORT"),
Entry("user.user_name", "user", "user_name collate nocase"),
)
})
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
return errors.New("no rows returned")
}
func checkCollation(conn *sql.DB, table string, column string) error {
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
if err != nil {
return err
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table string, column string) error {
if !re.MatchString(res) {
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
}
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
if re.MatchString(res) {
return nil
}
} else {
return fmt.Errorf("table '%s' not found", table)
}
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
}

View File

@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) {
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
// Convert the order_* columns to an expression using sort_* columns. Example:
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
// It finds order column names anywhere in the substring
func mapSortOrder(tableName, order string) string {
order = strings.ToLower(order)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
return sortOrderRegex.ReplaceAllString(order, repl)
}

View File

@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
sort := "ORDER_ALBUM_NAME asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
` collate nocase) asc`))
` collate NATURALSORT) asc`))
})
It("changes multiple order columns to sort expressions", func() {
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
})
})
})

View File

@@ -148,7 +148,9 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
}
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
m.CreatedAt = time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = time.Now()
}
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
if err != nil {
return err

View File

@@ -104,6 +104,68 @@ var _ = Describe("MediaRepository", func() {
}
})
Describe("Put CreatedAt behavior (#5050)", func() {
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
before := time.Now().Add(-time.Second)
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
_ = mr.Delete(newFile.ID)
})
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
newFile := model.MediaFile{
ID: id.NewRandom(),
LibraryID: 1,
Path: "/test/created-at-preserved.mp3",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
retrieved, err := mr.Get(newFile.ID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(newFile.ID)
})
It("does not reset CreatedAt when updating an existing file", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
fileID := id.NewRandom()
newFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "/test/created-at-update.mp3",
Title: "Original Title",
CreatedAt: originalTime,
}
Expect(mr.Put(&newFile)).To(Succeed())
// Update the file with a new title but zero CreatedAt
updatedFile := model.MediaFile{
ID: fileID,
LibraryID: 1,
Path: "/test/created-at-update.mp3",
Title: "Updated Title",
// CreatedAt is zero - should NOT overwrite the stored value
}
Expect(mr.Put(&updatedFile)).To(Succeed())
retrieved, err := mr.Get(fileID)
Expect(err).ToNot(HaveOccurred())
Expect(retrieved.Title).To(Equal("Updated Title"))
// CreatedAt should still be the original time (not reset)
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
_ = mr.Delete(fileID)
})
})
It("checks existence of mediafiles in the DB", func() {
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
Expect(mr.Exists("666")).To(BeFalse())

View File

@@ -96,6 +96,16 @@ func (r *playlistRepository) Exists(id string) (bool, error) {
}
func (r *playlistRepository) Delete(id string) error {
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
pls, err := r.Get(id)
if err != nil {
return err
}
if pls.OwnerID != usr.ID {
return rest.ErrPermissionDenied
}
}
return r.delete(And{Eq{"id": id}, r.userFilter()})
}
@@ -103,6 +113,14 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
pls := dbPlaylist{Playlist: *p}
if pls.ID == "" {
pls.CreatedAt = time.Now()
} else {
ok, err := r.Exists(pls.ID)
if err != nil {
return err
}
if !ok {
return model.ErrNotAuthorized
}
}
pls.UpdatedAt = time.Now()
@@ -114,6 +132,7 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
if p.IsSmartPlaylist() {
// Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process
//r.refreshSmartPlaylist(p)
return nil
}
// Only update tracks if they were specified
@@ -301,6 +320,10 @@ func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) er
}
func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error {
if !r.isWritable(playlistId) {
return rest.ErrPermissionDenied
}
// Remove old tracks
del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId})
_, err := r.executeSQL(del)
@@ -416,7 +439,8 @@ func (r *playlistRepository) NewInstance() any {
func (r *playlistRepository) Save(entity any) (string, error) {
pls := entity.(*model.Playlist)
pls.ID = "" // Force new creation
pls.OwnerID = loggedUser(r.ctx).ID
pls.ID = "" // Make sure we don't override an existing playlist
err := r.Put(pls)
if err != nil {
return "", err
@@ -426,9 +450,24 @@ func (r *playlistRepository) Save(entity any) (string, error) {
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
current, err := r.Get(id)
if err != nil {
return err
}
usr := loggedUser(r.ctx)
if !usr.IsAdmin {
// Only the owner can update the playlist
if current.OwnerID != usr.ID {
return rest.ErrPermissionDenied
}
// Regular users can't change the ownership of a playlist
if pls.OwnerID != "" && pls.OwnerID != usr.ID {
return rest.ErrPermissionDenied
}
}
pls.ID = id
pls.UpdatedAt = time.Now()
_, err := r.put(id, pls, append(cols, "updatedAt")...)
_, err = r.put(id, pls, append(cols, "updatedAt")...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@@ -468,31 +507,23 @@ func (r *playlistRepository) removeOrphans() error {
return nil
}
// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their
// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering.
// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id).
func (r *playlistRepository) renumber(id string) error {
// Step 1: Negate all IDs to clear the positive ID space
_, err := r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id))
var ids []string
sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id")
err := r.queryAllSlice(sq, &ids)
if err != nil {
return err
}
// Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE.
// The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues.
// ORDER BY id DESC restores original order since IDs are now negative.
_, err = r.executeSQL(Expr(
`WITH new_ids AS (
SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id
FROM playlist_tracks WHERE playlist_id = ?
)
UPDATE playlist_tracks SET id = new_ids.new_id
FROM new_ids
WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id))
if err != nil {
return err
return r.updatePlaylist(id, ids)
}
func (r *playlistRepository) isWritable(playlistId string) bool {
usr := loggedUser(r.ctx)
if usr.IsAdmin {
return true
}
return r.refreshCounters(&model.Playlist{ID: id})
pls, err := r.Get(playlistId)
return err == nil && pls.OwnerID == usr.ID
}
var _ model.PlaylistRepository = (*playlistRepository)(nil)

View File

@@ -401,79 +401,6 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Track Deletion and Renumbering", func() {
var testPlaylistID string
AfterEach(func() {
if testPlaylistID != "" {
Expect(repo.Delete(testPlaylistID)).To(BeNil())
testPlaylistID = ""
}
})
// helper to get track positions and media file IDs
getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) {
pls, err := repo.GetWithTracks(playlistID, false, false)
Expect(err).ToNot(HaveOccurred())
for _, t := range pls.Tracks {
ids = append(ids, t.ID)
mediaFileIDs = append(mediaFileIDs, t.MediaFileID)
}
return
}
It("renumbers correctly after deleting a track from the middle", func() {
By("creating a playlist with 4 tracks")
newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"}
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("deleting the second track (position 2)")
tracksRepo := repo.Tracks(newPls.ID, false)
Expect(tracksRepo.Delete("2")).To(Succeed())
By("verifying remaining tracks are renumbered sequentially")
ids, mediaFileIDs := getTrackInfo(newPls.ID)
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"}))
})
It("renumbers correctly after deleting the first track", func() {
By("creating a playlist with 3 tracks")
newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"}
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("deleting the first track (position 1)")
tracksRepo := repo.Tracks(newPls.ID, false)
Expect(tracksRepo.Delete("1")).To(Succeed())
By("verifying remaining tracks are renumbered sequentially")
ids, mediaFileIDs := getTrackInfo(newPls.ID)
Expect(ids).To(Equal([]string{"1", "2"}))
Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"}))
})
It("renumbers correctly after deleting the last track", func() {
By("creating a playlist with 3 tracks")
newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"}
newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"})
Expect(repo.Put(&newPls)).To(Succeed())
testPlaylistID = newPls.ID
By("deleting the last track (position 3)")
tracksRepo := repo.Tracks(newPls.ID, false)
Expect(tracksRepo.Delete("3")).To(Succeed())
By("verifying remaining tracks are renumbered sequentially")
ids, mediaFileIDs := getTrackInfo(newPls.ID)
Expect(ids).To(Equal([]string{"1", "2"}))
Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"}))
})
})
Describe("Smart Playlists Library Filtering", func() {
var mfRepo model.MediaFileRepository
var testPlaylistID string

View File

@@ -140,7 +140,15 @@ func (r *playlistTrackRepository) NewInstance() any {
return &model.PlaylistTrack{}
}
func (r *playlistTrackRepository) isTracksEditable() bool {
return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist()
}
func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
if !r.isTracksEditable() {
return 0, rest.ErrPermissionDenied
}
if len(mediaFileIds) > 0 {
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
} else {
@@ -188,7 +196,22 @@ func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) {
return r.addMediaFileIds(clauses)
}
// Get ids from all current tracks
func (r *playlistTrackRepository) getTracks() ([]string, error) {
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
var ids []string
err := r.queryAllSlice(all, &ids)
if err != nil {
log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err)
return nil, err
}
return ids, nil
}
func (r *playlistTrackRepository) Delete(ids ...string) error {
if !r.isTracksEditable() {
return rest.ErrPermissionDenied
}
err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}})
if err != nil {
return err
@@ -198,6 +221,9 @@ func (r *playlistTrackRepository) Delete(ids ...string) error {
}
func (r *playlistTrackRepository) DeleteAll() error {
if !r.isTracksEditable() {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"playlist_id": r.playlistId})
if err != nil {
return err
@@ -206,45 +232,16 @@ func (r *playlistTrackRepository) DeleteAll() error {
return r.playlistRepo.renumber(r.playlistId)
}
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
if pos == newPos {
return nil
if !r.isTracksEditable() {
return rest.ErrPermissionDenied
}
pid := r.playlistId
// Step 1: Move the source track out of the way (temporary sentinel value)
_, err := r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos))
ids, err := r.getTracks()
if err != nil {
return err
}
// Step 2: Shift the affected range using negative values to avoid unique constraint violations
if pos < newPos {
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`,
pid, pos, newPos))
} else {
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`,
pid, newPos, pos))
}
if err != nil {
return err
}
// Step 3: Flip the shifted range back to positive
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid))
if err != nil {
return err
}
// Step 4: Place the source track at its new position
_, err = r.executeSQL(Expr(
`UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid))
return err
newOrder := slice.Move(ids, pos-1, newPos-1)
return r.playlistRepo.updatePlaylist(r.playlistId, newOrder)
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)

View File

@@ -71,8 +71,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
//
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
// which gives precedence to sort tags.
// Ex: order_title => (coalesce(nullif(sort_title,),order_title) collate nocase)
// To avoid performance issues, indexes should be created for these sort expressions
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
// To avoid performance issues, indexes should be created for these sort expressions.
//
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.

View File

@@ -9,10 +9,10 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -27,7 +27,7 @@ var (
)
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
pls core.Playlists, m metrics.Metrics) model.Scanner {
c := &controller{
rootCtx: rootCtx,
ds: ds,
@@ -53,7 +53,7 @@ func (s *controller) getScanner() scanner {
// CallScan starts an in-process scan of specific library/folder pairs.
// If targets is empty, it scans all libraries.
// This is meant to be called from the command line (see cmd/scan.go).
func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
@@ -98,7 +98,7 @@ type controller struct {
cw artwork.CacheWarmer
broker events.Broker
metrics metrics.Metrics
pls playlists.Playlists
pls core.Playlists
limiter *rate.Sometimes
devExternalScanner bool
count atomic.Uint32

View File

@@ -5,9 +5,9 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
@@ -31,7 +31,7 @@ var _ = Describe("Controller", func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
ds.MockedProperty = &tests.MockedPropertyRepo{}
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance())
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
})
It("includes last scan error", func() {

View File

@@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
for _, target := range targets {
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
os.Remove(tmpFile.Name())
os.Remove(tmpFile.Name()) //nolint:gosec
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
}

View File

@@ -10,7 +10,7 @@ import (
"slices"
"time"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/chrono"
)
@@ -72,7 +72,7 @@ func (f *folderEntry) isOutdated() bool {
func (f *folderEntry) toFolder() *model.Folder {
folder := model.NewFolder(f.job.lib, f.path)
folder.NumAudioFiles = len(f.audioFiles)
if playlists.InPath(*folder) {
if core.InPlaylistsPath(*folder) {
folder.NumPlaylists = f.numPlaylists
}
folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles))

View File

@@ -2,6 +2,7 @@ package scanner
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -267,6 +268,10 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
oldAlbumID := missing.AlbumID
newAlbumID := target.AlbumID
// Preserve the original created_at from the missing file, so moved tracks
// don't appear in "Recently Added"
target.CreatedAt = missing.CreatedAt
// Update the target media file with the missing file's ID. This effectively "moves" the track
// to the new location while keeping its annotations and references intact.
target.ID = missing.ID
@@ -298,6 +303,14 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
// Keep created_at field from previous instance of the album, so moved albums
// don't appear in "Recently Added"
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
}
}
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
p.processedAlbumAnnotations[newAlbumID] = true
}

View File

@@ -724,6 +724,120 @@ var _ = Describe("phaseMissingTracks", func() {
}) // End of Context "with multiple libraries"
})
Describe("CreatedAt preservation (#5050)", func() {
var albumRepo *tests.MockAlbumRepo
BeforeEach(func() {
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
albumRepo.ReassignAnnotationCalls = make(map[string]string)
albumRepo.CopyAttributesCalls = make(map[string]string)
})
It("should preserve the missing track's created_at when moving within a library", func() {
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "1", PID: "A", Path: "old/song.mp3",
AlbumID: "album-1",
LibraryID: 1,
CreatedAt: originalTime,
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
matchedTrack := model.MediaFile{
ID: "2", PID: "A", Path: "new/song.mp3",
AlbumID: "album-1", // Same album
LibraryID: 1,
CreatedAt: time.Now(), // Much newer
Tags: model.Tags{"title": []string{"My Song"}},
Size: 100,
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
in := &missingTracks{
missing: []model.MediaFile{missingTrack},
matched: []model.MediaFile{matchedTrack},
}
_, err := phase.processMissingTracks(in)
Expect(err).ToNot(HaveOccurred())
movedTrack, _ := ds.MediaFile(ctx).Get("1")
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
})
It("should preserve created_at during cross-library moves with album change", func() {
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
AlbumID: "old-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
AlbumID: "new-album",
LibraryID: 2,
CreatedAt: time.Now(),
}
// Set up albums so CopyAttributes can find them
albumRepo.SetData(model.Albums{
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
})
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should be preserved from the missing file
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// Album's created_at should be copied from old to new
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
// Verify the new album's CreatedAt was actually updated
newAlbum, err := albumRepo.Get("new-album")
Expect(err).ToNot(HaveOccurred())
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
})
It("should not copy album created_at when album ID does not change", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
missingTrack := model.MediaFile{
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
AlbumID: "same-album",
LibraryID: 1,
CreatedAt: originalTime,
}
matchedTrack := model.MediaFile{
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
AlbumID: "same-album", // Same album
LibraryID: 1,
CreatedAt: time.Now(),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Track's created_at should still be preserved
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
// CopyAttributes should NOT have been called (same album)
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
})
})
Describe("Album Annotation Reassignment", func() {
var (
albumRepo *tests.MockAlbumRepo

View File

@@ -10,8 +10,8 @@ import (
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -21,12 +21,12 @@ type phasePlaylists struct {
ctx context.Context
scanState *scanState
ds model.DataStore
pls playlists.Playlists
pls core.Playlists
cw artwork.CacheWarmer
refreshed atomic.Uint32
}
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists {
return &phasePlaylists{
ctx: ctx,
scanState: scanState,

View File

@@ -9,8 +9,8 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
@@ -130,7 +130,7 @@ var _ = Describe("phasePlaylists", func() {
type mockPlaylists struct {
mock.Mock
playlists.Playlists
core.Playlists
}
func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {

View File

@@ -11,8 +11,8 @@ import (
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -23,7 +23,7 @@ import (
type scannerImpl struct {
ds model.DataStore
cw artwork.CacheWarmer
pls playlists.Playlists
pls core.Playlists
}
// scanState holds the state of an in-progress scan, to be passed to the various phases

View File

@@ -12,9 +12,9 @@ import (
"github.com/dustin/go-humanize"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
@@ -40,7 +40,7 @@ func BenchmarkScan(b *testing.B) {
ds := persistence.New(db.Db())
conf.Server.DevExternalScanner = false
s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
core.NewPlaylists(ds), metrics.NewNoopInstance())
fs := storagetest.FakeFS{}
storagetest.Register("fake", &fs)

View File

@@ -11,9 +11,9 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -77,7 +77,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
core.NewPlaylists(ds), metrics.NewNoopInstance())
// Create two test libraries (let DB auto-assign IDs)
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}

View File

@@ -8,9 +8,9 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -63,7 +63,7 @@ var _ = Describe("ScanFolders", Ordered, func() {
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
core.NewPlaylists(ds), metrics.NewNoopInstance())
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())

View File

@@ -11,9 +11,9 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@@ -84,7 +84,7 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
core.NewPlaylists(ds), metrics.NewNoopInstance())
lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
Expect(ds.Library(ctx).Put(&lib)).To(Succeed())

View File

@@ -80,7 +80,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e
}
c := http.Client{Timeout: imageRequestTimeout}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper
if errors.Is(err, context.DeadlineExceeded) {
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
return strings.NewReader(string(defaultImage)), nil

View File

@@ -21,7 +21,6 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
@@ -70,14 +69,6 @@ var (
Name: "Admin User",
IsAdmin: true,
}
// Regular (non-admin) user for permission tests
regularUser = model.User{
ID: "regular-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
}
)
func createFS(files fstest.MapFS) storagetest.FakeFS {
@@ -297,29 +288,19 @@ var _ = BeforeSuite(func() {
adminUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed())
regularUserWithPass := regularUser
regularUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&regularUserWithPass)).To(Succeed())
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
Expect(err).ToNot(HaveOccurred())
adminUser.Libraries = loadedUser.Libraries
loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
regularUser.Libraries = loadedRegular.Libraries
ctx = request.WithUser(GinkgoT().Context(), adminUser)
buildTestFS()
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(initDS), metrics.NewNoopInstance())
core.NewPlaylists(initDS), metrics.NewNoopInstance())
_, err = s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
@@ -353,7 +334,7 @@ func setupTestDB() {
// Create the Subsonic Router with real DS + noop stubs
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
core.NewPlaylists(ds), metrics.NewNoopInstance())
router = subsonic.New(
ds,
noopArtwork{},
@@ -363,7 +344,7 @@ func setupTestDB() {
noopProvider{},
s,
events.NoopBroker(),
playlists.NewPlaylists(ds),
core.NewPlaylists(ds),
noopPlayTracker{},
core.NewShare(ds),
playback.PlaybackServer(nil),

View File

@@ -6,9 +6,9 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner"
@@ -53,7 +53,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() {
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds), metrics.NewNoopInstance())
core.NewPlaylists(ds), metrics.NewNoopInstance())
_, err = s.ScanAll(ctx, false)
Expect(err).ToNot(HaveOccurred())

View File

@@ -1,11 +1,7 @@
package e2e
import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -19,9 +15,9 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
setupTestDB()
// Look up song IDs from scanned data for playlist operations
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3})
Expect(err).ToNot(HaveOccurred())
Expect(len(songs)).To(BeNumerically(">=", 5))
Expect(len(songs)).To(BeNumerically(">=", 3))
for _, s := range songs {
songIDs = append(songIDs, s.ID)
}
@@ -36,30 +32,24 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
})
It("createPlaylist creates a new playlist with songs", func() {
resp := doReq("createPlaylist", "name", "Test Playlist",
"songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2])
resp := doReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1])
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist).ToNot(BeNil())
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
Expect(resp.Playlist.Entry).To(HaveLen(3))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
playlistID = resp.Playlist.Id
})
It("getPlaylist returns playlist with tracks in order", func() {
It("getPlaylist returns playlist with tracks", func() {
resp := doReq("getPlaylist", "id", playlistID)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist).ToNot(BeNil())
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
Expect(resp.Playlist.Entry).To(HaveLen(3))
Expect(resp.Playlist.Entry).To(HaveLen(2))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
})
It("createPlaylist without name or playlistId returns error", func() {
@@ -69,150 +59,40 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
Expect(resp.Error).ToNot(BeNil())
})
It("createPlaylist with playlistId replaces tracks on existing playlist", func() {
// Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4]
resp := doReq("createPlaylist", "playlistId", playlistID,
"songId", songIDs[3], "songId", songIDs[4])
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist).ToNot(BeNil())
Expect(resp.Playlist.Id).To(Equal(playlistID))
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
Expect(resp.Playlist.Entry).To(HaveLen(2))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
})
It("updatePlaylist can rename the playlist", func() {
resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist")
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify the rename
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.Name).To(Equal("Renamed Playlist"))
// Tracks should be unchanged
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
})
It("updatePlaylist can set comment", func() {
resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.Comment).To(Equal("My favorite songs"))
})
It("updatePlaylist can set public visibility", func() {
resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.Public).To(BeTrue())
})
It("updatePlaylist can add songs", func() {
// Playlist currently has [song3, song4], add song0
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0])
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2])
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify the song was added
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
Expect(resp.Playlist.Entry).To(HaveLen(3))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4]))
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0]))
})
It("updatePlaylist can add multiple songs at once", func() {
// Playlist currently has [song3, song4, song0], add song1 and song2
resp := doReq("updatePlaylist", "playlistId", playlistID,
"songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2])
It("updatePlaylist can remove songs by index", func() {
// Remove the first song (index 0)
resp := doReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0")
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify the song was removed
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(5)))
Expect(resp.Playlist.Entry).To(HaveLen(5))
})
It("updatePlaylist can remove songs by index and verifies correct songs remain", func() {
// Playlist has [song3, song4, song0, song1, song2]
// Remove index 0 (song3) and index 2 (song0)
resp := doReq("updatePlaylist", "playlistId", playlistID,
"songIndexToRemove", "0", "songIndexToRemove", "2")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
Expect(resp.Playlist.Entry).To(HaveLen(3))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2]))
})
It("updatePlaylist can remove and add songs in a single call", func() {
// Playlist has [song4, song1, song2]
// Remove index 1 (song1) and add song3
resp := doReq("updatePlaylist", "playlistId", playlistID,
"songIndexToRemove", "1", "songIdToAdd", songIDs[3])
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
Expect(resp.Playlist.Entry).To(HaveLen(3))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2]))
Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3]))
})
It("updatePlaylist can combine metadata change with track removal", func() {
// Playlist has [song4, song2, song3]
// Rename + remove index 0 (song4)
resp := doReq("updatePlaylist", "playlistId", playlistID,
"name", "Final Playlist", "songIndexToRemove", "0")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.Name).To(Equal("Final Playlist"))
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3]))
})
It("updatePlaylist can remove all songs from playlist", func() {
// Playlist has [song2, song3] — remove both
resp := doReq("updatePlaylist", "playlistId", playlistID,
"songIndexToRemove", "0", "songIndexToRemove", "1")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(0)))
Expect(resp.Playlist.Entry).To(BeEmpty())
})
It("updatePlaylist can add songs to an empty playlist", func() {
resp := doReq("updatePlaylist", "playlistId", playlistID,
"songIdToAdd", songIDs[0])
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", playlistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(1)))
Expect(resp.Playlist.Entry).To(HaveLen(1))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
})
It("updatePlaylist without playlistId returns error", func() {
resp := doReq("updatePlaylist", "name", "No ID")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("getPlaylists shows the playlist", func() {
resp := doReq("getPlaylists")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlists.Playlist).To(HaveLen(1))
Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID))
Expect(resp.Playlist.Entry).To(HaveLen(2))
})
It("deletePlaylist removes the playlist", func() {
@@ -227,294 +107,4 @@ var _ = Describe("Playlist Endpoints", Ordered, func() {
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("getPlaylists returns empty after deletion", func() {
resp := doReq("getPlaylists")
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlists.Playlist).To(BeEmpty())
})
Describe("Playlist Permissions", Ordered, func() {
var songIDs []string
var adminPrivateID string
var adminPublicID string
var regularPlaylistID string
BeforeAll(func() {
setupTestDB()
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 6})
Expect(err).ToNot(HaveOccurred())
Expect(len(songs)).To(BeNumerically(">=", 3))
for _, s := range songs {
songIDs = append(songIDs, s.ID)
}
})
It("admin creates a private playlist", func() {
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private",
"songId", songIDs[0], "songId", songIDs[1])
Expect(resp.Status).To(Equal(responses.StatusOK))
adminPrivateID = resp.Playlist.Id
})
It("admin creates a public playlist", func() {
resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public",
"songId", songIDs[0], "songId", songIDs[1])
Expect(resp.Status).To(Equal(responses.StatusOK))
adminPublicID = resp.Playlist.Id
// Make it public
resp = doReqWithUser(adminUser, "updatePlaylist",
"playlistId", adminPublicID, "public", "true")
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("regular user creates a playlist", func() {
resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist",
"songId", songIDs[0])
Expect(resp.Status).To(Equal(responses.StatusOK))
regularPlaylistID = resp.Playlist.Id
})
// --- Private playlist: regular user gets "not found" (repo hides it entirely) ---
It("regular user cannot see admin's private playlist", func() {
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("regular user cannot update admin's private playlist (not found)", func() {
resp := doReqWithUser(regularUser, "updatePlaylist",
"playlistId", adminPrivateID, "name", "Hacked")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
It("regular user cannot delete admin's private playlist (not found)", func() {
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
// --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) ---
It("regular user can see admin's public playlist", func() {
resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
})
It("regular user cannot update admin's public playlist", func() {
resp := doReqWithUser(regularUser, "updatePlaylist",
"playlistId", adminPublicID, "name", "Hacked")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("regular user cannot add songs to admin's public playlist", func() {
resp := doReqWithUser(regularUser, "updatePlaylist",
"playlistId", adminPublicID, "songIdToAdd", songIDs[2])
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("regular user cannot remove songs from admin's public playlist", func() {
resp := doReqWithUser(regularUser, "updatePlaylist",
"playlistId", adminPublicID, "songIndexToRemove", "0")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("regular user cannot delete admin's public playlist", func() {
resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() {
resp := doReqWithUser(regularUser, "createPlaylist",
"playlistId", adminPublicID, "songId", songIDs[2])
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
})
// --- Regular user can manage their own playlists ---
It("regular user can update their own playlist", func() {
resp := doReqWithUser(regularUser, "updatePlaylist",
"playlistId", regularPlaylistID, "name", "My Updated Playlist")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
Expect(resp.Playlist.Name).To(Equal("My Updated Playlist"))
})
It("regular user can add songs to their own playlist", func() {
resp := doReqWithUser(regularUser, "updatePlaylist",
"playlistId", regularPlaylistID, "songIdToAdd", songIDs[1])
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID)
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
})
It("regular user can delete their own playlist", func() {
resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID)
Expect(resp.Status).To(Equal(responses.StatusOK))
})
// --- Admin can manage any user's playlists ---
It("admin can update any user's playlist", func() {
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited",
"songId", songIDs[0])
Expect(resp.Status).To(Equal(responses.StatusOK))
plsID := resp.Playlist.Id
resp = doReqWithUser(adminUser, "updatePlaylist",
"playlistId", plsID, "name", "Admin Edited")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
Expect(resp.Playlist.Name).To(Equal("Admin Edited"))
})
It("admin can delete any user's playlist", func() {
resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted",
"songId", songIDs[0])
Expect(resp.Status).To(Equal(responses.StatusOK))
plsID := resp.Playlist.Id
resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID)
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
})
// --- Verify admin's playlists are unchanged ---
It("admin's private playlist is unchanged after failed regular user operations", func() {
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist.Name).To(Equal("Admin Private"))
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
})
It("admin's public playlist is unchanged after failed regular user operations", func() {
resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist.Name).To(Equal("Admin Public"))
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
})
})
Describe("Smart Playlist Protection", Ordered, func() {
var smartPlaylistID string
var songID string
BeforeAll(func() {
setupTestDB()
// Look up a song ID for mutation tests
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID = songs[0].ID
// Insert a smart playlist directly into the DB
smartPls := &model.Playlist{
Name: "Smart Playlist",
OwnerID: adminUser.ID,
Public: false,
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}},
}
Expect(ds.Playlist(ctx).Put(smartPls)).To(Succeed())
smartPlaylistID = smartPls.ID
})
It("getPlaylist returns smart playlist with readonly flag and validUntil", func() {
resp := doReq("getPlaylist", "id", smartPlaylistID)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist.Name).To(Equal("Smart Playlist"))
Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil())
Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue())
expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay)
Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second))
})
It("createPlaylist rejects replacing tracks on smart playlist", func() {
resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("updatePlaylist rejects adding songs to smart playlist", func() {
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
"songIdToAdd", songID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("updatePlaylist rejects removing songs from smart playlist", func() {
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
"songIndexToRemove", "0")
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(int32(50)))
})
It("updatePlaylist allows renaming smart playlist", func() {
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
"name", "Renamed Smart")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", smartPlaylistID)
Expect(resp.Playlist.Name).To(Equal("Renamed Smart"))
})
It("updatePlaylist allows setting comment on smart playlist", func() {
resp := doReq("updatePlaylist", "playlistId", smartPlaylistID,
"comment", "Auto-generated playlist")
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", smartPlaylistID)
Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist"))
})
It("deletePlaylist can delete smart playlist", func() {
resp := doReq("deletePlaylist", "id", smartPlaylistID)
Expect(resp.Status).To(Equal(responses.StatusOK))
resp = doReq("getPlaylist", "id", smartPlaylistID)
Expect(resp.Status).To(Equal(responses.StatusFailed))
})
})
})

View File

@@ -22,6 +22,8 @@ var _ = Describe("Scan Endpoints", func() {
})
It("startScan requires admin user", func() {
regularUser := createUser("user-2", "regular", "Regular User", false)
resp := doReqWithUser(regularUser, "startScan")
Expect(resp.Status).To(Equal(responses.StatusFailed))

View File

@@ -104,7 +104,7 @@ func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Du
log.Debug(ctx, "Error setting write timeout", err)
}
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data)
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec
if err != nil {
return err
}

View File

@@ -60,7 +60,7 @@ func inspect(ds model.DataStore) http.HandlerFunc {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(response); err != nil {
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(ctx, "Error sending response to client", err)
}
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/metrics"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -38,7 +37,7 @@ type Router struct {
http.Handler
ds model.DataStore
share core.Share
playlists playlistsvc.Playlists
playlists core.Playlists
insights metrics.Insights
libs core.Library
users core.User
@@ -46,7 +45,7 @@ type Router struct {
pluginManager PluginManager
}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager}
r.Handler = r.routes()
return r
@@ -122,7 +121,7 @@ func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito
func (api *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return api.playlists.NewRepository(ctx)
return api.ds.Resource(ctx, model.Playlist{})
}
r.Route("/playlist", func(r chi.Router) {
@@ -147,26 +146,26 @@ func (api *Router) addPlaylistRoute(r chi.Router) {
func (api *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
getPlaylist(api.playlists)(w, r)
getPlaylist(api.ds)(w, r)
})
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
deleteFromPlaylist(api.playlists)(w, r)
deleteFromPlaylist(api.ds)(w, r)
})
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
addToPlaylist(api.playlists)(w, r)
addToPlaylist(api.ds)(w, r)
})
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
getPlaylistTrack(api.playlists)(w, r)
getPlaylistTrack(api.ds)(w, r)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
reorderItem(api.playlists)(w, r)
reorderItem(api.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
deleteFromPlaylist(api.playlists)(w, r)
deleteFromPlaylist(api.ds)(w, r)
})
})
})
@@ -174,7 +173,7 @@ func (api *Router) addPlaylistTrackRoute(r chi.Router) {
func (api *Router) addSongPlaylistsRoute(r chi.Router) {
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
getSongPlaylists(api.playlists)(w, r)
getSongPlaylists(api.ds)(w, r)
})
}
@@ -208,7 +207,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
_, err = w.Write(resp)
_, err = w.Write(resp) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -244,7 +243,7 @@ func (api *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
last, success := api.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec
} else {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req"
@@ -19,10 +19,10 @@ import (
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
plsId := chi.URLParam(r, "playlistId")
tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r))
tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r))
if tracks == nil {
http.Error(w, "not found", http.StatusNotFound)
return
@@ -31,27 +31,27 @@ func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refresh
}
}
func getPlaylist(pls playlists.Playlists) http.HandlerFunc {
handler := playlistTracksHandler(pls, rest.GetAll, func(r *http.Request) bool {
func getPlaylist(ds model.DataStore) http.HandlerFunc {
handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool {
return req.Params(r).Int64Or("_start", 0) == 0
})
return func(w http.ResponseWriter, r *http.Request) {
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
handleExportPlaylist(pls)(w, r)
handleExportPlaylist(ds)(w, r)
return
}
handler(w, r)
}
}
func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc {
return playlistTracksHandler(pls, rest.Get, func(*http.Request) bool { return true })
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
}
func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
pl, err := pls.ImportM3U(ctx, r.Body)
pls, err := playlists.ImportM3U(ctx, r.Body)
if err != nil {
log.Error(r.Context(), "Error parsing playlist", err)
// TODO: consider returning StatusBadRequest for playlists that are malformed
@@ -59,7 +59,7 @@ func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
return
}
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(pl.ToM3U8()))
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending m3u contents", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -68,41 +68,45 @@ func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc {
}
}
func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc {
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
plsRepo := ds.Playlist(ctx)
plsId := chi.URLParam(r, "playlistId")
playlist, err := pls.GetWithTracks(ctx, plsId)
pls, err := plsRepo.GetWithTracks(plsId, true, false)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Playlist not found", "playlistId", plsId)
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err)
log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name)
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
w.Header().Set("Content-Type", "audio/x-mpegurl")
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", playlist.Name)
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
w.Header().Set("Content-Disposition", disposition)
_, err = w.Write([]byte(playlist.ToM3U8()))
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending playlist", "name", playlist.Name)
log.Error(ctx, "Error sending playlist", "name", pls.Name)
return
}
}
}
func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
ids, _ := p.Strings("id")
err := pls.RemoveTracks(r.Context(), playlistId, ids)
err := ds.WithTxImmediate(func(tx model.DataStore) error {
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
return tracksRepo.Delete(ids...)
})
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
http.Error(w, "not found", http.StatusNotFound)
@@ -117,7 +121,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
}
}
func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
type addTracksPayload struct {
Ids []string `json:"ids"`
AlbumIds []string `json:"albumIds"`
@@ -126,7 +130,6 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
var payload addTracksPayload
@@ -135,43 +138,43 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
count, c := 0, 0
if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil {
if c, err = tracksRepo.Add(payload.Ids); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil {
if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil {
if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil {
if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
count += c
// Must return an object with an ID, to satisfy ReactAdmin `create` call
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func reorderItem(pls playlists.Playlists) http.HandlerFunc {
func reorderItem(ds model.DataStore) http.HandlerFunc {
type reorderPayload struct {
InsertBefore string `json:"insert_before"`
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
id := p.IntOr(":id", 0)
@@ -190,8 +193,9 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = pls.ReorderTrack(ctx, playlistId, id, newPos)
if errors.Is(err, model.ErrNotAuthorized) {
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
err = tracksRepo.Reorder(id, newPos)
if errors.Is(err, rest.ErrPermissionDenied) {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
@@ -200,18 +204,18 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc {
return
}
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
trackId, _ := p.String(":id")
playlists, err := svc.GetPlaylists(r.Context(), trackId)
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -221,6 +225,6 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
_, _ = w.Write(data) //nolint:gosec
}
}

View File

@@ -1,7 +1,6 @@
package nativeapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -12,7 +11,6 @@ import (
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
@@ -50,19 +48,11 @@ func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
return nil, rest.ErrNotFound
}
type mockPlaylistsService struct {
playlists.Playlists
tracksRepo rest.Repository
}
func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository {
return m.tracksRepo
}
var _ = Describe("Playlist Tracks Endpoint", func() {
var (
router http.Handler
plsSvc *mockPlaylistsService
ds *tests.MockDataStore
plsRepo *tests.MockPlaylistRepo
userRepo *tests.MockedUserRepo
w *httptest.ResponseRecorder
)
@@ -71,10 +61,11 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SessionTimeout = time.Minute
plsSvc = &mockPlaylistsService{}
plsRepo = &tests.MockPlaylistRepo{}
userRepo = tests.CreateMockUserRepo()
ds := &tests.MockDataStore{
ds = &tests.MockDataStore{
MockedPlaylist: plsRepo,
MockedUser: userRepo,
MockedProperty: &tests.MockedPropertyRepo{},
}
@@ -91,7 +82,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
@@ -114,7 +105,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
})
It("returns tracks when playlist exists", func() {
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
{ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"},
@@ -144,7 +135,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
})
It("returns the track when playlist exists", func() {
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
},
@@ -163,7 +154,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
})
It("returns 404 when track does not exist in playlist", func() {
plsSvc.tracksRepo = &mockPlaylistTrackRepo{
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
tracks: model.PlaylistTracks{},
}

View File

@@ -87,7 +87,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(resp)
_, _ = w.Write(resp) //nolint:gosec
}
}

View File

@@ -59,7 +59,7 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
s = pub.mapShareToM3U(r, *s)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "audio/x-mpegurl")
_, _ = w.Write([]byte(s.ToM3U8()))
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {

View File

@@ -244,7 +244,7 @@ func (s *Server) frontendAssetsHandler() http.Handler {
// It provides detailed error messages for common issues like encrypted private keys.
func validateTLSCertificates(certFile, keyFile string) error {
// Read the key file to check for encryption
keyData, err := os.ReadFile(keyFile)
keyData, err := os.ReadFile(keyFile) //nolint:gosec
if err != nil {
return fmt.Errorf("reading TLS key file: %w", err)
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -41,7 +40,7 @@ type Router struct {
archiver core.Archiver
players core.Players
provider external.Provider
playlists playlistsvc.Playlists
playlists core.Playlists
scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
@@ -52,7 +51,7 @@ type Router struct {
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
) *Router {
r := &Router{
@@ -291,8 +290,6 @@ func mapToSubsonicError(err error) subError {
err = newError(responses.ErrorGeneric, err.Error())
case errors.Is(err, model.ErrNotFound):
err = newError(responses.ErrorDataNotFound, "data not found")
case errors.Is(err, model.ErrNotAuthorized):
err = newError(responses.ErrorAuthorizationFail)
default:
err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err))
}
@@ -366,7 +363,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
}
}
if _, err := w.Write(response); err != nil {
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
}
}

View File

@@ -19,7 +19,7 @@ import (
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
allPls, err := api.playlists.GetAll(ctx, model.QueryOptions{Sort: "name"})
allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"})
if err != nil {
log.Error(r, err)
return nil, err
@@ -42,7 +42,7 @@ func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
}
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
pls, err := api.playlists.GetWithTracks(ctx, id)
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
if errors.Is(err, model.ErrNotFound) {
log.Error(ctx, err.Error(), "id", id)
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
@@ -60,6 +60,34 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
return response, nil
}
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
owner := getUser(ctx)
var pls *model.Playlist
var err error
if playlistId != "" {
pls, err = tx.Playlist(ctx).Get(playlistId)
if err != nil {
return err
}
if owner.ID != pls.OwnerID {
return model.ErrNotAuthorized
}
} else {
pls = &model.Playlist{Name: name}
pls.OwnerID = owner.ID
}
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID
return err
})
return playlistId, err
}
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
@@ -69,7 +97,7 @@ func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error)
if playlistId == "" && name == "" {
return nil, errors.New("required parameter name is missing")
}
id, err := api.playlists.Create(ctx, playlistId, name, songIds)
id, err := api.create(ctx, playlistId, name, songIds)
if err != nil {
log.Error(r, err)
return nil, err
@@ -83,7 +111,7 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error)
if err != nil {
return nil, err
}
err = api.playlists.Delete(r.Context(), id)
err = api.ds.Playlist(r.Context()).Delete(id)
if errors.Is(err, model.ErrNotAuthorized) {
return nil, newError(responses.ErrorAuthorizationFail)
}

View File

@@ -5,7 +5,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -14,7 +14,7 @@ import (
. "github.com/onsi/gomega"
)
var _ playlists.Playlists = (*fakePlaylists)(nil)
var _ core.Playlists = (*fakePlaylists)(nil)
var _ = Describe("buildPlaylist", func() {
var router *Router
@@ -272,7 +272,7 @@ var _ = Describe("UpdatePlaylist", func() {
})
type fakePlaylists struct {
playlists.Playlists
core.Playlists
lastPlaylistID string
lastName *string
lastComment *string

View File

@@ -21,6 +21,7 @@ type MockAlbumRepo struct {
Err bool
Options model.QueryOptions
ReassignAnnotationCalls map[string]string // prevID -> newID
CopyAttributesCalls map[string]string // fromID -> toID
}
func (m *MockAlbumRepo) SetError(err bool) {
@@ -142,6 +143,32 @@ func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
return nil
}
// CopyAttributes copies attributes from one album to another
func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error {
if m.Err {
return errors.New("unexpected error")
}
from, ok := m.Data[fromID]
if !ok {
return model.ErrNotFound
}
to, ok := m.Data[toID]
if !ok {
return model.ErrNotFound
}
for _, col := range columns {
switch col {
case "created_at":
to.CreatedAt = from.CreatedAt
}
}
if m.CopyAttributesCalls == nil {
m.CopyAttributesCalls = make(map[string]string)
}
m.CopyAttributesCalls[fromID] = toID
return nil
}
// SetRating sets the rating for an album
func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
if m.Err {

View File

@@ -121,7 +121,7 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository
if db.RealDS != nil {
return db.RealDS.Playlist(ctx)
}
db.MockedPlaylist = CreateMockPlaylistRepo()
db.MockedPlaylist = &MockPlaylistRepo{}
return db.MockedPlaylist
}

View File

@@ -1,111 +1,38 @@
package tests
import (
"errors"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
func CreateMockPlaylistRepo() *MockPlaylistRepo {
return &MockPlaylistRepo{
Data: make(map[string]*model.Playlist),
PathMap: make(map[string]*model.Playlist),
}
}
type MockPlaylistRepo struct {
model.PlaylistRepository
Data map[string]*model.Playlist // keyed by ID
PathMap map[string]*model.Playlist // keyed by path
Last *model.Playlist
Deleted []string
Err bool
TracksRepo model.PlaylistTrackRepository
Entity *model.Playlist
Error error
TracksReturn model.PlaylistTrackRepository
}
func (m *MockPlaylistRepo) SetError(err bool) {
m.Err = err
}
func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) {
if m.Err {
return nil, errors.New("error")
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
if m.Error != nil {
return nil, m.Error
}
if m.Data != nil {
if pls, ok := m.Data[id]; ok {
return pls, nil
}
if m.Entity == nil {
return nil, model.ErrNotFound
}
return nil, model.ErrNotFound
}
func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, error) {
return m.Get(id)
}
func (m *MockPlaylistRepo) Put(pls *model.Playlist) error {
if m.Err {
return errors.New("error")
}
if pls.ID == "" {
pls.ID = id.NewRandom()
}
m.Last = pls
if m.Data != nil {
m.Data[pls.ID] = pls
}
return nil
}
func (m *MockPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
if m.Err {
return nil, errors.New("error")
}
if m.PathMap != nil {
if pls, ok := m.PathMap[path]; ok {
return pls, nil
}
}
return nil, model.ErrNotFound
}
func (m *MockPlaylistRepo) Delete(id string) error {
if m.Err {
return errors.New("error")
}
m.Deleted = append(m.Deleted, id)
return nil
return m.Entity, nil
}
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
return m.TracksRepo
}
func (m *MockPlaylistRepo) Exists(id string) (bool, error) {
if m.Err {
return false, errors.New("error")
}
if m.Data != nil {
_, found := m.Data[id]
return found, nil
}
return false, nil
return m.TracksReturn
}
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
if m.Err {
return 0, errors.New("error")
if m.Error != nil {
return 0, m.Error
}
return int64(len(m.Data)), nil
}
func (m *MockPlaylistRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if m.Err {
return 0, errors.New("error")
if m.Entity == nil {
return 0, nil
}
return int64(len(m.Data)), nil
return 1, nil
}
var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil)

View File

@@ -1,53 +0,0 @@
package tests
import "github.com/navidrome/navidrome/model"
type MockPlaylistTrackRepo struct {
model.PlaylistTrackRepository
AddedIds []string
DeletedIds []string
Reordered bool
AddCount int
Err error
}
func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) {
m.AddedIds = append(m.AddedIds, ids...)
if m.Err != nil {
return 0, m.Err
}
return m.AddCount, nil
}
func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) {
if m.Err != nil {
return 0, m.Err
}
return m.AddCount, nil
}
func (m *MockPlaylistTrackRepo) AddArtists(_ []string) (int, error) {
if m.Err != nil {
return 0, m.Err
}
return m.AddCount, nil
}
func (m *MockPlaylistTrackRepo) AddDiscs(_ []model.DiscID) (int, error) {
if m.Err != nil {
return 0, m.Err
}
return m.AddCount, nil
}
func (m *MockPlaylistTrackRepo) Delete(ids ...string) error {
m.DeletedIds = append(m.DeletedIds, ids...)
return m.Err
}
func (m *MockPlaylistTrackRepo) Reorder(_, _ int) error {
m.Reordered = true
return m.Err
}
var _ model.PlaylistTrackRepository = (*MockPlaylistTrackRepo)(nil)

View File

@@ -127,10 +127,12 @@ const reducePlayNext = (state, { data }) => {
const newQueue = []
const current = state.current || {}
let foundPos = false
let currentIndex = 0
state.queue.forEach((item) => {
newQueue.push(item)
if (item.uuid === current.uuid) {
foundPos = true
currentIndex = newQueue.length - 1
Object.keys(data).forEach((id) => {
newQueue.push(mapToAudioLists(data[id]))
})
@@ -145,6 +147,7 @@ const reducePlayNext = (state, { data }) => {
return {
...state,
queue: newQueue,
playIndex: foundPos ? currentIndex : undefined,
clear: true,
}
}