Compare commits

..

6 commits

Author SHA1 Message Date
Deluan Quintão
d90ee1819e
Merge branch 'master' into t3code/fix-windows-unc-rel-hang 2026-09-28 10:16:11 -04:00
Deluan
83d1aa4c3b fix(artwork): let cancelled work unwind before the worker stops
On shutdown the drain returned while cancelled acquires were still running,
so Run could return, and the DB close, before they finished. The race
detector caught one of them reading config while the e2e suite restored it.

When the context is cancelled, process now waits up to shutdownGrace (5s)
for the cancelled acquire to return. Work that honors ctx unwinds in
milliseconds, so Run joins it; only a stuck item adds the grace period.
2026-09-27 18:23:14 -04:00
Deluan
3817637207 fix(artwork): lower the item timeout and stop draining at once on shutdown
Lower itemTimeout from 5 to 3 minutes. The slowest healthy item calls each
image agent in turn: a short rate-limiter wait, the agent call (10s HTTP
timeout for built-in agents, 30s for plugins) and a 5s image download. With
the default agents plus a plugin that is about 90s, so 3 minutes keeps a
safe margin without cancelling slow but healthy lookups.

When the worker context is cancelled, process now stops waiting at once and
leaves the queue row untouched for the next run, instead of holding shutdown
for up to itemTimeout on a stuck item.
2026-09-27 18:09:42 -04:00
Deluan
89e17f7ed8 refactor(artwork): cancel a timed-out item's context and simplify the busy set
Give acquire a context with the same itemTimeout deadline, so network and
ffmpeg work that honors ctx stops when the drain moves on. The select still
covers work that ignores ctx, like the filepath.Rel loop.

Replace the busy mutex, map and helpers with a sync.Map, use time.After,
record the timeout and still-running steps with traceStage, and let settle
read the trace from ctx.
2026-09-27 15:51:50 -04:00
Deluan
d513b1ea48 fix(artwork): stop a stuck item from stalling its drain pool
Worker.drain waits for every item in a batch before it dequeues the next
one, so a single item that never returns stops the whole pool. This is what
happened in discussion #6232: filepath.Rel looped forever on a UNC share
root, and all artist artwork stopped. Go can't stop a goroutine that
spins, so the only way to keep the pool going is to stop waiting.

process now runs acquire in its own goroutine and waits at most
itemTimeout (5 minutes). On timeout it logs an error, adds a trace step,
and settles the row as failed, so the normal backoff and give-up budget
apply. A per-worker busy set keeps one item from running twice at the
same time: if the row comes due again while the old run is still stuck,
it is settled as failed without starting a second run. This keeps the
existing guarantee that the drain resolves each item serially.

Shutdown still waits for in-flight items, but now at most itemTimeout
for one that is stuck.
2026-09-27 15:39:16 -04:00
Deluan
e42418db77 fix(windows): avoid filepath.Rel hang on UNC share roots
On Windows, filepath.Rel never returns when the base is a UNC share root
(\\host\share) and the target is the same root with a trailing separator
(golang/go#79784, fixed only in Go 1.28 by CL 786100). A user on Windows
Server with MusicFolder='\\server\Music' (discussion #6232) sent CPU
profiles showing 96% of CPU time in filepath.Rel, called from
fromArtistFolder <- resolveArtistFolderPattern <- resolver.resolveArtist.
For an album directly under the library root, loadArtistFolder's
filepath.Dir returns '\\server\Music\', so the artwork worker spins
forever and, because Worker.drain waits for the whole batch, the artist
artwork pool stops.

Add utils.RelPath, which normalizes a UNC volume root followed only by
separators to the volume plus one separator before calling filepath.Rel.
Both sides then compare equal and Rel returns '.' before reaching the
loop. A bare '\\host\share' target under a deeper base now also gives
'..' instead of an error. Drive-letter paths are left unchanged, and on
non-Windows systems the helper behaves exactly like filepath.Rel.

Use it at every production filepath.Rel call site: artist folder
artwork, scanner library-relative paths, the watcher, playlist path
resolution and PlaylistsPath matching, and the cache spread FS.

Windows-only tests cover the UNC cases, with a timeout so a regression
fails instead of hanging CI. Run under Wine, 6 of them fail with plain
filepath.Rel (4 by timeout) and all pass with RelPath. The helper can be
removed after moving to Go 1.28.
2026-09-27 15:25:19 -04:00
114 changed files with 1003 additions and 4014 deletions

View file

@ -519,18 +519,13 @@ jobs:
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub:
name: Push to Docker Hub (${{ matrix.repository }})
name: Push to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
strategy:
fail-fast: false
matrix:
# Always push to DOCKER_HUB_REPO. The upstream repository also pushes to the navidrome/navidrome org repository.
repository: ${{ fromJSON(github.repository == 'navidrome/navidrome' && format('["{0}","navidrome/navidrome"]', vars.DOCKER_HUB_REPO) || format('["{0}"]', vars.DOCKER_HUB_REPO)) }}
steps:
- uses: actions/checkout@v7
@ -546,7 +541,7 @@ jobs:
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ matrix.repository }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@ -563,7 +558,7 @@ jobs:
- name: Inspect image in Docker Hub
run: |
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
cleanup-digests:
name: Cleanup digest artifacts

View file

@ -35,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
<br>
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
<br>
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features

View file

@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
return new(metadata.ToMediaFile(1, "folderID"))
}
BeforeEach(func() {

View file

@ -1,17 +1,13 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
Long: "Show file tags as seen by Navidrome",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
runInspector(cmd.Context(), args)
runInspector(args)
},
}
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
return []byte(res.String()), nil
}
func runInspector(ctx context.Context, args []string) {
func runInspector(args []string) {
marshal := marshalers[format]
if marshal == nil {
log.Fatal("Invalid format", "format", format)
}
libs := loadLibraries(ctx)
matcher := model.NewLibraryMatcher(libs)
var out []core.InspectOutput
for _, filePath := range args {
if !model.IsAudioFile(filePath) {
log.Warn("Not an audio file", "file", filePath)
continue
}
lib, ok := libraryForFile(matcher, filePath)
if !ok && len(libs) > 0 {
log.Warn("File is not in any library, using the global PID config", "file", filePath)
}
output, err := core.Inspect(filePath, lib, "")
output, err := core.Inspect(filePath, 1, "")
if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err)
continue
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
data, _ := marshal(out)
fmt.Println(string(data))
}
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
func loadLibraries(ctx context.Context) model.Libraries {
if dbFile, ok := existingDBFile(); !ok {
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
return nil
}
defer db.Init(ctx)()
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
if err != nil {
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
return nil
}
for i := range libs {
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
libs[i].Path = absPath
}
}
return libs
}
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
if absPath, err := filepath.Abs(filePath); err == nil {
if lib, ok := matcher.FindLibrary(absPath); ok {
return lib, true
}
}
return model.Library{ID: model.DefaultLibraryID}, false
}

View file

@ -1,61 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("inspect", func() {
Describe("libraryForFile", func() {
var matcher *model.LibraryMatcher
var root string
BeforeEach(func() {
root = GinkgoT().TempDir()
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
matcher = model.NewLibraryMatcher(model.Libraries{
{ID: 1, Path: filepath.Join(root, "music")},
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
})
})
It("returns the library that contains an absolute path", func() {
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
Expect(ok).To(BeTrue())
Expect(lib.ID).To(Equal(1))
})
It("resolves a relative path against the working directory", func() {
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
Expect(ok).To(BeTrue())
Expect(lib.PIDAlbum).To(Equal("folder"))
})
It("falls back to the default library without overrides", func() {
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
Expect(ok).To(BeFalse())
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
})
})
Describe("loadLibraries", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does not create a database when there is none", func() {
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
Expect(dbFile).ToNot(BeAnExistingFile())
})
})
})

View file

@ -190,20 +190,16 @@ func schedulePeriodicScan(ctx context.Context) func() error {
}
}
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
// the one used by their last finished scan
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
libs, err := ds.Library().GetAll(ctx)
func pidHashChanged(ds model.DataStore) (bool, error) {
pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "")
if err != nil {
return nil, err
return false, err
}
var names []string
for _, lib := range libs {
if lib.PIDChanged() {
names = append(names, lib.Name)
}
pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "")
if err != nil {
return false, err
}
return names, nil
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
}
// runInitialScan runs an initial scan of the music library if needed.
@ -218,12 +214,12 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil {
return err
}
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
pidHasChanged, err := pidHashChanged(ds)
if err != nil {
return err
}
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded {
s := CreateScanner(ctx)
@ -231,9 +227,9 @@ func runInitialScan(ctx context.Context) func() error {
case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration")
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
case len(pidChangedLibs) > 0:
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
case pidHasChanged:
log.Warn(ctx, "PID config changed, performing full scan")
fullScanRequired = "1"
case inProgress:
log.Warn(ctx, "Resuming interrupted scan")
default:

View file

@ -1,7 +1,6 @@
package cmd
import (
"errors"
"net/http"
"net/http/httptest"
"path"
@ -10,8 +9,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -47,30 +44,3 @@ var _ = Describe("profilerHandler", func() {
Entry("with a trailing-slash BasePath", "/music/"),
)
})
var _ = Describe("librariesWithChangedPID", func() {
var ds *tests.MockDataStore
var libs *tests.MockLibraryRepo
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
libs = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{MockedLibrary: libs}
})
It("returns only the libraries whose PID config changed", func() {
pid := model.Library{}.EffectivePID()
libs.SetData(model.Libraries{
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 3, Name: "Never scanned"},
})
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
})
It("returns the error from the repository", func() {
libs.Err = errors.New("db down")
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
Expect(err).To(MatchError("db down"))
})
})

View file

@ -18,16 +18,11 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
func existingDBFile() (string, bool) {
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
_, err := os.Stat(path)
return path, err == nil
}
// requireExistingDB aborts the command when the database file does not exist.
// requireExistingDB aborts the command when the database file (DbPath minus DSN
// params) does not exist.
func requireExistingDB() {
if path, ok := existingDBFile(); !ok {
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
}
}

View file

@ -95,9 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
players := core.NewPlayers(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
@ -111,6 +110,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router
@ -159,10 +159,9 @@ func CreatePublicRouter() *public.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
return router
}

View file

@ -156,6 +156,8 @@ const (
//DefaultAlbumPID = "album_legacy"
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
PIDAlbumKey = "PIDAlbum"
PIDTrackKey = "PIDTrack"
)
const (

View file

@ -35,14 +35,13 @@ type Archiver interface {
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
}
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork}
}
type archiver struct {
ds model.DataStore
ms stream.MediaStreamer
decider stream.TranscodeDecider
shares Share
artwork artwork.Artwork
}
@ -79,9 +78,8 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
for _, mf := range album {
req := a.resolveRequest(ctx, &mf, format, bitrate)
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
file := a.albumFilename(mf, format, isMultiDisc, folder)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Stop iterating: continuing would just rack up more
// rejections from the limiter. Close finalises whatever
// tracks were already written; the rejected one is not
@ -206,9 +204,8 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
zippedMfs := make(model.MediaFiles, len(mfs))
for idx, mf := range mfs {
req := a.resolveRequest(ctx, &mf, format, bitrate)
file := a.playlistFilename(mf, req.Format, idx)
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
file := a.playlistFilename(mf, format, idx)
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
// Abort the whole archive: continuing would silently emit
// empty zip entries since the headers are already written.
_ = z.Close()
@ -254,14 +251,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
}
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
if format == "" || format == "raw" {
return stream.Request{Format: "raw"}
}
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
}
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
path := mf.AbsolutePath()
// Open the source before writing the zip entry header so a rejection
@ -269,13 +259,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
// archive.
var r io.ReadCloser
var err error
if req.Format != "raw" {
r, err = a.ms.NewStream(ctx, &mf, req)
if format != "raw" && format != "" {
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
} else {
r, err = os.Open(path)
}
if err != nil {
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
return err
}
defer func() {

View file

@ -26,7 +26,6 @@ var _ = Describe("Archiver", func() {
var (
arch core.Archiver
ms *mockMediaStreamer
dc *fakeDecider
ds *mockDataStore
sh *mockShare
ca *mockCoverArt
@ -34,11 +33,10 @@ var _ = Describe("Archiver", func() {
BeforeEach(func() {
ms = &mockMediaStreamer{}
dc = &fakeDecider{}
sh = &mockShare{}
ds = &mockDataStore{}
ca = &mockCoverArt{images: map[string][]byte{}}
arch = core.NewArchiver(ms, dc, ds, sh, ca)
arch = core.NewArchiver(ms, ds, sh, ca)
})
Context("ZipAlbum", func() {
@ -68,23 +66,6 @@ var _ = Describe("Archiver", func() {
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
})
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
ds.On("MediaFile").Return(mfRepo)
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
dc.resolved = &resolved
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
out := new(bytes.Buffer)
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
ms.AssertExpectations(GinkgoT())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
})
})
Context("ZipArtist", func() {
@ -315,30 +296,6 @@ var _ = Describe("Archiver", func() {
})
Context("ZipPlaylist", func() {
It("names the entries and the M3U lines after the resolved format", func() {
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
}}
plRepo := &mockPlaylistRepository{}
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
ds.On("Playlist").Return(plRepo)
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
out := new(bytes.Buffer)
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
m3u, err := zr.File[1].Open()
Expect(err).ToNot(HaveOccurred())
defer m3u.Close()
content, err := io.ReadAll(m3u)
Expect(err).ToNot(HaveOccurred())
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
})
It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
@ -614,19 +571,6 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
}
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
type fakeDecider struct {
stream.TranscodeDecider
resolved *stream.Request
}
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
if f.resolved != nil {
return *f.resolved
}
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
}
type mockShare struct {
mock.Mock
core.Share

View file

@ -30,7 +30,7 @@ func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, p
if libFS == nil {
return nil, "", fmt.Errorf("artist folder lookup unavailable")
}
rel, err := filepath.Rel(libPath, artistFolder)
rel, err := utils.RelPath(libPath, artistFolder)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
}

View file

@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
if tracks == nil {
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
}
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil {
return resolution{}, err
}

View file

@ -707,16 +707,6 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
It("returns an error when the playlist tracks cannot be loaded", func() {
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
})
})

View file

@ -4,11 +4,11 @@ import (
"bytes"
"cmp"
"context"
"errors"
"fmt"
"io"
"math"
"math/rand/v2"
"runtime/debug"
"sync"
"time"
@ -28,6 +28,10 @@ const (
// giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an
// explicit reprocess retries it.
giveUpAfter = 12 * time.Hour
// itemTimeout must outlast a slow but healthy item: each image agent's call plus its download, in turn.
itemTimeout = 3 * time.Minute
// shutdownGrace lets cancelled work unwind before Run returns and the DB closes, without waiting on a stuck item.
shutdownGrace = 5 * time.Second
)
// drainPool drains one class of work with its own slot budget, so a blocking kind cannot
@ -52,8 +56,13 @@ type Worker struct {
gatesMu sync.Mutex
gates map[string]*extGate
// busy holds the itemKeys still being acquired, including ones a drain stopped waiting for.
busy sync.Map
}
type itemKey struct{ kind, id, imageType string }
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
w := &Worker{
proc: &processor{ds: ds, store: store},
@ -244,10 +253,56 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
out, got, retryIn := w.safeAcquire(ctx, item)
ctx = withTrace(ctx, &ChainTrace{})
out, got, retryIn := w.acquireWithTimeout(ctx, item)
if ctx.Err() != nil {
return outcomeFailed, nil // shutting down: leave the row for the next run
}
w.settle(ctx, item, out, retryIn)
return out, got
}
// acquireWithTimeout stops waiting on a stuck item, which Go can't stop, and never runs one item twice at once.
func (w *Worker) acquireWithTimeout(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired, time.Duration) {
key := itemKey{item.ItemKind, item.ItemID, item.ImageType}
if _, running := w.busy.LoadOrStore(key, struct{}{}); running {
log.Debug(ctx, "Artwork: Item still running from an earlier drain", "kind", item.ItemKind, "id", item.ItemID)
traceStage(ctx, "worker", errors.New("previous attempt still running"))
return outcomeFailed, nil, 0
}
type result struct {
out outcome
got *acquired
retryIn time.Duration
}
done := make(chan result, 1)
go func() {
// The deadline stops work that honors ctx; the select below covers work that doesn't.
actx, cancel := context.WithTimeout(ctx, itemTimeout)
defer cancel()
out, got, retryIn := w.proc.acquire(actx, item)
// Cleared before the send, so the next drain never sees a finished item as busy.
w.busy.Delete(key)
done <- result{out, got, retryIn}
}()
select {
case r := <-done:
return r.out, r.got, r.retryIn
case <-ctx.Done():
select {
case <-done:
case <-time.After(shutdownGrace):
}
return outcomeFailed, nil, 0
case <-time.After(itemTimeout):
log.Error(ctx, "Artwork: Item timed out, moving on", "kind", item.ItemKind, "id", item.ItemID, "timeout", itemTimeout)
traceStage(ctx, "worker", fmt.Errorf("timed out after %s", itemTimeout))
return outcomeFailed, nil, 0
}
}
// settle deletes or reschedules the queue row for an acquire outcome.
func (w *Worker) settle(ctx context.Context, item model.ArtworkQueueItem, out outcome, retryIn time.Duration) {
queue := w.proc.ds.ArtworkQueue()
switch out {
case outcomeFound, outcomeAbsent:
@ -258,7 +313,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
}
case outcomeFoundStale, outcomeFailed:
retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn))
encoded := trace.encode("")
encoded := traceFrom(ctx).encode("")
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
// fresh, immediate eligibility.
@ -285,21 +340,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
}
return out, got
}
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
traceStage(ctx, "panic", fmt.Errorf("%v", r))
out, got, retryIn = outcomeFailed, nil, 0
}
}()
return w.proc.acquire(ctx, item)
}
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item

