Compare commits

..

2 commits

Author SHA1 Message Date
Deluan
e510f34604 perf(playlists): evaluate smart playlist criteria before taking the write lock
Smart playlists were refreshed with a single INSERT ... SELECT, so SQLite held the write lock while the criteria query ran. On a Raspberry Pi 4 with 300k songs and a rule matching 200k of them, that blocked every other writer for the whole 6 to 8 s evaluation, pushing the worst writer wait past the 15 s busy timeout at the end of a scan.

The criteria query now runs first as a plain read, which WAL lets run alongside writers. The matching ids are then written in one short transaction: delete the old tracks, insert the new ones in order through json_each, and update the counters and evaluated_at. On the same Pi the evaluation no longer raises the worst writer wait above the scan's own baseline; the write itself holds the lock for about 3 s instead of the full evaluation. This also applies when a playlist is refreshed on read, and the scanner no longer wraps Evaluate in a transaction, so the read runs outside any lock.
2026-09-26 21:08:02 -04:00
Deluan
210fd55021 feat(scanner): evaluate new and changed smart playlists at the end of a scan
Smart playlists imported from .nsp files showed 0 tracks until their owner opened them, because tracks are only materialized on read. The scanner now queues every imported smart playlist that has no evaluation yet (new, or its file changed) and evaluates them as the last scan step, after GC and stats, so the rules see the final library state. Unchanged files keep their evaluation and are not re-run.

The new PlaylistRepository.Evaluate runs the refresh as the playlist owner, ignoring the caller's visibility and the refresh delay, inside a transaction so readers never see an emptied playlist. Evaluating inside the scan also covers the default out-of-process scanner. Evaluation errors are logged and do not fail the scan; the playlist is still evaluated on its next read.

Fixes #4539
2026-09-26 19:50:05 -04:00
186 changed files with 1307 additions and 6639 deletions

View file

@ -519,18 +519,13 @@ jobs:
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }} docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub: push-manifest-dockerhub:
name: Push to Docker Hub (${{ matrix.repository }}) name: Push to Docker Hub
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
needs: [build, check-push-enabled] needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true 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: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
@ -546,7 +541,7 @@ jobs:
id: docker id: docker
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ matrix.repository }} hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
@ -563,7 +558,7 @@ jobs:
- name: Inspect image in Docker Hub - name: Inspect image in Docker Hub
run: | 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: cleanup-digests:
name: Cleanup digest artifacts name: Cleanup digest artifacts

View file

@ -18,7 +18,7 @@ endif
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386 SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS) PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= navidrome/navidrome:develop DOCKER_TAG ?= deluan/navidrome:develop
GOLANGCI_LINT_VERSION ?= v2.14.0 GOLANGCI_LINT_VERSION ?= v2.14.0
VACUUM_VERSION ?= v0.30.6 VACUUM_VERSION ?= v0.30.6
@ -223,7 +223,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
--output "./binaries" --target binary . --output "./binaries" --target binary .
.PHONY: docker-build .PHONY: docker-build
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true @echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true

View file

@ -5,7 +5,7 @@
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases) [![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master) [![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) [![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.navidrome.org%2Fbadges%2Fcontainer-downloads.json&logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/navidrome/navidrome) [![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF) [![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/) [![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
@ -35,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
## Cloud Hosting ## 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. [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 href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a> A share of the revenue helps fund the development of Navidrome at no additional cost for you.
<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/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features ## Features

View file

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

View file

@ -12,11 +12,10 @@ import (
) )
var ( var (
backupCount int backupCount int
backupDir string backupDir string
force bool force bool
integrityCheck bool restorePath string
restorePath string
) )
func init() { func init() {
@ -32,7 +31,6 @@ func init() {
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)") restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning") restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
restoreCommand.Flags().BoolVarP(&integrityCheck, "integrity-check", "i", false, "check the backup file with a full integrity check before restoring. Finds more kinds of damage, but takes longer")
_ = restoreCommand.MarkFlagRequired("backup-file") _ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand) backupRoot.AddCommand(restoreCommand)
} }
@ -138,7 +136,7 @@ func runRestore(ctx context.Context) {
} }
start := time.Now() start := time.Now()
err := db.Restore(ctx, restorePath, integrityCheck) err := db.Restore(ctx, restorePath)
if err != nil { if err != nil {
log.Fatal("Error restoring database", "backupFile", restorePath, err) log.Fatal("Error restoring database", "backupFile", restorePath, err)
} }

View file

@ -1,17 +1,13 @@
package cmd package cmd
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath"
"strings" "strings"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
Long: "Show file tags as seen by Navidrome", Long: "Show file tags as seen by Navidrome",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { 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 return []byte(res.String()), nil
} }
func runInspector(ctx context.Context, args []string) { func runInspector(args []string) {
marshal := marshalers[format] marshal := marshalers[format]
if marshal == nil { if marshal == nil {
log.Fatal("Invalid format", "format", format) log.Fatal("Invalid format", "format", format)
} }
libs := loadLibraries(ctx)
matcher := model.NewLibraryMatcher(libs)
var out []core.InspectOutput var out []core.InspectOutput
for _, filePath := range args { for _, filePath := range args {
if !model.IsAudioFile(filePath) { if !model.IsAudioFile(filePath) {
log.Warn("Not an audio file", "file", filePath) log.Warn("Not an audio file", "file", filePath)
continue continue
} }
lib, ok := libraryForFile(matcher, filePath) output, err := core.Inspect(filePath, 1, "")
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, "")
if err != nil { if err != nil {
log.Warn("Unable to process file", "file", filePath, "error", err) log.Warn("Unable to process file", "file", filePath, "error", err)
continue continue
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
data, _ := marshal(out) data, _ := marshal(out)
fmt.Println(string(data)) 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

@ -44,9 +44,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
preRun() preRun()
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if err := runNavidrome(cmd.Context()); err != nil { runNavidrome(cmd.Context())
log.Fatal("Fatal error in Navidrome. Aborting", err)
}
}, },
PostRun: func(cmd *cobra.Command, args []string) { PostRun: func(cmd *cobra.Command, args []string) {
postRun() postRun()
@ -78,12 +76,12 @@ func postRun() {
} }
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks. // runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
// If any of the services returns an error, it stops the others and returns that error, so the caller can // If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil. // it will cancel the context and exit gracefully.
func runNavidrome(parentCtx context.Context) error { func runNavidrome(ctx context.Context) {
defer db.Init(parentCtx)() defer db.Init(ctx)()
g, ctx := errgroup.WithContext(parentCtx) g, ctx := errgroup.WithContext(ctx)
g.Go(startServer(ctx)) g.Go(startServer(ctx))
g.Go(startSignaller(ctx)) g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx)) g.Go(startScheduler(ctx))
@ -104,11 +102,9 @@ func runNavidrome(parentCtx context.Context) error {
log.Warn(ctx, "Automatic Scanning is DISABLED") log.Warn(ctx, "Automatic Scanning is DISABLED")
} }
// Errors caused by a normal shutdown are not failures if err := g.Wait(); err != nil {
if err := g.Wait(); err != nil && parentCtx.Err() == nil { log.Error("Fatal error in Navidrome. Aborting", err)
return err
} }
return nil
} }
// mainContext returns a context that is cancelled when the process receives a signal to exit. // mainContext returns a context that is cancelled when the process receives a signal to exit.
@ -190,20 +186,16 @@ func schedulePeriodicScan(ctx context.Context) func() error {
} }
} }
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from func pidHashChanged(ds model.DataStore) (bool, error) {
// the one used by their last finished scan pidAlbum, err := ds.Property().DefaultGet(context.Background(), consts.PIDAlbumKey, "")
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
libs, err := ds.Library().GetAll(ctx)
if err != nil { if err != nil {
return nil, err return false, err
} }
var names []string pidTrack, err := ds.Property().DefaultGet(context.Background(), consts.PIDTrackKey, "")
for _, lib := range libs { if err != nil {
if lib.PIDChanged() { return false, err
names = append(names, lib.Name)
}
} }
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. // runInitialScan runs an initial scan of the music library if needed.
@ -218,12 +210,12 @@ func runInitialScan(ctx context.Context) func() error {
if err != nil { if err != nil {
return err return err
} }
pidChangedLibs, err := librariesWithChangedPID(ctx, ds) pidHasChanged, err := pidHashChanged(ds)
if err != nil { if err != nil {
return err return err
} }
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup 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 time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
if scanNeeded { if scanNeeded {
s := CreateScanner(ctx) s := CreateScanner(ctx)
@ -231,9 +223,9 @@ func runInitialScan(ctx context.Context) func() error {
case fullScanRequired == "1": case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration") log.Warn(ctx, "Full scan required after migration")
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
case len(pidChangedLibs) > 0: case pidHasChanged:
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it log.Warn(ctx, "PID config changed, performing full scan")
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs) fullScanRequired = "1"
case inProgress: case inProgress:
log.Warn(ctx, "Resuming interrupted scan") log.Warn(ctx, "Resuming interrupted scan")
default: default:

View file

@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path" "path"
@ -10,8 +9,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -47,30 +44,3 @@ var _ = Describe("profilerHandler", func() {
Entry("with a trailing-slash BasePath", "/music/"), 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

@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
p.done = make(chan struct{}) p.done = make(chan struct{})
p.ctx, p.cancel = context.WithCancel(context.Background()) p.ctx, p.cancel = context.WithCancel(context.Background())
go func() { go func() {
err := runNavidrome(p.ctx) runNavidrome(p.ctx)
close(p.done) close(p.done)
// service.Run() only returns when it gets a stop request, so exit here to let the
// service manager see the failure and restart the service
if err != nil {
log.Fatal("Fatal error in Navidrome. Aborting", err)
}
}() }()
return nil return nil
} }
@ -79,7 +74,7 @@ func (p *svcControl) Stop(service.Service) error {
var svcInstance = sync.OnceValue(func() service.Service { var svcInstance = sync.OnceValue(func() service.Service {
options := make(service.KeyValue) options := make(service.KeyValue)
options["Restart"] = "on-failure" options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "SIGKILL" options["SuccessExitStatus"] = "1 2 8 SIGKILL"
options["UserService"] = false options["UserService"] = false
options["LogDirectory"] = conf.Server.DataFolder.String() options["LogDirectory"] = conf.Server.DataFolder.String()
options["SystemdScript"] = systemdScript options["SystemdScript"] = systemdScript

View file

@ -18,16 +18,11 @@ import (
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
) )
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. // requireExistingDB aborts the command when the database file (DbPath minus DSN
func existingDBFile() (string, bool) { // params) does not exist.
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.
func requireExistingDB() { 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) 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) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache() transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore) share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
players := core.NewPlayers(dataStore) players := core.NewPlayers(dataStore)
broker := events.GetBroker() broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
@ -111,6 +110,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore) playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager) lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher) 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) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
return router return router
@ -159,10 +159,9 @@ func CreatePublicRouter() *public.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
transcodingCache := stream.GetTranscodingCache() transcodingCache := stream.GetTranscodingCache()
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
share := core.NewShare(dataStore) share := core.NewShare(dataStore)
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) archiver := core.NewArchiver(mediaStreamer, dataStore, share, artworkArtwork)
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
return router return router
} }

View file