View file

@ -142,18 +142,6 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id)
}
type panickingAlbumRepo struct {
*tests.MockAlbumRepo
panicID string
}
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
if id == r.panicID {
panic("boom")
}
return r.MockAlbumRepo.Get(ctx, id)
}
func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
@ -290,36 +278,6 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
})
It("fails an item that panics, without stopping the rest of the batch", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
})
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(2))
it := findQueued(queueRepo, "al", "alboom")
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
Expect(it.Trace).To(ContainSubstring("boom"))
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})

View file

@ -1,13 +1,17 @@
package artwork
import (
"context"
"errors"
"io"
"testing"
"testing/synctest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/gomega"
)
@ -113,3 +117,109 @@ func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) {
g.Expect(aCalls).To(Equal(1))
})
}
// newStuckItemWorker queues one artist whose agent blocks until the returned channel is closed.
func newStuckItemWorker(t *testing.T) (*Worker, *tests.MockArtworkQueueRepo, *fakeImageAgent, chan struct{}) {
t.Cleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external"
conf.Server.DevArtworkExternalMaxRPS = 1000
block := make(chan struct{})
agent := &fakeImageAgent{name: "stuckAgent", block: block}
ag := imageAgents(agent)
artists := tests.CreateMockArtistRepo()
artists.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
queue := tests.CreateMockArtworkQueueRepo()
ds := &tests.MockDataStore{MockedArtist: artists, MockedFolder: &fakeFolderRepo{}, MockedArtwork: tests.CreateMockArtworkRepo(), MockedArtworkQueue: queue}
w := NewWorker(ds, NewImageStore(t.TempDir()), ag, tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil)
if err := queue.Enqueue(t.Context(), model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}); err != nil {
t.Fatal(err)
}
return w, queue, agent, block
}
// Go can't stop a stuck item, so the drain must stop waiting for it, or the whole pool stalls.
func TestArtworkDrainMovesOnFromAStuckItem(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
w, queue, agent, block := newStuckItemWorker(t)
ctx := t.Context()
start := time.Now()
n, err := w.drain(ctx, 1)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(n).To(Equal(1))
g.Expect(time.Since(start)).To(Equal(itemTimeout), "the drain stops waiting once the item times out")
row := findQueued(queue, "ar", "ar1")
g.Expect(row).ToNot(BeNil(), "a timed-out item is rescheduled, not dropped")
g.Expect(row.Attempts).To(Equal(1))
g.Expect(row.RetryAt).To(BeTemporally(">", time.Now()))
g.Expect(row.Trace).To(ContainSubstring("timed out"))
// Due again while the first run is still stuck: it must not start a second one.
time.Sleep(time.Until(row.RetryAt))
start = time.Now()
_, err = w.drain(ctx, 1)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(time.Since(start)).To(BeZero(), "a still-running item is rescheduled without waiting")
row = findQueued(queue, "ar", "ar1")
g.Expect(row.Attempts).To(Equal(2))
g.Expect(row.Trace).To(ContainSubstring("still running"))
// Once the stuck run returns, the next attempt runs normally and settles the row.
close(block)
synctest.Wait()
time.Sleep(time.Until(row.RetryAt))
_, err = w.drain(ctx, 1)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(findQueued(queue, "ar", "ar1")).To(BeNil())
g.Expect(agent.artistCalls).To(Equal(2), "one stuck call and one retry, never two at once")
})
}
// Shutdown joins cancelled work that unwinds, so nothing touches the DB after Run returns.
func TestArtworkDrainShutdownWaitsForWorkToUnwind(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
w, queue, _, block := newStuckItemWorker(t)
before := *findQueued(queue, "ar", "ar1")
ctx, cancel := context.WithCancel(t.Context())
go func() {
time.Sleep(time.Second)
cancel()
time.Sleep(100 * time.Millisecond)
close(block)
}()
start := time.Now()
_, err := w.drain(ctx, 1)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(time.Since(start)).To(Equal(time.Second+100*time.Millisecond), "the drain waits for the cancelled item to return")
_, running := w.busy.Load(itemKey{"ar", "ar1", model.ImageTypePrimary})
g.Expect(running).To(BeFalse())
g.Expect(*findQueued(queue, "ar", "ar1")).To(Equal(before), "the row is left for the next run")
})
}
// Shutdown must not wait on a stuck item for longer than the grace period.
func TestArtworkDrainShutdownDoesNotWaitOnAStuckItem(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
w, queue, _, block := newStuckItemWorker(t)
before := *findQueued(queue, "ar", "ar1")
ctx, cancel := context.WithCancel(t.Context())
go func() {
time.Sleep(time.Second)
cancel()
}()
start := time.Now()
_, err := w.drain(ctx, 1)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(time.Since(start)).To(Equal(time.Second+shutdownGrace), "the drain gives up on the stuck item after the grace period")
g.Expect(*findQueued(queue, "ar", "ar1")).To(Equal(before), "the row is left for the next run")
close(block)
synctest.Wait()
})
}

View file

@ -15,7 +15,7 @@ type InspectOutput struct {
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
}
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
path, file := filepath.Split(filePath)
s, err := storage.For(path)
@ -39,22 +39,12 @@ func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutpu
return nil, model.ErrNotFound
}
md := metadata.New(scannerPath(lib, filePath), tag)
md := metadata.New(path, tag)
result := &InspectOutput{
File: filePath,
RawTags: tags[file].Tags,
MappedTags: new(md.ToMediaFile(lib, folderId)),
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
}
return result, nil
}
// scannerPath returns the path the scanner uses for the file (relative to its library), so
// folder-based PIDs match the DB. Files outside the library keep their absolute path.
func scannerPath(lib model.Library, filePath string) string {
absPath, err := filepath.Abs(filePath)
if err != nil || lib.Path == "" {
return filePath
}
return model.LibraryRelativePath(lib.Path, absPath)
}

View file

@ -1,45 +0,0 @@
package core_test
import (
"path/filepath"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Inspect", func() {
var fixtures string
BeforeEach(func() {
var err error
fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures"))
Expect(err).ToNot(HaveOccurred())
})
It("maps the file with the library-relative path the scanner uses", func() {
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3"))
Expect(out.MappedTags.LibraryID).To(Equal(2))
})
It("gives the same IDs for relative and absolute paths", func() {
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID))
Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID))
})
It("keeps the given path for a file outside the library", func() {
filePath := filepath.Join(fixtures, "test.mp3")
out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "")
Expect(err).ToNot(HaveOccurred())
Expect(out.MappedTags.Path).To(Equal(filePath))
})
})

View file

@ -2,12 +2,10 @@ package core
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@ -17,7 +15,6 @@ import (
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/slice"
@ -203,22 +200,23 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity
}
pathChanged := originalLib.Path != lib.Path
pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) ||
(updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack)
err = r.LibraryRepository.Put(ctx, lib, cols...)
if err != nil {
return r.mapError(err)
}
if pathChanged && r.watcher != nil {
if err := r.watcher.Watch(ctx, lib); err != nil {
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
// Restart watcher and trigger scan if path was updated
if pathChanged {
if r.watcher != nil {
if err := r.watcher.Watch(ctx, lib); err != nil {
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
}
if (pathChanged || pidChanged) && r.scanner != nil {
go r.triggerScan(ctx, lib, "updated")
if r.scanner != nil {
go r.triggerScan(ctx, lib, "updated")
}
}
// Send library refresh event to all clients
@ -327,15 +325,6 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
}
}
library.PIDAlbum = strings.TrimSpace(library.PIDAlbum)
library.PIDTrack = strings.TrimSpace(library.PIDTrack)
if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil {
validationErrors["pidAlbum"] = err.Error()
}
if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil {
validationErrors["pidTrack"] = err.Error()
}
if len(validationErrors) > 0 {
return &rest.ValidationError{Errors: validationErrors}
}
@ -343,11 +332,6 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
return nil
}
// updatesColumn reports whether an update with these columns writes col. No columns means all of them.
func updatesColumn(cols []string, col string) bool {
return len(cols) == 0 || slices.Contains(cols, col)
}
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
// Validate path format
if !filepath.IsAbs(library.Path) {
@ -415,27 +399,11 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil
}
var scanWaitInterval = time.Second
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
// Runs in its own goroutine and outlives the HTTP request
ctx = context.WithoutCancel(ctx)
// A running scan loaded the libraries before this change, and would reject a new request
for {
status, err := r.scanner.Status(ctx)
if err != nil || !status.Scanning {
break
}
time.Sleep(scanWaitInterval)
}
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
start := time.Now()
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full
if errors.Is(err, model.ErrAlreadyScanning) {
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
} else if err != nil {
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
if err != nil {
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else {
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))

View file

@ -322,37 +322,6 @@ var _ = Describe("Library Service", func() {
})
})
Describe("PID validation", func() {
pidError := func(err error, field string) string {
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
return validationErr.Errors[field]
}
It("rejects an unknown attribute in the album PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
})
It("rejects albumid in the album PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
})
It("rejects an unknown attribute in the track PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
})
It("trims spaces", func() {
library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "}
_, err := repo.Save(ctx, library)
Expect(err).ToNot(HaveOccurred())
Expect(library.PIDAlbum).To(Equal("folder"))
Expect(library.PIDTrack).To(BeEmpty())
})
})
Describe("Path Validation", func() {
Context("Create operation", func() {
It("fails when path is not absolute", func() {
@ -710,48 +679,6 @@ var _ = Describe("Library Service", func() {
}, "100ms", "10ms").Should(Equal(0))
})
It("triggers scan when updating the library PID config", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
Expect(repo.Update(ctx, "1", library)).To(Succeed())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// A quick scan: the scanner itself rescans this library in full
Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
})
It("does not trigger scan when the PID fields were not sent", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})
// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
It("waits for a running scan before triggering a new one", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
scanner.SetScanning(true)
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
Expect(repo.Update(ctx, "1", library)).To(Succeed())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "200ms", "20ms").Should(Equal(0))
scanner.SetScanning(false)
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "3s", "20ms").Should(Equal(1))
})
It("does not trigger scan when library creation fails", func() {
// Try to create library with invalid data (empty name)
library := &model.Library{Path: tempDir}

View file

@ -10,7 +10,6 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
"slices"
"strings"
"sync"
"sync/atomic"
@ -270,13 +269,9 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil {
log.Trace(ctx, "Error reading radios count", err)
}
libs, err := c.ds.Library().GetAll(ctx)
data.Library.Libraries, err = c.ds.Library().CountAll(ctx)
if err != nil {
log.Trace(ctx, "Error reading libraries", err)
}
data.Library.Libraries = int64(len(libs))
if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) {
data.Config.HasCustomPID = true
log.Trace(ctx, "Error reading libraries count", err)
}
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},

View file

@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde
if err != nil {
return nil, err
}
matcher := model.NewLibraryMatcher(libs)
lib, ok := matcher.FindLibrary(dir)
matcher := newLibraryMatcher(libs)
lib, ok := matcher.findLibrary(dir)
if !ok {
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
}

View file

@ -1,11 +1,13 @@
package playlists
import (
"cmp"
"context"
"fmt"
"io"
"net/url"
"path/filepath"
"slices"
"strings"
"time"
@ -13,6 +15,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@ -146,7 +149,7 @@ func (r pathResolution) ToQualifiedString() (string, error) {
if !r.valid {
return "", fmt.Errorf("invalid path resolution")
}
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
relativePath, err := utils.RelPath(r.libraryPath, r.absolutePath)
if err != nil {
return "", err
}
@ -154,9 +157,61 @@ func (r pathResolution) ToQualifiedString() (string, error) {
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
}
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
type libraryMatcher struct {
libraries model.Libraries
cleanedPaths []string
}
// findLibraryForPath finds which library contains the given absolute path.
// Returns library ID and path, or 0 and empty string if not found.
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
lib, ok := lm.findLibrary(absolutePath)
if !ok {
return 0, ""
}
return lib.ID, filepath.Clean(lib.Path)
}
// findLibrary checks if the absolute path is under any of the library paths.
func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
// Check sorted libraries (longest path first) to find the best match
for i, cleanLibPath := range lm.cleanedPaths {
// Check if absolutePath is under this library path
if strings.HasPrefix(absolutePath, cleanLibPath) {
// Ensure it's a proper path boundary (not just a prefix)
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
return lm.libraries[i], true
}
}
}
return model.Library{}, false
}
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
// This ensures correct matching when library paths are prefixes of each other.
// Example: /music-classical must be checked before /music
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
// Sort libraries by path length (descending) to ensure longest paths match first.
slices.SortFunc(libs, func(i, j model.Library) int {
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
})
// Pre-clean all library paths once for efficient matching
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanedPaths[i] = filepath.Clean(lib.Path)
}
return &libraryMatcher{
libraries: libs,
cleanedPaths: cleanedPaths,
}
}
// pathResolver handles path resolution logic for playlist imports.
type pathResolver struct {
matcher *model.LibraryMatcher
matcher *libraryMatcher
}
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
@ -165,7 +220,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er
if err != nil {
return nil, err
}
matcher := model.NewLibraryMatcher(libs)
matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil
}
@ -192,14 +247,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu
// a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
lib, ok := r.matcher.FindLibrary(absolutePath)
if !ok {
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
if libID == 0 {
return pathResolution{valid: false}
}
return pathResolution{
absolutePath: absolutePath,
libraryPath: filepath.Clean(lib.Path),
libraryID: lib.ID,
libraryPath: libPath,
libraryID: libID,
valid: true,
}
}
@ -234,7 +289,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher.
func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string {
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
@ -254,7 +309,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryM
return ""
}
lib, ok := matcher.FindLibrary(localPath)
lib, ok := matcher.findLibrary(localPath)
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
return ""

View file

@ -9,6 +9,187 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("libraryMatcher", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
BeforeEach(func() {
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
mockLibRepo = &tests.MockLibraryRepo{}
ds = &tests.MockDataStore{
MockedLibrary: mockLibRepo,
}
})
// Helper function to create a libraryMatcher from the mock datastore
createMatcher := func(ds model.DataStore) *libraryMatcher {
libs, err := ds.Library().GetAll(ctx)
Expect(err).ToNot(HaveOccurred())
return newLibraryMatcher(libs)
}
Describe("Longest library path matching", func() {
It("matches the longest library path when multiple libraries share a prefix", func() {
// Setup libraries with prefix conflicts
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
{ID: 3, Path: "/music-classical/opera"},
})
matcher := createMatcher(ds)
// Test that longest path matches first and returns correct library ID
testCases := []struct {
path string
expectedLibID int
expectedLibPath string
}{
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
{"/music-classical/track.mp3", 2, "/music-classical"},
{"/music/track.mp3", 1, "/music"},
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
}
for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
}
})
It("handles libraries with similar prefixes but different structures", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/home/user/music"},
{ID: 2, Path: "/home/user/music-backup"},
})
matcher := createMatcher(ds)
// Test that music-backup library is matched correctly
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/home/user/music-backup"))
// Test that music library is still matched correctly
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/home/user/music"))
})
It("matches path that is exactly the library root", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
{ID: 2, Path: "/music-classical"},
})
matcher := createMatcher(ds)
// Exact library path should match
libID, libPath := matcher.findLibraryForPath("/music-classical")
Expect(libID).To(Equal(2))
Expect(libPath).To(Equal("/music-classical"))
})
It("handles complex nested library structures", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/media"},
{ID: 2, Path: "/media/audio"},
{ID: 3, Path: "/media/audio/classical"},
{ID: 4, Path: "/media/audio/classical/baroque"},
})
matcher := createMatcher(ds)
testCases := []struct {
path string
expectedLibID int
expectedLibPath string
}{
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
{"/media/video/movie.mp4", 1, "/media"},
}
for _, tc := range testCases {
libID, libPath := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
}
})
})
Describe("Edge cases", func() {
It("handles empty library list", func() {
mockLibRepo.SetData([]model.Library{})
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Should not match anything
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(0))
Expect(libPath).To(BeEmpty())
})
It("handles single library", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music"},
})
matcher := createMatcher(ds)
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music"))
})
It("handles libraries with special characters in paths", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/music[test]"},
{ID: 2, Path: "/music(backup)"},
})
matcher := createMatcher(ds)
Expect(matcher).ToNot(BeNil())
// Special characters should match literally
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
Expect(libID).To(Equal(1))
Expect(libPath).To(Equal("/music[test]"))
})
})
Describe("Path matching order", func() {
It("ensures longest paths match first", func() {
mockLibRepo.SetData([]model.Library{
{ID: 1, Path: "/a"},
{ID: 2, Path: "/ab"},
{ID: 3, Path: "/abc"},
})
matcher := createMatcher(ds)
// Verify that longer paths match correctly (not cut off by shorter prefix)
testCases := []struct {
path string
expectedLibID int
}{
{"/abc/file.mp3", 3},
{"/ab/file.mp3", 2},
{"/a/file.mp3", 1},
}
for _, tc := range testCases {
libID, _ := matcher.findLibraryForPath(tc.path)
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
}
})
})
})
var _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo

View file

@ -15,6 +15,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils"
)
type Playlists interface {
@ -77,7 +78,7 @@ func InPath(folder model.Folder) bool {
if conf.Server.PlaylistsPath == "" {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
rel, _ := utils.RelPath(folder.LibraryPath, folder.AbsolutePath())
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
if match, _ := doublestar.Match(path, rel); match {
return true

View file

@ -76,17 +76,6 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
}
// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
var _ fs.ReadLinkFS = (*localFS)(nil)
func (lfs *localFS) ReadLink(name string) (string, error) {
return fs.ReadLink(lfs.FS, name)
}
func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
return fs.Lstat(lfs.FS, name)
}
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
res, err := lfs.extractor.Parse(path...)
if err != nil {

View file

@ -1,18 +0,0 @@
-- +goose Up
-- +goose StatementBegin
alter table library add column pid_album varchar default '' not null;
alter table library add column pid_track varchar default '' not null;
alter table library add column scanned_pid_album varchar default '' not null;
alter table library add column scanned_pid_track varchar default '' not null;
-- Every library was scanned with the global PID config, so seed it as their scanned config.
-- This way the upgrade does not trigger a full rescan.
update library set
scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''),
scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), '');
delete from property where id in ('PIDAlbum', 'PIDTrack');
-- +goose StatementEnd
-- +goose Down
SELECT 1;

View file

@ -1,13 +1,10 @@
package model
import (
"cmp"
"context"
"strings"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/utils/slice"
)
@ -30,38 +27,6 @@ type Library struct {
TotalSize int64 `json:"totalSize" db:"total_size"`
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
PIDAlbum string `json:"pidAlbum" db:"pid_album"`
PIDTrack string `json:"pidTrack" db:"pid_track"`
ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"`
ScannedPIDTrack string `json:"-" db:"scanned_pid_track"`
}
// PIDConfig holds the persistent ID specs used to compute track and album IDs.
type PIDConfig struct {
Track string
Album string
}
// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to
// the global config.
func (l Library) EffectivePID() PIDConfig {
return PIDConfig{
Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track),
Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album),
}
}
// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished
// scan of this library. A library that was never scanned counts as changed.
func (l Library) PIDChanged() bool {
pid := l.EffectivePID()
return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track)
}
// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be
// rescanned in full. A library that never finished a scan has nothing to regroup.
func (l Library) NeedsPIDRescan() bool {
return !l.LastScanAt.IsZero() && l.PIDChanged()
}
const (
@ -94,8 +59,6 @@ type LibraryRepository interface {
// TODO These methods should be moved to a core service
ScanBegin(ctx context.Context, id int, fullScan bool) error
ScanEnd(ctx context.Context, id int) error
// SetScannedPID records the PID specs used by the last finished scan of the library
SetScannedPID(ctx context.Context, id int, pid PIDConfig) error
ScanInProgress(ctx context.Context) (bool, error)
RefreshStats(ctx context.Context, id int) error
}

View file

@ -1,57 +0,0 @@
package model
import (
"cmp"
"path/filepath"
"slices"
"strings"
)
// LibraryMatcher finds the library that contains an absolute path.
type LibraryMatcher struct {
libraries Libraries
cleanedPaths []string
}
// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music.
func NewLibraryMatcher(libs Libraries) *LibraryMatcher {
libs = slices.Clone(libs)
slices.SortFunc(libs, func(i, j Library) int {
return cmp.Compare(len(j.Path), len(i.Path))
})
cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
cleanedPaths[i] = filepath.Clean(lib.Path)
}
return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths}
}
// FindLibrary returns the library whose path contains absolutePath.
func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) {
for i, libPath := range lm.cleanedPaths {
// A cleaned path only ends with a separator when it is a filesystem root
if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) ||
absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) {
return lm.libraries[i], true
}
}
return Library{}, false
}
// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it
// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged.
func LibraryRelativePath(libPath, path string) string {
if !filepath.IsAbs(path) {
return path
}
// The library root may be relative (e.g. the default "./music"); it resolves against the same cwd
absLib, err := filepath.Abs(libPath)
if err != nil {
return path
}
rel, err := filepath.Rel(absLib, path)
if err != nil || !filepath.IsLocal(rel) {
return path
}
return filepath.ToSlash(rel)
}

View file