@ -49,7 +49,6 @@ const (
DefaultEncryptionKey = "just for obfuscation" DefaultEncryptionKey = "just for obfuscation"
PasswordsEncryptedKey = "PasswordsEncryptedKey" PasswordsEncryptedKey = "PasswordsEncryptedKey"
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
APIKeyPrefix = "nds_"
DevInitialUserName = "admin" DevInitialUserName = "admin"
DevInitialName = "Dev Admin" DevInitialName = "Dev Admin"
@ -156,6 +155,8 @@ const (
//DefaultAlbumPID = "album_legacy" //DefaultAlbumPID = "album_legacy"
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
PIDAlbumKey = "PIDAlbum"
PIDTrackKey = "PIDTrack"
) )
const ( const (

View file

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

View file

@ -26,7 +26,6 @@ var _ = Describe("Archiver", func() {
var ( var (
arch core.Archiver arch core.Archiver
ms *mockMediaStreamer ms *mockMediaStreamer
dc *fakeDecider
ds *mockDataStore ds *mockDataStore
sh *mockShare sh *mockShare
ca *mockCoverArt ca *mockCoverArt
@ -34,11 +33,10 @@ var _ = Describe("Archiver", func() {
BeforeEach(func() { BeforeEach(func() {
ms = &mockMediaStreamer{} ms = &mockMediaStreamer{}
dc = &fakeDecider{}
sh = &mockShare{} sh = &mockShare{}
ds = &mockDataStore{} ds = &mockDataStore{}
ca = &mockCoverArt{images: map[string][]byte{}} ca = &mockCoverArt{images: map[string][]byte{}}
arch = core.NewArchiver(ms, dc, ds, sh, ca) arch = core.NewArchiver(ms, ds, sh, ca)
}) })
Context("ZipAlbum", func() { 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[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.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() { Context("ZipArtist", func() {
@ -315,30 +296,6 @@ var _ = Describe("Archiver", func() {
}) })
Context("ZipPlaylist", 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() { It("zips a playlist correctly", func() {
tracks := []model.PlaylistTrack{ 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"}}, {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 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 { type mockShare struct {
mock.Mock mock.Mock
core.Share core.Share

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) albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
if tracks == nil { GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
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()"})
if err != nil { if err != nil {
return resolution{}, err return resolution{}, err
} }

View file

@ -707,16 +707,6 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{})) 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,9 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"fmt"
"io" "io"
"math" "math"
"math/rand/v2" "math/rand/v2"
"runtime/debug"
"sync" "sync"
"time" "time"
@ -246,7 +244,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{} trace := &ChainTrace{}
ctx = withTrace(ctx, trace) ctx = withTrace(ctx, trace)
out, got, retryIn := w.safeAcquire(ctx, item) out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue() queue := w.proc.ds.ArtworkQueue()
switch out { switch out {
@ -288,20 +286,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got 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 // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent. // that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {

View file

@ -142,18 +142,6 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id) 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 { func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo() repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) 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") 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() { It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external" conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})

View file

@ -15,7 +15,7 @@ type InspectOutput struct {
MappedTags *model.MediaFile `json:"mappedTags,omitempty"` 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) path, file := filepath.Split(filePath)
s, err := storage.For(path) s, err := storage.For(path)
@ -39,22 +39,12 @@ func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutpu
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
md := metadata.New(scannerPath(lib, filePath), tag) md := metadata.New(path, tag)
result := &InspectOutput{ result := &InspectOutput{
File: filePath, File: filePath,
RawTags: tags[file].Tags, RawTags: tags[file].Tags,
MappedTags: new(md.ToMediaFile(lib, folderId)), MappedTags: new(md.ToMediaFile(libraryId, folderId)),
} }
return result, nil 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 ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -17,7 +15,6 @@ import (
"github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/slice" "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 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...) err = r.LibraryRepository.Put(ctx, lib, cols...)
if err != nil { if err != nil {
return r.mapError(err) return r.mapError(err)
} }
if pathChanged && r.watcher != nil { // Restart watcher and trigger scan if path was updated
if err := r.watcher.Watch(ctx, lib); err != nil { if pathChanged {
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) 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 { if r.scanner != nil {
go r.triggerScan(ctx, lib, "updated") go r.triggerScan(ctx, lib, "updated")
}
} }
// Send library refresh event to all clients // 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 { if len(validationErrors) > 0 {
return &rest.ValidationError{Errors: validationErrors} return &rest.ValidationError{Errors: validationErrors}
} }
@ -343,11 +332,6 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
return nil 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 { func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
// Validate path format // Validate path format
if !filepath.IsAbs(library.Path) { if !filepath.IsAbs(library.Path) {
@ -415,27 +399,11 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil return nil
} }
var scanWaitInterval = time.Second
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { 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) log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
start := time.Now() start := time.Now()
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
if errors.Is(err, model.ErrAlreadyScanning) { if err != nil {
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
} else if err != nil {
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else { } 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)) 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() { Describe("Path Validation", func() {
Context("Create operation", func() { Context("Create operation", func() {
It("fails when path is not absolute", func() { It("fails when path is not absolute", func() {
@ -710,48 +679,6 @@ var _ = Describe("Library Service", func() {
}, "100ms", "10ms").Should(Equal(0)) }, "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() { It("does not trigger scan when library creation fails", func() {
// Try to create library with invalid data (empty name) // Try to create library with invalid data (empty name)
library := &model.Library{Path: tempDir} library := &model.Library{Path: tempDir}

View file

@ -10,7 +10,6 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -270,13 +269,9 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil { if err != nil {
log.Trace(ctx, "Error reading radios count", err) 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 { if err != nil {
log.Trace(ctx, "Error reading libraries", err) log.Trace(ctx, "Error reading libraries count", 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
} }
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},

View file

@ -17,7 +17,6 @@ import (
type Players interface { type Players interface {
Get(ctx context.Context, playerId string) (*model.Player, error) Get(ctx context.Context, playerId string) (*model.Player, error)
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
} }
func NewPlayers(ds model.DataStore) Players { func NewPlayers(ds model.DataStore) Players {
@ -34,6 +33,7 @@ type players struct {
func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
var plr *model.Player var plr *model.Player
var trc *model.Transcoding
var err error var err error
user, _ := request.UserFrom(ctx) user, _ := request.UserFrom(ctx)
if playerID != "" { if playerID != "" {
@ -58,21 +58,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent) log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
} }
} }
if !plr.HasAPIKey { plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
}
return p.refresh(ctx, plr, userAgent, ip)
}
// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it.
func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
if plr.Client == "" {
plr.Client = client
}
return p.refresh(ctx, &plr, userAgent, ip)
}
func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
plr.UserAgent = userAgent plr.UserAgent = userAgent
plr.IP = ip plr.IP = ip
plr.LastSeen = time.Now() plr.LastSeen = time.Now()
@ -80,14 +66,14 @@ func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip
ctx, cancel := context.WithTimeout(ctx, time.Second) ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel() defer cancel()
if err := p.ds.Player().Put(ctx, plr); err != nil { err = p.ds.Player().Put(ctx, plr)
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err) if err != nil {
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err)
} }
}) })
if plr.TranscodingId == "" { if plr.TranscodingId != "" {
return plr, nil, nil trc, err = p.ds.Transcoding().Get(ctx, plr.TranscodingId)
} }
trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId)
return plr, trc, err return plr, trc, err
} }

View file

@ -114,15 +114,6 @@ var _ = Describe("Players", func() {
Expect(trc.ID).To(Equal("1")) Expect(trc.ID).To(Equal("1"))
}) })
It("does not rename a player that has an API key", func() {
plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true}
repo.add(plr)
p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(p.Name).To(Equal("My Phone"))
})
Context("bad username casing", func() { Context("bad username casing", func() {
ctx := log.NewContext(context.TODO()) ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"}) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
@ -139,34 +130,6 @@ var _ = Describe("Players", func() {
}) })
}) })
}) })
Describe("Touch", func() {
It("records usage but keeps the name and client", func() {
plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true}
p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.Name).To(Equal("My Phone"))
Expect(p.Client).To(Equal("Symfonium"))
Expect(p.UserAgent).To(Equal("android"))
Expect(p.IP).To(Equal("1.2.3.4"))
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("fills in the client on first use", func() {
p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.Client).To(Equal("Symfonium"))
})
It("returns the player's transcoding", func() {
p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).To(Equal("123"))
Expect(trc.ID).To(Equal("1"))
})
})
}) })
type mockPlayerRepository struct { type mockPlayerRepository struct {

View file

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

View file

@ -1,11 +1,13 @@
package playlists package playlists
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"time" "time"
@ -154,9 +156,61 @@ func (r pathResolution) ToQualifiedString() (string, error) {
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil 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. // pathResolver handles path resolution logic for playlist imports.
type pathResolver struct { type pathResolver struct {
matcher *model.LibraryMatcher matcher *libraryMatcher
} }
// newPathResolver creates a pathResolver with libraries loaded from the datastore. // newPathResolver creates a pathResolver with libraries loaded from the datastore.
@ -165,7 +219,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
matcher := model.NewLibraryMatcher(libs) matcher := newLibraryMatcher(libs)
return &pathResolver{matcher: matcher}, nil return &pathResolver{matcher: matcher}, nil
} }
@ -192,14 +246,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu
// a pathResolution with the library information. Returns an invalid resolution if // a pathResolution with the library information. Returns an invalid resolution if
// the path is not found in any library. // the path is not found in any library.
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
lib, ok := r.matcher.FindLibrary(absolutePath) libID, libPath := r.matcher.findLibraryForPath(absolutePath)
if !ok { if libID == 0 {
return pathResolution{valid: false} return pathResolution{valid: false}
} }
return pathResolution{ return pathResolution{
absolutePath: absolutePath, absolutePath: absolutePath,
libraryPath: filepath.Clean(lib.Path), libraryPath: libPath,
libraryID: lib.ID, libraryID: libID,
valid: true, valid: true,
} }
} }
@ -234,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). // HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path // Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher. // 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) value = strings.TrimSpace(value)
if value == "" { if value == "" {
return "" return ""
@ -254,7 +308,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryM
return "" 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. // 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)) { if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
return "" return ""

View file

@ -9,6 +9,187 @@ import (
. "github.com/onsi/gomega" . "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 _ = Describe("pathResolver", func() {
var ds *tests.MockDataStore var ds *tests.MockDataStore
var mockLibRepo *tests.MockLibraryRepo var mockLibRepo *tests.MockLibraryRepo

View file

@ -76,17 +76,6 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) 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) { func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
res, err := lfs.extractor.Parse(path...) res, err := lfs.extractor.Parse(path...)
if err != nil { if err != nil {

View file

@ -33,41 +33,59 @@ func backupPath(t time.Time) string {
) )
} }
func openBackupFile(path string) (*sql.DB, error) { func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
Db() // Registers Driver, which the CLI commands haven't done at this point. // heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
existingConn, err := Db().Conn(ctx)
if err != nil {
return fmt.Errorf("getting existing connection: %w", err)
}
defer existingConn.Close()
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
// path would create an empty database and "restore" it over the live one.
if !isBackup {
// The driver splits the DSN at '?', so such a path would open a different file.
if strings.ContainsRune(path, '?') {
return fmt.Errorf("backup path cannot contain '?': %s", path)
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup file not available: %w", err)
}
}
backupDb, err := sql.Open(Driver, path) backupDb, err := sql.Open(Driver, path)
if err != nil { if err != nil {
return nil, fmt.Errorf("opening backup database in '%s': %w", path, err) return fmt.Errorf("opening backup database in '%s': %w", path, err)
} }
return backupDb, nil defer backupDb.Close()
}
func copyDatabase(ctx context.Context, dest, source *sql.DB) error { backupConn, err := backupDb.Conn(ctx)
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
destConn, err := dest.Conn(ctx)
if err != nil { if err != nil {
return fmt.Errorf("getting destination connection: %w", err) return fmt.Errorf("getting backup connection: %w", err)
} }
defer destConn.Close() defer backupConn.Close()
sourceConn, err := source.Conn(ctx) err = existingConn.Raw(func(existing any) error {
if err != nil { return backupConn.Raw(func(backup any) error {
return fmt.Errorf("getting source connection: %w", err) var sourceOk, destOk bool
} var sourceConn, destConn *sqlite3.SQLiteConn
defer sourceConn.Close()
return destConn.Raw(func(rawDest any) error { if isBackup {
return sourceConn.Raw(func(rawSource any) error { sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn) destConn, destOk = backup.(*sqlite3.SQLiteConn)
if !destOk { } else {
return fmt.Errorf("error trying to convert destination to sqlite connection") sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
destConn, destOk = existing.(*sqlite3.SQLiteConn)
} }
sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
if !sourceOk { if !sourceOk {
return fmt.Errorf("error trying to convert source to sqlite connection") return fmt.Errorf("error trying to convert source to sqlite connection")
} }
if !destOk {
return fmt.Errorf("error trying to convert destination to sqlite connection")
}
backupOp, err := sqliteDest.Backup("main", sqliteSource, "main") backupOp, err := destConn.Backup("main", sourceConn, "main")
if err != nil { if err != nil {
return fmt.Errorf("error starting sqlite backup: %w", err) return fmt.Errorf("error starting sqlite backup: %w", err)
} }
@ -91,89 +109,24 @@ func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
return nil return nil
}) })
}) })
}
// checkRestoreSource runs sanity checks on a file about to be restored over the live return err
// database. They catch the common ways a backup goes bad, not every kind of damage.
func checkRestoreSource(ctx context.Context, backupDb *sql.DB, path string, integrityCheck bool) error {
// SQLite opens an empty file as an empty database, and restoring it would wipe the live one.
// user and media_file exist since the first migration, so backups of any age have them.
var tables int
err := backupDb.QueryRowContext(ctx,
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('goose_db_version', 'user', 'media_file')").Scan(&tables)
if err != nil {
return fmt.Errorf("reading backup file '%s': %w", path, err)
}
if tables != 3 {
return fmt.Errorf("backup file '%s' is not a Navidrome database", path)
}
// The table can be there with nothing in it, or with only the row goose adds when it creates it.
var applied int
err = backupDb.QueryRowContext(ctx,
"SELECT count(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0").Scan(&applied)
if err != nil {
return fmt.Errorf("reading backup file '%s': %w", path, err)
}
if applied == 0 {
return fmt.Errorf("backup file '%s' has no applied migrations", path)
}
// The queries above only read the first pages. Both checks walk the whole file, and
// integrity_check also compares indexes to their tables, which is much slower.
check := "PRAGMA quick_check(1)"
if integrityCheck {
check = "PRAGMA integrity_check(1)"
}
var result string
if err := backupDb.QueryRowContext(ctx, check).Scan(&result); err != nil {
return fmt.Errorf("checking backup file '%s': %w", path, err)
}
if result != "ok" {
return fmt.Errorf("backup file '%s' is damaged: %s", path, result)
}
return nil
} }
func Backup(ctx context.Context) (string, error) { func Backup(ctx context.Context) (string, error) {
destPath := backupPath(time.Now()) destPath := backupPath(time.Now())
log.Debug(ctx, "Creating backup", "path", destPath) log.Debug(ctx, "Creating backup", "path", destPath)
backupDb, err := openBackupFile(destPath) err := backupOrRestore(ctx, true, destPath)
if err != nil { if err != nil {
return "", err return "", err
} }
defer backupDb.Close()
if err := copyDatabase(ctx, backupDb, Db()); err != nil {
return "", err
}
return destPath, nil return destPath, nil
} }
func Restore(ctx context.Context, path string, integrityCheck bool) error { func Restore(ctx context.Context, path string) error {
log.Debug(ctx, "Restoring backup", "path", path) log.Debug(ctx, "Restoring backup", "path", path)
return backupOrRestore(ctx, false, path)
// The driver splits the DSN at '?', so such a path would open a different file.
if strings.ContainsRune(path, '?') {
return fmt.Errorf("backup path cannot contain '?': %s", path)
}
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
// path would create an empty database and "restore" it over the live one.
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup file not available: %w", err)
}
backupDb, err := openBackupFile(path)
if err != nil {
return err
}
defer backupDb.Close()
if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
return err
}
return copyDatabase(ctx, Db(), backupDb)
} }
func Prune(ctx context.Context) (int, error) { func Prune(ctx context.Context) (int, error) {

View file

@ -21,22 +21,6 @@ func shortTime(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC) return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
} }
const (
gooseTableDDL = "CREATE TABLE goose_db_version(version_id, is_applied)"
navidromeTablesDDL = "CREATE TABLE user(id); CREATE TABLE media_file(id)"
)
func sqliteFile(statements string) func(path string) {
return func(path string) {
GinkgoHelper()
other, err := sql.Open(Driver, path)
Expect(err).ToNot(HaveOccurred())
defer other.Close()
_, err = other.Exec(statements)
Expect(err).ToNot(HaveOccurred())
}
}
var _ = Describe("database backups", func() { var _ = Describe("database backups", func() {
When("there are a few backup files", func() { When("there are a few backup files", func() {
var ctx context.Context var ctx context.Context
@ -174,7 +158,7 @@ var _ = Describe("database backups", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue()) Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue())
err = Restore(ctx, path, false) err = Restore(ctx, path)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse()) Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
}) })
@ -192,7 +176,6 @@ var _ = Describe("database backups", func() {
var err error var err error
tempFolder, err = os.MkdirTemp("", "navidrome_restore") tempFolder, err = os.MkdirTemp("", "navidrome_restore")
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
conf.Server.Backup.Path = conf.NewDir(tempFolder)
DeferCleanup(func() { DeferCleanup(func() {
Close(ctx) Close(ctx)
_ = os.RemoveAll(tempFolder) _ = os.RemoveAll(tempFolder)
@ -226,7 +209,7 @@ var _ = Describe("database backups", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
By("restoring the backup") By("restoring the backup")
Expect(Restore(ctx, path, false)).To(Succeed()) Expect(Restore(ctx, path)).To(Succeed())
By("verifying the restored data is readable through a fresh connection") By("verifying the restored data is readable through a fresh connection")
singleton.DeleteInstance[*sql.DB]() singleton.DeleteInstance[*sql.DB]()
@ -235,91 +218,23 @@ var _ = Describe("database backups", func() {
Expect(userName).To(Equal("drilladmin")) Expect(userName).To(Equal("drilladmin"))
}) })
expectRestoreRefused := func(path, expectedErr string) { It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
GinkgoHelper()
By("seeding user data in the current database") By("seeding user data in the current database")
_, err := Db().ExecContext(ctx, `INSERT OR REPLACE INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`) VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
By("attempting the restore") By("attempting a restore from a nonexistent file")
Expect(Restore(ctx, path, false)).To(MatchError(ContainSubstring(expectedErr))) missingPath := filepath.Join(tempFolder, "does_not_exist.db")
err = Restore(ctx, missingPath)
Expect(err).To(HaveOccurred())
By("verifying the database was not wiped") By("verifying the database was not wiped")
var userName string var userName string
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed()) Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
Expect(userName).To(Equal("keepme")) Expect(userName).To(Equal("keepme"))
}
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
expectRestoreRefused(missingPath, "backup file not available")
_, statErr := os.Stat(missingPath) _, statErr := os.Stat(missingPath)
Expect(statErr).To(MatchError(os.ErrNotExist)) Expect(statErr).To(MatchError(os.ErrNotExist))
}) })
DescribeTable("fails to restore from a file that is not a Navidrome database, leaving the database intact",
func(createFile func(path string), expectedErr string) {
invalidPath := filepath.Join(GinkgoT().TempDir(), "invalid_backup.db")
createFile(invalidPath)
expectRestoreRefused(invalidPath, expectedErr)
},
Entry("empty file", func(path string) {
Expect(os.WriteFile(path, nil, 0600)).To(Succeed())
}, "is not a Navidrome database"),
Entry("file that is not a SQLite database", func(path string) {
Expect(os.WriteFile(path, []byte("this is not a database, just some text"), 0600)).To(Succeed())
}, "reading backup file"),
Entry("SQLite database without the Navidrome schema",
sqliteFile("CREATE TABLE something_else(x)"), "is not a Navidrome database"),
Entry("another project's goose database",
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1)"), "is not a Navidrome database"),
Entry("goose database with only one of the Navidrome tables",
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1); CREATE TABLE media_file(id)"), "is not a Navidrome database"),
Entry("SQLite database with an empty goose_db_version table",
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL), "has no applied migrations"),
Entry("SQLite database with only goose's initial version row",
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (0, 1)"), "has no applied migrations"),
Entry("SQLite database whose migrations were all rolled back",
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (1, 0)"), "has no applied migrations"),
Entry("backup with garbage over its last pages", func(path string) {
// Filler rows go to the end of the file, so the damage below stays clear of
// the schema and of goose_db_version, which still read fine.
_, err := Db().ExecContext(ctx, "CREATE TABLE _restore_filler AS SELECT zeroblob(1000000) AS x")
Expect(err).ToNot(HaveOccurred())
backupFile, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(backupFile)
Expect(err).ToNot(HaveOccurred())
for i := len(data) * 4 / 5; i < len(data); i++ {
data[i] = 0xFF
}
Expect(os.WriteFile(path, data, 0600)).To(Succeed())
}, "is damaged"),
)
It("restores a good backup with the integrity check", func() {
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(Restore(ctx, path, true)).To(Succeed())
})
It("refuses an index that doesn't match its table only with the integrity check", func() {
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
writeMismatchedIndex(ctx, path, 1)
restored := func() int {
var count int
Expect(Db().QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE name = 't'").Scan(&count)).To(Succeed())
return count
}
Expect(Restore(ctx, path, true)).To(MatchError(ContainSubstring("is damaged")))
Expect(restored()).To(BeZero())
Expect(Restore(ctx, path, false)).To(Succeed())
Expect(restored()).To(Equal(1))
})
}) })
}) })