@ -1,91 +0,0 @@
package model_test
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("LibraryMatcher", func() {
// Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does
find := func(libs model.Libraries, path string) int {
for i := range libs {
libs[i].Path = filepath.FromSlash(libs[i].Path)
}
lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path))
if !ok {
return 0
}
return lib.ID
}
DescribeTable("matches the longest library path",
func(libs model.Libraries, path string, expectedID int) {
Expect(find(libs, path)).To(Equal(expectedID))
},
Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3),
Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2),
Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1),
Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2),
Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3),
Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2),
Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1),
Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1),
Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1),
Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2),
)
It("does not match a path outside every library", func() {
Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero())
})
It("does not match anything without libraries", func() {
Expect(find(nil, "/music/track.mp3")).To(BeZero())
})
It("does not reorder the caller's libraries", func() {
libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}}
model.NewLibraryMatcher(libs)
Expect(libs.IDs()).To(Equal([]int{1, 2}))
})
})
var _ = Describe("LibraryRelativePath", func() {
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
// (a Unix-style "/foo" is not absolute on Windows).
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
It("returns a relative path unchanged", func() {
Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
})
It("rebases an absolute target when the library root is relative", func() {
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
})
It("rebases an absolute path that equals the library root to '.'", func() {
Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal("."))
})
It("rebases an absolute path under the library root", func() {
Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("handles a trailing slash on the library path", func() {
Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("leaves an absolute path outside the library root unchanged", func() {
Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside))
})
It("returns an empty path unchanged", func() {
Expect(model.LibraryRelativePath(libRoot, "")).To(Equal(""))
})
})

View file

@ -1,73 +0,0 @@
package model_test
import (
"encoding/json"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Library PID config", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "global_album"
conf.Server.PID.Track = "global_track"
})
Describe("EffectivePID", func() {
It("falls back to the global config", func() {
Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"}))
})
It("uses the library overrides", func() {
lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"}
Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"}))
})
})
Describe("PIDChanged", func() {
It("is false when the scanned specs match, ignoring case", func() {
lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeFalse())
})
It("is true when the album override differs from the scanned spec", func() {
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeTrue())
})
It("is true when only the track spec changed", func() {
lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeTrue())
})
It("is true when the global config changed for a library without overrides", func() {
lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"}
Expect(lib.PIDChanged()).To(BeTrue())
})
It("is true for a library that was never scanned", func() {
Expect(model.Library{}.PIDChanged()).To(BeTrue())
})
})
Describe("NeedsPIDRescan", func() {
It("is false for a library that never finished a scan", func() {
Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse())
})
It("is true for a scanned library whose PID config changed", func() {
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
Expect(lib.NeedsPIDRescan()).To(BeTrue())
})
It("is false for a scanned library whose PID config did not change", func() {
lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
Expect(lib.NeedsPIDRescan()).To(BeFalse())
})
})
It("does not expose the scanned specs in JSON", func() {
data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"})
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`))
Expect(string(data)).ToNot(ContainSubstring("secret_"))
})
})

View file

@ -8,14 +8,15 @@ import (
"math"
"strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
)
func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile {
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf := model.MediaFile{
LibraryID: lib.ID,
LibraryID: libID,
FolderID: folderID,
Tags: maps.Clone(md.tags),
}
@ -83,9 +84,8 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs
pid := lib.EffectivePID()
mf.PID = md.trackPID(mf, pid)
mf.AlbumID = md.albumID(mf, pid.Album)
mf.PID = md.trackPID(mf)
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
// BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers

View file

@ -30,23 +30,9 @@ var _ = Describe("ToMediaFile", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
return md.ToMediaFile(1, "folderID")
}
Describe("Persistent IDs", func() {
It("uses the library PID config for the album ID and for albumid in the track spec", func() {
props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}}
md = metadata.New("Jazz/Loose/01.mp3", props)
byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID")
byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID")
Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID))
Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder")))
Expect(byFolder.PID).ToNot(Equal(byTags.PID))
})
})
Describe("Dates", func() {
It("should parse properly tagged dates ", func() {
mf = toMediaFile(model.RawTags{

View file

@ -38,7 +38,7 @@ var _ = Describe("Participants", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags
md = metadata.New("filepath", props)
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
return md.ToMediaFile(1, "folderID")
}
Describe("ARTIST(S) tags", func() {

View file

@ -323,7 +323,7 @@ var _ = Describe("Metadata", func() {
tag: {tagValue},
}
md = metadata.New(filePath, props)
return md.ToMediaFile(model.Library{}, "0")
return md.ToMediaFile(0, "0")
}
DescribeTable("Gain",

View file

@ -2,11 +2,11 @@ package metadata
import (
"cmp"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -22,13 +22,12 @@ type hashFunc = func(...string) string
// attributes. Attributes can be either tags or processed values like folder,
// albumid, albumartistid, etc. For each field, it gets all its attribute values
// and concatenates them, then hashes the result. If a field is empty, it is
// skipped and the function looks for the next field. albumSpec is the album PID
// spec used to resolve the `albumid` attribute.
// skipped and the function looks for the next field.
//
// Taking hash as a parameter (instead of closing over it in a factory) keeps
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
// heap on every call.
func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string {
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
switch spec {
case "track_legacy":
return legacyTrackID(mf, prependLibId)
@ -42,7 +41,7 @@ func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prepend
values := make([]string, len(attributes))
hasValue := false
for i, attr := range attributes {
v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash)
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
if v != "" {
hasValue = true
}
@ -59,15 +58,15 @@ func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prepend
return hash(pid)
}
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string {
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
if spec == albumSpec {
if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
}
return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash)
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
case "folder":
return filepath.Dir(mf.Path)
case "albumartistid":
@ -80,50 +79,18 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool,
return md.String(model.TagName(attr))
}
// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config".
// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them.
func ValidatePIDSpec(spec string, isAlbum bool) error {
switch {
case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy":
return nil
}
for field := range strings.SplitSeq(spec, "|") {
for attr := range strings.SplitSeq(field, ",") {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "":
return fmt.Errorf("empty attribute in %q", spec)
case "albumid":
if isAlbum {
return errors.New("albumid cannot be used in an album PID")
}
case "folder", "albumartistid":
default:
name, ok := model.CanonicalTagName(attr)
if !ok {
return fmt.Errorf("unknown attribute %q", attr)
}
if isAlbum && string(name) != attr {
return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr)
}
}
}
}
return nil
}
func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string {
return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash)
func (md Metadata) trackPID(mf model.MediaFile) string {
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
}
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return computePID(mf, md, pidConf, pidConf, true, id.NewHash)
return computePID(mf, md, pidConf, true, id.NewHash)
}
// BFR Must be configurable?
func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name}
return computePID(mf, md, "albumartistid", "", false, id.NewHash)
return computePID(mf, md, "albumartistid", false, id.NewHash)
}
func (md Metadata) mapTrackTitle() string {

View file

@ -3,7 +3,8 @@ package metadata
import (
"strings"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -12,18 +13,16 @@ import (
var _ = Describe("getPID", func() {
var (
md Metadata
mf model.MediaFile
sum hashFunc
albumSpec string
md Metadata
mf model.MediaFile
sum hashFunc
)
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
return computePID(mf, md, spec, albumSpec, prependLibId, sum)
return computePID(mf, md, spec, prependLibId, sum)
}
BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
albumSpec = consts.DefaultAlbumPID
})
Context("attributes are tags", func() {
@ -67,7 +66,8 @@ var _ = Describe("getPID", func() {
Context("calculated attributes", func() {
BeforeEach(func() {
albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
})
When("field is title", func() {
It("should return the pid", func() {
@ -121,8 +121,8 @@ var _ = Describe("getPID", func() {
When("albumid configuration refers to albumid recursively", func() {
It("should avoid infinite recursion", func() {
// Reproduce the issue from #4920
albumSpec = "albumid,album,albumversion,releasedate"
spec := albumSpec
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
spec := conf.Server.PID.Album
md.tags = map[model.TagName][]string{
"album": {"Album Name"},
"albumversion": {"Version"},
@ -205,7 +205,8 @@ var _ = Describe("getPID", func() {
})
When("prependLibId is true with nested albumid", func() {
It("should handle nested albumid calls correctly", func() {
albumSpec = "album"
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album"
spec := "albumid"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.AlbumArtist = "Test Artist"
@ -305,34 +306,3 @@ var _ = Describe("getPID", func() {
})
})
})
var _ = Describe("ValidatePIDSpec", func() {
DescribeTable("accepts valid specs",
func(spec string, isAlbum bool) {
Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed())
},
Entry("empty, meaning the global config", "", true),
Entry("default album spec", consts.DefaultAlbumPID, true),
Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false),
Entry("folder", "folder", true),
Entry("album legacy", "album_legacy", true),
Entry("track legacy", "track_legacy", false),
Entry("computed attributes", "albumartistid,album|title", true),
Entry("albumid in a track spec", "albumid,title", false),
Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true),
)
DescribeTable("rejects invalid specs",
func(spec string, isAlbum bool, msg string) {
Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg)))
},
Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`),
Entry("empty field", "album||title", true, "empty attribute"),
Entry("empty attribute", "album,,title", true, "empty attribute"),
Entry("trailing separator", "album|", true, "empty attribute"),
Entry("albumid in an album spec", "albumid,album", true, "albumid"),
Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`),
Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`),
Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`),
)
})

View file

@ -2,15 +2,12 @@ package model
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
var ErrAlreadyScanning = errors.New("already scanning")
// ScanTarget represents a specific folder within a library to be scanned.
// NOTE: This struct is used as a map key, so it should only contain comparable types.
type ScanTarget struct {

View file

@ -195,28 +195,6 @@ func TagMappings() map[TagName]TagConf {
return mappings
}
// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name.
func CanonicalTagName(name string) (TagName, bool) {
tagName, ok := tagNameIndex()[TagName(name).ToLower()]
return tagName, ok
}
// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases
// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid).
var tagNameIndex = sync.OnceValue(func() map[TagName]TagName {
mappings := TagMappings()
index := make(map[TagName]TagName, len(mappings))
for name, tag := range mappings {
for _, alias := range tag.Aliases {
index[TagName(alias)] = name
}
}
for name := range mappings {
index[name] = name
}
return index
})
func TagRolesConf() TagConf {
_, cfg := parseMappings()
return cfg.Roles

View file

@ -192,22 +192,3 @@ var _ = Describe("TagConf", func() {
})
})
})
var _ = Describe("CanonicalTagName", func() {
DescribeTable("resolves tag names and aliases",
func(name string, expected TagName) {
tagName, ok := CanonicalTagName(name)
Expect(ok).To(BeTrue())
Expect(tagName).To(Equal(expected))
},
Entry("tag name", "album", TagAlbum),
Entry("alias", "talb", TagAlbum),
Entry("mixed case alias", "TALB", TagAlbum),
Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID),
)
It("does not resolve an unknown name", func() {
_, ok := CanonicalTagName("nosuchtag")
Expect(ok).To(BeFalse())
})
})

View file

@ -131,7 +131,6 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"recently_played": recentlyPlayedFilter,
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
"played": annotationBoolFilter("play_count"),
"missing": booleanFilter,
"genre_id": genreFilter(AlbumGenres),
"role_total_id": allRolesFilter,

View file

@ -417,80 +417,6 @@ var _ = Describe("AlbumRepository", func() {
}
})
})
Describe("played", func() {
var playedAlbum model.Album
BeforeEach(func() {
playedAlbum = model.Album{ID: "played-album", Name: "Played Album", LibraryID: 1, SongCount: 1}
Expect(albumRepo.Put(ctx, &playedAlbum)).To(Succeed())
Expect(albumRepo.IncPlayCount(ctx, playedAlbum.ID, time.Now())).To(Succeed())
})
AfterEach(func() {
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": playedAlbum.ID}))
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": playedAlbum.ID}))
})
It("false includes items without annotations", func() {
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
Filters: map[string]any{"played": "false"},
})
Expect(err).ToNot(HaveOccurred())
albums := res
var found bool
for _, a := range albums {
if a.ID == albumWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Album without annotation should be included in played=false filter")
})
It("true excludes items without annotations", func() {
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
Filters: map[string]any{"played": "true"},
})
Expect(err).ToNot(HaveOccurred())
albums := res
for _, a := range albums {
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
}
})
It("true includes items with play count", func() {
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
Filters: map[string]any{"played": "true"},
})
Expect(err).ToNot(HaveOccurred())
albums := res
var found bool
for _, a := range albums {
if a.ID == playedAlbum.ID {
found = true
Expect(a.PlayCount).To(BeNumerically(">", 0))
break
}
}
Expect(found).To(BeTrue(), "Album with play count should be included in played=true filter")
})
It("false excludes items with play count", func() {
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
Filters: map[string]any{"played": "false"},
})
Expect(err).ToNot(HaveOccurred())
albums := res
for _, a := range albums {
Expect(a.ID).ToNot(Equal(playedAlbum.ID))
}
})
})
})
Describe("Album.PlayCount", func() {

View file

@ -93,8 +93,6 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd
"path": l.Path,
"remote_path": l.RemotePath,
"default_new_users": l.DefaultNewUsers,
"pid_album": l.PIDAlbum,
"pid_track": l.PIDTrack,
}, colsToUpdate...)
cols["updated_at"] = l.UpdatedAt
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
@ -178,15 +176,6 @@ func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error {
return err
}
func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error {
sq := Update(r.tableName).
Set("scanned_pid_album", pid.Album).
Set("scanned_pid_track", pid.Track).
Where(Eq{"id": id})
_, err := r.executeSQL(ctx, sq)
return err
}
func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) {
query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}})
count, err := r.count(ctx, query)

View file

@ -270,38 +270,6 @@ var _ = Describe("LibraryRepository", func() {
})
})
Describe("PID config", func() {
It("stores the overrides, and Put never touches the scanned specs", func() {
lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"}
Expect(repo.Put(ctx, lib)).To(Succeed())
Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed())
// An update coming from the REST API has no scanned specs. It must not clear them
update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"}
Expect(repo.Put(ctx, update)).To(Succeed())
saved, err := repo.Get(ctx, lib.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.PIDAlbum).To(BeEmpty())
Expect(saved.PIDTrack).To(Equal("title"))
Expect(saved.ScannedPIDAlbum).To(Equal("folder"))
Expect(saved.ScannedPIDTrack).To(Equal("title"))
})
It("keeps the overrides when a partial update does not send them", func() {
lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"}
Expect(repo.Put(ctx, lib)).To(Succeed())
Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed())
saved, err := repo.Get(ctx, lib.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.Name).To(Equal("Renamed"))
Expect(saved.PIDAlbum).To(Equal("folder"))
Expect(saved.PIDTrack).To(Equal("title"))
})
})
Describe("Delete", func() {
var adminRepo model.LibraryRepository
var artistRepo model.ArtistRepository

View file

@ -352,15 +352,6 @@ func GetDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Dialect)
}
// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
func storedRows(playlistID string) []string {
GinkgoHelper()
var rows []string
Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
return rows
}
// collectCursor takes the cursor's underlying func type so the named cursor types
// (model.AlbumCursor, ...) infer T.
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {

View file

@ -281,7 +281,7 @@ func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId stri
}
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
// the library and existence check: every caller, including a full replace through Put, goes through it.
// the library check: every caller, including a full replace through Put, goes through it.
func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
if err != nil {
@ -305,9 +305,12 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s
return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId})
}
// keepAccessible drops ids the caller cannot read or that match no track, preserving order and duplicates.
// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) {
return mediaFileIds, nil
}
accessible := make(map[string]struct{}, len(mediaFileIds))
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")

View file

@ -469,26 +469,6 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("removeOrphans", func() {
It("deletes rows pointing to no track and renumbers the rest", func() {
pls := model.Playlist{Name: "Orphans", OwnerID: "userid"}
Expect(repo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) })
// Rows as an older version stored them, with an unknown id between real tracks
_, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id)
VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`).
Bind(dbx.Params{"id": pls.ID}).Execute()
Expect(err).ToNot(HaveOccurred())
Expect(repo.(*playlistRepository).removeOrphans(ctx)).To(Succeed())
Expect(storedRows(pls.ID)).To(Equal([]string{"1:1001", "2:1002"}))
got, err := repo.Get(ctx, pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.SongCount).To(Equal(2))
})
})
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
Describe("Exists visibility", func() {

View file

@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str
if err != nil || inserted == n {
return inserted, err
}
// The shift above reserved a slot per requested id, so ids dropped by keepAccessible
// The shift above reserved a slot per requested id, so ids dropped by the library filter
// leave a hole. Close it.
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
}

View file

@ -83,14 +83,12 @@ var _ = Describe("PlaylistTrackRepository", func() {
Describe("Insert", func() {
var tracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
plsID = pls.ID
tracks = plsRepo.Tracks(ctx, pls.ID, false)
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
@ -125,40 +123,6 @@ var _ = Describe("PlaylistTrackRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
})
It("drops ids that match no track, keeping positions contiguous", func() {
Expect(tracks.Insert(ctx, []string{"no-such-track", songComeTogether.ID}, 1)).To(Equal(1))
Expect(storedRows(plsID)).To(Equal([]string{
"1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID,
}))
})
})
Describe("Add", func() {
var tracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Add", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
plsID = pls.ID
tracks = plsRepo.Tracks(ctx, pls.ID, false)
})
It("drops ids that match no track and counts only the tracks added", func() {
Expect(tracks.Add(ctx, []string{songDayInALife.ID, "no-such-track", songComeTogether.ID, "!!not-an-id!!"})).To(Equal(2))
Expect(storedRows(plsID)).To(Equal([]string{"1:" + songDayInALife.ID, "2:" + songComeTogether.ID}))
})
It("adds nothing when no id matches a track", func() {
Expect(tracks.Add(ctx, []string{"no-such-track", "!!not-an-id!!"})).To(BeZero())
Expect(storedRows(plsID)).To(BeEmpty())
})
})
Describe("Reorder", func() {

View file

@ -91,8 +91,6 @@ var _ = Describe("Annotation Filters", func() {
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)),
Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)),
)
It("returns nil if value is not a string", func() {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

View file

@ -1,25 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:ns1="http://www.bohemiancoding.com/sketch/ns" width="200" height="40" viewBox="0 0 200 40" role="img" aria-labelledby="badge-title"><!-- Artwork source: https://www.pikapods.com/static/run-button.svg; preserved and centered without distortion. --><title id="badge-title">Run on PikaPods</title><rect width="200" height="40" rx="6" fill="#43A047" /><svg viewBox="0 0 147 32" style="enable-background:new 0 0 147 32;" xml:space="preserve" x="8.12500000" y="0" width="183.75000000" height="40" preserveAspectRatio="xMidYMid meet">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#43A047;}
.st1{fill:#FFFFFF;}
.st2{opacity:0.6;fill:#FFFFFF;}
</style>
<title>Run on PikaPods Button</title>
<path id="Background" ns1:type="MSShapeGroup" class="st0" d="M4,0h139c2.2,0,4,1.8,4,4v24c0,2.2-1.8,4-4,4H4c-2.2,0-4-1.8-4-4V4 C0,1.8,1.8,0,4,0z" />
<path class="st1" d="M44.3,23.3c-0.3,0.6-0.8,0.8-1.4,0.8c-10,0-20,0-30,0c-2.6,0-4.8-1.7-5.4-4.3c-0.7-3,1.4-6,4.4-6.5 c0.3,0,0.5-0.1,0.8-0.1c0.7,0,1.2-0.5,1.2-1.1c0-0.6-0.5-1.1-1.2-1.1c-1.4,0-2.7,0-4.1,0c-0.7,0-1.2-0.4-1.2-1.1 C7.4,9.5,7.9,9,8.6,9c1.4,0,2.8,0,4.3,0c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.3-3.2,3.3c-1.8,0-3.1,1.2-3.3,2.9 c-0.2,1.5,0.8,3.4,2.9,3.5c1.2,0.1,2.3,0,3.5,0c0.5-4.5,2.5-8,6.3-10.6c2.2-1.5,4.6-2.2,7.1-2.4c-0.1,0.5-0.3,1-0.3,1.5 c-0.4,2.9,1.8,5.6,4.7,6c0.3,0,0.5,0.1,0.8,0c0.6-0.1,1-0.6,1-1.1c0-0.6-0.5-1-1.1-1c-1.3-0.1-2.3-0.7-2.9-1.8 c-0.6-1.2-0.4-2.6,0.6-3.7C33,8,34.3,7.6,35.5,8c1.2,0.4,2.1,1.6,2.2,2.9c0,0.1,0.1,0.3,0.2,0.4c3.2,2.2,5.2,5.2,6,9 c0.1,0.5,0.2,1.1,0.3,1.6C44.3,22.4,44.3,22.9,44.3,23.3z M38.8,17.7c0-0.6-0.5-1.1-1.1-1.1c-0.6,0-1.1,0.5-1.1,1.1 c0,0.6,0.5,1.1,1.1,1.1C38.3,18.7,38.8,18.2,38.8,17.7z" />
<g>
<path class="st1" d="M49.7,20.2v-8.6h3.7c0.9,0,1.6,0.1,2,0.2s0.7,0.4,1,0.8s0.4,0.8,0.4,1.4c0,0.6-0.2,1.2-0.6,1.6 s-0.9,0.7-1.7,0.8c0.4,0.2,0.7,0.5,0.9,0.7s0.6,0.7,1,1.4l1,1.7h-2.1L54,18.4c-0.4-0.7-0.8-1.1-0.9-1.3s-0.3-0.3-0.5-0.4 s-0.5-0.1-0.9-0.1h-0.4v3.6H49.7z M51.4,15.3h1.3c0.8,0,1.4,0,1.6-0.1s0.4-0.2,0.5-0.4s0.2-0.4,0.2-0.6c0-0.3-0.1-0.5-0.2-0.7 s-0.4-0.3-0.7-0.3c-0.1,0-0.6,0-1.3,0h-1.4V15.3z" />
<path class="st1" d="M62.4,20.2v-0.9c-0.2,0.3-0.5,0.6-0.9,0.8s-0.8,0.3-1.2,0.3c-0.4,0-0.8-0.1-1.1-0.3s-0.6-0.4-0.7-0.8 s-0.2-0.8-0.2-1.4V14h1.6v2.9c0,0.9,0,1.4,0.1,1.6s0.2,0.4,0.3,0.5s0.4,0.2,0.6,0.2c0.3,0,0.5-0.1,0.8-0.2s0.4-0.3,0.5-0.6 c0.1-0.2,0.1-0.8,0.1-1.7V14h1.6v6.2H62.4z" />
<path class="st1" d="M71.3,20.2h-1.6V17c0-0.7,0-1.1-0.1-1.3s-0.2-0.4-0.3-0.5s-0.3-0.2-0.6-0.2c-0.3,0-0.5,0.1-0.8,0.2 s-0.4,0.4-0.5,0.6s-0.1,0.7-0.1,1.4v2.8h-1.6V14h1.5v0.9c0.5-0.7,1.2-1.1,2.1-1.1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5 s0.3,0.4,0.3,0.7s0.1,0.6,0.1,1.1V20.2z" />
<path class="st2" d="M75.8,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S75.8,18.2,75.8,17.1z M76.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S76.9,16.3,76.9,17.1z" />
<path class="st2" d="M82.9,20.2V14h0.9v0.9c0.5-0.7,1.1-1,2-1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5s0.3,0.5,0.3,0.8 c0,0.2,0.1,0.5,0.1,1v3.8h-1.1v-3.8c0-0.4,0-0.8-0.1-1s-0.2-0.4-0.4-0.5s-0.5-0.2-0.7-0.2c-0.4,0-0.8,0.1-1.2,0.4S84,16,84,16.8 v3.4H82.9z" />
<path class="st2" d="M93,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H93z M94.2,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
<path class="st2" d="M100.9,12.8v-1.2h1.1v1.2H100.9z M100.9,20.2V14h1.1v6.2H100.9z" />
<path class="st2" d="M103.6,20.2v-8.6h1.1v4.9l2.5-2.5h1.4l-2.4,2.3l2.6,3.9h-1.3l-2.1-3.2l-0.7,0.7v2.5H103.6z" />
<path class="st2" d="M113.6,19.5c-0.4,0.3-0.8,0.6-1.1,0.7s-0.7,0.2-1.2,0.2c-0.7,0-1.2-0.2-1.6-0.5s-0.6-0.8-0.6-1.3 c0-0.3,0.1-0.6,0.2-0.8s0.3-0.5,0.5-0.6s0.5-0.3,0.8-0.3c0.2-0.1,0.5-0.1,0.9-0.2c0.9-0.1,1.5-0.2,1.9-0.4c0-0.1,0-0.2,0-0.3 c0-0.4-0.1-0.7-0.3-0.9c-0.3-0.2-0.7-0.4-1.2-0.4c-0.5,0-0.9,0.1-1.1,0.3s-0.4,0.5-0.5,0.9l-1-0.1c0.1-0.4,0.2-0.8,0.5-1.1 s0.5-0.5,0.9-0.6s0.9-0.2,1.4-0.2c0.5,0,1,0.1,1.3,0.2s0.6,0.3,0.7,0.5s0.3,0.4,0.3,0.7c0,0.2,0.1,0.5,0.1,1v1.4c0,1,0,1.6,0.1,1.9 s0.1,0.5,0.3,0.7h-1.1C113.7,20,113.7,19.8,113.6,19.5z M113.6,17.1c-0.4,0.2-1,0.3-1.7,0.4c-0.4,0.1-0.7,0.1-0.9,0.2 s-0.3,0.2-0.4,0.3s-0.1,0.3-0.1,0.5c0,0.3,0.1,0.5,0.3,0.7s0.5,0.3,0.9,0.3c0.4,0,0.8-0.1,1.1-0.3s0.5-0.4,0.7-0.7 c0.1-0.2,0.2-0.6,0.2-1.1V17.1z" />
<path class="st2" d="M116.4,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H116.4z M117.5,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
<path class="st2" d="M123.9,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S123.9,18.2,123.9,17.1z M124.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S124.9,16.3,124.9,17.1z" />
<path class="st2" d="M135,20.2v-0.8c-0.4,0.6-1,0.9-1.7,0.9c-0.5,0-1-0.1-1.4-0.4s-0.7-0.7-1-1.1s-0.3-1.1-0.3-1.7 c0-0.6,0.1-1.2,0.3-1.7s0.5-0.9,0.9-1.2s0.9-0.4,1.4-0.4c0.4,0,0.7,0.1,1,0.2s0.5,0.4,0.7,0.6v-3.1h1v8.6H135z M131.6,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.7,0.6,1.2,0.6c0.5,0,0.9-0.2,1.2-0.6s0.5-1,0.5-1.7c0-0.9-0.2-1.5-0.5-1.9s-0.7-0.6-1.2-0.6 c-0.5,0-0.9,0.2-1.2,0.6S131.6,16.3,131.6,17.1z" />
<path class="st2" d="M137.2,18.4l1-0.2c0.1,0.4,0.2,0.7,0.5,1s0.6,0.3,1.1,0.3c0.5,0,0.8-0.1,1.1-0.3s0.4-0.4,0.4-0.7 c0-0.2-0.1-0.4-0.3-0.6c-0.1-0.1-0.5-0.2-1.1-0.4c-0.8-0.2-1.3-0.4-1.6-0.5s-0.5-0.3-0.7-0.6s-0.2-0.5-0.2-0.8 c0-0.3,0.1-0.5,0.2-0.8s0.3-0.4,0.5-0.6c0.2-0.1,0.4-0.2,0.7-0.3s0.6-0.1,0.9-0.1c0.5,0,0.9,0.1,1.3,0.2s0.6,0.3,0.8,0.6 s0.3,0.6,0.4,1l-1,0.1c0-0.3-0.2-0.6-0.4-0.8s-0.5-0.3-1-0.3c-0.5,0-0.8,0.1-1,0.2s-0.3,0.3-0.3,0.6c0,0.1,0,0.3,0.1,0.4 c0.1,0.1,0.2,0.2,0.4,0.3c0.1,0,0.4,0.1,0.9,0.3c0.7,0.2,1.3,0.4,1.6,0.5s0.5,0.3,0.7,0.6s0.3,0.5,0.3,0.9c0,0.4-0.1,0.7-0.3,1 s-0.5,0.6-0.9,0.7s-0.8,0.3-1.3,0.3c-0.8,0-1.4-0.2-1.8-0.5S137.3,19,137.2,18.4z" />
</g>
</svg></svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -328,22 +328,11 @@
"totalDuration": "Duração",
"defaultNewUsers": "Padrão para Novos Usuários",
"createdAt": "Data de Criação",
"updatedAt": "Últ. Atualização",
"pidAlbum": "Agrupamento de álbuns",
"pidTrack": "Identificação das faixas"
"updatedAt": "Últ. Atualização"
},
"sections": {
"basic": "Informações Básicas",
"statistics": "Estatísticas",
"pid": "IDs Persistentes"
},
"pid": {
"global": "Usar configuração global (%{value})",
"folder": "Pasta (um álbum por pasta)",
"custom": "Personalizado",
"spec": "Especificação do PID",
"help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:",
"docs": "IDs Persistentes"
"statistics": "Estatísticas"
},
"actions": {
"scan": "Scanear Biblioteca",
@ -373,9 +362,7 @@
"messages": {
"deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.",
"scanInProgress": "Scan em progresso...",
"noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário",
"pidChangeTitle": "Alterar os IDs persistentes?",
"pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo."
"noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário"
}
},
"plugin": {

View file

@ -25,7 +25,7 @@ import (
)
var (
ErrAlreadyScanning = model.ErrAlreadyScanning
ErrAlreadyScanning = errors.New("already scanning")
)
func New(rootCtx context.Context, ds model.DataStore, broker events.Broker,
@ -304,14 +304,14 @@ func LockForMaintenance() (func(), bool) {
return scanMaintenanceMux.Unlock, true
}
// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan,
// or will rescan a library in full because its PID config changed, in one of the included libraries.
// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted
// full scan in one of the included libraries.
func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool {
if fullScan {
return true
}
return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool {
return library.FullScanInProgress || library.NeedsPIDRescan()
return library.FullScanInProgress
})
}

View file

@ -2,7 +2,6 @@ package scanner_test
import (
"context"
"time"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
@ -71,21 +70,14 @@ var _ = Describe("EffectiveFullScan", func() {
var ds *tests.MockDataStore
BeforeEach(func() {
pid := model.Library{}.EffectivePID()
libraries := &tests.MockLibraryRepo{}
libraries.SetData(model.Libraries{
{ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
{ID: 1, FullScanInProgress: true},
{ID: 2},
})
ds = &tests.MockDataStore{MockedLibrary: libraries}
})
It("detects a library that needs a full rescan for a PID change", func() {
targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}}
Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue())
})
It("detects an interrupted full scan in a targeted library", func() {
targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}}
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue())

View file

@ -40,7 +40,6 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
state.sendError(err)
state.markFailed(lib.ID)
continue
}
jobs = append(jobs, job)
@ -52,13 +51,12 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
}
type scanJob struct {
lib model.Library
fs storage.MusicFS
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
targetFolders []string // Specific folders to scan (including all descendants)
prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one
lock sync.Mutex
numFolders atomic.Int64
lib model.Library
fs storage.MusicFS
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
targetFolders []string // Specific folders to scan (including all descendants)
lock sync.Mutex
numFolders atomic.Int64
}
func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
@ -79,32 +77,16 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full
return nil, fmt.Errorf("getting fs for library: %w", err)
}
pid := lib.EffectivePID()
if lib.NeedsPIDRescan() {
msg := "Scanner: PID config changed, rescanning library in full"
if len(targetFolders) > 0 {
msg = "Scanner: PID config changed, rescanning target folders in full"
}
log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders,
"album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack)
fullScan = true
}
var prevAlbumPIDConf string
if lib.ScannedPIDAlbum != pid.Album {
prevAlbumPIDConf = lib.ScannedPIDAlbum
}
// Ensure FullScanInProgress reflects the current scan request.
// This is important when resuming an interrupted quick scan as a full scan:
// the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
lib: lib,
fs: fsys,
lastUpdates: lastUpdates,
targetFolders: targetFolders,
prevAlbumPIDConf: prevAlbumPIDConf,
lib: lib,
fs: fsys,
lastUpdates: lastUpdates,
targetFolders: targetFolders,
}, nil
}
@ -140,13 +122,14 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry {
// The phaseFolders struct implements the phase interface, providing methods to produce
// folder entries, process folders, persist changes to the database, and log the results.
type phaseFolders struct {
jobs []*scanJob
ds model.DataStore
ctx context.Context //nolint:containedctx // phase runs under a single scan ctx
walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early
stopWalk context.CancelCauseFunc
state *scanState
imageChanges *imageChangeCollector
jobs []*scanJob
ds model.DataStore
ctx context.Context //nolint:containedctx // phase runs under a single scan ctx
walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early
stopWalk context.CancelCauseFunc
state *scanState
prevAlbumPIDConf string
imageChanges *imageChangeCollector
}
func (p *phaseFolders) description() string {
@ -155,6 +138,12 @@ func (p *phaseFolders) description() string {
func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
return ppl.NewProducer(func(put func(entry *folderEntry)) error {
var err error
p.prevAlbumPIDConf, err = p.ds.Property().DefaultGet(p.ctx, consts.PIDAlbumKey, "")
if err != nil {
return fmt.Errorf("getting album PID conf: %w", err)
}
// TODO Parallelize multiple job when we have multiple libraries
var total int64
var totalChanged int64
@ -184,7 +173,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
// Check if folder is outdated
if folder.isOutdated() {
if !folder.job.lib.FullScanInProgress {
if !p.state.fullScan {
// Ancestor folders need a row even with no files of their own: artwork
// resolution climbs them, and an image added later needs a state to diff.
if folder.isEmpty() && folder.isNew() {
@ -250,7 +239,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
for afPath, af := range entry.audioFiles {
fullPath := path.Join(entry.path, afPath)
dbTrack, foundInDB := dbTracks[fullPath]
if !foundInDB || entry.job.lib.FullScanInProgress {
if !foundInDB || p.state.fullScan {
filesToImport[fullPath] = dbTrack
} else {
info, err := af.Info()
@ -300,18 +289,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
}
for filePath, info := range allInfo {
md := metadata.New(filePath, info)
track := md.ToMediaFile(entry.job.lib, entry.id)
track := md.ToMediaFile(entry.job.lib.ID, entry.id)
tracks = append(tracks, track)
for _, t := range track.Tags.FlattenAll() {
uniqueTags[t.ID] = t
}
// Keep track of any album ID changes, to reassign annotations later
prevAlbumID := track.AlbumID
prevAlbumID := ""
if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID
} else if entry.job.prevAlbumPIDConf != "" {
prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf)
} else {
prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
}
_, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok {
@ -464,7 +453,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en
if len(queueItems) > 0 {
queue := tx.ArtworkQueue()
enqueue := queue.Enqueue
if entry.job.lib.FullScanInProgress {
if p.state.fullScan {
enqueue = queue.EnqueueIfMissing
}
if err := enqueue(ctx, queueItems...); err != nil {

View file

@ -4,15 +4,18 @@ import (
"context"
"fmt"
"maps"
"path/filepath"
"slices"
"sync/atomic"
"time"
ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/slice"
)
@ -30,7 +33,6 @@ type scanState struct {
libraries model.Libraries // Store libraries list for consistency across phases
targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection
failedLibs map[int]bool // Libraries that could not be scanned in this run
}
func (s *scanState) sendProgress(info *ProgressInfo) {
@ -47,17 +49,31 @@ func (s *scanState) sendWarning(msg string) {
s.sendProgress(&ProgressInfo{Warning: msg})
}
func (s *scanState) markFailed(libID int) {
if s.failedLibs == nil {
s.failedLibs = map[int]bool{}
}
s.failedLibs[libID] = true
}
func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: err.Error()})
}
// libraryRelativePath rebases an absolute scan target path onto the library root, since the
// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside
// the library root, are returned unchanged.
func libraryRelativePath(libPath, folderPath string) string {
if !filepath.IsAbs(folderPath) {
return folderPath
}
// The library root may be relative (e.g. the default "./music"); it must be made absolute
// to match against an absolute target, and it resolves against the same cwd as the scanner's fs.
absLib, err := filepath.Abs(libPath)
if err != nil {
return folderPath
}
rel, err := utils.RelPath(absLib, folderPath)
if err != nil || !filepath.IsLocal(rel) {
return folderPath
}
// The scanner's fs.FS is an io/fs, which always uses forward slashes.
return filepath.ToSlash(rel)
}
func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now()
@ -89,7 +105,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
})
for _, target := range targets {
folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
if folderPath == "" {
folderPath = "."
}
@ -122,10 +138,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
// if there was a full scan in progress, force a full scan
if !state.fullScan {
for _, lib := range state.libraries {
// A pending PID rescan already restarts in full through its own job
if lib.NeedsPIDRescan() {
continue
}
if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true
@ -204,13 +216,10 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt
var successfulLibs []model.Library
for _, lib := range state.libraries {
// A library with a changed PID config restarts its scan: resuming would skip the folders that
// the interrupted scan already processed with the old config
pidRescan := lib.NeedsPIDRescan()
if lib.LastScanStartedAt.IsZero() || pidRescan {
if lib.LastScanStartedAt.IsZero() {
// This is a new scan - mark it as started
err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan)
return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan)
}, "scanner: begin library scan")
if err != nil {
log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
@ -332,12 +341,11 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState)
if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil {
return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err)
}
// A selective scan covers only part of the library, so the rest may still use the old PID
// config. A library that could not be scanned did not apply it either.
if !state.isSelectiveScan() && !state.failedLibs[lib.ID] {
if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil {
return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err)
}
if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil {
return fmt.Errorf("updating track PID conf: %w", err)
}
if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil {
return fmt.Errorf("updating album PID conf: %w", err)
}
if state.changesDetected.Load() {
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)

View file

@ -4,6 +4,8 @@ package scanner
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline"
@ -11,6 +13,43 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("libraryRelativePath", func() {
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
// (a Unix-style "/foo" is not absolute on Windows).
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
It("returns a relative path unchanged", func() {
Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
})
It("rebases an absolute target when the library root is relative", func() {
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
})
It("rebases an absolute path that equals the library root to '.'", func() {
Expect(libraryRelativePath(libRoot, libRoot)).To(Equal("."))
})
It("rebases an absolute path under the library root", func() {
Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("handles a trailing slash on the library path", func() {
Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("leaves an absolute path outside the library root unchanged", func() {
Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside))
})
It("returns an empty path unchanged", func() {
Expect(libraryRelativePath(libRoot, "")).To(Equal(""))
})
})
type mockPhase struct {
num int
produceFunc func() ppl.Producer[int]

View file

@ -835,170 +835,4 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
Expect(lastError).To(BeEmpty())
})
})
Context("Per-library PID config", func() {
albumsOf := func(libID int) model.Albums {
// The mock datastore's GC is a no-op, so run the real one to purge the albums left
// empty by a regroup, as the scanner does in production
Expect(ds.RealDS.GC(ctx)).To(Succeed())
albums, err := ds.Album().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"library_id": libID, "missing": false},
Sort: "name",
})
Expect(err).ToNot(HaveOccurred())
return albums
}
trackByTitle := func(libID int, title string) model.MediaFile {
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
Filters: squirrel.Eq{"library_id": libID, "title": title},
})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).To(HaveLen(1))
return mfs[0]
}
rockTitles := func() []string {
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}})
Expect(err).ToNot(HaveOccurred())
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title })
}
// changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would
// restore the title from the file tags, a quick scan leaves it alone.
changeRockInDB := func() {
_, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID)
Expect(err).ToNot(HaveOccurred())
}
// changeBlueTrainInDB does the same for one jazz track
changeBlueTrainInDB := func() {
_, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID)
Expect(err).ToNot(HaveOccurred())
}
BeforeEach(func() {
beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969})
_ = createFS("rock", fstest.MapFS{
"The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")),
})
miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959})
coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960})
blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957})
_ = createFS("jazz", fstest.MapFS{
"Loose/01 - So What.mp3": miles(track(1, "So What")),
"Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")),
"Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")),
})
})
It("regroups only the library whose PID config changed, keeping annotations", func() {
Expect(runScanner(ctx, true)).To(Succeed())
Expect(albumsOf(lib2.ID)).To(HaveLen(3))
// Star Blue Train, to check the star follows the album to its new ID
oldBlueTrain := trackByTitle(lib2.ID, "Blue Train")
Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed())
changeRockInDB()
lib2.PIDAlbum = "folder"
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
Expect(runScanner(ctx, false)).To(Succeed())
// Jazz is grouped by folder now: "Loose" is one album
Expect(albumsOf(lib2.ID)).To(HaveLen(2))
Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID))
newBlueTrain := trackByTitle(lib2.ID, "Blue Train")
Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID))
album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID)
Expect(err).ToNot(HaveOccurred())
Expect(album.Starred).To(BeTrue())
// Rock only got a quick scan
Expect(rockTitles()).To(ConsistOf("Changed In DB"))
jazz, err := ds.Library().Get(ctx, lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazz.ScannedPIDAlbum).To(Equal("folder"))
Expect(jazz.PIDChanged()).To(BeFalse())
rock, err := ds.Library().Get(ctx, lib1.ID)
Expect(err).ToNot(HaveOccurred())
Expect(rock.PIDChanged()).To(BeFalse())
})
It("rescans only libraries that follow the global config", func() {
lib2.PIDAlbum = "folder"
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
Expect(runScanner(ctx, true)).To(Succeed())
changeRockInDB()
changeBlueTrainInDB()
conf.Server.PID.Album = "album"
Expect(runScanner(ctx, false)).To(Succeed())
// Rock follows the global config, so it was rescanned in full and its title restored
Expect(rockTitles()).To(ConsistOf("Come Together"))
// Jazz has its own override, so it only got a quick scan
trackByTitle(lib2.ID, "Blue Train In DB")
jazz, err := ds.Library().Get(ctx, lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazz.ScannedPIDAlbum).To(Equal("folder"))
})
It("restarts an interrupted scan when the PID config changed meanwhile", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Simulate a quick scan of jazz that was interrupted after it had processed every folder:
// the folders were updated after the (old) scan start time
_, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?",
time.Now().Add(-time.Hour), lib2.ID)
Expect(err).ToNot(HaveOccurred())
lib2.PIDAlbum = "folder"
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
Expect(runScanner(ctx, false)).To(Succeed())
// Every folder was revisited with the new config
Expect(albumsOf(lib2.ID)).To(HaveLen(2))
})
It("does not turn an interrupted PID rescan into a full scan of every library", func() {
Expect(runScanner(ctx, true)).To(Succeed())
changeRockInDB()
lib2.PIDAlbum = "folder"
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
// Simulate a PID full scan of jazz that was interrupted
Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed())
Expect(runScanner(ctx, false)).To(Succeed())
// Rock only got a quick scan, jazz was rescanned with the new config
Expect(rockTitles()).To(ConsistOf("Changed In DB"))
Expect(albumsOf(lib2.ID)).To(HaveLen(2))
})
It("does not record the PID config for a library that could not be scanned", func() {
Expect(runScanner(ctx, true)).To(Succeed())
broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"}
Expect(ds.Library().Put(ctx, &broken)).To(Succeed())
// The scan reports an error for the broken library, and still finishes the others
_ = runScanner(ctx, false)
reloaded, err := ds.Library().Get(ctx, broken.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.PIDChanged()).To(BeTrue())
})
It("does not record the PID config after a selective scan", func() {
Expect(runScanner(ctx, true)).To(Succeed())
lib2.PIDAlbum = "folder"
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
_, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}})
Expect(err).ToNot(HaveOccurred())
jazz, err := ds.Library().Get(ctx, lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazz.PIDChanged()).To(BeTrue())
})
})
})