View file

@ -1,8 +0,0 @@
-- +goose Up
-- +goose StatementBegin
alter table player add column api_key_hash varchar default null;
create unique index if not exists player_api_key_hash on player(api_key_hash);
-- +goose StatementEnd
-- +goose Down
SELECT 1;

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

@ -38,13 +38,18 @@ func newDB(ctx context.Context, upTo int64) *sql.DB {
return d return d
} }
// writeMismatchedIndex adds an index declared over a different column than the one it // openMismatchedIndexDB builds a database whose index is declared over a different
// was populated from, so integrity_check reports one issue per row and quick_check none. // column than the one it was populated from, so integrity_check reports one issue per row.
func writeMismatchedIndex(ctx context.Context, path string, rows int) { func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
GinkgoHelper() GinkgoHelper()
d, err := sql.Open(db.Dialect, path) path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
Expect(err).ToNot(HaveOccurred()) open := func() *sql.DB {
d.SetMaxOpenConns(1) d, err := sql.Open(db.Dialect, path)
Expect(err).ToNot(HaveOccurred())
d.SetMaxOpenConns(1)
return d
}
d := open()
for _, stmt := range []string{ for _, stmt := range []string{
`create table t(a, b)`, `create table t(a, b)`,
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d) fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
@ -57,16 +62,8 @@ func writeMismatchedIndex(ctx context.Context, path string, rows int) {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
} }
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
}
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB { d = open()
GinkgoHelper()
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
writeMismatchedIndex(ctx, path, rows)
d, err := sql.Open(db.Dialect, path)
Expect(err).ToNot(HaveOccurred())
d.SetMaxOpenConns(1)
DeferCleanup(func() { _ = d.Close() }) DeferCleanup(func() { _ = d.Close() })
return d return d
} }

View file

@ -27,16 +27,14 @@ var redacted = &Hook{
AcceptedLevels: logrus.AllLevels, AcceptedLevels: logrus.AllLevels,
RedactionList: []string{ RedactionList: []string{
// Keys from the config // Keys from the config
"(ApiKey:[\\s]*\")[\\w]*", "(ApiKey:\")[\\w]*",
"(Secret:[\\s]*\")[\\w]*", "(Secret:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*",
"(UserHeader:[\\s]*\")[^\"]*", "(UserHeader:[\\s]*\")[^\"]*",
"(TrustedSources:[\\s]*\")[^\"]*", "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*",
// Prometheus.Password. Any character is allowed, so skip escaped quotes in the value
`(Password:[\s]*")(?:[^"\\]|\\.)*`,
// UI appConfig // UI appConfig
"(subsonicToken:)[\\w]+(\\s)", "(subsonicToken:)[\\w]+(\\s)",

View file

@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/kr/pretty"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -95,7 +94,7 @@ var _ = Describe("Logger", func() {
SetLogSourceLine(true) SetLogSourceLine(true)
Error("A crash happened") Error("A crash happened")
// NOTE: This assertion breaks if the line number above changes // NOTE: This assertion breaks if the line number above changes
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96")) Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95"))
Expect(hook.LastEntry().Message).To(Equal("A crash happened")) Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
}) })
@ -292,69 +291,5 @@ var _ = Describe("Logger", func() {
Expect(got).ToNot(ContainSubstring("secret")) Expect(got).ToNot(ContainSubstring("secret"))
Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`))
}) })
// https://github.com/navidrome/navidrome/discussions/6232
DescribeTable("redacts config keys in the startup Configuration dump",
func(line, expected string) {
Expect(Redact(line)).To(Equal(expected))
},
Entry("unpadded ApiKey", `ApiKey:"0123456789abcdef0123456789abcdef"`, `ApiKey:"[REDACTED]"`),
Entry("unpadded Secret", `Secret:"fedcba9876543210fedcba9876543210"`, `Secret:"[REDACTED]"`),
Entry("padded ApiKey", ` ApiKey: "0123456789abcdef0123456789abcdef",`,
` ApiKey: "[REDACTED]",`),
Entry("padded Secret", ` Secret: "fedcba9876543210fedcba9876543210",`,
` Secret: "[REDACTED]",`),
Entry("unpadded Prometheus Password", `Password:"p@ss w0rd!"`, `Password:"[REDACTED]"`),
Entry("padded Prometheus Password", ` Password: "p@ss w0rd!",`, ` Password: "[REDACTED]",`),
Entry("Prometheus Password with escaped quotes", ` Password: "a\"b\\\"c",`,
` Password: "[REDACTED]",`),
)
It("redacts secrets in a pretty-printed config struct", func() {
// Mirrors conf.lastfmOptions and conf.prometheusOptions (conf imports log, so it can't be
// used here). pretty only breaks a struct into padded lines when it is long enough, so
// keep all the fields.
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
Language string
ScrobbleFirstArtistOnly bool
Languages []string
}
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
}
type configOptions struct {
Address string
LastFM lastfmOptions
Prometheus prometheusOptions
}
cfg := configOptions{
Address: "0.0.0.0",
LastFM: lastfmOptions{ //nolint:gosec
Enabled: true,
ApiKey: "0123456789abcdef0123456789abcdef",
Secret: "fedcba9876543210fedcba9876543210",
Language: "en",
Languages: []string{"en"},
},
Prometheus: prometheusOptions{ //nolint:gosec
Enabled: true,
MetricsPath: "/metrics",
Password: `prom"pass-tail`,
},
}
dump := pretty.Sprintf("Configuration: %# v", cfg)
Expect(dump).To(MatchRegexp(`ApiKey:\s{2,}"`), "the dump must use the padded layout")
got := Redact(dump)
Expect(got).ToNot(ContainSubstring(cfg.LastFM.ApiKey))
Expect(got).ToNot(ContainSubstring(cfg.LastFM.Secret))
Expect(got).ToNot(ContainSubstring("pass-tail"))
Expect(got).To(ContainSubstring(`"en"`))
})
}) })
}) })

View file