View file

@ -43,13 +43,6 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
continue
}
// A full walk never descends into symlinked folders when following is disabled, so a
// target reached through one (e.g. a watcher event for a new link) is skipped too.
if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) {
log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath)
continue
}
// Create checker and push patterns from root to this folder
checker := newIgnoreChecker(job.fs)
err = checker.PushAllParents(ctx, folderPath)
@ -232,18 +225,6 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
return fileInfo.IsDir(), nil
}
// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link.
// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected.
func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
info, err := fs.Lstat(fsys, p)
if err == nil && info.Mode()&fs.ModeSymlink != 0 {
return true
}
}
return false
}
const maxSymlinkHops = 40
// resolveEntryName returns the name to classify the entry by, and whether to

View file

@ -4,10 +4,8 @@ import (
"context"
"fmt"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
"testing/fstest"
"github.com/navidrome/navidrome/conf"
@ -262,44 +260,6 @@ var _ = Describe("walk_dir_tree", func() {
// Folders not in targets should remain in lastUpdates
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
})
// #6292: a watcher event for a new folder symlink makes the link itself a scan target
Context("symlinked target folders (production local storage FS)", func() {
BeforeEach(func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed())
job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}}
})
walkTargets := func(targets ...string) map[string]*folderEntry {
results, err := walkDirTree(ctx, job, targets...)
Expect(err).ToNot(HaveOccurred())
folders := map[string]*folderEntry{}
for folder := range results {
folders[folder.path] = folder
}
return folders
}
DescribeTable("with FollowSymlinks disabled",
func(target string, expected ...string) {
conf.Server.Scanner.FollowSymlinks = false
Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected))
},
Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"),
Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"),
Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"),
)
It("walks a symlinked target when FollowSymlinks is enabled", func() {
conf.Server.Scanner.FollowSymlinks = true
folders := walkTargets("Wolfgang Amadeus Mozart")
Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1"))
Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3"))
})
})
})
})
@ -473,8 +433,8 @@ var _ = Describe("walk_dir_tree", func() {
})
// Regression for #5752: the production localFS must resolve file symlinks.
// fs.ReadLink-based resolution can't follow targets outside the library
// root, so full OS-level resolution is required.
// It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
// resolution is not available and full OS-level resolution is required.
Context("production local storage FS", func() {
var libRoot string
var musicFS storage.MusicFS
@ -500,7 +460,12 @@ var _ = Describe("walk_dir_tree", func() {
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
musicFS = newLocalMusicFS(libRoot)
u, err := storage.LocalPathToURL(libRoot)
Expect(err).ToNot(HaveOccurred())
s, err := storage.For(u.String())
Expect(err).ToNot(HaveOccurred())
musicFS, err = s.FS()
Expect(err).ToNot(HaveOccurred())
})
walkRoot := func() *folderEntry {
@ -735,17 +700,6 @@ func getDirEntry(baseDir, name string) os.DirEntry {
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
}
// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot
func newLocalMusicFS(libRoot string) storage.MusicFS {
u, err := storage.LocalPathToURL(libRoot)
Expect(err).ToNot(HaveOccurred())
s, err := storage.For(u.String())
Expect(err).ToNot(HaveOccurred())
musicFS, err := s.FS()
Expect(err).ToNot(HaveOccurred())
return musicFS
}
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
type mockMusicFS struct {
storage.MusicFS

View file

@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/singleton"
)
@ -245,7 +246,7 @@ func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library,
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
return nil
case path := <-events:
path, err := filepath.Rel(absLibPath, path)
path, err := utils.RelPath(absLibPath, path)
if err != nil {
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
continue

View file

@ -158,8 +158,8 @@ and `MusicArtist` queries accept it too, matching artists credited on an album o
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFolder` and
`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
unrecognized keys are skipped, and `Random` always sorts alone);
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
returns direct children only (no tracks — no track is a library's direct child).
@ -176,7 +176,7 @@ returns direct children only (no tracks — no track is a library's direct child
| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` |
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE UserItems/{itemId}/Rating`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET`/`POST UserItems/{itemId}/UserData`, `GET`/`POST Users/{userId}/Items/{itemId}/UserData` |
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` |
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
| Lyrics | `GET Audio/{itemId}/Lyrics` |
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
@ -402,20 +402,12 @@ make test PKG=./server/jellyfin/...
in the playlist, so ids change when entries are inserted or removed; re-read the list after an
edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work;
clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`.
- **Ratings are stored as 0-5 stars.** Jellyfin's 0-10 `Rating` is rounded to the nearest star
(any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores
5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating.
- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome
answers `400` and keeps it, as it does for a malformed or out-of-range value. Use
`DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is
absent.
- **`POST .../UserData` writes only `Rating`, `Likes` and `IsFavorite`.** `Played`, `PlayCount`,
`LastPlayedDate` and `PlaybackPositionTicks` are ignored. A body that is not a single JSON
object, or a `Rating` outside 0-10, is a `400` and nothing is written.
- **Annotation writes can't target another user.** Rating and UserData writes whose `userId` names
a different user get `403`, even for admins; Jellyfin lets admins edit other users' data.
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed) and `Search/Hints` are not
implemented. Play counts only change through playback reporting.
- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating`
(0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends
only `likes` clears the rating.
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy
`UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback
reporting.
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
accepted but never returned.
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork

View file

@ -1,52 +1,47 @@
package jellyfin
import (
"encoding/json"
"errors"
"io"
"math"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
)
type annotatedItem struct {
repo model.AnnotatedRepository
resource string // name used in refreshResource events
ann model.Annotations
}
// resolveAnnotated finds id's repo and the caller's annotations. Only albums and songs are library-gated
// (artists span libraries; playlists gate in Get). When !ok, the response has already been written.
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (item annotatedItem, ok bool) {
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
// already been written, so callers must return without writing the annotation.
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
ctx := r.Context()
entity, err := model.GetEntityByID(ctx, api.ds, id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return annotatedItem{}, false
return nil, ""
}
u, _ := request.UserFrom(ctx)
switch e := entity.(type) {
case *model.Album:
if u.HasLibraryAccess(e.LibraryID) {
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true
return api.ds.Album(), "album"
}
case *model.Artist:
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true
return api.ds.Artist(), "artist"
case *model.MediaFile:
if u.HasLibraryAccess(e.LibraryID) {
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true
return api.ds.MediaFile(), "song"
}
case *model.Playlist:
return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true
return api.ds.Playlist(), "playlist"
}
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
http.Error(w, "Not Found", http.StatusNotFound)
return annotatedItem{}, false
return nil, ""
}
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
@ -70,135 +65,65 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, data)
}
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched.
type userDataChange struct {
rating *int // Navidrome stars; 0 clears
favorite *bool
}
// applyUserData writes change for the itemId path param and answers with the item's resulting
// UserData, as Jellyfin does.
func (api *Router) applyUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
item, ok := api.resolveAnnotated(w, r, id)
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return
}
if err := repo.SetStar(r.Context(), starred, id); err != nil {
api.internalError(w, r, err)
return
}
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
encodedID := dto.EncodeID(id)
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
}
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
api.setFavorite(w, r, false)
}
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
ctx := r.Context()
if change.rating != nil {
if err := item.repo.SetRating(ctx, *change.rating, id); err != nil {
api.internalError(w, r, err)
return
}
item.ann.Rating = *change.rating
}
if change.favorite != nil {
if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil {
api.internalError(w, r, err)
return
}
item.ann.Starred = *change.favorite
}
if change.rating != nil || change.favorite != nil {
api.broker.SendMessage(ctx, (&events.RefreshResource{}).With(item.resource, id))
}
api.ok(w, r, dto.UserData(item.ann, id))
}
// applyCallerUserData is applyUserData for routes that may name a userId: only the caller's own
// annotations can be written, so naming another user is refused. Undecodable ids are ignored.
func (api *Router) applyCallerUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
raw := chi.URLParam(r, "userId")
if raw == "" {
raw = r.URL.Query().Get("userid")
}
if target, ok := dto.DecodeID(raw); ok {
if u, _ := request.UserFrom(r.Context()); target != u.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
}
api.applyUserData(w, r, change)
}
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) {
api.applyUserData(w, r, userDataChange{favorite: new(true)})
}
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
api.applyUserData(w, r, userDataChange{favorite: new(false)})
}
// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike
// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job.
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
var stars int
switch {
case q.Has("likes"):
likes, err := strconv.ParseBool(q.Get("likes"))
if err != nil {
http.Error(w, "likes must be true or false", http.StatusBadRequest)
return
}
stars = dto.StarsFromLikes(likes)
case q.Has("rating"):
rating, err := strconv.ParseFloat(q.Get("rating"), 64)
s, ok := dto.StarsFromRating(rating)
if err != nil || !ok {
http.Error(w, "rating must be a number from 0 to 10", http.StatusBadRequest)
return
}
stars = s
default:
http.Error(w, "likes is required", http.StatusBadRequest)
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return
}
api.applyCallerUserData(w, r, userDataChange{rating: &stars})
if err := repo.SetRating(r.Context(), rating, id); err != nil {
api.internalError(w, r, err)
return
}
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
encodedID := dto.EncodeID(id)
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
if rating > 0 {
jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData
d.Rating = &jfRating
}
api.ok(w, r, d)
}
// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to
// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since
// SetRating(0) is the delete path.
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
jfRating := req.Params(r).Float64Or("rating", 0)
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
rating := int(math.Round(jfRating / 2))
if jfRating > 0 {
rating = max(rating, 1)
}
api.setItemRating(w, r, rating)
}
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
api.applyCallerUserData(w, r, userDataChange{rating: new(0)})
}
// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched.
// Play state is ignored because it only changes through playback reporting.
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
body, ok := decodeUserDataBody(w, r)
if !ok {
return
}
change := userDataChange{favorite: body.IsFavorite}
// Jellyfin applies Likes before Rating, so Rating wins when a body carries both.
switch {
case body.Rating != nil:
stars, ok := dto.StarsFromRating(*body.Rating)
if !ok {
http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest)
return
}
change.rating = &stars
case body.Likes != nil:
change.rating = new(dto.StarsFromLikes(*body.Likes))
}
api.applyCallerUserData(w, r, change)
}
const maxUserDataBody = 64 << 10
// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it.
func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody))
var body *dto.UpdateUserItemDataDto
err := dec.Decode(&body)
if err == nil && body != nil {
if _, err = dec.Token(); errors.Is(err, io.EOF) {
return body, true
}
}
http.Error(w, "Bad Request", http.StatusBadRequest)
return nil, false
api.setItemRating(w, r, 0)
}