@ -1,13 +1,10 @@
package model package model
import ( import (
"cmp"
"context" "context"
"strings"
"time" "time"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
) )
@ -30,38 +27,6 @@ type Library struct {
TotalSize int64 `json:"totalSize" db:"total_size"` TotalSize int64 `json:"totalSize" db:"total_size"`
TotalDuration float64 `json:"totalDuration" db:"total_duration"` TotalDuration float64 `json:"totalDuration" db:"total_duration"`
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` 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 ( const (
@ -94,8 +59,6 @@ type LibraryRepository interface {
// TODO These methods should be moved to a core service // TODO These methods should be moved to a core service
ScanBegin(ctx context.Context, id int, fullScan bool) error ScanBegin(ctx context.Context, id int, fullScan bool) error
ScanEnd(ctx context.Context, id int) 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) ScanInProgress(ctx context.Context) (bool, error)
RefreshStats(ctx context.Context, id int) 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

@ -6,18 +6,17 @@ import (
"encoding/json" "encoding/json"
"maps" "maps"
"math" "math"
"slices"
"strconv" "strconv"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str" "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{ mf := model.MediaFile{
LibraryID: lib.ID, LibraryID: libID,
FolderID: folderID, FolderID: folderID,
Tags: maps.Clone(md.tags), Tags: maps.Clone(md.tags),
} }
@ -44,7 +43,6 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
} }
mf.Lyrics = md.mapLyrics() mf.Lyrics = md.mapLyrics()
mf.ExplicitStatus = md.mapExplicitStatusTag() mf.ExplicitStatus = md.mapExplicitStatusTag()
md.mapMovementNumbers(mf.Tags)
// Dates // Dates
date, origDate, relDate := md.mapDates() date, origDate, relDate := md.mapDates()
@ -86,9 +84,8 @@ func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFi
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
// Persistent IDs // Persistent IDs
pid := lib.EffectivePID() mf.PID = md.trackPID(mf)
mf.PID = md.trackPID(mf, pid) mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
mf.AlbumID = md.albumID(mf, pid.Album)
// BFR These IDs will go away once the UI handle multiple participants. // 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 // BFR For Legacy Subsonic compatibility, we will set them in the API handlers
@ -138,27 +135,6 @@ func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
return nil return nil
} }
// mapMovementNumbers splits ID3 MVIN "n/N" values, like tuple() does for track and disc numbers.
// Totals are only filled when every value has one, so the parallel movement tags stay aligned.
func (md Metadata) mapMovementNumbers(tags model.Tags) {
values := md.Strings(model.TagMovementNumber)
if !slices.ContainsFunc(values, func(v string) bool { return strings.Contains(v, "/") }) {
return
}
numbers := make([]string, len(values))
totals := make([]string, len(values))
allHaveTotals := true
for i, v := range values {
var found bool
numbers[i], totals[i], found = strings.Cut(v, "/")
allHaveTotals = allHaveTotals && found
}
tags[model.TagMovementNumber] = numbers
if allHaveTotals && len(md.Strings(model.TagMovementTotal)) == 0 {
tags[model.TagMovementTotal] = totals
}
}
func (md Metadata) mapLyrics() string { func (md Metadata) mapLyrics() string {
rawLyrics := md.Pairs(model.TagLyrics) rawLyrics := md.Pairs(model.TagLyrics)

View file

@ -30,23 +30,9 @@ var _ = Describe("ToMediaFile", func() {
var toMediaFile = func(tags model.RawTags) model.MediaFile { var toMediaFile = func(tags model.RawTags) model.MediaFile {
props.Tags = tags props.Tags = tags
md = metadata.New("filepath", props) 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() { Describe("Dates", func() {
It("should parse properly tagged dates ", func() { It("should parse properly tagged dates ", func() {
mf = toMediaFile(model.RawTags{ mf = toMediaFile(model.RawTags{
@ -156,27 +142,6 @@ var _ = Describe("ToMediaFile", func() {
}) })
}) })
Describe("Movements", func() {
It("splits an ID3 MVIN n/N value into movement number and total", func() {
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movementnumber": {"1/4"}})
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementNumber, []string{"1"}))
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementTotal, []string{"4"}))
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 1, Count: 4}}))
})
It("maps MP4 movement number and count", func() {
mf = toMediaFile(model.RawTags{"movementname": {"Andante"}, "movementnumber": {"2"}, "movementcount": {"4"}})
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Andante", Number: 2, Count: 4}}))
})
It("prefers an explicit movement total over the n/N total", func() {
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movement": {"2/4"}, "movementtotal": {"5"}})
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 2, Count: 5}}))
})
It("does not add a total when only some values have one", func() {
mf = toMediaFile(model.RawTags{"movementname": {"I", "II"}, "movement": {"1/4", "2"}})
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "I", Number: 1}, {Name: "II", Number: 2}}))
})
})
Describe("BitDepth", func() { Describe("BitDepth", func() {
It("maps the bit depth when present", func() { It("maps the bit depth when present", func() {
props.AudioProperties = metadata.AudioProperties{BitDepth: 24} props.AudioProperties = metadata.AudioProperties{BitDepth: 24}

View file

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

View file

@ -75,13 +75,6 @@ var _ = Describe("Metadata", func() {
}) })
It("should map the TagLib-normalized initialkey tag to key", func() {
props.Tags = model.RawTags{"initialkey": {"Ebm"}}
md = metadata.New(filePath, props)
Expect(md.String(model.TagName("key"))).To(Equal("Ebm"))
})
It("should clean the tags map correctly", func() { It("should clean the tags map correctly", func() {
const unknownTag = "UNKNOWN_TAG" const unknownTag = "UNKNOWN_TAG"
props.Tags = model.RawTags{ props.Tags = model.RawTags{
@ -330,7 +323,7 @@ var _ = Describe("Metadata", func() {
tag: {tagValue}, tag: {tagValue},
} }
md = metadata.New(filePath, props) md = metadata.New(filePath, props)
return md.ToMediaFile(model.Library{}, "0") return md.ToMediaFile(0, "0")
} }
DescribeTable("Gain", DescribeTable("Gain",

View file

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

View file

@ -3,7 +3,8 @@ package metadata
import ( import (
"strings" "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/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -12,18 +13,16 @@ import (
var _ = Describe("getPID", func() { var _ = Describe("getPID", func() {
var ( var (
md Metadata md Metadata
mf model.MediaFile mf model.MediaFile
sum hashFunc sum hashFunc
albumSpec string
) )
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { 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() { BeforeEach(func() {
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
albumSpec = consts.DefaultAlbumPID
}) })
Context("attributes are tags", func() { Context("attributes are tags", func() {
@ -67,7 +66,8 @@ var _ = Describe("getPID", func() {
Context("calculated attributes", func() { Context("calculated attributes", func() {
BeforeEach(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() { When("field is title", func() {
It("should return the pid", func() { It("should return the pid", func() {
@ -121,8 +121,8 @@ var _ = Describe("getPID", func() {
When("albumid configuration refers to albumid recursively", func() { When("albumid configuration refers to albumid recursively", func() {
It("should avoid infinite recursion", func() { It("should avoid infinite recursion", func() {
// Reproduce the issue from #4920 // Reproduce the issue from #4920
albumSpec = "albumid,album,albumversion,releasedate" conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
spec := albumSpec spec := conf.Server.PID.Album
md.tags = map[model.TagName][]string{ md.tags = map[model.TagName][]string{
"album": {"Album Name"}, "album": {"Album Name"},
"albumversion": {"Version"}, "albumversion": {"Version"},
@ -205,7 +205,8 @@ var _ = Describe("getPID", func() {
}) })
When("prependLibId is true with nested albumid", func() { When("prependLibId is true with nested albumid", func() {
It("should handle nested albumid calls correctly", func() { It("should handle nested albumid calls correctly", func() {
albumSpec = "album" DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album"
spec := "albumid" spec := "albumid"
md.tags = map[model.TagName][]string{"album": {"Test Album"}} md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.AlbumArtist = "Test Artist" 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

@ -21,8 +21,6 @@ type Player struct {
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"` MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"` ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"` ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
HasAPIKey bool `structs:"-" db:"has_api_key" json:"hasApiKey"`
APIKey *string `structs:"-" json:"apiKey,omitempty"`
} }
type Players []Player type Players []Player
@ -35,6 +33,4 @@ type PlayerRepository interface {
Put(ctx context.Context, p *Player) error Put(ctx context.Context, p *Player) error
CountAll(ctx context.Context, options ...QueryOptions) (int64, error) CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error) CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error)
FindByAPIKey(ctx context.Context, key string) (*Player, error)
SetAPIKey(ctx context.Context, playerID, key string) error
} }

View file

@ -221,6 +221,8 @@ type PlaylistRepository interface {
FindByPath(ctx context.Context, path string) (*Playlist, error) FindByPath(ctx context.Context, path string) (*Playlist, error)
Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(ctx context.Context, mediaFileId string) (Playlists, error) GetPlaylists(ctx context.Context, mediaFileId string) (Playlists, error)
// Evaluate refreshes a smart playlist's tracks as its owner, ignoring visibility and the refresh delay.
Evaluate(ctx context.Context, id string) error
} }
type PlaylistTrack struct { type PlaylistTrack struct {

View file

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

View file

@ -195,28 +195,6 @@ func TagMappings() map[TagName]TagConf {
return mappings 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 { func TagRolesConf() TagConf {
_, cfg := parseMappings() _, cfg := parseMappings()
return cfg.Roles 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, "recently_played": recentlyPlayedFilter,
"starred": annotationBoolFilter("starred"), "starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"), "has_rating": annotationBoolFilter("rating"),
"played": annotationBoolFilter("play_count"),
"missing": booleanFilter, "missing": booleanFilter,
"genre_id": genreFilter(AlbumGenres), "genre_id": genreFilter(AlbumGenres),
"role_total_id": allRolesFilter, "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() { Describe("Album.PlayCount", func() {

View file

@ -93,8 +93,6 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd
"path": l.Path, "path": l.Path,
"remote_path": l.RemotePath, "remote_path": l.RemotePath,
"default_new_users": l.DefaultNewUsers, "default_new_users": l.DefaultNewUsers,
"pid_album": l.PIDAlbum,
"pid_track": l.PIDTrack,
}, colsToUpdate...) }, colsToUpdate...)
cols["updated_at"] = l.UpdatedAt cols["updated_at"] = l.UpdatedAt
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) 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 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) { func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) {
query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}})
count, err := r.count(ctx, query) 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() { Describe("Delete", func() {
var adminRepo model.LibraryRepository var adminRepo model.LibraryRepository
var artistRepo model.ArtistRepository var artistRepo model.ArtistRepository

View file

@ -352,15 +352,6 @@ func GetDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Dialect) 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 // collectCursor takes the cursor's underlying func type so the named cursor types
// (model.AlbumCursor, ...) infer T. // (model.AlbumCursor, ...) infer T.
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {

View file

@ -2,16 +2,10 @@ package persistence
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
) )
@ -23,8 +17,7 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
r := &playerRepository{} r := &playerRepository{}
r.db = db r.db = db
r.registerModel(&model.Player{}, map[string]filterFunc{ r.registerModel(&model.Player{}, map[string]filterFunc{
"name": containsFilter("player.name"), "name": containsFilter("player.name"),
"hasapikey": hasAPIKeyFilter,
}) })
r.setSortMappings(map[string]string{ r.setSortMappings(map[string]string{
"user_name": "username", //TODO rename all user_name and userName to username "user_name": "username", //TODO rename all user_name and userName to username
@ -32,13 +25,6 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
return r return r
} }
func hasAPIKeyFilter(_ string, value any) Sqlizer {
if v, _ := value.(string); strings.EqualFold(v, "true") {
return NotEq{"player.api_key_hash": nil}
}
return Eq{"player.api_key_hash": nil}
}
func (r *playerRepository) Put(ctx context.Context, p *model.Player) error { func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
_, err := r.put(ctx, p.ID, p) _, err := r.put(ctx, p.ID, p)
return err return err
@ -46,7 +32,7 @@ func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder { func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
return r.newSelect(ctx, options...). return r.newSelect(ctx, options...).
Columns("player.*", "player.api_key_hash is not null as has_api_key"). Columns("player.*").
Join("user ON player.user_id = user.id"). Join("user ON player.user_id = user.id").
Columns("user.user_name username") Columns("user.user_name username")
} }
@ -117,115 +103,32 @@ func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOpt
return res, err return res, err
} }
var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`) // isPermitted authorizes creating a new record, based on the owner declared in the request body.
// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
func apiKeyValidationError(msg string) error { // player they own. Updates must not use this (the body owner is attacker-controlled); they go
return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}} // through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
} func (r *playerRepository) isPermitted(ctx context.Context, p *model.Player) bool {
u := loggedUser(ctx)
func validateAPIKey(key string) error { return u.IsAdmin || p.UserId == u.ID
if !apiKeyFormat.MatchString(key) {
return apiKeyValidationError("resources.player.validation.apiKeyFormat")
}
return nil
} }
func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) { func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) {
u := loggedUser(ctx) if !r.isPermitted(ctx, t) {
if t.UserId == "" && u.ID != invalidUserId {
t.UserId = u.ID
}
if t.UserId != u.ID {
return "", rest.ErrPermissionDenied return "", rest.ErrPermissionDenied
} }
// Hand-made players are only reachable through a key, so one is required return r.put(ctx, "", t) // Save only creates; edits go through the owner-scoped Update
if t.APIKey == nil || *t.APIKey == "" {
return "", apiKeyValidationError("ra.validation.required")
}
if err := validateAPIKey(*t.APIKey); err != nil {
return "", err
}
values, err := toSQLArgs(t)
if err != nil {
return "", err
}
// Save only creates, so the key hash goes in the same INSERT and the unique index settles races
values["id"] = id.NewRandom()
values["api_key_hash"] = hashAPIKey(*t.APIKey)
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
if isUniqueViolation(err) {
return "", apiKeyValidationError("ra.validation.unique")
}
if err != nil {
return "", err
}
return values["id"].(string), nil
} }
func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error { func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error {
t := &entity t := &entity
t.ID = id t.ID = id
if t.APIKey == nil { return r.updateOwned(ctx, id, t, cols...)
return r.updateOwned(ctx, id, t, cols...)
}
// The key and the other columns are two writes; commit both or neither
return r.inTx(func(tx *playerRepository) error {
if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil {
return err
}
return tx.updateOwned(ctx, id, t, cols...)
})
}
func (r *playerRepository) inTx(block func(tx *playerRepository) error) error {
conn, ok := r.db.(*dbx.DB)
if !ok {
return block(r) // already inside a transaction
}
return conn.Transactional(func(tx *dbx.Tx) error {
return block(NewPlayerRepository(tx).(*playerRepository))
})
} }
func (r *playerRepository) Delete(ctx context.Context, ids ...string) error { func (r *playerRepository) Delete(ctx context.Context, ids ...string) error {
return r.deleteOwnedAll(ctx, ids...) return r.deleteOwnedAll(ctx, ids...)
} }
// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed.
func hashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) {
sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)})
var res model.Player
if err := r.queryOne(ctx, sel, &res); err != nil {
return nil, err
}
return &res, nil
}
// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for
// admins, so nobody can mint a login for someone else.
func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error {
if key == "" {
return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil})
}
if err := validateAPIKey(key); err != nil {
return err
}
err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)})
if isUniqueViolation(err) {
return apiKeyValidationError("ra.validation.unique")
}
return err
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
var _ model.PlayerRepository = (*playerRepository)(nil) var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository[model.Player] = (*playerRepository)(nil) var _ rest.Repository[model.Player] = (*playerRepository)(nil)
var _ rest.Persistable[model.Player] = (*playerRepository)(nil) var _ rest.Persistable[model.Player] = (*playerRepository)(nil)

View file

@ -2,7 +2,6 @@ package persistence
import ( import (
"context" "context"
"errors"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@ -13,14 +12,6 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
) )
const testAPIKey = "nds_0123456789abcdefghijkl"
func expectAPIKeyError(err error, msg string) {
var verr *rest.ValidationError
ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue())
ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg))
}
var _ = Describe("PlayerRepository", func() { var _ = Describe("PlayerRepository", func() {
var adminRepo *playerRepository var adminRepo *playerRepository
var database *dbx.DB var database *dbx.DB
@ -187,12 +178,11 @@ var _ = Describe("PlayerRepository", func() {
clone := player clone := player
clone.ID = "" clone.ID = ""
clone.IP = "192.168.1.1" clone.IP = "192.168.1.1"
clone.APIKey = new(testAPIKey)
id, err := repo.Save(repoCtx, &clone) id, err := repo.Save(repoCtx, &clone)
if clone.UserId == "" { if clone.UserId == "" {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
} else if player.UserId != userPlayer.UserId { } else if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied)) Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.UserId = "" clone.UserId = ""
} else { } else {
@ -212,13 +202,12 @@ var _ = Describe("PlayerRepository", func() {
} else { } else {
Expect(count).To(Equal(baseCount + 1)) Expect(count).To(Equal(baseCount + 1))
Expect(err).To(BeNil()) Expect(err).To(BeNil())
clone.APIKey = nil
clone.HasAPIKey = true
Expect(*newItem).To(Equal(clone)) Expect(*newItem).To(Equal(clone))
} }
}, },
Entry("same user", userPlayer), Entry("same user", userPlayer),
Entry("other item", otherPlayer), Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
) )
}) })
@ -262,259 +251,6 @@ var _ = Describe("PlayerRepository", func() {
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
) )
Describe("API keys", func() {
const key = testAPIKey
const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV"
var ownerCtx, otherCtx context.Context
BeforeEach(func() {
ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser)
otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser)
})
storedHash := func(id string) string {
var row struct {
Hash string `db:"api_key_hash"`
}
Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}").
Bind(dbx.Params{"id": id}).One(&row)).To(Succeed())
return row.Hash
}
Describe("SetAPIKey", func() {
It("stores only the hash and finds the player by the key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
plr, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).ToNot(HaveOccurred())
Expect(plr.ID).To(Equal(regularPlayer.ID))
Expect(plr.HasAPIKey).To(BeTrue())
Expect(plr.APIKey).To(BeNil())
})
It("replaces the previous key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed())
_, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = adminRepo.FindByAPIKey(ctx, otherKey)
Expect(err).ToNot(HaveOccurred())
})
DescribeTable("rejects malformed keys",
func(bad string) {
err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad)
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
},
Entry("no prefix", "0123456789abcdefghijklmn"),
Entry("too short", "nds_short"),
Entry("too long", key+"x"),
Entry("bad chars", "nds_0123456789abcdefghij-!"),
)
It("revokes with an empty key, by the owner or an admin", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("accepts revoking a player that has no key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
})
It("does not let an admin set a key on another user's player", func() {
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied))
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("does not let another user set or revoke", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied))
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied))
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
})
It("returns not found for a missing player", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound))
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound))
})
It("does not find unknown or empty keys", func() {
_, err := adminRepo.FindByAPIKey(ctx, otherKey)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = adminRepo.FindByAPIKey(ctx, "")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("drops the key with the player", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed())
_, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("hasApiKey filter", func() {
BeforeEach(func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
})
filtered := func(value string) []string {
res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}})
Expect(err).ToNot(HaveOccurred())
var ids []string
for _, p := range res {
ids = append(ids, p.ID)
}
return ids
}
It("lists only players with a key", func() {
Expect(filtered("true")).To(ConsistOf(regularPlayer.ID))
count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}})
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(1)))
})
It("lists only players without a key", func() {
Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID))
})
})
Describe("Save (create)", func() {
It("creates the player with the key, owned by the logged-in user", func() {
id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)})
Expect(err).ToNot(HaveOccurred())
plr, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).ToNot(HaveOccurred())
Expect(plr.ID).To(Equal(id))
Expect(plr.UserId).To(Equal(regularUser.ID))
})
It("requires a key", func() {
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"})
expectAPIKeyError(err, "ra.validation.required")
_, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")})
expectAPIKeyError(err, "ra.validation.required")
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
It("rejects a malformed key without creating the player", func() {
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")})
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
It("does not let an admin create a keyed player for another user", func() {
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)})
Expect(err).To(MatchError(rest.ErrPermissionDenied))
_, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID})
Expect(err).To(MatchError(rest.ErrPermissionDenied))
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
It("rejects a key already used by another player without creating the player", func() {
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
count, _ := adminRepo.CountAll(ctx)
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)})
expectAPIKeyError(err, "ra.validation.unique")
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
})
})
Describe("Update (edit)", func() {
It("rolls back the key change when the rest of the edit fails", func() {
_, err := database.NewQuery(`create trigger fail_player_rename before update of name on player
when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute()
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute()
})
plr := regularPlayer
plr.Name = "boom"
plr.APIKey = new(key)
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("keeps the key when apiKey is absent (a normal edit)", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
plr := regularPlayer
plr.Name = "Renamed"
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed())
Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed())
found, err := adminRepo.FindByAPIKey(ctx, key)
Expect(err).ToNot(HaveOccurred())
Expect(found.Name).To(Equal("Renamed"))
})
It("sets a new key when apiKey has a value", func() {
plr := regularPlayer
plr.APIKey = new(key)
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
})
It("revokes the key when apiKey is empty", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
plr := regularPlayer
plr.APIKey = new("")
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("lets an admin edit another user's keyed player without touching the key", func() {
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
plr := regularPlayer
plr.MaxBitRate = 192
Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed())
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
})
It("refuses an admin setting a key on another user's player and leaves other columns alone", func() {
plr := regularPlayer
plr.Name = "Hijacked"
plr.APIKey = new(key)
Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied))
got, err := adminRepo.Get(ctx, regularPlayer.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.Name).To(Equal(regularPlayer.Name))
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
})
It("refuses a key already used by another player and leaves other columns alone", func() {
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
plr := regularPlayer
plr.Name = "Renamed"
plr.APIKey = new(key)
err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")
expectAPIKeyError(err, "ra.validation.unique")
got, err := adminRepo.Get(ctx, regularPlayer.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.Name).To(Equal(regularPlayer.Name))
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key)))
})
})
})
Describe("Ownership enforcement (cross-tenant write protection)", func() { Describe("Ownership enforcement (cross-tenant write protection)", func() {
var regularRepo *playerRepository var regularRepo *playerRepository
var regularCtx context.Context var regularCtx context.Context
@ -551,7 +287,6 @@ var _ = Describe("PlayerRepository", func() {
Name: "HIJACKED", Name: "HIJACKED",
UserId: regularUser.ID, UserId: regularUser.ID,
ReportRealPath: true, ReportRealPath: true,
APIKey: new(testAPIKey),
} }
id, err := regularRepo.Save(regularCtx, &spoofed) id, err := regularRepo.Save(regularCtx, &spoofed)

View file

@ -260,6 +260,17 @@ func (r *playlistRepository) selectPlaylist(ctx context.Context, options ...mode
return r.withAnnotation(ctx, sel, r.tableName+".id") return r.withAnnotation(ctx, sel, r.tableName+".id")
} }
// inTx runs fn in a transaction, joining the caller's if one is already open.
func (r *playlistRepository) inTx(fn func(tx *playlistRepository) error) error {
conn, ok := r.db.(*dbx.DB)
if !ok {
return fn(r)
}
return conn.Transactional(func(tx *dbx.Tx) error {
return fn(NewPlaylistRepository(tx).(*playlistRepository))
})
}
func (r *playlistRepository) updateTracks(ctx context.Context, id string, tracks model.MediaFiles) error { func (r *playlistRepository) updateTracks(ctx context.Context, id string, tracks model.MediaFiles) error {
ids := make([]string, len(tracks)) ids := make([]string, len(tracks))
for i := range tracks { for i := range tracks {
@ -281,7 +292,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 // 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) { func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds) mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
if err != nil { if err != nil {
@ -305,9 +316,12 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s
return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId}) 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. // keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. // 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) { 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)) accessible := make(map[string]struct{}, len(mediaFileIds))
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") 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" // 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. // -- the public image route serving a share -- must elevate, or a private playlist looks gone.
Describe("Exists visibility", func() { Describe("Exists visibility", func() {

View file

@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str
if err != nil || inserted == n { if err != nil || inserted == n {
return inserted, err 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. // leave a hole. Close it.
return inserted, r.playlistRepo.renumber(ctx, r.playlistId) return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
} }
@ -234,9 +234,7 @@ func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []stri
} }
func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) { func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) {
// Match by album-artist participation, not the deprecated album_artist_id return r.addMediaFileIds(ctx, Eq{"album_artist_id": artistIds})
// column, which only holds the first album artist.
return r.addMediaFileIds(ctx, ParticipantIDFilter("media_file", artistIds, model.RoleAlbumArtist))
} }
func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) { func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) {

View file

@ -83,14 +83,12 @@ var _ = Describe("PlaylistTrackRepository", func() {
Describe("Insert", func() { Describe("Insert", func() {
var tracks model.PlaylistTrackRepository var tracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() { BeforeEach(func() {
plsRepo := NewPlaylistRepository(GetDBXBuilder()) plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
plsID = pls.ID
tracks = plsRepo.Tracks(ctx, pls.ID, false) tracks = plsRepo.Tracks(ctx, pls.ID, false)
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) 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(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) 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() { Describe("Reorder", func() {
@ -255,45 +219,6 @@ var _ = Describe("PlaylistTrackRepository", func() {
}) })
}) })
Describe("AddArtists", func() {
var tracks model.PlaylistTrackRepository
var joint model.MediaFile
BeforeEach(func() {
mfRepo := NewMediaFileRepository(GetDBXBuilder())
joint = mf(model.MediaFile{ID: "pls-coartist-track", Title: "Joint Track", ArtistID: artistPunctuation.ID,
Artist: artistPunctuation.Name, AlbumID: "pls-coartist-album", Album: "Joint Album",
AlbumArtistID: artistKraftwerk.ID, AlbumArtist: artistKraftwerk.Name, Path: p("joint/track.mp3")})
joint.Participants[model.RoleAlbumArtist] = model.ParticipantList{
{Artist: artistKraftwerk},
{Artist: artistBeatles},
}
Expect(mfRepo.Put(ctx, &joint)).To(Succeed())
DeferCleanup(func() { _ = mfRepo.Delete(ctx, joint.ID) })
plsRepo := NewPlaylistRepository(GetDBXBuilder())
pls := model.Playlist{Name: "Co-album-artist", OwnerID: adminUser.ID, OwnerName: adminUser.UserName}
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = plsRepo.Delete(ctx, pls.ID) })
tracks = plsRepo.Tracks(ctx, pls.ID, false)
})
It("adds tracks where the artist is the first album artist", func() {
Expect(tracks.AddArtists(ctx, []string{artistKraftwerk.ID})).To(Equal(1))
Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID))
})
It("adds tracks where the artist is not the first album artist", func() {
Expect(tracks.AddArtists(ctx, []string{artistBeatles.ID})).To(Equal(1))
Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID))
})
It("does not add tracks where the artist is only the track artist", func() {
Expect(tracks.AddArtists(ctx, []string{artistPunctuation.ID})).To(Equal(0))
Expect(tracks.GetMediaFileIDs(ctx)).To(BeEmpty())
})
})
Describe("library access", func() { Describe("library access", func() {
var otherLib model.Library var otherLib model.Library
var restrictedUser model.User var restrictedUser model.User

View file

@ -10,7 +10,6 @@ import (
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
) )
@ -78,7 +77,7 @@ func (r *shareRepository) loadMedia(ctx context.Context, share *model.Share) err
return And{cond, Eq{"missing": false}} return And{cond, Eq{"missing": false}}
} }
// Load as the share owner so their library access is applied, whoever renders the share. // Load as the share owner so their library access is applied, whoever renders the share.
ownerCtx, err := r.ownerContext(ctx, share) ownerCtx, err := r.ownerContext(ctx, share.UserID)
if err != nil { if err != nil {
return err return err
} }
@ -128,19 +127,6 @@ func (r *shareRepository) loadMedia(ctx context.Context, share *model.Share) err
return nil return nil
} }
// ownerContext returns a context scoped to the share owner, so repository
// queries apply the owner's library access when a public share is rendered.
func (r *shareRepository) ownerContext(ctx context.Context, share *model.Share) (context.Context, error) {
owner, err := NewUserRepository(r.db).Get(ctx, share.UserID)
if err != nil {
return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err)
}
if owner == nil {
return nil, fmt.Errorf("share owner %q not found", share.UserID)
}
return request.WithUser(ctx, *owner), nil
}
func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
m := map[string]int{} m := map[string]int{}
for i, mf := range mfs { for i, mf := range mfs {

View file

@ -2,6 +2,9 @@ package persistence
import ( import (
"context" "context"
"encoding/json"
"errors"
"fmt"
"slices" "slices"
"time" "time"
@ -18,6 +21,23 @@ import (
// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and // in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and
// configured refresh delay. // configured refresh delay.
func (r *playlistRepository) Evaluate(ctx context.Context, id string) error {
var res dbPlaylist
if err := r.queryOne(ctx, r.selectPlaylist(ctx).Where(Eq{"playlist.id": id}), &res); err != nil {
return err
}
pls := res.Playlist
ownerCtx, err := r.ownerContext(ctx, pls.OwnerID)
if err != nil {
return err
}
pls.EvaluatedAt = nil
if !r.refreshSmartPlaylist(ownerCtx, &pls) {
return fmt.Errorf("evaluating smart playlist %s", id)
}
return nil
}
// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. // refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
func (r *playlistRepository) refreshSmartPlaylist(ctx context.Context, pls *model.Playlist) bool { func (r *playlistRepository) refreshSmartPlaylist(ctx context.Context, pls *model.Playlist) bool {
return r.refreshSmartPlaylistTree(ctx, pls, map[string]struct{}{}) return r.refreshSmartPlaylistTree(ctx, pls, map[string]struct{}{})
@ -39,12 +59,6 @@ func (r *playlistRepository) refreshSmartPlaylistTree(ctx context.Context, pls *
log.Debug(ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) log.Debug(ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now() start := time.Now()
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
if _, err := r.executeSQL(ctx, del); err != nil {
log.Error(ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr))
if !r.refreshChildPlaylists(ctx, pls, rulesSQL, visited) { if !r.refreshChildPlaylists(ctx, pls, rulesSQL, visited) {
@ -55,37 +69,56 @@ func (r *playlistRepository) refreshSmartPlaylistTree(ctx context.Context, pls *
return false return false
} }
sq := r.buildSmartPlaylistQuery(ctx, pls, rulesSQL, usr.ID) sq, err := r.addCriteria(r.buildSmartPlaylistQuery(ctx, rulesSQL, usr.ID), rulesSQL)
sq, err := r.addCriteria(sq, rulesSQL)
if err != nil { if err != nil {
log.Error(ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) log.Error(ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false return false
} }
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) // Evaluate the criteria before writing, so the write lock is only held for the short replace below
if _, err = r.executeSQL(ctx, insSql); err != nil { var ids []string
if err = r.queryAllSlice(ctx, sq, &ids); err != nil && !errors.Is(err, model.ErrNotFound) {
log.Error(ctx, "Error evaluating smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
err = r.inTx(func(tx *playlistRepository) error { return tx.replaceSmartPlaylistTracks(ctx, pls, ids) })
if err != nil {
log.Error(ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) log.Error(ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false return false
} }
if err = r.refreshCounters(ctx, pls); err != nil {
log.Error(ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
// Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree
now := pls.UpdatedAt
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
if _, err = r.executeSQL(ctx, updSql); err != nil {
log.Error(ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) log.Debug(ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true return true
} }
func (r *playlistRepository) replaceSmartPlaylistTracks(ctx context.Context, pls *model.Playlist, ids []string) error {
if _, err := r.executeSQL(ctx, Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})); err != nil {
return err
}
if len(ids) > 0 {
idsJSON, err := json.Marshal(ids)
if err != nil {
return err
}
ins := Expr("INSERT INTO playlist_tracks (id, playlist_id, media_file_id) SELECT key + 1, ?, value FROM json_each(?)",
pls.ID, string(idsJSON))
if _, err = r.executeSQL(ctx, ins); err != nil {
return err
}
}
if err := r.refreshCounters(ctx, pls); err != nil {
return err
}
// Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree
now := pls.UpdatedAt
if _, err := r.executeSQL(ctx, Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})); err != nil {
return err
}
pls.EvaluatedAt = &now
return nil
}
// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated // shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated
// time, and ownership. // time, and ownership.
func (r *playlistRepository) shouldRefreshSmartPlaylist(ctx context.Context, pls *model.Playlist, usr *model.User) bool { func (r *playlistRepository) shouldRefreshSmartPlaylist(ctx context.Context, pls *model.Playlist, usr *model.User) bool {
@ -176,12 +209,10 @@ func (r *playlistRepository) resolvePercentageLimit(ctx context.Context, pls *mo
return nil return nil
} }
// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, // buildSmartPlaylistQuery constructs the SQL query to select the ids of media files matching the smart playlist
// including the joins its fields require and library filtering. // criteria, including the joins its fields require and library filtering.
func (r *playlistRepository) buildSmartPlaylistQuery(ctx context.Context, pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { func (r *playlistRepository) buildSmartPlaylistQuery(ctx context.Context, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
orderBy := rulesSQL.orderBy() sq := Select("media_file.id").From("media_file")
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file")
sq = rulesSQL.applyRequiredJoins(sq, userID) sq = rulesSQL.applyRequiredJoins(sq, userID)
sq = r.applyLibraryFilter(ctx, sq, "media_file") sq = r.applyLibraryFilter(ctx, sq, "media_file")
return sq return sq

View file

@ -330,6 +330,47 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
}) })
}) })
Describe("Evaluate", func() {
dayRules := func() *criteria.Criteria {
return &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
RefreshDelay: 24 * time.Hour,
}
}
It("evaluates even when the refresh delay has not elapsed", func() {
evaluatedAt := time.Now().Add(-1 * time.Hour)
pls := model.Playlist{Name: "Evaluate Delay", OwnerID: "userid", Rules: dayRules(), EvaluatedAt: &evaluatedAt}
Expect(repo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) })
Expect(repo.Evaluate(ctx, pls.ID)).To(Succeed())
got, err := repo.Get(ctx, pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
Expect(got.SongCount).To(Equal(1))
})
It("evaluates as the owner, even when the caller cannot see the playlist", func() {
pls := model.Playlist{Name: "Evaluate Owner", OwnerID: regularUser.ID, Rules: dayRules()}
Expect(repo.Put(ctx, &pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) })
otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser)
Expect(repo.Evaluate(otherCtx, pls.ID)).To(Succeed())
got, err := repo.Get(ctx, pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(got.EvaluatedAt).ToNot(BeNil())
Expect(got.SongCount).To(Equal(1))
})
It("returns ErrNotFound for an unknown playlist", func() {
Expect(repo.Evaluate(ctx, "nonexistent-id")).To(MatchError(model.ErrNotFound))
})
})
Describe("Playlist Track Sorting", func() { Describe("Playlist Track Sorting", func() {
var testPlaylistID string var testPlaylistID string

View file

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

View file

@ -58,6 +58,18 @@ func loggedUser(ctx context.Context) *model.User {
} }
} }
// ownerContext scopes ctx to the given user, so queries apply that user's library access and annotations.
func (r sqlRepository) ownerContext(ctx context.Context, userID string) (context.Context, error) {
owner, err := NewUserRepository(r.db).Get(ctx, userID)
if err != nil {
return nil, fmt.Errorf("loading owner %q: %w", userID, err)
}
if owner == nil {
return nil, fmt.Errorf("owner %q not found", userID)
}
return request.WithUser(ctx, *owner), nil
}
// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for // ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for
// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid // tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid
// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. // user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil.
@ -85,22 +97,6 @@ func (r sqlRepository) addRestriction(ctx context.Context, sql ...Sqlizer) Sqliz
return s return s
} }
// writeAccess says who may change a row in a table with a user_id column.
type writeAccess int
const (
ownerOrAdmin writeAccess = iota // admins may write any row
ownerOnly // even admins may only write their own rows
)
// ownedRow matches the row rowID only if the logged-in user may write it under access.
func (r sqlRepository) ownedRow(ctx context.Context, rowID string, access writeAccess) Sqlizer {
if access == ownerOnly {
return And{Eq{"id": rowID}, Eq{"user_id": loggedUser(ctx).ID}}
}
return r.addRestriction(ctx, Eq{"id": rowID})
}
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
if r.tableName == "" { if r.tableName == "" {
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
@ -510,12 +506,15 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo
} }
updateValues := filterUpdateValues(values, id, colsToUpdate...) updateValues := filterUpdateValues(values, id, colsToUpdate...)
delete(updateValues, "user_id") // ownership is immutable on update delete(updateValues, "user_id") // ownership is immutable on update
return r.updateOwnedRow(ctx, id, ownerOrAdmin, updateValues) update := Update(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})).SetMap(updateValues)
} count, err := r.executeSQL(ctx, update)
if err != nil {
// updateOwnedRow sets values on the row rowID if the logged-in user may write it under access. return err
func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access writeAccess, values map[string]any) error { }
return r.runRowWrite(ctx, rowID, Update(r.tableName).SetMap(values).Where(r.ownedRow(ctx, rowID, access))) if count == 0 {
return r.classifyOwnedWriteMiss(ctx, id)
}
return nil
} }
// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for // deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
@ -524,17 +523,12 @@ func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access
// does not match and is left untouched. The failure path mirrors updateOwned (see // does not match and is left untouched. The failure path mirrors updateOwned (see
// classifyOwnedWriteMiss), so there is no TOCTOU on the delete. // classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
func (r sqlRepository) deleteOwned(ctx context.Context, id string) error { func (r sqlRepository) deleteOwned(ctx context.Context, id string) error {
return r.runRowWrite(ctx, id, Delete(r.tableName).Where(r.ownedRow(ctx, id, ownerOrAdmin))) count, err := r.executeSQL(ctx, Delete(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})))
}
// runRowWrite executes q, a write already filtered by ownedRow(rowID, …), and classifies a miss.
func (r sqlRepository) runRowWrite(ctx context.Context, rowID string, q Sqlizer) error {
count, err := r.executeSQL(ctx, q)
if err != nil { if err != nil {
return err return err
} }
if count == 0 { if count == 0 {
return r.classifyOwnedWriteMiss(ctx, rowID) return r.classifyOwnedWriteMiss(ctx, id)
} }
return nil return nil
} }

View file

@ -19,26 +19,6 @@ var _ = Describe("sqlRepository", func() {
r.tableName = "table" r.tableName = "table"
}) })
Describe("ownedRow", func() {
DescribeTable("matches the row, limited to what the logged-in user may write",
func(user model.User, access writeAccess, expectedSQL string, expectedArgs ...any) {
userCtx := request.WithUser(GinkgoT().Context(), user)
sql, args, err := r.ownedRow(userCtx, "row-1", access).ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(Equal(expectedArgs))
},
Entry("admin, ownerOrAdmin: any row", model.User{ID: "admin", IsAdmin: true}, ownerOrAdmin,
"(id = ?)", "row-1"),
Entry("regular, ownerOrAdmin: own rows", model.User{ID: "user"}, ownerOrAdmin,
"(id = ? AND user_id = ?)", "row-1", "user"),
Entry("admin, ownerOnly: own rows", model.User{ID: "admin", IsAdmin: true}, ownerOnly,
"(id = ? AND user_id = ?)", "row-1", "admin"),
Entry("regular, ownerOnly: own rows", model.User{ID: "user"}, ownerOnly,
"(id = ? AND user_id = ?)", "row-1", "user"),
)
})
Describe("applyOptions", func() { Describe("applyOptions", func() {
var sq squirrel.SelectBuilder var sq squirrel.SelectBuilder
BeforeEach(func() { BeforeEach(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

@ -93,8 +93,7 @@
"addToPlaylist": "Gehitu zerrendara", "addToPlaylist": "Gehitu zerrendara",
"download": "Deskargatu", "download": "Deskargatu",
"info": "Erakutsi informazioa", "info": "Erakutsi informazioa",
"share": "Partekatu", "share": "Partekatu"
"refresh": "Freskatu metadatuak"
}, },
"lists": { "lists": {
"all": "Guztiak", "all": "Guztiak",
@ -156,13 +155,11 @@
"newPassword": "Pasahitz berria", "newPassword": "Pasahitz berria",
"token": "Tokena", "token": "Tokena",
"lastAccessAt": "Azken sarbidea", "lastAccessAt": "Azken sarbidea",
"libraries": "Liburutegiak", "libraries": "Liburutegiak"
"scrobbleFilter": "Erabiltzailearen ohituren iragazkia"
}, },
"helperTexts": { "helperTexts": {
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira", "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko", "libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
"scrobbleFilter": "Erreprodukzio-zerrenda adimenduaren arauekin bat datozen abestiak ez dira Last.fm, ListenBrainz edo erabiltzailearen ohiturak aztertzen dituzten pluginetara bidaltzen. Erreprodukzio-zerrenda adimenduen JSON sintaxi eta portaera bera erabiltzen du. Adibidea: {\"all\":[{\"lt\":{\"rating\":4}}]}. Utzi hutsik erabiltzailearen ohitura guztiak aztertzeko. Ez dio eragiten lokalean erreproduzitutako zenbaketari."
}, },
"notifications": { "notifications": {
"created": "Erabiltzailea sortu da", "created": "Erabiltzailea sortu da",
@ -170,8 +167,7 @@
"deleted": "Erabiltzailea ezabatu da" "deleted": "Erabiltzailea ezabatu da"
}, },
"validation": { "validation": {
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat", "librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
"invalidScrobbleFilter": "Erreprodukzio-zerrenda adimendunaren baliozko arauak izan behar dute. Muga, konpentsazioa eta freskatze-atzerapena ez dira onartzen."
}, },
"message": { "message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena", "listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
@ -200,9 +196,6 @@
"targetFormat": "Helburuko formatua", "targetFormat": "Helburuko formatua",
"defaultBitRate": "Bit tasa, defektuz", "defaultBitRate": "Bit tasa, defektuz",
"command": "Komandoa" "command": "Komandoa"
},
"choices": {
"noDefaultBitRate": "Bat ere ez"
} }
}, },
"playlist": { "playlist": {
@ -217,8 +210,7 @@
"songCount": "abesti", "songCount": "abesti",
"comment": "Iruzkina", "comment": "Iruzkina",
"sync": "Automatikoki inportatuak", "sync": "Automatikoki inportatuak",
"path": "Inportatze-data:", "path": "Inportatze-data:"
"starred": "Gogokoa"
}, },
"actions": { "actions": {
"selectPlaylist": "Hautatu zerrenda:", "selectPlaylist": "Hautatu zerrenda:",
@ -400,7 +392,6 @@
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da" "invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
}, },
"messages": { "messages": {
"idHelp": "Pluginaren IDa, fitxategi-izenetik eratorria. Erabili konfigurazioaren aukeretan pluginari erreferentzia egitean, agenteak esaterako.",
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.", "configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
"configValidationError": "Huts egin du konfigurazioaren balidazioak:", "configValidationError": "Huts egin du konfigurazioaren balidazioak:",
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.", "schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
@ -574,7 +565,6 @@
"coverRemoved": "Diskoaren azala kendu da", "coverRemoved": "Diskoaren azala kendu da",
"coverUploadError": "Errorea diskoaren azala igotzean", "coverUploadError": "Errorea diskoaren azala igotzean",
"coverRemoveError": "Errorea diskoaren azala kentzean", "coverRemoveError": "Errorea diskoaren azala kentzean",
"metadataRefreshStarted": "Metadata atzeko planoan freskatzen ari da",
"note": "OHARRA", "note": "OHARRA",
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.", "transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.", "transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
@ -645,7 +635,6 @@
}, },
"albumList": "Albumak", "albumList": "Albumak",
"playlists": "Zerrendak", "playlists": "Zerrendak",
"onlyFavourites": "Erakutsi gogokoak bakarrik",
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak", "sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
"about": "Honi buruz" "about": "Honi buruz"
}, },

View file

@ -182,7 +182,6 @@
}, },
"player": { "player": {
"name": "Tocador |||| Tocadores", "name": "Tocador |||| Tocadores",
"menuName": "Tocadores e chaves de API",
"fields": { "fields": {
"name": "Nome", "name": "Nome",
"transcodingId": "Conversão", "transcodingId": "Conversão",
@ -191,29 +190,7 @@
"userName": "Usuário", "userName": "Usuário",
"lastSeen": "Últ. acesso", "lastSeen": "Últ. acesso",
"reportRealPath": "Use paths reais", "reportRealPath": "Use paths reais",
"scrobbleEnabled": "Enviar scrobbles para serviços externos", "scrobbleEnabled": "Enviar scrobbles para serviços externos"
"hasApiKey": "Chave de API"
},
"actions": {
"generateApiKey": "Gerar chave de API",
"regenerateApiKey": "Gerar nova",
"revokeApiKey": "Revogar",
"copyApiKey": "Copiar"
},
"message": {
"apiKeyActive": "Este tocador tem uma chave de API. Use-a no seu app como chave de API, ou como senha se o app não usar autenticação por token.",
"apiKeyNone": "Sem chave de API. Gere uma para conectar um app a este tocador.",
"apiKeyNoneOther": "Sem chave de API.",
"apiKeyPending": "Copie esta chave agora. Ela será salva quando você clicar em Salvar e não será exibida novamente.",
"apiKeyRevokePending": "A chave de API será removida quando você salvar.",
"deleteWithKeyTitle": "Excluir tocador",
"deleteWithKeyContent": "Este tocador tem uma chave de API. Os apps que a usam vão parar de funcionar."
},
"notifications": {
"apiKeyCopied": "Chave de API copiada para o clipboard"
},
"validation": {
"apiKeyFormat": "Formato de chave de API inválido"
} }
}, },
"transcoding": { "transcoding": {
@ -328,22 +305,11 @@
"totalDuration": "Duração", "totalDuration": "Duração",
"defaultNewUsers": "Padrão para Novos Usuários", "defaultNewUsers": "Padrão para Novos Usuários",
"createdAt": "Data de Criação", "createdAt": "Data de Criação",
"updatedAt": "Últ. Atualização", "updatedAt": "Últ. Atualização"
"pidAlbum": "Agrupamento de álbuns",
"pidTrack": "Identificação das faixas"
}, },
"sections": { "sections": {
"basic": "Informações Básicas", "basic": "Informações Básicas",
"statistics": "Estatísticas", "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"
}, },
"actions": { "actions": {
"scan": "Scanear Biblioteca", "scan": "Scanear Biblioteca",
@ -373,9 +339,7 @@
"messages": { "messages": {
"deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.",
"scanInProgress": "Scan em progresso...", "scanInProgress": "Scan em progresso...",
"noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário", "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."
} }
}, },
"plugin": { "plugin": {
@ -660,11 +624,6 @@
"options": { "options": {
"theme": "Tema", "theme": "Tema",
"language": "Língua", "language": "Língua",
"dateFormat": "Formato de data",
"dateFormats": {
"language": "Igual à língua",
"browser": "Igual ao navegador"
},
"defaultView": "Tela inicial", "defaultView": "Tela inicial",
"desktop_notifications": "Notificações", "desktop_notifications": "Notificações",
"lastfmScrobbling": "Enviar scrobbles para Last.fm", "lastfmScrobbling": "Enviar scrobbles para Last.fm",

View file

@ -218,7 +218,7 @@ additional:
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ] aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
album: true album: true
key: key:
aliases: [ tkey, key, initialkey, ----:com.apple.itunes:initialkey, wm/initialkey ] aliases: [ tkey, key, ----:com.apple.itunes:initialkey, wm/initialkey ]
isrc: isrc:
aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ] aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ]
language: language:
@ -231,9 +231,9 @@ additional:
movementname: movementname:
aliases: [ mvnm, movementname, ©mvn ] aliases: [ mvnm, movementname, ©mvn ]
movementtotal: movementtotal:
aliases: [ movementtotal, movementcount, mvc ] aliases: [ movementtotal, mvc ]
movement: movement:
aliases: [ mvin, movement, movementnumber, mvi ] aliases: [ mvin, movement, mvi ]
recordlabel: recordlabel:
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ] aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
album: true album: true

View file

@ -25,7 +25,7 @@ import (
) )
var ( var (
ErrAlreadyScanning = model.ErrAlreadyScanning ErrAlreadyScanning = errors.New("already scanning")
) )
func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, func New(rootCtx context.Context, ds model.DataStore, broker events.Broker,
@ -304,14 +304,14 @@ func LockForMaintenance() (func(), bool) {
return scanMaintenanceMux.Unlock, true return scanMaintenanceMux.Unlock, true
} }
// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan, // EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted
// or will rescan a library in full because its PID config changed, in one of the included libraries. // full scan in one of the included libraries.
func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool {
if fullScan { if fullScan {
return true return true
} }
return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { 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 ( import (
"context" "context"
"time"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
@ -71,21 +70,14 @@ var _ = Describe("EffectiveFullScan", func() {
var ds *tests.MockDataStore var ds *tests.MockDataStore
BeforeEach(func() { BeforeEach(func() {
pid := model.Library{}.EffectivePID()
libraries := &tests.MockLibraryRepo{} libraries := &tests.MockLibraryRepo{}
libraries.SetData(model.Libraries{ libraries.SetData(model.Libraries{
{ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, {ID: 1, FullScanInProgress: true},
{ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, {ID: 2},
{ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
}) })
ds = &tests.MockDataStore{MockedLibrary: libraries} 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() { It("detects an interrupted full scan in a targeted library", func() {
targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}}
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) 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 { if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
state.sendError(err) state.sendError(err)
state.markFailed(lib.ID)
continue continue
} }
jobs = append(jobs, job) jobs = append(jobs, job)
@ -52,13 +51,12 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
} }
type scanJob struct { type scanJob struct {
lib model.Library lib model.Library
fs storage.MusicFS fs storage.MusicFS
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library 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) 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
lock sync.Mutex numFolders atomic.Int64
numFolders atomic.Int64
} }
func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { 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) 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. // Ensure FullScanInProgress reflects the current scan request.
// This is important when resuming an interrupted quick scan as a full scan: // 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. // the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
lib.FullScanInProgress = lib.FullScanInProgress || fullScan lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{ return &scanJob{
lib: lib, lib: lib,
fs: fsys, fs: fsys,
lastUpdates: lastUpdates, lastUpdates: lastUpdates,
targetFolders: targetFolders, targetFolders: targetFolders,
prevAlbumPIDConf: prevAlbumPIDConf,
}, nil }, nil
} }
@ -140,13 +122,14 @@ func (j *scanJob) createFolderEntry(path string) *folderEntry {
// The phaseFolders struct implements the phase interface, providing methods to produce // The phaseFolders struct implements the phase interface, providing methods to produce
// folder entries, process folders, persist changes to the database, and log the results. // folder entries, process folders, persist changes to the database, and log the results.
type phaseFolders struct { type phaseFolders struct {
jobs []*scanJob jobs []*scanJob
ds model.DataStore ds model.DataStore
ctx context.Context //nolint:containedctx // phase runs under a single scan ctx 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 walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early
stopWalk context.CancelCauseFunc stopWalk context.CancelCauseFunc
state *scanState state *scanState
imageChanges *imageChangeCollector prevAlbumPIDConf string
imageChanges *imageChangeCollector
} }
func (p *phaseFolders) description() string { func (p *phaseFolders) description() string {
@ -155,6 +138,12 @@ func (p *phaseFolders) description() string {
func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
return ppl.NewProducer(func(put func(entry *folderEntry)) error { 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 // TODO Parallelize multiple job when we have multiple libraries
var total int64 var total int64
var totalChanged int64 var totalChanged int64
@ -184,7 +173,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
// Check if folder is outdated // Check if folder is outdated
if folder.isOutdated() { 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 // 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. // resolution climbs them, and an image added later needs a state to diff.
if folder.isEmpty() && folder.isNew() { if folder.isEmpty() && folder.isNew() {
@ -250,7 +239,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
for afPath, af := range entry.audioFiles { for afPath, af := range entry.audioFiles {
fullPath := path.Join(entry.path, afPath) fullPath := path.Join(entry.path, afPath)
dbTrack, foundInDB := dbTracks[fullPath] dbTrack, foundInDB := dbTracks[fullPath]
if !foundInDB || entry.job.lib.FullScanInProgress { if !foundInDB || p.state.fullScan {
filesToImport[fullPath] = dbTrack filesToImport[fullPath] = dbTrack
} else { } else {
info, err := af.Info() info, err := af.Info()
@ -300,18 +289,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
} }
for filePath, info := range allInfo { for filePath, info := range allInfo {
md := metadata.New(filePath, info) 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) tracks = append(tracks, track)
for _, t := range track.Tags.FlattenAll() { for _, t := range track.Tags.FlattenAll() {
uniqueTags[t.ID] = t uniqueTags[t.ID] = t
} }
// Keep track of any album ID changes, to reassign annotations later // Keep track of any album ID changes, to reassign annotations later
prevAlbumID := track.AlbumID prevAlbumID := ""
if prev := toImport[filePath]; prev != nil { if prev := toImport[filePath]; prev != nil {
prevAlbumID = prev.AlbumID prevAlbumID = prev.AlbumID
} else if entry.job.prevAlbumPIDConf != "" { } else {
prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf) prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
} }
_, ok := entry.albumIDMap[track.AlbumID] _, ok := entry.albumIDMap[track.AlbumID]
if prevAlbumID != track.AlbumID && !ok { if prevAlbumID != track.AlbumID && !ok {
@ -464,7 +453,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en
if len(queueItems) > 0 { if len(queueItems) > 0 {
queue := tx.ArtworkQueue() queue := tx.ArtworkQueue()
enqueue := queue.Enqueue enqueue := queue.Enqueue
if entry.job.lib.FullScanInProgress { if p.state.fullScan {
enqueue = queue.EnqueueIfMissing enqueue = queue.EnqueueIfMissing
} }
if err := enqueue(ctx, queueItems...); err != nil { if err := enqueue(ctx, queueItems...); err != nil {

View file

@ -144,6 +144,10 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model.
continue continue
} }
if pls.IsSmartPlaylist() { if pls.IsSmartPlaylist() {
// A nil EvaluatedAt means the playlist is new or its file changed
if pls.ID != "" && pls.EvaluatedAt == nil {
p.scanState.queueSmartPlaylist(pls.ID)
}
log.Debug("Scanner: Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "elapsed", time.Since(started)) log.Debug("Scanner: Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "elapsed", time.Since(started))
} else { } else {
log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started))

View file

@ -6,12 +6,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
@ -213,6 +215,27 @@ var _ = Describe("phasePlaylists", func() {
))) )))
}) })
It("queues only smart playlists that were never evaluated", func() {
libPath := GinkgoT().TempDir()
folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"}
_ = os.MkdirAll(folder.AbsolutePath(), 0755)
for _, name := range []string{"new.nsp", "evaluated.nsp", "regular.m3u"} {
_ = os.WriteFile(filepath.Join(folder.AbsolutePath(), name), []byte{}, 0600)
}
rules := &criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}}
pls.On("ImportFromFolder", mock.Anything, folder, "new.nsp").
Return(&model.Playlist{ID: "new", Rules: rules}, nil)
pls.On("ImportFromFolder", mock.Anything, folder, "evaluated.nsp").
Return(&model.Playlist{ID: "evaluated", Rules: rules, EvaluatedAt: new(time.Now())}, nil)
pls.On("ImportFromFolder", mock.Anything, folder, "regular.m3u").
Return(&model.Playlist{ID: "regular"}, nil)
_, err := phase.processPlaylistsInFolder(folder)
Expect(err).ToNot(HaveOccurred())
Expect(state.smartPlaylistsToEvaluate()).To(ConsistOf("new"))
})
It("reports an error if there is an error reading files", func() { It("reports an error if there is an error reading files", func() {
tests.SkipOnWindows("relies on Unix /etc filesystem") tests.SkipOnWindows("relies on Unix /etc filesystem")
progress := make(chan *ProgressInfo) progress := make(chan *ProgressInfo)

View file

@ -4,11 +4,14 @@ import (
"context" "context"
"fmt" "fmt"
"maps" "maps"
"path/filepath"
"slices" "slices"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
ppl "github.com/google/go-pipeline/pkg/pipeline" ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@ -30,7 +33,21 @@ type scanState struct {
libraries model.Libraries // Store libraries list for consistency across phases libraries model.Libraries // Store libraries list for consistency across phases
targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection 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
smartPlaylistsMu sync.Mutex
smartPlaylists []string
}
func (s *scanState) queueSmartPlaylist(id string) {
s.smartPlaylistsMu.Lock()
defer s.smartPlaylistsMu.Unlock()
s.smartPlaylists = append(s.smartPlaylists, id)
}
func (s *scanState) smartPlaylistsToEvaluate() []string {
s.smartPlaylistsMu.Lock()
defer s.smartPlaylistsMu.Unlock()
return s.smartPlaylists
} }
func (s *scanState) sendProgress(info *ProgressInfo) { func (s *scanState) sendProgress(info *ProgressInfo) {
@ -47,17 +64,31 @@ func (s *scanState) sendWarning(msg string) {
s.sendProgress(&ProgressInfo{Warning: msg}) 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) { func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: 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 := filepath.Rel(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) { func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now() startTime := time.Now()
@ -89,7 +120,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
}) })
for _, target := range targets { for _, target := range targets {
folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath) folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
if folderPath == "" { if folderPath == "" {
folderPath = "." folderPath = "."
} }
@ -122,10 +153,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
// if there was a full scan in progress, force a full scan // if there was a full scan in progress, force a full scan
if !state.fullScan { if !state.fullScan {
for _, lib := range state.libraries { for _, lib := range state.libraries {
// A pending PID rescan already restarts in full through its own job
if lib.NeedsPIDRescan() {
continue
}
if lib.FullScanInProgress { if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true state.fullScan = true
@ -176,6 +203,9 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
// Update last_scan_completed_at for all libraries // Update last_scan_completed_at for all libraries
s.runUpdateLibraries(ctx, &state), s.runUpdateLibraries(ctx, &state),
// Evaluate new/changed smart playlists last, so their rules see the final library state
s.runEvaluateSmartPlaylists(ctx, &state),
) )
if err != nil { if err != nil {
log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err)
@ -204,13 +234,10 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt
var successfulLibs []model.Library var successfulLibs []model.Library
for _, lib := range state.libraries { for _, lib := range state.libraries {
// A library with a changed PID config restarts its scan: resuming would skip the folders that if lib.LastScanStartedAt.IsZero() {
// the interrupted scan already processed with the old config
pidRescan := lib.NeedsPIDRescan()
if lib.LastScanStartedAt.IsZero() || pidRescan {
// This is a new scan - mark it as started // This is a new scan - mark it as started
err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { 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") }, "scanner: begin library scan")
if err != nil { if err != nil {
log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
@ -324,6 +351,21 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun
} }
} }
// Failures are logged but never fail the scan: the playlist is still evaluated when it is next read.
func (s *scannerImpl) runEvaluateSmartPlaylists(ctx context.Context, state *scanState) func() error {
return func() error {
for _, id := range state.smartPlaylistsToEvaluate() {
start := time.Now()
if err := s.ds.Playlist().Evaluate(ctx, id); err != nil {
log.Warn(ctx, "Scanner: Could not evaluate smart playlist", "id", id, err)
continue
}
log.Debug(ctx, "Scanner: Evaluated smart playlist", "id", id, "elapsed", time.Since(start))
}
return nil
}
}
func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error {
return func() error { return func() error {
start := time.Now() start := time.Now()
@ -332,12 +374,11 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState)
if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil {
return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) 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 if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil {
// config. A library that could not be scanned did not apply it either. return fmt.Errorf("updating track PID conf: %w", err)
if !state.isSelectiveScan() && !state.failedLibs[lib.ID] { }
if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil { if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil {
return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err) return fmt.Errorf("updating album PID conf: %w", err)
}
} }
if state.changesDetected.Load() { if state.changesDetected.Load() {
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)

View file

@ -4,13 +4,53 @@ package scanner
import ( import (
"context" "context"
"errors" "errors"
"os"
"path/filepath"
"sync/atomic" "sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline" ppl "github.com/google/go-pipeline/pkg/pipeline"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "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 { type mockPhase struct {
num int num int
produceFunc func() ppl.Producer[int] produceFunc func() ppl.Producer[int]
@ -96,3 +136,32 @@ var _ = Describe("runPhase", func() {
Expect(counter.Load()).To(Equal(int64(3))) Expect(counter.Load()).To(Equal(int64(3)))
}) })
}) })
var _ = Describe("runEvaluateSmartPlaylists", func() {
var ctx context.Context
var plsRepo *tests.MockPlaylistRepo
var s *scannerImpl
var state *scanState
BeforeEach(func() {
ctx = GinkgoT().Context()
plsRepo = tests.CreateMockPlaylistRepo()
s = &scannerImpl{ds: &tests.MockDataStore{MockedPlaylist: plsRepo}}
state = &scanState{}
})
It("evaluates every queued smart playlist", func() {
state.queueSmartPlaylist("p1")
state.queueSmartPlaylist("p2")
Expect(s.runEvaluateSmartPlaylists(ctx, state)()).To(Succeed())
Expect(plsRepo.Evaluated).To(Equal([]string{"p1", "p2"}))
})
It("does not fail the scan when an evaluation fails", func() {
plsRepo.SetError(true)
state.queueSmartPlaylist("p1")
Expect(s.runEvaluateSmartPlaylists(ctx, state)()).To(Succeed())
})
})

View file

@ -835,170 +835,4 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
Expect(lastError).To(BeEmpty()) 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 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 // Create checker and push patterns from root to this folder
checker := newIgnoreChecker(job.fs) checker := newIgnoreChecker(job.fs)
err = checker.PushAllParents(ctx, folderPath) err = checker.PushAllParents(ctx, folderPath)
@ -232,18 +225,6 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
return fileInfo.IsDir(), nil 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 const maxSymlinkHops = 40
// resolveEntryName returns the name to classify the entry by, and whether to // resolveEntryName returns the name to classify the entry by, and whether to

View file

@ -4,10 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"io/fs" "io/fs"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing/fstest" "testing/fstest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@ -262,44 +260,6 @@ var _ = Describe("walk_dir_tree", func() {
// Folders not in targets should remain in lastUpdates // Folders not in targets should remain in lastUpdates
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3"))) 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. // Regression for #5752: the production localFS must resolve file symlinks.
// fs.ReadLink-based resolution can't follow targets outside the library // It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
// root, so full OS-level resolution is required. // resolution is not available and full OS-level resolution is required.
Context("production local storage FS", func() { Context("production local storage FS", func() {
var libRoot string var libRoot string
var musicFS storage.MusicFS 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, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).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 { 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)) 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 // mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
type mockMusicFS struct { type mockMusicFS struct {
storage.MusicFS storage.MusicFS

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`; `Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone `Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as `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 in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome
`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; 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); unrecognized keys are skipped, and `Random` always sorts alone);
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` `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). 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` | | 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=` | | 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) | | 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) | | 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` | | 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]` | | 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 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; 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`. 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 - **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating`
(any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores (0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends
5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating. only `likes` clears the rating.
- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome - **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy
answers `400` and keeps it, as it does for a malformed or out-of-range value. Use `UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback
`DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is reporting.
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.
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are - **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
accepted but never returned. accepted but never returned.
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork - **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork

View file

@ -1,52 +1,47 @@
package jellyfin package jellyfin
import ( import (
"encoding/json"
"errors" "errors"
"io" "math"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
) )
type annotatedItem struct { // resolveAnnotated finds which annotated repo owns id, returning the resource name used in
repo model.AnnotatedRepository // refreshResource events. Albums and songs 404 when the user can't access their library; artists
resource string // name used in refreshResource events // span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
ann model.Annotations // 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) {
// 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) {
ctx := r.Context() ctx := r.Context()
entity, err := model.GetEntityByID(ctx, api.ds, id) entity, err := model.GetEntityByID(ctx, api.ds, id)
if err != nil && !errors.Is(err, model.ErrNotFound) { if err != nil && !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err) api.internalError(w, r, err)
return annotatedItem{}, false return nil, ""
} }
u, _ := request.UserFrom(ctx) u, _ := request.UserFrom(ctx)
switch e := entity.(type) { switch e := entity.(type) {
case *model.Album: case *model.Album:
if u.HasLibraryAccess(e.LibraryID) { if u.HasLibraryAccess(e.LibraryID) {
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true return api.ds.Album(), "album"
} }
case *model.Artist: case *model.Artist:
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true return api.ds.Artist(), "artist"
case *model.MediaFile: case *model.MediaFile:
if u.HasLibraryAccess(e.LibraryID) { if u.HasLibraryAccess(e.LibraryID) {
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true return api.ds.MediaFile(), "song"
} }
case *model.Playlist: 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. // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
http.Error(w, "Not Found", http.StatusNotFound) 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 // 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) api.ok(w, r, data)
} }
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched. func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
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) {
id, ok := itemIDParam(w, r, "itemId") id, ok := itemIDParam(w, r, "itemId")
if !ok { if !ok {
return 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 { if !ok {
return return
} }
ctx := r.Context() repo, resource := api.resolveAnnotated(w, r, id)
if change.rating != nil { if repo == 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)
return 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) { func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
api.applyCallerUserData(w, r, userDataChange{rating: new(0)}) api.setItemRating(w, r, 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
} }

View file

@ -5,8 +5,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"time"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
@ -23,7 +21,7 @@ var _ = Describe("Annotations", func() {
var broker *fakeEventBroker var broker *fakeEventBroker
// alice has access to library 1 only. // alice has access to library 1 only.
ctxUser := func() context.Context { 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() { BeforeEach(func() {
@ -32,16 +30,6 @@ var _ = Describe("Annotations", func() {
api = &Router{ds: ds, broker: broker} 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() { Describe("markFavorite / unmarkFavorite", func() {
It("stars a song and returns IsFavorite=true", func() { It("stars a song and returns IsFavorite=true", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
@ -280,275 +268,41 @@ var _ = Describe("Annotations", func() {
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) 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() { It("emits a refreshResource event when rating a song", func() {
songWith(model.Annotations{}) mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", "")) 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).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) 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 { type fakeEventBroker struct {
http.Handler http.Handler
Events []events.Event Events []events.Event

View file

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

View file

@ -40,14 +40,6 @@ type UserItemDataDto struct {
Key string `json:"Key"` Key string `json:"Key"`
ItemId string `json:"ItemId,omitempty"` ItemId string `json:"ItemId,omitempty"`
LastPlayedDate *string `json:"LastPlayedDate,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 { type BaseItemDto struct {

View file

@ -3,7 +3,6 @@ package dto
import ( import (
"cmp" "cmp"
"fmt" "fmt"
"math"
"time" "time"
"github.com/navidrome/navidrome/conf" "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 { 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. // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
encodedID := EncodeID(itemID) encodedID := EncodeID(itemID)
@ -159,7 +134,6 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
if a.Rating > 0 { if a.Rating > 0 {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r d.Rating = &r
d.Likes = new(r >= minLikeRating)
} }
if s := JellyfinDate(a.PlayDate); s != "" { if s := JellyfinDate(a.PlayDate); s != "" {
d.LastPlayedDate = &s d.LastPlayedDate = &s

View file

@ -478,30 +478,6 @@ var _ = Describe("mappers", func() {
Expect(item.ImageBlurHashes).To(BeNil()) 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() { 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)} 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")) 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))) 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!") 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 var data dto.UserItemDataDto
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data) parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data)
Expect(*data.Rating).To(Equal(float64(6))) // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out.
Expect(*data.Likes).To(BeFalse()) Expect(data.Rating).ToNot(BeNil())
Expect(*data.Rating).To(Equal(float64(10)))
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())
}) })
}) })
}) })

View file

@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "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() { Describe("private playlists", func() {
It("does not expose another user's private playlist", func() { It("does not expose another user's private playlist", func() {
adminPl := createPlaylist("Admin Private", nil) adminPl := createPlaylist("Admin Private", nil)
@ -84,7 +53,6 @@ var _ = Describe("Multi-user access control", func() {
adminPl := createPlaylist("Admin Private", nil) 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/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, "/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() { It("lets each user manage their own playlist", func() {

View file

@ -18,13 +18,11 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since // withChiURLParam simulates chi's routing having captured a path parameter, since these
// these tests call handlers directly instead of going through the full router. // tests call handlers directly instead of going through the full router.
func withChiURLParam(r *http.Request, kv ...string) *http.Request { func withChiURLParam(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext() rctx := chi.NewRouteContext()
for i := 0; i+1 < len(kv); i += 2 { rctx.URLParams.Add(key, value)
rctx.URLParams.Add(kv[i], kv[i+1])
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) 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 return nil, model.ErrNotFound
} }
lib, err := ds.Library().Get(ctx, file.LibraryID) return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
if err != nil {
return nil, err
}
return core.Inspect(file.AbsolutePath(), *lib, file.FolderID)
} }
func inspect(ds model.DataStore) http.HandlerFunc { func inspect(ds model.DataStore) http.HandlerFunc {

View file

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

View file

@ -116,11 +116,11 @@ var _ = Describe("handleStream", func() {
BeforeEach(func() { BeforeEach(func() {
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} ds = &tests.MockDataStore{}
shareRepo = &tests.MockShareRepo{} shareRepo = &tests.MockShareRepo{}
ds.MockedShare = shareRepo ds.MockedShare = shareRepo
streamer = &mockStreamer{} 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 { makeRequest := func(token string) *httptest.ResponseRecorder {
@ -152,17 +152,8 @@ var _ = Describe("handleStream", func() {
makeRequest(token) makeRequest(token)
Expect(streamer.called).To(BeTrue()) Expect(streamer.called).To(BeTrue())
}) Expect(streamer.req.Format).To(Equal("mp3"))
Expect(streamer.req.BitRate).To(Equal(192))
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}))
}) })
It("returns 404 when the track is outside the share owner's libraries", func() { 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 http.Handler
artwork artwork.Artwork artwork artwork.Artwork
streamer stream.MediaStreamer streamer stream.MediaStreamer
decider stream.TranscodeDecider
archiver core.Archiver archiver core.Archiver
share core.Share share core.Share
assetsHandler http.Handler assetsHandler http.Handler
ds model.DataStore ds model.DataStore
} }
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router { 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, decider: decider, share: share, archiver: archiver} p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver}
shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic)
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
p.Handler = p.routes() 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, "uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
"uiCoverArtSize": conf.Server.UICoverArtSize, "uiCoverArtSize": conf.Server.UICoverArtSize,
"enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableCoverAnimation": conf.Server.EnableCoverAnimation,
"pidAlbum": conf.Server.PID.Album,
"pidTrack": conf.Server.PID.Track,
"enableNowPlaying": conf.Server.EnableNowPlaying, "enableNowPlaying": conf.Server.EnableNowPlaying,
"playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(),
"gaTrackingId": conf.Server.GATrackingID, "gaTrackingId": conf.Server.GATrackingID,

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