View file

@ -5,8 +5,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -23,7 +21,7 @@ var _ = Describe("Annotations", func() {
var broker *fakeEventBroker
// alice has access to library 1 only.
ctxUser := func() context.Context {
return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
}
BeforeEach(func() {
@ -32,16 +30,6 @@ var _ = Describe("Annotations", func() {
api = &Router{ds: ds, broker: broker}
})
songWith := func(ann model.Annotations) *tests.MockMediaFileRepo {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: ann}})
return mfRepo
}
songReq := func(method, target, body string, params ...string) *http.Request {
r := httptest.NewRequest(method, target, strings.NewReader(body)).WithContext(ctxUser())
return withChiURLParam(r, append([]string{"itemId", dto.EncodeID(testID("s1"))}, params...)...)
}
Describe("markFavorite / unmarkFavorite", func() {
It("stars a song and returns IsFavorite=true", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
@ -280,275 +268,41 @@ var _ = Describe("Annotations", func() {
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
})
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
})
It("clamps a negative Rating to Navidrome's min (0)", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
})
It("emits a refreshResource event when rating a song", func() {
songWith(model.Annotations{})
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", ""))
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
})
Context("Jellyfin likes semantics", func() {
It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() {
mfRepo := songWith(model.Annotations{})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
d := parseUserData(w)
Expect(*d.Rating).To(Equal(10.0))
Expect(*d.Likes).To(BeTrue())
})
It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() {
mfRepo := songWith(model.Annotations{Rating: 4})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", ""))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
d := parseUserData(w)
Expect(*d.Rating).To(Equal(2.0))
Expect(*d.Likes).To(BeFalse())
})
It("prefers likes over the numeric rating extension when both are sent", func() {
mfRepo := songWith(model.Annotations{})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", ""))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
})
It("still clears on an explicit rating=0 from numeric clients", func() {
mfRepo := songWith(model.Annotations{Rating: 4})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", ""))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
Expect(parseUserData(w).Rating).To(BeNil())
})
It("keeps the favorite and play state and reports them in the response", func() {
playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
Expect(w.Code).To(Equal(http.StatusOK))
song := mfRepo.Data[testID("s1")]
Expect(song.Starred).To(BeTrue())
Expect(song.PlayCount).To(Equal(int64(3)))
Expect(song.PlayDate).To(Equal(&playDate))
d := parseUserData(w)
Expect(d.IsFavorite).To(BeTrue())
Expect(d.PlayCount).To(Equal(3))
Expect(d.Played).To(BeTrue())
})
DescribeTable("rejects a missing or malformed value without touching the stored rating",
func(query string) {
mfRepo := songWith(model.Annotations{Rating: 4})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, ""))
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
Expect(broker.Events).To(BeEmpty())
},
Entry("no params (Jellyfin would clear)", ""),
Entry("unrelated params only", "?userId=x&foo=1"),
Entry("empty likes", "?likes="),
Entry("non-boolean likes", "?likes=maybe"),
Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"),
Entry("empty rating", "?rating="),
Entry("non-numeric rating", "?rating=abc"),
Entry("NaN rating", "?rating=NaN"),
Entry("infinite rating", "?rating=Inf"),
Entry("rating above 10", "?Rating=100"),
Entry("negative rating", "?Rating=-5"),
)
})
Context("authorization", func() {
DescribeTable("refuses a userId naming another user and leaves the rating",
func(method, target string, params ...string) {
handler := api.setRating
if method == "DELETE" {
handler = api.removeRating
}
mfRepo := songWith(model.Annotations{Rating: 4})
w := httptest.NewRecorder()
invoke(handler, w, songReq(method, target, "", params...))
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
Expect(broker.Events).To(BeEmpty())
},
Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))),
Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))),
Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))),
)
It("accepts the caller's own userId", func() {
mfRepo := songWith(model.Annotations{})
w := httptest.NewRecorder()
invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1"))))
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
})
})
})
Describe("updateUserItemData", func() {
post := func(body string, params ...string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...))
return w
}
It("maps a 0-10 Rating to stars and answers with the full UserData", func() {
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2})
w := post(`{"Rating":8}`)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
d := parseUserData(w)
Expect(*d.Rating).To(Equal(8.0))
Expect(*d.Likes).To(BeTrue())
Expect(d.IsFavorite).To(BeTrue())
Expect(d.PlayCount).To(Equal(2))
Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1"))))
Expect(broker.Events).To(HaveLen(1))
})
DescribeTable("stores the personal rating",
func(body string, before, expected int) {
mfRepo := songWith(model.Annotations{Rating: before})
Expect(post(body).Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected))
},
Entry("Likes true", `{"Likes":true}`, 0, 5),
Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1),
Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1),
Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4),
Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1),
Entry("Rating 0 clears", `{"Rating":0}`, 4, 0),
)
DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null",
func(body string, wantEvents int) {
mfRepo := songWith(model.Annotations{Rating: 4})
w := post(body)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
Expect(*parseUserData(w).Rating).To(Equal(8.0))
Expect(broker.Events).To(HaveLen(wantEvents))
},
Entry("empty object writes nothing", `{}`, 0),
Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0),
Entry("favorite only", `{"IsFavorite":true}`, 1),
)
It("sets and unsets the favorite without touching the rating", func() {
mfRepo := songWith(model.Annotations{Rating: 4})
Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
})
It("ignores play-state fields, which only change through playback reporting", func() {
mfRepo := songWith(model.Annotations{PlayCount: 2})
w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`)
Expect(w.Code).To(Equal(http.StatusOK))
song := mfRepo.Data[testID("s1")]
Expect(song.PlayCount).To(Equal(int64(2)))
Expect(song.PlayDate).To(BeNil())
Expect(broker.Events).To(BeEmpty())
})
It("leaves state unchanged when a client echoes back the UserData it read", func() {
mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1})
echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1")))
Expect(err).ToNot(HaveOccurred())
Expect(post(string(echo)).Code).To(Equal(http.StatusOK))
song := mfRepo.Data[testID("s1")]
Expect(song.Rating).To(Equal(3))
Expect(song.Starred).To(BeTrue())
Expect(song.PlayCount).To(Equal(int64(1)))
})
DescribeTable("rejects an invalid body without writing anything",
func(body string) {
mfRepo := songWith(model.Annotations{Rating: 4})
Expect(post(body).Code).To(Equal(http.StatusBadRequest))
song := mfRepo.Data[testID("s1")]
Expect(song.Rating).To(Equal(4))
Expect(song.Starred).To(BeFalse())
Expect(broker.Events).To(BeEmpty())
},
Entry("empty body", ""),
Entry("malformed JSON", `{"Rating":`),
Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`),
Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`),
Entry("Rating as a string", `{"Rating":"8"}`),
Entry("Likes as a string", `{"Likes":"yes"}`),
Entry("IsFavorite as a number", `{"IsFavorite":1}`),
Entry("JSON null", `null`),
Entry("JSON array", `[]`),
Entry("trailing garbage after the object", `{"Rating":8} x`),
Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`),
Entry("a second object after whitespace", "{\"Rating\":8}\n{}"),
)
It("accepts trailing whitespace after the object", func() {
mfRepo := songWith(model.Annotations{})
Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
})
It("rejects a body larger than the limit", func() {
mfRepo := songWith(model.Annotations{Rating: 4})
big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}`
Expect(post(big).Code).To(Equal(http.StatusBadRequest))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
})
Context("authorization", func() {
It("refuses a userId naming another user without writing", func() {
mfRepo := songWith(model.Annotations{Rating: 4})
w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2")))
Expect(w.Code).To(Equal(http.StatusForbidden))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
})
It("accepts the caller's own userId", func() {
songWith(model.Annotations{})
Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK))
})
It("returns 404 and writes nothing for an album in a library the user can't access", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser())
invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))))
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
})
It("returns 404 for an unknown item", func() {
w := post(`{"Rating":10}`)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})
})
func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto {
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
return d
}
type fakeEventBroker struct {
http.Handler
Events []events.Event

View file

@ -146,8 +146,6 @@ func (api *Router) routes() http.Handler {
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
r.Post("/useritems/{itemId}/rating", api.setRating)
r.Delete("/useritems/{itemId}/rating", api.removeRating)
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
@ -155,8 +153,6 @@ func (api *Router) routes() http.Handler {
// /Users/{userId}/Items is the legacy spelling.
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
r.Post("/useritems/{itemId}/userdata", api.updateUserItemData)
r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData)
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
r.Get("/items/{itemId}/similar", api.getSimilarItems)

View file

@ -40,14 +40,6 @@ type UserItemDataDto struct {
Key string `json:"Key"`
ItemId string `json:"ItemId,omitempty"`
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
Likes *bool `json:"Likes,omitempty"`
}
// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored.
type UpdateUserItemDataDto struct {
Rating *float64 `json:"Rating"`
Likes *bool `json:"Likes"`
IsFavorite *bool `json:"IsFavorite"`
}
type BaseItemDto struct {

View file

@ -3,7 +3,6 @@ package dto
import (
"cmp"
"fmt"
"math"
"time"
"github.com/navidrome/navidrome/conf"
@ -122,30 +121,6 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
}
}
// minLikeRating is Jellyfin's UserItemData.MinLikeValue: a rating at or above it reads as Likes.
const minLikeRating = 6.5
// StarsFromRating maps Jellyfin's 0-10 rating to 0-5 stars, rejecting out-of-range values and NaN.
// A nonzero rating floors at one star, since 0 stars means "not rated".
func StarsFromRating(rating float64) (int, bool) {
if !(rating >= 0 && rating <= 10) {
return 0, false
}
stars := int(math.Round(rating / 2))
if rating > 0 {
stars = max(stars, 1)
}
return stars, true
}
// StarsFromLikes mirrors Jellyfin, which stores likes as Rating 10 and dislikes as Rating 1.
func StarsFromLikes(likes bool) int {
if likes {
return 5
}
return 1
}
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
encodedID := EncodeID(itemID)
@ -159,7 +134,6 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
if a.Rating > 0 {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r
d.Likes = new(r >= minLikeRating)
}
if s := JellyfinDate(a.PlayDate); s != "" {
d.LastPlayedDate = &s

View file

@ -478,30 +478,6 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes).To(BeNil())
})
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
func(stars int, expected *bool) {
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
Expect(d.Likes).To(Equal(expected))
},
Entry("unrated has no Likes", 0, nil),
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
)
It("emits Rating as a whole number and omits Likes when unrated", func() {
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
Expect(err).ToNot(HaveOccurred())
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
Expect(err).ToNot(HaveOccurred())
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
})
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))

View file

@ -130,78 +130,13 @@ var _ = Describe("Annotations", func() {
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
})
It("rejects an out-of-range rating and keeps the stored one", func() {
It("clamps an out-of-range rating to the valid domain", func() {
id := albumID("Help!")
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK))
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", "").Code).To(Equal(http.StatusBadRequest))
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
})
It("stores Jellyfin likes on the current /UserItems route instead of clearing", func() {
id := songID("So What")
var liked dto.UserItemDataDto
parseInto(post("/UserItems/"+enc(id)+"/Rating?likes=true", ""), &liked)
Expect(*liked.Rating).To(Equal(float64(10)))
Expect(*liked.Likes).To(BeTrue())
var disliked dto.UserItemDataDto
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?likes=false", ""), &disliked)
Expect(*disliked.Rating).To(Equal(float64(2)))
Expect(*disliked.Likes).To(BeFalse())
Expect(*itemUserData(id).Likes).To(BeFalse())
Expect(del("/UserItems/" + enc(id) + "/Rating").Code).To(Equal(http.StatusOK))
Expect(itemUserData(id).Rating).To(BeNil())
Expect(itemUserData(id).Likes).To(BeNil())
})
It("does not clear a rating when the request has no value", func() {
id := songID("So What")
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusOK))
Expect(post("/UserItems/"+enc(id)+"/Rating", "").Code).To(Equal(http.StatusBadRequest))
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=sure", "").Code).To(Equal(http.StatusBadRequest))
Expect(*itemUserData(id).Rating).To(Equal(float64(10)))
})
})
Describe("POST /UserItems/{id}/UserData", func() {
It("sets Rating, Likes and IsFavorite on both route spellings", func() {
id := songID("Come Together")
var data dto.UserItemDataDto
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data)
Expect(*data.Rating).To(Equal(float64(6)))
Expect(*data.Likes).To(BeFalse())
parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data)
stored := itemUserData(id)
Expect(*stored.Rating).To(Equal(float64(10)))
Expect(*stored.Likes).To(BeTrue())
Expect(stored.IsFavorite).To(BeTrue())
})
It("leaves omitted fields and play state untouched", func() {
id := songID("Come Together")
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
before := itemUserData(id)
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK))
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
after := itemUserData(id)
Expect(*after.Rating).To(Equal(float64(8)))
Expect(after.IsFavorite).To(BeFalse())
Expect(after.PlayCount).To(Equal(before.PlayCount))
Expect(after.Played).To(Equal(before.Played))
Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate))
})
It("rejects invalid bodies without writing", func() {
id := songID("Come Together")
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest))
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest))
stored := itemUserData(id)
Expect(stored.Rating).To(BeNil())
Expect(stored.IsFavorite).To(BeFalse())
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data)
// 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out.
Expect(data.Rating).ToNot(BeNil())
Expect(*data.Rating).To(Equal(float64(10)))
})
})
})

View file

@ -4,7 +4,6 @@ import (
"net/http"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -29,36 +28,6 @@ var _ = Describe("Multi-user access control", func() {
})
})
Describe("personal ratings", func() {
userData := func(u model.User, id string) *dto.UserItemDataDto {
var data dto.UserItemDataDto
parseInto(getAs(u, "/UserItems/"+enc(id)+"/UserData"), &data)
return &data
}
It("keeps each user's rating separate", func() {
id := songID("So What")
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusOK))
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/Rating?likes=false", "").Code).To(Equal(http.StatusOK))
Expect(*userData(adminUser, id).Rating).To(Equal(float64(10)))
Expect(*userData(regularUser, id).Rating).To(Equal(float64(2)))
})
It("refuses writes that name another user, even for an admin, and changes nothing", func() {
id := songID("So What")
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/UserData", `{"Rating":4}`).Code).To(Equal(http.StatusOK))
Expect(postAs(regularUser, "/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
Expect(post("/Users/"+enc(regularUser.ID)+"/Items/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusForbidden))
Expect(del("/Users/" + enc(regularUser.ID) + "/Items/" + enc(id) + "/Rating").Code).To(Equal(http.StatusForbidden))
Expect(post("/UserItems/"+enc(id)+"/UserData?userId="+enc(regularUser.ID), `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
Expect(userData(adminUser, id).Rating).To(BeNil())
Expect(*userData(regularUser, id).Rating).To(Equal(float64(4)))
})
})
Describe("private playlists", func() {
It("does not expose another user's private playlist", func() {
adminPl := createPlaylist("Admin Private", nil)
@ -84,7 +53,6 @@ var _ = Describe("Multi-user access control", func() {
adminPl := createPlaylist("Admin Private", nil)
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound))
})
It("lets each user manage their own playlist", func() {

View file

@ -18,13 +18,11 @@ import (
. "github.com/onsi/gomega"
)
// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since
// these tests call handlers directly instead of going through the full router.
func withChiURLParam(r *http.Request, kv ...string) *http.Request {
// withChiURLParam simulates chi's routing having captured a path parameter, since these
// tests call handlers directly instead of going through the full router.
func withChiURLParam(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
for i := 0; i+1 < len(kv); i += 2 {
rctx.URLParams.Add(kv[i], kv[i+1])
}
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}

View file

@ -22,12 +22,7 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec
return nil, model.ErrNotFound
}
lib, err := ds.Library().Get(ctx, file.LibraryID)
if err != nil {
return nil, err
}
return core.Inspect(file.AbsolutePath(), *lib, file.FolderID)
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
}
func inspect(ds model.DataStore) http.HandlerFunc {

View file

@ -60,8 +60,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
return
}
streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0)
stream, err := pub.streamer.NewStream(ctx, mf, streamReq)
stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{
Format: info.format, BitRate: info.bitrate,
})
if err != nil {
if errors.Is(err, streampkg.ErrTooManyTranscodes) {
w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds))

View file

@ -116,11 +116,11 @@ var _ = Describe("handleStream", func() {
BeforeEach(func() {
ctx = GinkgoT().Context()
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo
streamer = &mockStreamer{}
pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))}
pub = &Router{ds: ds, streamer: streamer}
})
makeRequest := func(token string) *httptest.ResponseRecorder {
@ -152,17 +152,8 @@ var _ = Describe("handleStream", func() {
makeRequest(token)
Expect(streamer.called).To(BeTrue())
})
It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() {
mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2}
shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf)
claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"}
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
makeRequest(token)
Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}))
Expect(streamer.req.Format).To(Equal("mp3"))
Expect(streamer.req.BitRate).To(Equal(192))
})
It("returns 404 when the track is outside the share owner's libraries", func() {

View file

@ -21,15 +21,14 @@ type Router struct {
http.Handler
artwork artwork.Artwork
streamer stream.MediaStreamer
decider stream.TranscodeDecider
archiver core.Archiver
share core.Share
assetsHandler http.Handler
ds model.DataStore
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router {
p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver}
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router {
p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver}
shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic)
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
p.Handler = p.routes()

View file

@ -58,8 +58,6 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
"uiCoverArtSize": conf.Server.UICoverArtSize,
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
"pidAlbum": conf.Server.PID.Album,
"pidTrack": conf.Server.PID.Track,
"enableNowPlaying": conf.Server.EnableNowPlaying,
"playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(),
"gaTrackingId": conf.Server.GATrackingID,

View file

@ -89,8 +89,6 @@ var _ = Describe("serveIndex", func() {
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"),
Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"),
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),

View file

@ -137,7 +137,7 @@ var _ = Describe("Artwork Serving", Ordered, func() {
artRouter = buildArtworkRouter(artSvc)
router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router
pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{})
pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{})
})
It("emits a bare optimistic coverArt id before the queue is drained", func() {

View file

@ -145,17 +145,6 @@ func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error {
return nil
}
func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error {
if m.Err != nil {
return m.Err
}
if lib, ok := m.Data[id]; ok {
lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track
m.Data[id] = lib
}
return nil
}
func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) {
if m.Err != nil {
return false, m.Err

View file

@ -16,7 +16,6 @@ import {
useRecordContext,
useTranslate,
} from 'react-admin'
import clsx from 'clsx'
import Lightbox from 'react-image-lightbox'
import config from '../config'
import 'react-image-lightbox/style.css'
@ -80,9 +79,6 @@ const useStyles = makeStyles(
alignItems: 'center',
justifyContent: 'center',
},
noCoverAnimation: {
'&, &::before, &::after': { animation: 'none' },
},
cover: {
objectFit: 'contain',
cursor: 'pointer',
@ -254,12 +250,7 @@ const AlbumDetails = (props) => {
return (
<Card className={classes.root}>
<div className={classes.cardContents}>
<div
className={clsx(
classes.coverParent,
!config.enableCoverAnimation && classes.noCoverAnimation,
)}
>
<div className={classes.coverParent}>
<Artwork
record={record}
fit="contain"

View file

@ -3,29 +3,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'
import { render } from '@testing-library/react'
import { RecordContextProvider } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { createTheme, ThemeProvider } from '@material-ui/core/styles'
import config from '../config'
import AlbumDetails, { Details } from './AlbumDetails'
vi.mock('../subsonic', () => ({
default: {
getAlbumInfo: () =>
Promise.resolve({
json: { 'subsonic-response': { status: 'ok', albumInfo: {} } },
}),
getCoverArtUrl: () => '',
},
}))
vi.mock('react-admin', async () => {
const actual = await vi.importActual('react-admin')
return {
...actual,
useDataProvider: () => ({ getOne: vi.fn() }),
useNotify: () => vi.fn(),
useRefresh: () => vi.fn(),
}
})
import { Details } from './AlbumDetails'
// Mock useMediaQuery
vi.mock('@material-ui/core', async () => {
@ -365,49 +343,3 @@ describe('Details component', () => {
})
})
})
describe('AlbumDetails cover animation', () => {
const albumRecord = {
id: '123',
name: 'Test Album',
songCount: 12,
duration: 3600,
size: 102400,
}
const originalEnableCoverAnimation = config.enableCoverAnimation
beforeEach(() => {
vi.mocked(useMediaQuery).mockReturnValue(false)
})
afterEach(() => {
config.enableCoverAnimation = originalEnableCoverAnimation
})
const renderAlbum = () =>
render(
<ThemeProvider theme={createTheme()}>
<RecordContextProvider value={albumRecord}>
<AlbumDetails />
</RecordContextProvider>
</ThemeProvider>,
)
test('applies noCoverAnimation when enableCoverAnimation is false', () => {
config.enableCoverAnimation = false
const { container } = renderAlbum()
const cover = container.querySelector('[class*="coverParent"]')
expect(cover).not.toBeNull()
expect(cover.className).toMatch(/noCoverAnimation/)
})
test('omits noCoverAnimation when enableCoverAnimation is true', () => {
config.enableCoverAnimation = true
const { container } = renderAlbum()
const cover = container.querySelector('[class*="coverParent"]')
expect(cover).not.toBeNull()
expect(cover.className).not.toMatch(/noCoverAnimation/)
})
})

View file

@ -163,7 +163,6 @@ const AlbumFilter = (props) => {
/>
</ReferenceInput>
<NullableBooleanInput source="compilation" />
<NullableBooleanInput source="played" defaultValue={false} />
<NumberInput source="year" />
{config.enableFavourites && (
<NullableBooleanInput

View file

@ -32,8 +32,6 @@ const defaultConfig = {
listenBrainzEnabled: true,
enableExternalServices: true,
enableCoverAnimation: true,
pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID
pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID
enableNowPlaying: true,
playbackReportIntervalMs: 60000,
devShowArtistPage: true,

View file

@ -83,8 +83,7 @@
"grouping": "Grouping",
"media": "Media",
"mood": "Mood",
"missing": "Missing",
"played": "Played"
"missing": "Missing"
},
"actions": {
"playAll": "Play",
@ -331,22 +330,11 @@
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
"updatedAt": "Updated",
"pidAlbum": "Album grouping",
"pidTrack": "Track identity"
"updatedAt": "Updated"
},
"sections": {
"basic": "Basic Information",
"statistics": "Statistics",
"pid": "Persistent IDs"
},
"pid": {
"global": "Use global setting (%{value})",
"folder": "Folder (one album per folder)",
"custom": "Custom",
"spec": "PID spec",
"help": "Tags and attributes that identify an item. See the documentation for the syntax:",
"docs": "Persistent IDs"
"statistics": "Statistics"
},
"actions": {
"scan": "Scan Library",
@ -376,9 +364,7 @@
"messages": {
"deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.",
"scanInProgress": "Scan in progress...",
"noLibrariesAssigned": "No libraries assigned to this user",
"pidChangeTitle": "Change persistent IDs?",
"pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one."
"noLibrariesAssigned": "No libraries assigned to this user"
}
},
"plugin": {

View file

@ -1,26 +1,11 @@
import React from 'react'
import { Notification as RANotification } from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
// RA's primary.light Undo is unreadable on the light snackbar of dark themes
const useStyles = makeStyles(
{
undo: {
color: 'inherit',
},
},
{ name: 'NDNotification' },
const Notification = (props) => (
<RANotification
{...props}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
/>
)
const Notification = (props) => {
const classes = useStyles()
return (
<RANotification
{...props}
classes={{ undo: classes.undo }}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
/>
)
}
export default Notification

View file

@ -1,5 +1,4 @@
import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import {
Create,
SimpleForm,
@ -11,34 +10,7 @@ import {
useNotify,
useRedirect,
} from 'react-admin'
import { Typography } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { Title } from '../common'
import { PIDInputs } from './PIDInput'
const useStyles = makeStyles((theme) => ({
spaced: { marginTop: theme.spacing(3) },
}))
// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly
const SectionTitle = ({ label, spaced }) => {
const translate = useTranslate()
const classes = useStyles()
return (
<Typography
variant="h6"
gutterBottom
className={spaced ? classes.spaced : undefined}
>
{translate(label)}
</Typography>
)
}
SectionTitle.propTypes = {
label: PropTypes.string.isRequired,
spaced: PropTypes.bool,
}
const LibraryCreate = (props) => {
const translate = useTranslate()
@ -101,12 +73,9 @@ const LibraryCreate = (props) => {
return (
<Create title={<Title subTitle={title} />} {...props}>
<SimpleForm save={save} variant={'outlined'}>
<SectionTitle label="resources.library.sections.basic" />
<TextInput source="name" validate={[required()]} />
<TextInput source="path" validate={[required()]} fullWidth />
<BooleanInput source="defaultNewUsers" />
<SectionTitle label="resources.library.sections.pid" spaced />
<PIDInputs />
</SimpleForm>
</Create>
)

View file

@ -1,11 +1,9 @@
import React, { useCallback, useState } from 'react'
import PropTypes from 'prop-types'
import React, { useCallback } from 'react'
import {
Edit,
FormWithRedirect,
TextInput,
BooleanInput,
Confirm,
required,
SaveButton,
useTranslate,
@ -24,9 +22,6 @@ import {
ReadOnlySizeField,
Title,
} from '../common'
import config from '../config'
import { PIDInputs } from './PIDInput'
import { pidConfigChanged } from './pidPresets'
const useStyles = makeStyles({
toolbar: {
@ -58,131 +53,8 @@ const CustomToolbar = ({ showDelete, ...props }) => (
</Toolbar>
)
export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => {
const translate = useTranslate()
const [confirmOpen, setConfirmOpen] = useState(false)
// Every submit path (Save button and Enter key) goes through here, so a PID change always asks first
const submit = () => {
if (
pidConfigChanged(
formProps.form.getState().values,
formProps.record,
config,
)
) {
setConfirmOpen(true)
return
}
formProps.handleSubmit()
}
const handleConfirm = () => {
setConfirmOpen(false)
formProps.handleSubmit()
}
return (
<form
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<Box p="1em" maxWidth="800px">
<Box display="flex">
<Box flex={1} mr="1em">
{/* Basic Information */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.basic')}
</Typography>
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate('resources.library.fields.defaultNewUsers')}
variant="outlined"
/>
<Box mt="2em" />
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.pid')}
</Typography>
<PIDInputs />
<Box mt="2em" />
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<Box
display="grid"
gridTemplateColumns="1fr 1fr"
gridColumnGap="1em"
>
<ReadOnlyNumberField source="totalSongs" {...readOnlyProps} />
<ReadOnlyNumberField source="totalAlbums" {...readOnlyProps} />
<ReadOnlyNumberField source="totalArtists" {...readOnlyProps} />
<ReadOnlySizeField source="totalSize" {...readOnlyProps} />
<ReadOnlyDurationField
source="totalDuration"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalMissingFiles"
{...readOnlyProps}
/>
<Box gridColumn="1 / -1">
<ReadOnlyDateField source="lastScanAt" {...readOnlyProps} />
</Box>
<ReadOnlyDateField source="updatedAt" {...readOnlyProps} />
<ReadOnlyDateField source="createdAt" {...readOnlyProps} />
</Box>
</Box>
</Box>
</Box>
<CustomToolbar
handleSubmitWithRedirect={submit}
pristine={formProps.pristine}
saving={formProps.saving}
record={formProps.record}
showDelete={canDelete}
/>
<Confirm
isOpen={confirmOpen}
loading={formProps.saving}
title="resources.library.messages.pidChangeTitle"
content="resources.library.messages.pidChangeConfirm"
onConfirm={handleConfirm}
onClose={() => setConfirmOpen(false)}
/>
</form>
)
}
LibraryEditForm.propTypes = {
formProps: PropTypes.object.isRequired,
canEditPath: PropTypes.bool,
canDelete: PropTypes.bool,
}
const LibraryEdit = (props) => {
const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
@ -221,11 +93,91 @@ const LibraryEdit = (props) => {
{...props}
save={save}
render={(formProps) => (
<LibraryEditForm
formProps={formProps}
canEditPath={canEditPath}
canDelete={canDelete}
/>
<form onSubmit={formProps.handleSubmit}>
<Box p="1em" maxWidth="800px">
<Box display="flex">
<Box flex={1} mr="1em">
{/* Basic Information */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.basic')}
</Typography>
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate(
'resources.library.fields.defaultNewUsers',
)}
variant="outlined"
/>
<Box mt="2em" />
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<Box
display="grid"
gridTemplateColumns="1fr 1fr"
gridColumnGap="1em"
>
<ReadOnlyNumberField
source="totalSongs"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalAlbums"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalArtists"
{...readOnlyProps}
/>
<ReadOnlySizeField source="totalSize" {...readOnlyProps} />
<ReadOnlyDurationField
source="totalDuration"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalMissingFiles"
{...readOnlyProps}
/>
<Box gridColumn="1 / -1">
<ReadOnlyDateField
source="lastScanAt"
{...readOnlyProps}
/>
</Box>
<ReadOnlyDateField source="updatedAt" {...readOnlyProps} />
<ReadOnlyDateField source="createdAt" {...readOnlyProps} />
</Box>
</Box>
</Box>
</Box>
<CustomToolbar
handleSubmitWithRedirect={formProps.handleSubmitWithRedirect}
pristine={formProps.pristine}
saving={formProps.saving}
record={formProps.record}
showDelete={canDelete}
/>
</form>
)}
/>
</Edit>

View file

@ -1,125 +0,0 @@
import * as React from 'react'
import { TestContext } from 'ra-test'
import {
FormWithRedirect,
RecordContextProvider,
SaveContextProvider,
} from 'react-admin'
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { LibraryEditForm } from './LibraryEdit'
import config from '../config'
const record = {
id: '2',
name: 'Jazz',
path: '/music/jazz',
pidAlbum: '',
pidTrack: '',
}
// Edit provides a save context in the app. SaveButton only reads these setters from it
const saveContext = {
save: vi.fn(),
setOnSuccess: vi.fn(),
setOnFailure: vi.fn(),
setTransform: vi.fn(),
}
const renderForm = (save) =>
render(
<TestContext>
<SaveContextProvider value={saveContext}>
<RecordContextProvider value={record}>
<FormWithRedirect
record={record}
save={save}
render={(formProps) => (
<LibraryEditForm
formProps={formProps}
canEditPath
canDelete={false}
/>
)}
/>
</RecordContextProvider>
</SaveContextProvider>
</TestContext>,
)
const chooseAlbumGrouping = (optionText) => {
fireEvent.mouseDown(
screen.getByLabelText('resources.library.fields.pidAlbum'),
)
fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText))
}
const dialogTitle = 'resources.library.messages.pidChangeTitle'
describe('LibraryEditForm', () => {
afterEach(cleanup)
it('saves directly when the PID config did not change', async () => {
const save = vi.fn()
renderForm(save)
fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), {
target: { value: 'Jazz Renamed' },
})
fireEvent.click(screen.getByText('ra.action.save'))
await waitFor(() => expect(save).toHaveBeenCalled())
expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument()
})
it('asks before saving a PID change, and Cancel keeps the edits', async () => {
const save = vi.fn()
renderForm(save)
chooseAlbumGrouping('resources.library.pid.folder')
fireEvent.click(screen.getByText('ra.action.save'))
expect(await screen.findByText(dialogTitle)).toBeInTheDocument()
expect(save).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('ra.action.cancel'))
await waitFor(() =>
expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(),
)
expect(save).not.toHaveBeenCalled()
expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument()
})
it('saves the PID change after Confirm', async () => {
const save = vi.fn()
renderForm(save)
chooseAlbumGrouping('resources.library.pid.folder')
fireEvent.click(screen.getByText('ra.action.save'))
fireEvent.click(await screen.findByText('ra.action.confirm'))
await waitFor(() => expect(save).toHaveBeenCalled())
expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' })
})
it('pre-fills a Custom spec with the global spec', () => {
renderForm(vi.fn())
chooseAlbumGrouping('resources.library.pid.custom')
expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue(
config.pidAlbum,
)
})
it('asks before saving when the form is submitted with Enter', async () => {
const save = vi.fn()
const { container } = renderForm(save)
chooseAlbumGrouping('resources.library.pid.folder')
fireEvent.submit(container.querySelector('form'))
expect(await screen.findByText(dialogTitle)).toBeInTheDocument()
expect(save).not.toHaveBeenCalled()
})
})

View file

@ -1,114 +0,0 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { TextInput, required, useTranslate } from 'react-admin'
import { useField } from 'react-final-form'
import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import {
PID_CUSTOM,
PID_FOLDER,
PID_GLOBAL,
pidModeFromValue,
pidValueForMode,
} from './pidPresets'
import config from '../config'
import { docsUrl } from '../utils'
const PID_DOCS_URL = docsUrl('/docs/usage/pids/')
const useStyles = makeStyles((theme) => ({
help: { marginBottom: theme.spacing(1) },
}))
// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec
export const PIDInput = ({ source, label, globalValue, allowFolder }) => {
const translate = useTranslate()
const classes = useStyles()
const { input } = useField(source)
// Local state, so choosing Custom shows the text box before anything is typed
const [mode, setMode] = useState(() =>
pidModeFromValue(input.value, allowFolder),
)
const choices = [
{
id: PID_GLOBAL,
name: translate('resources.library.pid.global', { value: globalValue }),
},
...(allowFolder
? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }]
: []),
{ id: PID_CUSTOM, name: translate('resources.library.pid.custom') },
]
const handleModeChange = (event) => {
const newMode = event.target.value
setMode(newMode)
input.onChange(pidValueForMode(newMode, globalValue))
}
return (
<>
<TextField
id={`${source}-mode`}
select
fullWidth
variant="outlined"
margin="dense"
label={label}
value={mode}
onChange={handleModeChange}
>
{choices.map((choice) => (
<MenuItem key={choice.id} value={choice.id}>
{choice.name}
</MenuItem>
))}
</TextField>
{mode === PID_CUSTOM && (
<>
<TextInput
source={source}
label={translate('resources.library.pid.spec')}
validate={[required()]}
fullWidth
variant="outlined"
helperText={false}
/>
<FormHelperText className={classes.help}>
{translate('resources.library.pid.help')}{' '}
<Link href={PID_DOCS_URL} target="_blank" rel="noopener noreferrer">
{translate('resources.library.pid.docs')}
</Link>
</FormHelperText>
</>
)}
</>
)
}
PIDInput.propTypes = {
source: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
globalValue: PropTypes.string,
allowFolder: PropTypes.bool,
}
export const PIDInputs = () => {
const translate = useTranslate()
return (
<>
<PIDInput
source="pidAlbum"
label={translate('resources.library.fields.pidAlbum')}
globalValue={config.pidAlbum}
allowFolder
/>
<PIDInput
source="pidTrack"
label={translate('resources.library.fields.pidTrack')}
globalValue={config.pidTrack}
/>
</>
)
}

View file

@ -1,33 +0,0 @@
export const PID_GLOBAL = 'global'
export const PID_FOLDER = 'folder'
export const PID_CUSTOM = 'custom'
export const pidModeFromValue = (value, allowFolder) => {
const v = (value || '').trim()
if (v === '') return PID_GLOBAL
if (allowFolder && v === PID_FOLDER) return PID_FOLDER
return PID_CUSTOM
}
// Returns the value to store for a dropdown choice. Custom starts from the global spec
export const pidValueForMode = (mode, globalValue) => {
switch (mode) {
case PID_GLOBAL:
return ''
case PID_FOLDER:
return PID_FOLDER
default:
return globalValue || ''
}
}
// Reports whether the form values change the effective PID spec of the saved record. Like the
// server, it trims, treats empty as the global value and compares case-insensitively
export const pidConfigChanged = (values, record, globals) => {
const effective = (value, field) =>
((value || '').trim() || globals[field] || '').toLowerCase()
return ['pidAlbum', 'pidTrack'].some(
(field) =>
effective(values[field], field) !== effective(record[field], field),
)
}

View file

@ -1,65 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
PID_CUSTOM,
PID_FOLDER,
PID_GLOBAL,
pidConfigChanged,
pidModeFromValue,
pidValueForMode,
} from './pidPresets'
describe('pidModeFromValue', () => {
it('maps an empty value to the global setting', () => {
expect(pidModeFromValue('', true)).toBe(PID_GLOBAL)
expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL)
})
it('maps folder to the Folder preset when allowed', () => {
expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER)
})
it('maps folder to Custom when the Folder preset is not offered', () => {
expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM)
})
it('maps any other value to Custom', () => {
expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM)
})
})
describe('pidValueForMode', () => {
it('stores an empty value for the global setting', () => {
expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('')
})
it('stores folder for the Folder preset', () => {
expect(pidValueForMode(PID_FOLDER, '')).toBe('folder')
})
it('starts Custom from the global spec', () => {
expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title')
expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('')
})
})
describe('pidConfigChanged', () => {
const record = { pidAlbum: 'folder', pidTrack: '' }
const globals = {
pidAlbum: 'musicbrainz_albumid|albumartistid,album',
pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title',
}
it.each([
['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false],
['a missing value equals an empty one', { pidAlbum: 'folder' }, false],
[
'Custom set to the global value',
{ pidAlbum: 'folder', pidTrack: globals.pidTrack },
false,
],
['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false],
[
'a whitespace-only change',
{ pidAlbum: ' folder ', pidTrack: ' ' },
false,
],
['the album PID changed', { pidAlbum: '', pidTrack: '' }, true],
['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true],
])('%s', (_, values, expected) => {
expect(pidConfigChanged(values, record, globals)).toBe(expected)
})
})

View file

@ -67,15 +67,15 @@ const PlaylistFilter = (props) => {
)
}
export const ToggleField = ({ resource, source }) => {
const TogglePublicInput = ({ resource, source }) => {
const record = useRecordContext()
const notify = useNotify()
const [toggle] = useUpdate(
const [togglePublic] = useUpdate(
resource,
record?.id,
record.id,
{
...record,
[source]: !record?.[source],
public: !record.public,
},
{
undoable: false,
@ -86,25 +86,48 @@ export const ToggleField = ({ resource, source }) => {
)
const handleClick = (e) => {
toggle()
togglePublic()
e.stopPropagation()
}
if (!record) return null
return (
<Switch
checked={record[source]}
color="primary"
onClick={handleClick}
disabled={!isWritable(record.ownerId)}
/>
)
}
export const ToggleAutoImport = (props) => {
const ToggleAutoImport = ({ resource, source }) => {
const record = useRecordContext()
return record?.path ? <ToggleField {...props} /> : null
const notify = useNotify()
const [ToggleAutoImport] = useUpdate(
resource,
record.id,
{
...record,
sync: !record.sync,
},
{
undoable: false,
onFailure: (error) => {
notify('ra.page.error', 'warning')
},
},
)
const handleClick = (e) => {
ToggleAutoImport()
e.stopPropagation()
}
return record.path ? (
<Switch
checked={record[source]}
onClick={handleClick}
disabled={!isWritable(record.ownerId)}
/>
) : null
}
const PlaylistListBulkActions = (props) => {
@ -146,7 +169,9 @@ const PlaylistList = (props) => {
updatedAt: isDesktop && (
<DateField source="updatedAt" sortByOrder={'DESC'} />
),
public: !isXsmall && <ToggleField source="public" sortByOrder={'DESC'} />,
public: !isXsmall && (
<TogglePublicInput source="public" sortByOrder={'DESC'} />
),
comment: <TextField source="comment" />,
sync: !isXsmall && (
<ToggleAutoImport source="sync" sortByOrder={'DESC'} />

View file

@ -1,9 +1,7 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TestContext } from 'ra-test'
import { RecordContextProvider } from 'react-admin'
import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
import { PlaylistLove } from './PlaylistList'
vi.mock('../config', () => ({
default: { enableFavourites: true },
@ -15,7 +13,6 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
</button>
),
isWritable: (ownerId) => ownerId === 'me',
}))
describe('<PlaylistLove />', () => {
@ -35,50 +32,3 @@ describe('<PlaylistLove />', () => {
})
})
})
// react-admin evicts records older than 10 minutes while the list still holds
// their ids, so rows can render with no record.
describe('playlist toggles without a record', () => {
it('<ToggleField /> renders nothing', () => {
const { container } = render(
<TestContext>
<ToggleField resource="playlist" source="public" />
</TestContext>,
)
expect(container.innerHTML).toBe('')
})
it('<ToggleAutoImport /> renders nothing', () => {
const { container } = render(
<TestContext>
<ToggleAutoImport resource="playlist" source="sync" />
</TestContext>,
)
expect(container.innerHTML).toBe('')
})
})
// Secondary is a surface color in many themes, so these toggles must use primary
describe('<ToggleField />', () => {
const renderToggle = (record) =>
render(
<TestContext>
<RecordContextProvider value={record}>
<ToggleField resource="playlist" source="public" />
</RecordContextProvider>
</TestContext>,
)
it.each([
['owner', 'me', false],
['non-owner', 'someone-else', true],
])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => {
renderToggle({ id: 'pl-1', public: true, ownerId })
const input = screen.getByRole('checkbox')
const switchBase = input.closest('.MuiSwitch-switchBase')
expect(input.checked).toBe(true)
expect(input.disabled).toBe(disabled)
expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary')
})
})

View file

@ -79,16 +79,6 @@ export default {
color: '#eee',
backgroundColor: '#ff4e6b',
},
containedPrimary: {
color: '#fff',
backgroundColor: '#D60017',
'&:hover': {
backgroundColor: '#a30011',
'@media (hover: none)': {
backgroundColor: '#D60017',
},
},
},
textSizeSmall: {
fontSize: '0.8rem',
paddingRight: '0.5rem',
@ -202,11 +192,6 @@ export default {
paddingBottom: '1rem',
},
},
NDNotification: {
undo: {
color: '#fff',
},
},
RaConfirm: {
confirmPrimary: {
color: '#fff',

View file

@ -185,6 +185,16 @@ export default {
color: `${foreground} !important`,
},
},
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: green,
},
'&$checked + $track': {
backgroundColor: green,
},
},
},
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',

View file

@ -121,6 +121,16 @@ export default {
boxShadow: '3px 3px 5px #3c3836',
},
},
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: '#458588',
},
'&$checked + $track': {
backgroundColor: '#458588',
},
},
},
NDMobileArtistDetails: {
bgContainer: {
background:

Some files were not shown because too many files have changed in this diff Show more