Merge branch 'master' into t3code/enable-get-now-playing

This commit is contained in:
Deluan Quintão 2026-09-24 19:46:23 -04:00 • committed by GitHub
commit a8af29ff26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
222 changed files with 7196 additions and 1012 deletions

View file

@ -549,7 +549,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done

View file

@ -241,6 +241,10 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
// methods, which the page block does not affect.
errNoArtistPage = errors.New("no artist image in Last.fm page")
)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
@ -267,7 +271,9 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
return res, nil
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
return nil, errNoArtistPage
}
for _, attr := range n.Attr {
if attr.Key != "content" {

View file

@ -648,18 +648,41 @@ var _ = Describe("lastfmAgent", func() {
Expect(images).To(BeEmpty())
})
It("returns empty list if page has no meta tags", func() {
It("errors when the page has no meta tags", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(MatchError(errNoArtistPage))
})
It("errors when Last.fm serves a bot challenge page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(err).To(MatchError(errNoArtistPage))
Expect(images).To(BeEmpty())
})
It("does not park the agent: the failure is not a retry-later", func() {
// A RetryLaterError would cool down the agent's API-backed methods too.
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
})
It("returns error if API call fails", func() {
apiClient.Err = errors.New("api error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")

View file

@ -49,6 +49,12 @@ type client struct {
hc httpDoer
}
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
// album.getInfo decodes only once, so it must not use this.
func escapePlus(s string) string {
return strings.ReplaceAll(s, "+", "%2B")
}
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
params := url.Values{}
params.Add("method", "album.getInfo")
@ -66,7 +72,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
params := url.Values{}
params.Add("method", "artist.getInfo")
params.Add("artist", name)
params.Add("artist", escapePlus(name))
params.Add("lang", lang)
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -78,7 +84,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
params := url.Values{}
params.Add("method", "artist.getSimilar")
params.Add("artist", name)
params.Add("artist", escapePlus(name))
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -90,7 +96,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
params := url.Values{}
params.Add("method", "artist.getTopTracks")
params.Add("artist", name)
params.Add("artist", escapePlus(name))
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
@ -102,8 +108,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
params := url.Values{}
params.Add("method", "track.getSimilar")
params.Add("track", name)
params.Add("artist", artist)
params.Add("track", escapePlus(name))
params.Add("artist", escapePlus(artist))
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {

View file

@ -35,6 +35,15 @@ var _ = Describe("client", func() {
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
})
It("does not double-encode plus signs", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
})
})
Describe("artistGetInfo", func() {
@ -48,6 +57,15 @@ var _ = Describe("client", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
})
It("fails if Last.fm returns an http status != 200", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
@ -107,6 +125,15 @@ var _ = Describe("client", func() {
Expect(len(similar.Artists)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
})
})
Describe("artistGetTopTracks", func() {
@ -119,6 +146,15 @@ var _ = Describe("client", func() {
Expect(len(top.Track)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
})
})
Describe("trackGetSimilar", func() {
@ -135,6 +171,17 @@ var _ = Describe("client", func() {
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
})
It("double-encodes plus signs in the track and artist names", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
Expect(err).ToNot(HaveOccurred())
query := httpClient.SavedRequest.URL.Query()
Expect(query.Get("track")).To(Equal("1%2B1"))
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
})
It("returns empty list when no similar tracks found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}

View file

@ -86,6 +86,7 @@ func runNavidrome(ctx context.Context) {
g.Go(startSignaller(ctx))
g.Go(startScheduler(ctx))
g.Go(startPlaybackServer(ctx))
g.Go(startJellyfinDiscovery(ctx))
g.Go(schedulePeriodicBackup(ctx))
g.Go(startInsightsCollector(ctx))
g.Go(scheduleDBAnalyzer(ctx))
@ -343,6 +344,18 @@ func startInsightsCollector(ctx context.Context) func() error {
}
}
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
func startJellyfinDiscovery(ctx context.Context) func() error {
return func() error {
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
log.Debug("Jellyfin auto-discovery is DISABLED")
return nil
}
CreateJellyfinDiscovery().Serve(ctx)
return nil
}
}
// startPlaybackServer starts the Navidrome playback server, if configured.
// It is responsible for the Jukebox functionality
func startPlaybackServer(ctx context.Context) func() error {
@ -362,10 +375,26 @@ func startPlaybackServer(ctx context.Context) func() error {
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
return func() error {
log.Info(ctx, "Starting artwork worker")
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
worker.PauseWhile(scanner.IsScanning)
return worker.Run(ctx)
}
}
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
// until it ends; both write to the DB, and competing for the lock can make either fail.
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
release, ok := scanner.LockForMaintenance()
if !ok {
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
return
}
defer release()
if err := run(ctx); err != nil {
log.Error(ctx, "Error running "+job, err)
}
}
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
// reports an artwork config change without acting on it.
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
@ -373,26 +402,20 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
schedulerInstance := scheduler.GetInstance()
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
if err := worker.EnqueueMissingAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
}); err != nil {
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
}
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
if err := worker.RunPrune(ctx); err != nil {
log.Error(ctx, "Error running artwork prune", err)
}
outsideScan(ctx, "artwork prune", worker.RunPrune)
}); err != nil {
log.Error(ctx, "Error scheduling artwork prune", err)
}
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
if err := worker.EnqueueMissingAll(ctx); err != nil {
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
}
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
if err := worker.ReconcileConfig(ctx); err != nil {
log.Error(ctx, "Error checking the artwork config fingerprint", err)

View file

@ -21,6 +21,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -79,7 +80,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
quickConnect := quickconnect.GetInstance()
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
return router
}
@ -135,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
quickConnect := quickconnect.GetInstance()
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
return router
}
@ -168,6 +171,13 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
return router
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
discovery := jellyfin.NewDiscovery(dataStore)
return discovery
}
func CreateInsights() metrics.Insights {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
@ -249,7 +259,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View file

@ -36,6 +36,7 @@ var allProviders = wire.NewSet(
server.New,
subsonic.New,
jellyfin.New,
jellyfin.NewDiscovery,
nativeapi.New,
public.New,
persistence.New,
@ -108,6 +109,12 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
))
}
func CreateJellyfinDiscovery() *jellyfin.Discovery {
panic(wire.Build(
allProviders,
))
}
func CreateInsights() metrics.Insights {
panic(wire.Build(
allProviders,

View file

@ -236,6 +236,8 @@ type jellyfinOptions struct {
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
ExposedPublicUsers string
AutoDiscovery bool
QuickConnect bool
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
@ -1089,6 +1091,8 @@ func setViperDefaults() {
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("jellyfin.enabled", false)
viper.SetDefault("jellyfin.servername", "")
viper.SetDefault("jellyfin.autodiscovery", false)
viper.SetDefault("jellyfin.quickconnect", true)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")

View file

@ -36,7 +36,7 @@ RestrictNamespaces=yes
RestrictRealtime=yes
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallFilter=setrlimit
SystemCallFilter=setrlimit mbind
SystemCallArchitectures=native
UMask=0066

View file

@ -168,6 +168,11 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
if !entityExists(ctx, s.ds, artID) {
return nil, ErrUnavailable
}
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
return s.dangling(ctx, artID)
}
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
if err != nil {
if errors.Is(err, model.ErrNotFound) {

View file

@ -2,18 +2,21 @@ package artwork
import (
"io/fs"
"net/netip"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/goleak"
@ -37,6 +40,14 @@ func TestArtwork(t *testing.T) {
RunSpecs(t, "Artwork Suite")
}
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
var productionImageClient = remoteImageClient
// httptest servers listen on loopback, which the production client refuses.
var _ = BeforeSuite(func() {
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
})
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
type osDirFS struct{ fs.FS }

View file

@ -159,6 +159,53 @@ var _ = Describe("Artwork", func() {
Expect(readAll(img)).To(Equal(coverBytes))
})
It("treats a file-backed row pointing at a non-image file as dangling", func() {
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alni")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
secret := []byte("password=secret")
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
seedEntity("al", "alnic")
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
})).To(Succeed())
// Older versions cached the raw bytes when the resize failed.
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
Expect(err).ToNot(HaveOccurred())
Expect(io.ReadAll(stream)).To(Equal(secret))
Expect(stream.Close()).To(Succeed())
Eventually(func(g Gomega) {
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
g.Expect(err).ToNot(HaveOccurred())
g.Expect(s.Cached).To(BeTrue())
_ = s.Close()
}).Should(Succeed())
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
Expect(err).To(MatchError(ErrUnavailable))
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
})
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
dir := GinkgoT().TempDir()
imgPath := filepath.Join(dir, "cover.jpg")

View file

@ -90,6 +90,28 @@ var _ = Describe("Artist artwork resolution", func() {
})
})
When("the artist's only album folder has no images of its own", func() {
// Artist/
// ├── backdrop1.jpg
// ├── folder.jpg ← matched by folder.*
// ├── logo.png
// └── Album/
// ├── 01 - Track.mp3
// └── 02 - Track.mp3
It("resolves the artist folder, not the library root", func() {
conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*"
setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
"Artist/backdrop1.jpg": smallPNG("backdrop"),
"Artist/folder.jpg": smallPNG("artist-folder"),
"Artist/logo.png": smallPNG("logo"),
})
scan()
expectArtistFolder(soleArtist(), "Artist/folder.jpg")
})
})
When("the artist's only album has its tracks in disc subfolders", func() {
// Artist/
// ├── artist.jpg ← wins (artist.* before album/artist.*)

View file

@ -56,7 +56,8 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- worker.Run(runCtx) }()
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
// Long enough for one retry (3-7s backoff, 5s poll tick).
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
cancel()
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
}

View file

@ -98,6 +98,9 @@ func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0640); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}

View file

@ -48,6 +48,17 @@ var _ = Describe("ImageStore", func() {
Expect(got).To(Equal(data))
})
It("writes group-readable image files", func() {
tests.SkipOnWindows("uses Unix file permission bits")
data := []byte("jpeg-bytes")
h, _ := hashImage(bytes.NewReader(data))
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
info, err := os.Stat(store.path(h, "image/jpeg"))
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640)))
})
It("is idempotent on duplicate writes and preserves the original content", func() {
data := []byte("dup")
h, _ := hashImage(bytes.NewReader(data))

View file

@ -572,7 +572,7 @@ func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFold
// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other
// open failure says nothing about whether the image exists.
func resolveLocalFile(path, source string) (resolution, bool) {
if path == "" {
if path == "" || !model.IsImageFile(path) {
return resolution{}, false
}
f, err := os.Open(path)

View file

@ -498,6 +498,23 @@ var _ = Describe("resolveItem", func() {
Expect(res.refMtime).To(BeNumerically(">", 0))
})
It("never opens a local ExternalImageURL that is not an image file", func() {
folderRepo.result = nil // no grid tiles, so only the local file could produce a reader
dir := GinkgoT().TempDir()
secretPath := filepath.Join(dir, "config.ini")
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(res.sourcePath).ToNot(Equal(secretPath))
})
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces

View file

@ -18,6 +18,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/netguard"
"go.senan.xyz/taglib"
)
@ -37,7 +38,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
if !match || !model.IsImageFile(name) {
continue
}
f, err := libFS.Open(file)
@ -150,10 +151,18 @@ type readCloser struct {
io.Closer
}
// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach
// internal hosts. Shared so fetches reuse connections.
var remoteImageClient = httpclient.NewExternal(5 * time.Second)
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
hc := httpclient.New(5 * time.Second)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
resp, err := hc.Do(req) //nolint:gosec
resp, err := remoteImageClient.Do(req)
if errors.Is(err, netguard.ErrPrivateAddress) {
// Retrying cannot change where the URL points: settle absent instead of tripping the breaker.
log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err)
return nil, "", model.ErrNotFound
}
if err != nil {
return nil, "", err
}

View file

@ -5,13 +5,87 @@ import (
"errors"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"strings"
"sync/atomic"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/httpclient"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("fromURL", func() {
var (
hits atomic.Int32
target *httptest.Server
)
BeforeEach(func() {
hits.Store(0)
target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = w.Write([]byte("image-bytes"))
}))
DeferCleanup(target.Close)
})
useClient := func(c *http.Client) {
prev := remoteImageClient
remoteImageClient = c
DeferCleanup(func() { remoteImageClient = prev })
}
fetch := func(rawURL string) ([]byte, error) {
u, err := url.Parse(rawURL)
Expect(err).ToNot(HaveOccurred())
r, _, err := fromURL(GinkgoT().Context(), u)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused.
onlyLocalhostV4 := func() *http.Client {
return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32"))
}
DescribeTable("refuses private and loopback targets as a definitive miss",
func(rawURL string) {
useClient(productionImageClient)
u, _ := url.Parse(target.URL)
_, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port()))
Expect(err).To(MatchError(model.ErrNotFound))
Expect(hits.Load()).To(BeZero())
},
Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"),
Entry("localhost", "http://localhost:PORT/x"),
Entry("cloud metadata", "http://169.254.169.254/"),
Entry("IPv6 loopback", "http://[::1]/"),
)
It("refuses a redirect from an allowed host to a loopback address", func() {
useClient(onlyLocalhostV4())
redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound))
DeferCleanup(redirector.Close)
_, err := fetch(redirector.URL)
Expect(err).To(MatchError(model.ErrNotFound))
Expect(hits.Load()).To(BeZero())
})
It("fetches from an allowed address", func() {
useClient(onlyLocalhostV4())
Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes")))
})
})
var _ = Describe("fromExternalFile", func() {
It("opens a matching file via the library FS", func() {
fsys := fstest.MapFS{
@ -48,6 +122,18 @@ var _ = Describe("fromExternalFile", func() {
Expect(b).To(Equal([]byte("a")))
Expect(path).To(Equal("a/cover.jpg"))
})
It("skips a matching file that is not an image", func() {
fsys := fstest.MapFS{
"a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")},
"a/cover.jpg": &fstest.MapFile{Data: []byte("a")},
}
f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*")
r, path, err := f()
Expect(err).ToNot(HaveOccurred())
defer r.Close()
Expect(path).To(Equal("a/cover.jpg"))
})
})
var _ = Describe("fromTag", func() {

View file

@ -46,6 +46,7 @@ type Worker struct {
pruneMu sync.RWMutex
pools []*drainPool
runCtx context.Context
paused func() bool
gatesMu sync.Mutex
gates map[string]*extGate
@ -59,6 +60,7 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg
broker: broker,
pools: newDrainPools(),
runCtx: context.Background(),
paused: func() bool { return false },
gates: map[string]*extGate{},
}
w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate)
@ -90,6 +92,11 @@ var (
}
)
// PauseWhile holds off queue draining whenever paused reports true. Call it before Run.
func (w *Worker) PauseWhile(paused func() bool) {
w.paused = paused
}
// Run blocks draining the queue until ctx is cancelled.
func (w *Worker) Run(ctx context.Context) error {
w.runCtx = ctx
@ -143,6 +150,9 @@ func (w *Worker) EnqueueMissingAll(ctx context.Context) error {
}
func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) {
if w.paused() {
return 0, nil
}
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
@ -170,6 +180,9 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
wg.Wait()
return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error
}
if w.paused() {
break
}
wg.Go(func() {
defer func() { <-sem }()
out, got := w.process(ctx, item)

View file

@ -866,6 +866,34 @@ var _ = Describe("Worker", func() {
}
})
It("stops dispatching and leaves the rest queued when paused mid-batch", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
albums := model.Albums{}
for i := range 8 {
id := fmt.Sprintf("alp%d", i)
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
})).To(Succeed())
}
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
// Pauses as soon as the first item has left the queue.
w.PauseWhile(func() bool {
n, _ := queueRepo.Count()
return n < 8
})
_, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
count, err := queueRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
})
It("dequeues past the worker pool so one drain covers many items", func() {
for i := range 16 {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
@ -896,6 +924,30 @@ var _ = Describe("Worker", func() {
Eventually(done, time.Second).Should(Receive(BeNil()))
})
It("does not drain the queue while paused", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
})
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
})).To(Succeed())
w.PauseWhile(func() bool { return true })
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- w.Run(runCtx) }()
DeferCleanup(func() {
cancel()
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
})
Consistently(func() any { return findQueued(queueRepo, "al", "al1") }, 300*time.Millisecond).ShouldNot(BeNil())
})
It("does not leak goroutines after Run exits", func() {
DeferCleanup(configtest.SetupConfig())

View file

@ -2,7 +2,6 @@ package core
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
@ -307,14 +306,7 @@ func (r *libraryRepositoryWrapper) mapError(err error) error {
}
}
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return err
}
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {

View file

@ -38,7 +38,7 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
user, _ := request.UserFrom(ctx)
if playerID != "" {
plr, err = p.ds.Player(ctx).Get(playerID)
if err == nil && plr.Client != client {
if err == nil && (plr.Client != client || plr.UserId != user.ID) {
playerID = ""
}
}

View file

@ -61,8 +61,19 @@ var _ = Describe("Players", func() {
Expect(trc).To(BeNil())
})
It("does not reuse another user's player by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
Expect(p.ID).ToNot(Equal("123"))
Expect(p.UserId).To(Equal("userid"))
Expect(repo.lastSaved).To(Equal(p))
Expect(trc).To(BeNil())
})
It("finds players by ID", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())
@ -93,7 +104,7 @@ var _ = Describe("Players", func() {
})
It("finds player by ID and return its transcoding", func() {
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"}
plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"}
repo.add(plr)
p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
Expect(err).ToNot(HaveOccurred())

View file

@ -236,6 +236,24 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() {
tmpDir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed())
m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n"
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(BeEmpty())
})
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
conf.Server.EnableM3UExternalAlbumArt = false
@ -1011,6 +1029,19 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ExternalImageURL).To(BeEmpty())
})
DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries",
func(imageURL, expected string) {
ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}})
repo.data = []string{"tests/test.mp3"}
m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n"
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
Expect(err).ToNot(HaveOccurred())
Expect(pls.ExternalImageURL).To(Equal(expected))
},
Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")),
Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""),
)
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
// so we need exact matching for non-ASCII characters.
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {

View file

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@ -36,7 +37,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
continue
}
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
owner, _ := request.UserFrom(ctx)
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner)
continue
}
// Skip empty lines and extended info
@ -286,7 +288,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
// Local paths (file://, absolute, or relative) are resolved to an absolute path
// and validated against known library boundaries via matcher.
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
@ -302,12 +304,13 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
// Resolve to local absolute path
localPath, ok := resolveLocalPath(value, folder)
if !ok {
if !ok || !model.IsImageFile(localPath) {
return ""
}
// Validate path is within a known library
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
lib, ok := matcher.findLibrary(localPath)
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
return ""
}
return localPath

View file

@ -32,6 +32,7 @@ type Playlists interface {
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
@ -268,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
// InsertTracks adds tracks before the 1-based position pos; a position past the end appends.
func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
var count int
// Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under
// concurrent writers instead of waiting for the lock.
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var err error
count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos)
return err
})
return count, err
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
@ -302,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}

View file

@ -324,6 +324,43 @@ var _ = Describe("Playlists", func() {
})
})
Describe("InsertTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
})
It("inserts the tracks at the given position for the owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"}))
Expect(mockTracks.InsertPos).To(Equal(3))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockTracks.AddedIds).To(BeEmpty())
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo

View file

@ -2,7 +2,6 @@ package playlists
import (
"context"
"errors"
"reflect"
"strings"
@ -41,15 +40,7 @@ func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string
}
func (r *playlistRepositoryWrapper) Delete(id string) error {
err := r.service.Delete(r.ctx, id)
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return r.service.Delete(r.ctx, id)
}
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
@ -92,14 +83,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
current, err := s.checkWritable(ctx, id)
if err != nil {
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
return err
}
sent := sentFields(cols)

View file

@ -0,0 +1,203 @@
// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and
// an already signed-in user approves it, so the client can sign in without a password.
package quickconnect
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/singleton"
)
const timeout = 10 * time.Minute
// Initiate is unauthenticated, so the number of live requests must be bounded.
const maxPending = 1000
var (
ErrAlreadyAuthorized = errors.New("quick connect request already authorized")
ErrTooManyRequests = errors.New("too many pending quick connect requests")
)
type Device struct {
ID string
Name string
App string
AppVersion string
}
type Request struct {
Device Device
Secret string
Code string
DateAdded time.Time
UserID string
}
func (r Request) Authorized() bool {
return r.UserID != ""
}
type QuickConnect interface {
Initiate(device Device) (Request, error)
Status(secret string) (Request, error)
// Lookup returns a request still waiting for approval.
Lookup(code string) (Request, error)
Authorize(code, userID string) (Request, error)
// Redeem returns the id of the user who approved the request. It succeeds only once per secret.
Redeem(secret string) (string, error)
}
type entry struct {
Request
expiresAt time.Time
}
type quickConnect struct {
mu sync.Mutex
bySecret map[string]*entry
byCode map[string]*entry
}
// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests.
func GetInstance() QuickConnect {
return singleton.GetInstance(newStore)
}
func New() QuickConnect {
return newStore()
}
func newStore() *quickConnect {
return &quickConnect{
bySecret: map[string]*entry{},
byCode: map[string]*entry{},
}
}
// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API.
func Enabled() bool {
return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect
}
func (qc *quickConnect) Initiate(device Device) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
qc.expire()
if len(qc.bySecret) >= maxPending {
return Request{}, ErrTooManyRequests
}
// The fields may be substrings of a much larger header; copy them so the header isn't retained.
device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name),
App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)}
now := time.Now()
e := &entry{
Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now},
expiresAt: now.Add(timeout),
}
qc.bySecret[e.Secret] = e
qc.byCode[e.Code] = e
return e.Request, nil
}
func (qc *quickConnect) Status(secret string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Lookup(code string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
return e.Request, nil
}
func (qc *quickConnect) Authorize(code, userID string) (Request, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.findPending(code)
if err != nil {
return Request{}, err
}
e.UserID = userID
e.expiresAt = time.Now().Add(timeout)
return e.Request, nil
}
func (qc *quickConnect) Redeem(secret string) (string, error) {
qc.mu.Lock()
defer qc.mu.Unlock()
e, err := qc.find(qc.bySecret, secret)
if err != nil || !e.Authorized() {
return "", model.ErrNotFound
}
qc.remove(e)
return e.UserID, nil
}
func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) {
qc.expire()
e, ok := index[key]
if !ok {
return nil, model.ErrNotFound
}
return e, nil
}
func (qc *quickConnect) findPending(code string) (*entry, error) {
e, err := qc.find(qc.byCode, normalizeCode(code))
if err == nil && e.Authorized() {
return nil, ErrAlreadyAuthorized
}
return e, err
}
func (qc *quickConnect) expire() {
now := time.Now()
for _, e := range qc.bySecret {
if !now.Before(e.expiresAt) {
qc.remove(e)
}
}
}
func (qc *quickConnect) remove(e *entry) {
delete(qc.bySecret, e.Secret)
delete(qc.byCode, e.Code)
}
func (qc *quickConnect) newCode() string {
for {
code := fmt.Sprintf("%06d", random.Int64N(900000)+100000)
if _, taken := qc.byCode[code]; !taken {
return code
}
}
}
func newSecret() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Users often type the code in groups ("123 456").
func normalizeCode(code string) string {
return strings.Join(strings.Fields(code), "")
}

View file

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

View file

@ -0,0 +1,190 @@
package quickconnect
import (
"testing"
"testing/synctest"
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}
var _ = Describe("QuickConnect", func() {
var qc QuickConnect
BeforeEach(func() {
qc = New()
})
Describe("Initiate", func() {
It("creates a pending request with a 6-digit code and a random secret", func() {
req, err := qc.Initiate(device)
Expect(err).ToNot(HaveOccurred())
Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`))
Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`))
Expect(req.Device).To(Equal(device))
Expect(req.DateAdded).ToNot(BeZero())
Expect(req.Authorized()).To(BeFalse())
})
It("never gives two live requests the same code or secret", func() {
codes := map[string]bool{}
secrets := map[string]bool{}
for range 500 {
req, err := qc.Initiate(device)
Expect(err).ToNot(HaveOccurred())
codes[req.Code] = true
secrets[req.Secret] = true
}
Expect(codes).To(HaveLen(500))
Expect(secrets).To(HaveLen(500))
})
It("refuses new requests when too many are pending", func() {
for range maxPending {
_, err := qc.Initiate(device)
Expect(err).ToNot(HaveOccurred())
}
_, err := qc.Initiate(device)
Expect(err).To(MatchError(ErrTooManyRequests))
})
})
Describe("Status", func() {
It("returns the request for a known secret", func() {
req, _ := qc.Initiate(device)
got, err := qc.Status(req.Secret)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(req))
})
It("returns ErrNotFound for an unknown secret", func() {
_, err := qc.Status("nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Lookup", func() {
It("finds a request by code, ignoring spaces", func() {
req, _ := qc.Initiate(device)
got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:])
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(req))
})
It("returns ErrNotFound for an unknown code", func() {
_, err := qc.Lookup("000000")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("refuses a request that is already authorized", func() {
req, _ := qc.Initiate(device)
_, _ = qc.Authorize(req.Code, "user-1")
_, err := qc.Lookup(req.Code)
Expect(err).To(MatchError(ErrAlreadyAuthorized))
})
})
Describe("Authorize", func() {
It("marks the request as authorized by the user", func() {
req, _ := qc.Initiate(device)
got, err := qc.Authorize(" "+req.Code+" ", "user-1")
Expect(err).ToNot(HaveOccurred())
Expect(got.Authorized()).To(BeTrue())
Expect(got.UserID).To(Equal("user-1"))
status, _ := qc.Status(req.Secret)
Expect(status.Authorized()).To(BeTrue())
})
It("refuses a request that is already authorized", func() {
req, _ := qc.Initiate(device)
_, _ = qc.Authorize(req.Code, "user-1")
_, err := qc.Authorize(req.Code, "user-2")
Expect(err).To(MatchError(ErrAlreadyAuthorized))
})
It("returns ErrNotFound for an unknown code", func() {
_, err := qc.Authorize("000000", "user-1")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Redeem", func() {
It("returns the approving user once, then forgets the request", func() {
req, _ := qc.Initiate(device)
_, _ = qc.Authorize(req.Code, "user-1")
userID, err := qc.Redeem(req.Secret)
Expect(err).ToNot(HaveOccurred())
Expect(userID).To(Equal("user-1"))
_, err = qc.Redeem(req.Secret)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Status(req.Secret)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Lookup(req.Code)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("refuses a request that is not authorized yet", func() {
req, _ := qc.Initiate(device)
_, err := qc.Redeem(req.Secret)
Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Status(req.Secret)
Expect(err).ToNot(HaveOccurred())
})
})
})
// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give.
func TestPendingRequestExpires(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
qc := New()
req, _ := qc.Initiate(device)
time.Sleep(timeout - time.Second)
_, err := qc.Status(req.Secret)
g.Expect(err).ToNot(HaveOccurred())
time.Sleep(2 * time.Second)
_, err = qc.Status(req.Secret)
g.Expect(err).To(MatchError(model.ErrNotFound))
_, err = qc.Authorize(req.Code, "user-1")
g.Expect(err).To(MatchError(model.ErrNotFound))
})
}
func TestAuthorizeExtendsExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
qc := New()
req, _ := qc.Initiate(device)
time.Sleep(timeout - time.Second)
_, err := qc.Authorize(req.Code, "user-1")
g.Expect(err).ToNot(HaveOccurred())
time.Sleep(timeout - time.Second)
userID, err := qc.Redeem(req.Secret)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(userID).To(Equal("user-1"))
})
}
func TestExpiredRequestsFreeCapacity(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
qc := New()
for range maxPending {
_, _ = qc.Initiate(device)
}
time.Sleep(timeout + time.Second)
_, err := qc.Initiate(device)
g.Expect(err).ToNot(HaveOccurred())
})
}

View file

@ -17,6 +17,7 @@ import (
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/navidrome/navidrome/utils/slice"
)
const (
@ -444,8 +445,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
return nil
}
func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) {
func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) {
// The cache is process-global, so it holds every user's playback, across all libraries.
res := p.playMap.Values()
if user, ok := request.UserFrom(ctx); ok {
res = slice.Filter(res, func(s PlaybackSession) bool {
return user.HasLibraryAccess(s.MediaFile.LibraryID)
})
}
slices.SortFunc(res, func(a, b PlaybackSession) int {
return b.Start.Compare(a.Start)
})

View file

@ -92,7 +92,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
fake = &fakeScrobbler{Authorized: true}
@ -108,6 +108,7 @@ var _ = Describe("PlayTracker", func() {
track = model.MediaFile{
ID: "123",
LibraryID: 1,
Title: "Track Title",
Album: "Track Album",
AlbumID: "al-1",
@ -174,6 +175,46 @@ var _ = Describe("PlayTracker", func() {
Expect(playing[1].Username).To(Equal("user-1"))
Expect(playing[1].MediaFile.ID).To(Equal("123"))
})
It("hides sessions playing from libraries the caller cannot access", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
)
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
playing, err := tracker.GetNowPlaying(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only")
})
It("shows every session to an admin", func() {
hidden := track
hidden.ID = "789"
hidden.LibraryID = 2
_ = ds.MediaFile(ctx).Put(&hidden)
reporter := request.WithPlayer(
request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}),
model.Player{ScrobbleEnabled: true},
)
_ = tracker.ReportPlayback(reporter, ReportPlaybackParams{
MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two",
})
adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true})
playing, err := tracker.GetNowPlaying(adminCtx)
Expect(err).ToNot(HaveOccurred())
Expect(playing).To(HaveLen(1))
Expect(playing[0].MediaFile.ID).To(Equal("789"))
})
})
Describe("Expiration events", func() {

View file

@ -23,6 +23,7 @@ type TranscodeDecider interface {
CreateTranscodeParams(decision *TranscodeDecision) (string, error)
ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request
}
func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {

View file

@ -73,37 +73,8 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate)
// Apply server-side player transcoding override before making the decision
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok {
modified := *clientInfo
if modified.CapBitrate(player.MaxBitRate) {
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
if err != nil {
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
req.Format = "raw"
return req
}
if decision.CanDirectPlay {
req.Format = "raw"
return req
}
if decision.CanTranscode {
req.Format = decision.TargetFormat
req.BitRate = decision.TargetBitrate
req.SampleRate = decision.TargetSampleRate
req.BitDepth = decision.TargetBitDepth
req.Channels = decision.TargetChannels
return req
if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok {
return resolved
}
// No compatible profile for the requested format — retry with DefaultDownsamplingFormat
@ -119,3 +90,47 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
req.Format = "raw"
return req
}
// ResolveClientRequest resolves a stream request for a client that declared its own direct play
// and transcoding profiles, falling back to raw when none fits.
func (s *deciderService) ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request {
if req, ok := s.resolve(ctx, mf, clientInfo, offset); ok {
return req
}
return Request{Format: "raw", Offset: offset}
}
// resolve applies the server-side player overrides to clientInfo and maps the decision to a
// Request. ok is false when no profile fits.
func (s *deciderService) resolve(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) (Request, bool) {
req := Request{Offset: offset}
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok {
modified := *clientInfo
if modified.CapBitrate(player.MaxBitRate) {
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}
}
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
if err != nil {
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
req.Format = "raw"
return req, true
}
switch {
case decision.CanDirectPlay:
req.Format = "raw"
case decision.CanTranscode:
req.Format = decision.TargetFormat
req.BitRate = decision.TargetBitrate
req.SampleRate = decision.TargetSampleRate
req.BitDepth = decision.TargetBitDepth
req.Channels = decision.TargetChannels
default:
return req, false
}
return req, true
}

View file

@ -414,3 +414,72 @@ var _ = Describe("ResolveRequest", func() {
})
})
})
var _ = Describe("ResolveClientRequest", func() {
var (
svc TranscodeDecider
ctx context.Context
)
mp3Target := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}}
BeforeEach(func() {
ctx = GinkgoT().Context()
ds := &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedTranscoding: &tests.MockTranscodingRepo{},
}
auth.Init(ds)
svc = NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))
})
It("direct plays a source matching a profile through container aliases", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}}},
TranscodingProfiles: mp3Target,
}
req := svc.ResolveClientRequest(ctx, mf, ci, 5)
Expect(req.Format).To(Equal("raw"))
Expect(req.Offset).To(Equal(5))
})
It("transcodes to the client's transcoding profile when no direct play profile matches", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp3"}}},
TranscodingProfiles: mp3Target,
}
Expect(svc.ResolveClientRequest(ctx, mf, ci, 0).Format).To(Equal("mp3"))
})
It("transcodes a direct-playable source over the bitrate cap to the client's target", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxAudioBitrate: 128,
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}},
TranscodingProfiles: mp3Target,
}
req := svc.ResolveClientRequest(ctx, mf, ci, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(128))
})
It("applies the player's MaxBitRate cap", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ctx = request.WithPlayer(ctx, model.Player{MaxBitRate: 192})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}},
TranscodingProfiles: mp3Target,
}
req := svc.ResolveClientRequest(ctx, mf, ci, 0)
Expect(req.Format).To(Equal("mp3"))
Expect(req.BitRate).To(Equal(192))
})
})

View file

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
)
@ -32,6 +33,7 @@ var Set = wire.NewSet(
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,
quickconnect.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
)

View file

@ -133,6 +133,12 @@ func ErrorCodes(err error) (code, extended int, ok bool) {
return int(se.Code), int(se.ExtendedCode), true
}
// IsBusy reports whether err is SQLITE_BUSY, including BUSY_SNAPSHOT, which only a new transaction clears.
func IsBusy(err error) bool {
code, _, ok := ErrorCodes(err)
return ok && code == int(sqlite3.ErrBusy)
}
type statusLogger struct{ numPending int }
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }

View file

@ -3,8 +3,12 @@ package db_test
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
@ -19,6 +23,18 @@ func TestDB(t *testing.T) {
RunSpecs(t, "DB Suite")
}
var _ = DescribeTable("IsBusy",
func(err error, expected bool) {
Expect(db.IsBusy(err)).To(Equal(expected))
},
Entry("SQLITE_BUSY", sqlite3.Error{Code: sqlite3.ErrBusy}, true),
Entry("SQLITE_BUSY_SNAPSHOT", sqlite3.Error{Code: sqlite3.ErrBusy, ExtendedCode: sqlite3.ErrBusySnapshot}, true),
Entry("a wrapped SQLITE_BUSY", fmt.Errorf("persisting: %w", sqlite3.Error{Code: sqlite3.ErrBusy}), true),
Entry("another SQLite error", sqlite3.Error{Code: sqlite3.ErrConstraint}, false),
Entry("a non-SQLite error", errors.New("database is locked"), false),
Entry("nil", nil, false),
)
var _ = Describe("IsSchemaEmpty", func() {
var database *sql.DB
var ctx context.Context

View file

@ -0,0 +1,24 @@
-- +goose Up
-- +goose StatementBegin
-- 32-bit builds cannot read values above the int32 range written by 64-bit builds.
update media_file set track_number = 0
where track_number < 0 or track_number > 2147483647;
update media_file set disc_number = 0
where disc_number < 0 or disc_number > 2147483647;
update media_file set bpm = null
where bpm < 0 or bpm > 2147483647;
update album set discs = (
select json_group_object(key, value) from json_each(album.discs)
where cast(key as integer) between 0 and 2147483647
)
where json_valid(discs) and exists (
select 1 from json_each(album.discs)
where cast(key as integer) not between 0 and 2147483647
);
-- +goose StatementEnd
-- +goose Down
SELECT 1;

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
@ -134,16 +135,65 @@ func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error {
return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err))
}
log.Debug(ctx, "Refreshing query planner statistics")
_, err := db.ExecContext(ctx, "ANALYZE")
if err != nil {
if err := analyzeInSteps(ctx, db); err != nil {
return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err))
}
if err = recordAnalyzeSuccess(ctx, db, now); err != nil {
if err := recordAnalyzeSuccess(ctx, db, now); err != nil {
return recordAnalyzeError(ctx, db, now, err)
}
return nil
}
// One ANALYZE per index (whole table if WITHOUT ROWID or lacking a non-partial index) yields the
// same sqlite_stat1 rows as a full ANALYZE, but frees the write lock between steps.
const analyzeTargetsSQL = `
SELECT i.name FROM sqlite_schema i JOIN pragma_table_list t ON t.schema = 'main' AND t.name = i.tbl_name
WHERE i.type = 'index' AND t.wr = 0 AND EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0)
UNION ALL
SELECT t.name FROM pragma_table_list t
WHERE t.schema = 'main' AND t.type IN ('table', 'shadow') AND t.name NOT LIKE 'sqlite_%'
AND (t.wr = 1 OR NOT EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0))`
// analyzeMaxYield is just above SQLite's longest busy-handler sleep, so every waiting writer
// retries during the pause.
const analyzeMaxYield = 150 * time.Millisecond
func analyzeInSteps(ctx context.Context, db *sql.DB) error {
targets, err := analyzeTargets(ctx, db)
if err != nil {
return err
}
for _, target := range targets {
start := time.Now()
if _, err := db.ExecContext(ctx, `ANALYZE "`+strings.ReplaceAll(target, `"`, `""`)+`"`); err != nil {
return fmt.Errorf("analyzing %s: %w", target, err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(min(time.Since(start), analyzeMaxYield)):
}
}
return nil
}
func analyzeTargets(ctx context.Context, db *sql.DB) ([]string, error) {
rows, err := db.QueryContext(ctx, analyzeTargetsSQL)
if err != nil {
return nil, fmt.Errorf("listing ANALYZE targets: %w", err)
}
defer rows.Close()
var targets []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("listing ANALYZE targets: %w", err)
}
targets = append(targets, name)
}
return targets, rows.Err()
}
func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {

View file

@ -75,6 +75,43 @@ var _ = Describe("Optimize", func() {
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
})
It("produces the same statistics as a single full ANALYZE", func() {
putProperty(consts.DBAnalyzePendingKey, "1")
for _, stmt := range []string{
"create table unindexed(id integer primary key, v int)",
"insert into unindexed(v) select flag from analyze_probe",
"create table no_rowid(k text primary key, v int) without rowid",
"insert into no_rowid select 'k' || id, id % 7 from analyze_probe",
"create index no_rowid_v on no_rowid(v)",
"create table partial_only(id integer primary key, v int)",
"insert into partial_only(v) select id % 5 from analyze_probe",
"create index partial_only_v on partial_only(v) where v = 1",
"analyze",
} {
_, err := database.Exec(stmt)
Expect(err).ToNot(HaveOccurred())
}
statRows := func() []string {
rows, err := database.Query("select tbl || '|' || coalesce(idx, '') || '|' || stat from sqlite_stat1 order by 1")
Expect(err).ToNot(HaveOccurred())
defer rows.Close()
var res []string
for rows.Next() {
var s string
Expect(rows.Scan(&s)).To(Succeed())
res = append(res, s)
}
return res
}
fullAnalyze := statRows()
_, err := database.Exec("delete from sqlite_stat1")
Expect(err).ToNot(HaveOccurred())
Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed())
Expect(statRows()).To(Equal(fullAnalyze))
})
It("runs when no previous analysis was recorded", func() {
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
Expect(err).ToNot(HaveOccurred())

2
go.mod
View file

@ -9,7 +9,7 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.5
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36
github.com/djherbis/atime v1.1.0

4
go.sum
View file

@ -31,8 +31,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk=
github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12 h1:x4N/tx0XC9zcnipk5rpdI4qaG9JmovHW5HL97XYnO1I=
github.com/deluan/rest v0.0.0-20260913134927-47b21f30cc12/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA=

View file

@ -64,18 +64,21 @@ func (c Criteria) IsPercentageLimit() bool {
}
func (c Criteria) ChildPlaylistIds() []string {
if c.Expression == nil {
return nil
}
return c.childPlaylistRefs(conjunction.ChildPlaylistIds)
}
func (c Criteria) ChildPlaylistPaths() []string {
return c.childPlaylistRefs(conjunction.ChildPlaylistPaths)
}
func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string {
parent, ok := c.Expression.(conjunction)
if !ok {
return nil
}
ids := parent.ChildPlaylistIds()
slices.Sort(ids)
return slices.Compact(ids)
refs := extract(parent)
slices.Sort(refs)
return slices.Compact(refs)
}
func (c Criteria) MarshalJSON() ([]byte, error) {

View file

@ -323,19 +323,23 @@ var _ = Describe("Criteria", func() {
Context("with child playlists", func() {
var (
topLevelInPlaylistID string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
topLevelInPlaylistID string
topLevelInPlaylistPath string
topLevelNotInPlaylistID string
nestedAnyInPlaylistID string
nestedAnyNotInPlaylistID string
nestedAllInPlaylistID string
nestedAllNotInPlaylistID string
nestedAnyNotInPlaylistPath string
)
BeforeEach(func() {
topLevelInPlaylistID = uuid.NewString()
topLevelInPlaylistPath = "./test.nsp"
topLevelNotInPlaylistID = uuid.NewString()
nestedAnyInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistID = uuid.NewString()
nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u"
nestedAllInPlaylistID = uuid.NewString()
nestedAllNotInPlaylistID = uuid.NewString()
@ -343,10 +347,12 @@ var _ = Describe("Criteria", func() {
goObj = Criteria{
Expression: All{
InPlaylist{"id": topLevelInPlaylistID},
InPlaylist{"path": topLevelInPlaylistPath},
NotInPlaylist{"id": topLevelNotInPlaylistID},
Any{
InPlaylist{"id": nestedAnyInPlaylistID},
NotInPlaylist{"id": nestedAnyNotInPlaylistID},
NotInPlaylist{"path": nestedAnyNotInPlaylistPath},
},
All{
InPlaylist{"id": nestedAllInPlaylistID},
@ -359,6 +365,18 @@ var _ = Describe("Criteria", func() {
ids := goObj.ChildPlaylistIds()
gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID))
})
It("extracts all child smart playlist paths from expression criteria", func() {
paths := goObj.ChildPlaylistPaths()
gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath))
})
It("ignores empty child playlist paths", func() {
c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}}
gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty())
})
It("ignores empty child playlist ids", func() {
c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}}
gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty())
})
It("extracts child smart playlist IDs from deeply nested expression", func() {
goObj = Criteria{
Expression: Any{

View file

@ -1,8 +1,9 @@
package criteria
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively
// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively
type conjunction interface {
ChildPlaylistIds() []string
ChildPlaylistPaths() []string
}
type (
@ -16,9 +17,9 @@ func (all All) MarshalJSON() ([]byte, error) {
return marshalConjunction("all", all)
}
func (all All) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(all)
}
func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") }
func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") }
type (
Any []Expression
@ -31,9 +32,9 @@ func (any Any) MarshalJSON() ([]byte, error) {
return marshalConjunction("any", any)
}
func (any Any) ChildPlaylistIds() (ids []string) {
return extractPlaylistIds(any)
}
func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") }
func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") }
type Is map[string]any
type Eq = Is
@ -172,28 +173,20 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) {
func (ip IsPresent) fields() map[string]any { return ip }
func extractPlaylistIds(inputRule any) (ids []string) {
var id string
var ok bool
func extractPlaylistField(inputRule any, field string) (values []string) {
switch rule := inputRule.(type) {
case Any:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
values = append(values, extractPlaylistField(rules, field)...)
}
case All:
for _, rules := range rule {
ids = append(ids, extractPlaylistIds(rules)...)
values = append(values, extractPlaylistField(rules, field)...)
}
case InPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
}
case NotInPlaylist:
if id, ok = rule["id"].(string); ok {
ids = append(ids, id)
case InPlaylist, NotInPlaylist:
if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" {
values = append(values, value)
}
}
return
}

View file

@ -47,5 +47,8 @@ type DataStore interface {
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
// WithTxRetry runs block in a transaction, rerunning it while SQLite reports the database busy.
// For background work only (it can take minutes), and block must be safe to rerun after a rollback.
WithTxRetry(ctx context.Context, block func(ctx context.Context, tx DataStore) error, scope ...string) error
GC(ctx context.Context, libraryIDs ...int) error
}

View file

@ -1,11 +1,15 @@
package model
import "errors"
import (
"errors"
"github.com/deluan/rest"
)
var (
ErrNotFound = errors.New("data not found")
ErrNotFound = rest.ErrNotFound
ErrInvalidAuth = errors.New("invalid authentication")
ErrNotAuthorized = errors.New("not authorized")
ErrNotAuthorized = rest.ErrPermissionDenied
ErrExpired = errors.New("access expired")
ErrNotAvailable = errors.New("functionality not available")
ErrValidation = errors.New("validation error")

View file

@ -37,8 +37,8 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.CatalogNum = md.String(model.TagCatalogNumber)
mf.Comment = md.String(model.TagComment)
if f := md.NullableFloat(model.TagBPM); f != nil {
if v := int(math.Round(*f)); v != 0 {
mf.BPM = new(v)
if r := math.Round(*f); r > 0 && r <= math.MaxInt32 {
mf.BPM = new(int(r))
}
}
mf.Lyrics = md.mapLyrics()

View file

@ -131,6 +131,15 @@ var _ = Describe("ToMediaFile", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil())
Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil())
})
It("leaves BPM nil when the tag does not fit in 32 bits", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil())
})
It("leaves BPM nil when the tag is negative", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil())
})
It("keeps the largest 32-bit BPM value", func() {
Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647)))
})
})
Describe("BitDepth", func() {

View file

@ -148,15 +148,20 @@ func (md Metadata) tuple(key model.TagName) (int, int) {
return 0, 0
}
tuple := strings.Split(tag, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
total := md.first(key + "total")
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2tag := md.first(key + "total")
t2, _ = strconv.Atoi(t2tag)
total = tuple[1]
}
return t1, t2
return tagNumber(tuple[0]), tagNumber(total)
}
// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds.
func tagNumber(s string) int {
v, err := strconv.ParseInt(s, 10, 32)
if err != nil || v < 0 {
return 0
}
return int(v)
}
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)

View file

@ -226,6 +226,10 @@ var _ = Describe("Metadata", func() {
Entry(nil, "2/10", "", 2, 10),
Entry(nil, "", "", 0, 0),
Entry(nil, "A", "", 0, 0),
Entry("ignores values that do not fit in 32 bits", "4294967295", "4294967296", 0, 0),
Entry("ignores a total that does not fit in 32 bits", "2/4294967295", "", 2, 0),
Entry("keeps the largest 32-bit value", "2147483647", "", 2147483647, 0),
Entry("ignores negative values", "-1", "-2", 0, 0),
)
Describe("Performers", func() {

View file

@ -2,12 +2,16 @@ package model
import (
"iter"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/criteria"
)
@ -137,6 +141,66 @@ func (pls Playlist) UploadedImagePath() string {
return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage)
}
// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths.
func (pls Playlist) NormalizedRules() *criteria.Criteria {
if pls.Rules == nil || pls.Rules.Expression == nil {
return pls.Rules
}
rules := *pls.Rules
rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path)
return &rules
}
func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression {
switch rule := inputRule.(type) {
case criteria.Any:
anyCriteria := make(criteria.Any, len(rule))
for i, rules := range rule {
anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
}
return anyCriteria
case criteria.All:
allCriteria := make(criteria.All, len(rule))
for i, rules := range rule {
allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath)
}
return allCriteria
case criteria.InPlaylist:
return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
case criteria.NotInPlaylist:
return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath))
}
return inputRule
}
func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any {
path, ok := rule["path"].(string)
if !ok || path == "" {
return rule
}
// References use forward slashes to stay portable, while Playlist.Path is OS-native.
path = filepath.FromSlash(path)
switch {
case isAbsPlaylistRef(path):
path = filepath.Clean(path)
case referencingPlaylistPath != "":
path = filepath.Join(filepath.Dir(referencingPlaylistPath), path)
default:
log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path)
}
normalized := maps.Clone(rule)
normalized["path"] = path
return normalized
}
// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute.
func isAbsPlaylistRef(path string) bool {
return filepath.IsAbs(path) || os.IsPathSeparator(path[0])
}
type Playlists []Playlist
type PlaylistCursor iter.Seq2[Playlist, error]
@ -184,6 +248,7 @@ type PlaylistTrackRepository interface {
GetAlbumIDs(options ...QueryOptions) ([]string, error)
GetMediaFileIDs(options ...QueryOptions) ([]string, error)
Add(mediaFileIds []string) (int, error)
Insert(mediaFileIds []string, pos int) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)
AddDiscs(discs []DiscID) (int, error)

View file

@ -1,6 +1,7 @@
package model_test
import (
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -88,4 +89,102 @@ var _ = Describe("Playlist", func() {
Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse())
})
})
Describe("NormalizedRules()", func() {
// absPath builds an OS-native absolute path so these specs also run on Windows.
absPath := func(parts ...string) string {
abs, err := filepath.Abs(filepath.Join(parts...))
Expect(err).ToNot(HaveOccurred())
return abs
}
normalize := func(pls model.Playlist) criteria.Expression {
return pls.NormalizedRules().Expression
}
It("resolves relative references against the playlist folder", func() {
pls := model.Playlist{
Path: absPath("test", "nested", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{
criteria.InPlaylist{"path": "../up.m3u"},
criteria.NotInPlaylist{"path": "./sibling.nsp"},
criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}},
}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": absPath("test", "up.m3u")},
criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")},
criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}},
}))
})
It("cleans absolute references", func() {
dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp"
pls := model.Playlist{
Path: absPath("test", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.NotInPlaylist{"path": absPath("music", "child.nsp")},
}))
})
It("treats a leading slash as absolute on every OS", func() {
pls := model.Playlist{
Path: absPath("test", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")},
}))
})
It("leaves empty paths and id references untouched", func() {
pls := model.Playlist{
Path: absPath("test", "my-playlist.nsp"),
Rules: &criteria.Criteria{Expression: criteria.All{
criteria.InPlaylist{"path": ""},
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
criteria.Eq{"artist": "Bob Dealin"},
}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": ""},
criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"},
criteria.Eq{"artist": "Bob Dealin"},
}))
})
It("skips relative references when the playlist has no path", func() {
pls := model.Playlist{
Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}},
}
Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{
criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")},
}))
})
It("preserves every other criteria field", func() {
rules := criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}},
Sort: "title",
Order: "desc",
Limit: 10,
LimitPercent: 25,
Offset: 5,
RefreshDelay: 3 * time.Hour,
}
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules}
normalized := *pls.NormalizedRules()
normalized.Expression = rules.Expression
Expect(normalized).To(Equal(rules))
})
It("does not mutate the original playlist rules", func() {
original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}}
pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}}
_ = pls.NormalizedRules()
Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"}))
})
})
})

View file

@ -13,6 +13,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"golang.org/x/text/unicode/norm"
)
type smartPlaylistJoinType int
@ -344,11 +345,15 @@ func startOfPeriod(numDays int64, from time.Time) string {
}
func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
playlistID, ok := values["id"].(string)
if !ok {
return nil, errors.New("playlist id not given")
var condition squirrel.Sqlizer
if playlistId, ok := values["id"].(string); ok && playlistId != "" {
condition = squirrel.Eq{"pl.playlist_id": playlistId}
} else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" {
condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)}
} else {
return nil, errors.New("playlist id or path not given")
}
filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}}
filters := squirrel.And{condition}
if !c.owner.IsAdmin {
if c.owner.ID == "" {
filters = append(filters, squirrel.Eq{"playlist.public": 1})
@ -373,6 +378,18 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir
return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
}
// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD.
func pathVariants(path string) []string {
variants := []string{path}
if alt := norm.NFC.String(path); alt != path {
variants = append(variants, alt)
}
if alt := norm.NFD.String(path); alt != path {
variants = append(variants, alt)
}
return variants
}
func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
if info.IsRole {
return roleCond{role: info.Name(), cond: cond, not: negate}

View file

@ -47,7 +47,10 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990),
Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)),
Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("in playlist [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1),
Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1),
Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1),
Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1),
Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3),
Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true),
@ -268,6 +271,13 @@ var _ = Describe("Smart playlist criteria SQL", func() {
Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression")))
})
It("returns an error when inPlaylist has empty path", func() {
_, err := newSmartPlaylistCriteria(
criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}},
withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where()
Expect(err).To(MatchError(ContainSubstring("playlist id or path not given")))
})
It("returns an error for a range over a tag/role field", func() {
_, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where()
Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field")))

View file

@ -27,6 +27,14 @@ type dbFolder struct {
ImageFiles string `structs:"-" json:"-"`
}
// String guards the promoted Folder.String(), which would dereference a nil Folder.
func (f dbFolder) String() string {
if f.Folder == nil {
return "<nil>"
}
return f.Folder.String()
}
func (f *dbFolder) PostScan() error {
var err error
if f.ImageFiles != "" {

View file

@ -310,6 +310,13 @@ var _ = Describe("FolderRepository", func() {
})
})
Describe("dbFolder.String", func() {
It("does not dereference a nil Folder", func() {
Expect(fmt.Sprint(dbFolder{})).To(Equal("<nil>"))
Expect(fmt.Sprint(&dbFolder{})).To(Equal("<nil>"))
})
})
Describe("wrapFolderCursor", func() {
It("does not panic when the cursor yields a dbFolder with nil Folder", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:

View file

@ -38,6 +38,14 @@ type dbMediaFile struct {
RgTrackPeak *float64 `structs:"-" json:"-"`
}
// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile.
func (m dbMediaFile) String() string {
if m.MediaFile == nil {
return "<nil>"
}
return m.MediaFile.String()
}
func (m *dbMediaFile) PostScan() error {
m.RGTrackGain = m.RgTrackGain
m.RGTrackPeak = m.RgTrackPeak

View file

@ -1203,6 +1203,13 @@ var _ = Describe("MediaRepository", func() {
})
})
Describe("dbMediaFile.String", func() {
It("does not dereference a nil MediaFile", func() {
Expect(fmt.Sprint(dbMediaFile{})).To(Equal("<nil>"))
Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("<nil>"))
})
})
Describe("wrapMediaFileCursor", func() {
It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:

View file

@ -3,6 +3,7 @@ package persistence
import (
"context"
"database/sql"
"fmt"
"reflect"
"time"
@ -138,11 +139,15 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository
return nil
}
func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error {
var msg string
func scopeLabel(scope []string) string {
if len(scope) > 0 {
msg = scope[0]
return scope[0]
}
return ""
}
func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error {
msg := scopeLabel(scope)
start := time.Now()
conn, inTx := s.db.(*dbx.DB)
if !inTx {
@ -177,6 +182,51 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope .
}, scope...)
}
// txRetryDelay spaces out reruns of a busy transaction. Each attempt has already waited out the
// busy timeout, so WithTxRetry gives up only after a sustained lock.
var txRetryDelay = 5 * time.Second
const txMaxRetries = 3
func (s *SQLStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, scope ...string) error {
// Inside a transaction, join it: the outer one holds the lock and owns commit and rollback
if _, ok := s.db.(*dbx.DB); !ok {
return block(ctx, s)
}
for attempt := 0; ; attempt++ {
attemptCtx := ctx
if attempt < txMaxRetries {
attemptCtx = withBusyRetry(ctx)
}
err := s.WithTx(func(tx model.DataStore) error { return block(attemptCtx, tx) }, scope...)
if attempt == txMaxRetries || !db.IsBusy(err) {
return err
}
log.Warn(ctx, "Database busy, retrying transaction", "scope", scopeLabel(scope), "attempt", attempt+1, err)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(attempt+1) * txRetryDelay):
}
}
}
type busyRetryKey struct{}
// withBusyRetry marks a transaction attempt that WithTxRetry will rerun, so a busy statement in it
// is logged as a warning rather than an error.
func withBusyRetry(ctx context.Context) context.Context {
return context.WithValue(ctx, busyRetryKey{}, true)
}
func hasBusyRetry(ctx context.Context) bool {
if ctx == nil {
return false
}
retry, _ := ctx.Value(busyRetryKey{}).(bool)
return retry
}
func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace := func(ctx context.Context, msg string, f func() error) func() error {
return func() error {
@ -207,9 +257,9 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }),
)
if err != nil {
log.Error(ctx, "Error tidying up database", err)
return fmt.Errorf("tidying up database: %w", err)
}
return err
return nil
}
func (s *SQLStore) getDBXBuilder() dbx.Builder {

View file

@ -169,6 +169,28 @@ func p(path string) string {
return filepath.FromSlash(path)
}
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
// specs can assert that a query filters by library. Cleans itself up after the spec.
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
db := GetDBXBuilder()
lib := model.Library{Name: name + " Library", Path: "/" + name}
lr := NewLibraryRepository(adminCtx, db)
Expect(lr.Put(&lib)).To(Succeed())
user := createUserWithLibraries(name+"-restricted", []int{1})
ur := NewUserRepository(adminCtx, db)
Expect(ur.Put(&user)).To(Succeed())
Expect(ur.SetUserLibraries(user.ID, []int{1})).To(Succeed())
DeferCleanup(func() {
_ = NewUserRepository(adminCtx, db).Delete(user.ID)
_ = NewLibraryRepository(adminCtx, db).(*libraryRepository).delete(squirrel.Eq{"id": lib.ID})
})
return adminCtx, lib, user
}
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())

View file

@ -2,7 +2,10 @@ package persistence
import (
"context"
"errors"
"time"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
@ -55,4 +58,74 @@ var _ = Describe("SQLStore", func() {
})
})
})
Describe("WithTxRetry", func() {
busy := sqlite3.Error{Code: sqlite3.ErrBusy}
BeforeEach(func() {
DeferCleanup(func(d time.Duration) { txRetryDelay = d }, txRetryDelay)
txRetryDelay = 0
})
It("reruns a busy transaction from a clean rollback", func() {
var attempts []bool
err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
attempts = append(attempts, hasBusyRetry(ctx))
Expect(tx.Property(ctx).Put("retry-key", "attempt")).To(Succeed())
if len(attempts) < 3 {
return busy
}
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(attempts).To(Equal([]bool{true, true, true}))
Expect(ds.Property(ctx).Get("retry-key")).To(Equal("attempt"))
})
It("gives up after the last retry, which is not marked as retried", func() {
var attempts []bool
err := ds.WithTxRetry(ctx, func(ctx context.Context, _ model.DataStore) error {
attempts = append(attempts, hasBusyRetry(ctx))
return busy
})
Expect(db.IsBusy(err)).To(BeTrue())
Expect(attempts).To(Equal([]bool{true, true, true, false}))
})
It("does not rerun on other errors", func() {
calls := 0
err := ds.WithTxRetry(ctx, func(context.Context, model.DataStore) error {
calls++
return sqlite3.Error{Code: sqlite3.ErrConstraint}
})
Expect(err).To(HaveOccurred())
Expect(calls).To(Equal(1))
})
It("does not rerun when called inside a transaction", func() {
calls := 0
err := ds.WithTx(func(tx model.DataStore) error {
return tx.WithTxRetry(ctx, func(context.Context, model.DataStore) error {
calls++
return busy
})
})
Expect(db.IsBusy(err)).To(BeTrue())
Expect(calls).To(Equal(1))
})
It("joins the enclosing transaction instead of opening another", func() {
rollback := errors.New("rollback")
err := ds.WithTx(func(tx model.DataStore) error {
Expect(tx.Property(ctx).Put("outer-key", "v")).To(Succeed())
Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error {
Expect(inner.Property(ctx).Get("outer-key")).To(Equal("v"))
return inner.Property(ctx).Put("inner-key", "v")
})).To(Succeed())
return rollback
})
Expect(err).To(MatchError(rollback))
_, err = ds.Property(ctx).Get("inner-key")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
})

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -127,11 +126,7 @@ func (r *playerRepository) Save(entity any) (string, error) {
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
}
id, err := r.put(t.ID, t)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return r.put("", t) // Save only creates; edits go through the owner-scoped Update
}
func (r *playerRepository) Update(id string, entity any, cols ...string) error {

View file

@ -288,6 +288,27 @@ var _ = Describe("PlayerRepository", func() {
Expect(*stored).To(Equal(adminPlayer1))
})
It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() {
spoofed := model.Player{
ID: adminPlayer1.ID,
Name: "HIJACKED",
UserId: regularUser.ID,
ReportRealPath: true,
}
id, err := regularRepo.Save(&spoofed)
Expect(err).To(BeNil())
Expect(id).ToNot(Equal(adminPlayer1.ID))
stored, err := adminRepo.Get(adminPlayer1.ID)
Expect(err).To(BeNil())
Expect(*stored).To(Equal(adminPlayer1))
created, err := adminRepo.Get(id)
Expect(err).To(BeNil())
Expect(created.UserId).To(Equal(regularUser.ID))
})
It("does not let a regular user reassign their own player to another user", func() {
// Owner updates their own player but tries to give it away to the admin. The update
// succeeds for the other fields, but user_id is never written, so ownership stays put.

View file

@ -13,6 +13,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -276,10 +277,17 @@ func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []st
return err
}
return r.addTracks(playlistId, 1, mediaFileIds)
_, err = r.addTracks(playlistId, 1, mediaFileIds)
return err
}
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error {
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
// the library check: every caller, including a full replace through Put, goes through it.
func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) (int, error) {
mediaFileIds, err := r.keepAccessible(mediaFileIds)
if err != nil {
return 0, err
}
// Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit
// Add new tracks, chunk by chunk
pos := startingPos
@ -289,14 +297,36 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media
ins = ins.Values(playlistId, t, pos)
pos++
}
_, err := r.executeSQL(ins)
if err != nil {
return err
if _, err := r.executeSQL(ins); err != nil {
return 0, err
}
}
r.enqueueCoverRebuild(playlistId)
return r.refreshCounters(&model.Playlist{ID: playlistId})
return len(mediaFileIds), r.refreshCounters(&model.Playlist{ID: playlistId})
}
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
func (r *playlistRepository) keepAccessible(mediaFileIds []string) ([]string, error) {
if visible, err := r.visibleLibraryIDs(); err == nil && r.userSeesAllLibraries(visible) {
return mediaFileIds, nil
}
accessible := make(map[string]struct{}, len(mediaFileIds))
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
sq := r.applyLibraryFilter(Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
var found []string
if err := r.queryAllSlice(sq, &found); err != nil {
return nil, err
}
for _, id := range found {
accessible[id] = struct{}{}
}
}
return slice.Filter(mediaFileIds, func(id string) bool {
_, ok := accessible[id]
return ok
}), nil
}
// refreshCounters updates total playlist duration, size and count
@ -416,9 +446,6 @@ func (r *playlistRepository) Update(id string, entity any, cols ...string) error
pls.ID = id
pls.UpdatedAt = time.Now()
_, err := r.put(id, pls, append(cols, "updatedAt")...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
}

View file

@ -91,6 +91,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
query := Select().
LeftJoin("media_file f on f.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "f")
return r.count(query, r.parseRestOptions(r.ctx, options...))
}
@ -113,6 +114,7 @@ func (r *playlistTrackRepository) Read(id string) (any, error) {
).
Join("media_file f on f.id = media_file_id").
Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}})
sel = r.applyLibraryFilter(sel, "f")
var trk dbPlaylistTrack
err := r.queryOne(sel, &trk)
return trk.PlaylistTrack, err
@ -157,6 +159,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
query := r.newSelect(options...).Columns("distinct mf.album_id").
Join("media_file mf on mf.id = media_file_id").
Where(Eq{"playlist_id": r.playlistId})
query = r.applyLibraryFilter(query, "mf")
var ids []string
err := r.queryAllSlice(query, &ids)
if err != nil {
@ -187,12 +190,40 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
// Get next pos (ID) in playlist
sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId})
var res struct{ Max sql.NullInt32 }
err := r.queryOne(sq, &res)
if err != nil {
if err := r.queryOne(sq, &res); err != nil {
return 0, err
}
return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
return r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
}
// Insert adds tracks before the 1-based position pos, shifting the following entries down; a
// position past the end appends. Callers must run it in a transaction.
func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) {
if len(mediaFileIds) == 0 {
return 0, nil
}
pos = max(pos, 1)
n := len(mediaFileIds)
// Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index.
_, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos))
if err != nil {
return 0, err
}
res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId))
if err != nil {
return 0, err
}
if res == 0 {
return r.Add(mediaFileIds)
}
inserted, err := r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
if err != nil || inserted == n {
return inserted, err
}
// The shift above reserved a slot per requested id, so ids dropped by the library filter
// leave a hole. Close it.
return inserted, r.playlistRepo.renumber(r.playlistId)
}
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
@ -247,8 +278,18 @@ func (r *playlistTrackRepository) DeleteAll() error {
return r.playlistRepo.renumber(r.playlistId)
}
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped
// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
var res struct{ Max sql.NullInt32 }
if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil {
return err
}
last := int(res.Max.Int32)
if pos < 1 || pos > last {
return model.ErrNotFound
}
newPos = min(max(newPos, 1), last)
if pos == newPos {
return nil
}

View file

@ -1,11 +1,13 @@
package persistence
import (
"context"
"strconv"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -78,6 +80,105 @@ var _ = Describe("PlaylistTrackRepository", func() {
})
})
Describe("Insert", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
})
order := func() []string {
ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
return ids
}
DescribeTable("inserts before a 1-based position, keeping the new tracks' order",
func(pos int, want func() []string) {
Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2))
Expect(order()).To(Equal(want()))
Expect(tracks.CountAll()).To(Equal(int64(4)))
},
Entry("in the middle", 2, func() []string {
return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID}
}),
Entry("at the start, for zero or less", 0, func() []string {
return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID}
}),
Entry("at the end, past the last position", 9, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID}
}),
)
It("renumbers positions contiguously", func() {
Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1))
all, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
})
})
Describe("Reorder", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3))
})
rows := func() ([]string, []string) {
all, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
var ids, songs []string
for _, t := range all {
ids = append(ids, t.ID)
songs = append(songs, t.MediaFileID)
}
return ids, songs
}
DescribeTable("clamps the destination to the playlist",
func(newPos int, want func() []string) {
Expect(tracks.Reorder(1, newPos)).To(Succeed())
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal(want()))
},
Entry("past the end moves to the end", 9, func() []string {
return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID}
}),
Entry("below 1 stays first", -4, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}
}),
)
DescribeTable("rejects a source position outside the playlist, leaving rows untouched",
func(pos int) {
Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound))
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}))
},
Entry("past the end", 4),
Entry("zero", 0),
)
})
Describe("Delete", func() {
var tracks model.PlaylistTrackRepository
const numTracks = deleteChunkSize*2 + 1
@ -122,4 +223,151 @@ var _ = Describe("PlaylistTrackRepository", func() {
Expect(tracks.CountAll()).To(BeZero())
})
})
Describe("library access", func() {
var otherLib model.Library
var restrictedUser model.User
var adminCtx, userCtx context.Context
var userTracks model.PlaylistTrackRepository
var plsID string
BeforeEach(func() {
adminCtx, otherLib, restrictedUser = restrictedFixture("pls")
userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
db := GetDBXBuilder()
adminMr := NewMediaFileRepository(adminCtx, db)
Expect(adminMr.Put(&model.MediaFile{
ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album",
Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist",
})).To(Succeed())
DeferCleanup(func() { _ = adminMr.Delete("pls-otherlib-track") })
adminPls := NewPlaylistRepository(adminCtx, db)
pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true}
Expect(adminPls.Put(&pls)).To(Succeed())
plsID = pls.ID
DeferCleanup(func() { _ = adminPls.Delete(plsID) })
Expect(adminPls.Tracks(plsID, false).Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2))
userTracks = NewPlaylistRepository(userCtx, db).Tracks(plsID, false)
})
It("Read does not return a track outside the user's libraries", func() {
_, err := userTracks.Read("2")
Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access")
})
It("Read still returns a track inside the user's libraries", func() {
trk, err := userTracks.Read("1")
Expect(err).ToNot(HaveOccurred())
Expect(trk.(*model.PlaylistTrack).MediaFile.ID).To(Equal(songDayInALife.ID))
})
It("Count excludes tracks outside the user's libraries", func() {
Expect(userTracks.Count()).To(Equal(int64(1)), "Count must agree with the filtered listing")
})
It("GetAlbumIDs excludes albums outside the user's libraries", func() {
Expect(userTracks.GetAlbumIDs()).ToNot(ContainElement("pls-hidden-album"))
})
Describe("Add", func() {
var ownTracks model.PlaylistTrackRepository
BeforeEach(func() {
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName}
Expect(userPls.Put(&own)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(own.ID) })
ownTracks = userPls.Tracks(own.ID, false)
})
It("drops ids outside the user's libraries", func() {
Expect(ownTracks.Add([]string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs()).To(ConsistOf(songDayInALife.ID))
})
It("drops them when reached through AddAlbums", func() {
Expect(ownTracks.AddAlbums([]string{"pls-hidden-album"})).To(BeZero())
})
It("drops them when reached through Insert", func() {
Expect(ownTracks.Add([]string{songDayInALife.ID})).To(Equal(1))
Expect(ownTracks.Insert([]string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1))
Expect(ownTracks.GetMediaFileIDs()).To(Equal([]string{songComeTogether.ID, songDayInALife.ID}))
trks, err := ownTracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}),
"positions must stay contiguous when an id is dropped")
})
})
Describe("Put", func() {
storedIDs := func(id string) []string {
ids, err := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(id, false).GetMediaFileIDs()
Expect(err).ToNot(HaveOccurred())
return ids
}
put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string {
pls.OwnerID = owner.ID
pls.Tracks = nil
pls.AddMediaFilesByID(ids)
Expect(NewPlaylistRepository(ctx, GetDBXBuilder()).Put(pls)).To(Succeed())
DeferCleanup(func() { _ = NewPlaylistRepository(adminCtx, GetDBXBuilder()).Delete(pls.ID) })
return pls.ID
}
It("drops ids outside the user's libraries when creating a playlist", func() {
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track")
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID}))
})
It("drops them when replacing the tracks of an existing playlist", func() {
pls := &model.Playlist{Name: "Replaced"}
put(userCtx, restrictedUser, pls, songDayInALife.ID)
put(userCtx, restrictedUser, pls, "pls-otherlib-track")
Expect(storedIDs(pls.ID)).To(BeEmpty())
})
It("does not count a dropped id, so it cannot be told apart from an unknown one", func() {
hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track")
unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track")
userPls := NewPlaylistRepository(userCtx, GetDBXBuilder())
h, err := userPls.Get(hidden)
Expect(err).ToNot(HaveOccurred())
u, err := userPls.Get(unknown)
Expect(err).ToNot(HaveOccurred())
Expect(h.SongCount).To(Equal(u.SongCount))
Expect(h.Duration).To(Equal(u.Duration))
Expect(h.Size).To(Equal(u.Size))
})
It("keeps order and duplicates of the accessible ids", func() {
id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"},
songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID)
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID}))
})
It("keeps every id when run as an admin, as the scanner's playlist sync does", func() {
id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track")
Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"}))
})
})
It("still shows everything to an admin", func() {
adminTracks := NewPlaylistRepository(adminCtx, GetDBXBuilder()).Tracks(plsID, false)
Expect(adminTracks.Count()).To(Equal(int64(2)))
_, err := adminTracks.Read("2")
Expect(err).ToNot(HaveOccurred())
})
})
})

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
"time"
. "github.com/Masterminds/squirrel"
@ -47,7 +46,7 @@ func (r *radioRepository) Delete(id string) error {
return rest.ErrPermissionDenied
}
return r.delete(Eq{"id": id})
return r.deleteByID(id)
}
func (r *radioRepository) Get(id string) (*model.Radio, error) {
@ -132,9 +131,6 @@ func (r *radioRepository) Save(entity any) (string, error) {
return "", rest.ErrPermissionDenied
}
err := r.Put(t)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return t.ID, err
}
@ -144,11 +140,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
err := r.Put(t, cols...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
return r.Put(t, cols...)
}
var _ model.RadioRepository = (*radioRepository)(nil)

View file

@ -52,6 +52,10 @@ var _ = Describe("RadioRepository", func() {
_, err = repo.Get(radioWithHomePage.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("errors when missing", func() {
Expect(repo.Delete("notanid")).To(MatchError(model.ErrNotFound))
})
})
Describe("Get", func() {

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
"fmt"
"strings"
"time"
@ -178,11 +177,7 @@ func (r *shareRepository) Save(entity any) (string, error) {
}
s.CreatedAt = time.Now()
s.UpdatedAt = time.Now()
id, err := r.put(s.ID, s)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return r.put(s.ID, s)
}
func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) {

View file

@ -1,11 +1,14 @@
package persistence
import (
"slices"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated
@ -16,6 +19,17 @@ import (
// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly.
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return r.refreshSmartPlaylistTree(pls, map[string]struct{}{})
}
// The visited set stops playlists that reference each other from recursing forever.
func (r *playlistRepository) refreshSmartPlaylistTree(pls *model.Playlist, visited map[string]struct{}) bool {
if _, seen := visited[pls.ID]; seen {
log.Trace(r.ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID)
return false
}
visited[pls.ID] = struct{}{}
usr := loggedUser(r.ctx)
if !r.shouldRefreshSmartPlaylist(pls, usr) {
return false
@ -30,9 +44,9 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
return false
}
rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr))
if !r.refreshChildPlaylists(pls, rulesSQL) {
if !r.refreshChildPlaylists(pls, rulesSQL, visited) {
return false
}
@ -89,28 +103,47 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr
// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria.
// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort.
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool {
childPlaylistIds := rulesSQL.ChildPlaylistIds()
if len(childPlaylistIds) == 0 {
childPlaylistPaths := rulesSQL.ChildPlaylistPaths()
if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 {
return true
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
var conditions Or
if len(childPlaylistIds) > 0 {
conditions = append(conditions, Eq{"playlist.id": childPlaylistIds})
}
if len(childPlaylistPaths) > 0 {
lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...)
conditions = append(conditions, Eq{"playlist.path": lookupPaths})
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: conditions})
if err != nil {
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err)
return false
}
found := make(map[string]struct{}, len(childPlaylists))
found := make(map[string]struct{}, len(childPlaylists)*2)
for i := range childPlaylists {
found[childPlaylists[i].ID] = struct{}{}
r.refreshSmartPlaylist(&childPlaylists[i])
if childPlaylists[i].Path != "" {
found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{}
}
r.refreshSmartPlaylistTree(&childPlaylists[i], visited)
}
for _, id := range childPlaylistIds {
if _, ok := found[id]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
}
}
for _, path := range childPlaylistPaths {
if _, ok := found[norm.NFC.String(path)]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "ownerId", pls.OwnerID)
}
}
return true
}

View file

@ -1,6 +1,7 @@
package persistence
import (
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -124,13 +125,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
nestedPls := model.Playlist{Name: "Nested [ID]", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
childRules = &criteria.Criteria{
Expression: criteria.All{
criteria.Eq{"artist": "シートベルツ"},
},
}
nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPathPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPathPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.Any{
criteria.InPlaylist{"id": nestedPls.ID},
criteria.InPlaylist{"path": nestedPathPls.Path},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
@ -148,17 +159,88 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks).To(HaveLen(2))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
// Nested playlists should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
nestedPlsAfterParentGet, err = repo.Get(nestedPathPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
It("does not recurse forever when two smart playlists reference each other", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(&plsA)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(plsA.ID) })
plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}},
}}
Expect(repo.Put(&plsB)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(plsB.ID) })
plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}}
Expect(repo.Put(&plsA)).To(Succeed())
_, err := repo.GetWithTracks(plsA.ID, true, false)
Expect(err).ToNot(HaveOccurred())
})
It("does not treat an empty path as a reference to every playlist without a path", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(&bystander)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(bystander.ID) })
parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": ""}},
}}
Expect(repo.Put(&parent)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parent.ID) })
_, err := repo.GetWithTracks(parent.ID, true, false)
Expect(err).ToNot(HaveOccurred())
reloaded, err := repo.Get(bystander.ID)
Expect(err).ToNot(HaveOccurred())
Expect(reloaded.EvaluatedAt).To(BeNil())
})
It("matches a child path stored in a different Unicode normalization form", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "Day"}},
}}
Expect(repo.Put(&child)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(child.ID) })
parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}},
}}
Expect(repo.Put(&parent)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parent.ID) })
pls, err := repo.GetWithTracks(parent.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
})
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour

View file

@ -378,8 +378,9 @@ func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.S
for row, err := range cursor {
m := toModel(row)
if m == nil {
// Don't format row: its String() derefs the nil model (golang/go#81238).
var zero T
yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err))
yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err))
return
}
if !yield(*m, err) || err != nil {
@ -614,12 +615,20 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri
}
func (r sqlRepository) delete(cond Sqlizer) error {
del := Delete(r.tableName).Where(cond)
_, err := r.executeSQL(del)
if errors.Is(err, sql.ErrNoRows) {
_, err := r.executeSQL(Delete(r.tableName).Where(cond))
return err
}
// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently.
func (r sqlRepository) deleteByID(id string) error {
count, err := r.executeSQL(Delete(r.tableName).Where(Eq{"id": id}))
if err != nil {
return err
}
if count == 0 {
return model.ErrNotFound
}
return err
return nil
}
func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) {
@ -634,5 +643,9 @@ func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffect
if code, extended, ok := db.ErrorCodes(err); ok {
fields = append(fields, "sqliteCode", code, "sqliteExtended", extended)
}
if db.IsBusy(err) && hasBusyRetry(r.ctx) {
log.Warn(append(fields, err)...)
return
}
log.Error(append(fields, err)...)
}

View file

@ -103,6 +103,7 @@ func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) {
sq := r.newSelect().Columns(r.tableName + ".*")
sq = r.withAnnotation(sq, idField)
sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil})
sq = r.applyLibraryFilter(sq)
var mfs dbMediaFiles // TODO Decouple from media_file
err := r.queryAll(sq, &mfs)
if err != nil {

View file

@ -71,4 +71,52 @@ var _ = Describe("sqlBookmarks", func() {
Expect(mr.GetBookmarks()).To(BeEmpty())
})
})
Describe("library access", func() {
var otherLib model.Library
var restrictedUser model.User
var adminCtx context.Context
var userMr model.MediaFileRepository
BeforeEach(func() {
adminCtx, otherLib, restrictedUser = restrictedFixture("bmk")
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.Put(&model.MediaFile{
ID: "bmk-otherlib-track", LibraryID: otherLib.ID,
Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked",
})).To(Succeed())
DeferCleanup(func() { _ = adminMr.Delete("bmk-otherlib-track") })
userCtx := request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser)
userMr = NewMediaFileRepository(userCtx, GetDBXBuilder())
})
It("does not return bookmarks for tracks outside the user's libraries", func() {
Expect(userMr.AddBookmark("bmk-otherlib-track", "sneaky", 1)).To(Succeed())
Expect(userMr.GetBookmarks()).To(BeEmpty())
})
It("still returns the bookmark for an admin", func() {
adminMr := NewMediaFileRepository(adminCtx, GetDBXBuilder())
Expect(adminMr.AddBookmark("bmk-otherlib-track", "mine", 1)).To(Succeed())
DeferCleanup(func() { _ = adminMr.DeleteBookmark("bmk-otherlib-track") })
bms, err := adminMr.GetBookmarks()
Expect(err).ToNot(HaveOccurred())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track"))
})
It("keeps returning bookmarks for tracks inside the user's libraries", func() {
Expect(userMr.AddBookmark(songAntenna.ID, "allowed", 5)).To(Succeed())
DeferCleanup(func() { _ = userMr.DeleteBookmark(songAntenna.ID) })
bms, err := userMr.GetBookmarks()
Expect(err).ToNot(HaveOccurred())
Expect(bms).To(HaveLen(1))
Expect(bms[0].Item.ID).To(Equal(songAntenna.ID))
})
})
})

View file

@ -2,7 +2,6 @@ package persistence
import (
"context"
"errors"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
@ -91,11 +90,7 @@ func (r *transcodingRepository) Save(entity any) (string, error) {
return "", rest.ErrPermissionDenied
}
t := entity.(*model.Transcoding)
id, err := r.put(t.ID, t)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return r.put(t.ID, t)
}
func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
@ -105,9 +100,6 @@ func (r *transcodingRepository) Update(id string, entity any, cols ...string) er
t := entity.(*model.Transcoding)
t.ID = id
_, err := r.put(id, t)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
}
@ -115,11 +107,7 @@ func (r *transcodingRepository) Delete(id string) error {
if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
return r.deleteByID(id)
}
var _ model.TranscodingRepository = (*transcodingRepository)(nil)

View file

@ -65,6 +65,11 @@ var _ = Describe("TranscodingRepository", func() {
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns not found when deleting a missing transcoding", func() {
err := adminRepo.(*transcodingRepository).Delete("does-not-exist")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("reads the Command field via the REST Read method", func() {
tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"}
Expect(adminRepo.Put(tr)).To(Succeed())

View file

@ -244,11 +244,7 @@ func (r *userRepository) Read(id string) (any, error) {
if !usr.IsAdmin && usr.ID != id {
return nil, rest.ErrPermissionDenied
}
usr, err := r.Get(id)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return usr, err
return r.Get(id)
}
func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
@ -314,11 +310,7 @@ func (r *userRepository) Update(id string, entity any, _ ...string) error {
if err := validateScrobbleFilter(u); err != nil {
return err
}
err := r.Put(u)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
return err
return r.Put(u)
}
func validatePasswordChange(newUser *model.User, logged *model.User) error {
@ -393,11 +385,7 @@ func (r *userRepository) Delete(id string) error {
if !usr.IsAdmin {
return rest.ErrPermissionDenied
}
err := r.delete(Eq{"id": id})
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
if err != nil {
if err := r.deleteByID(id); err != nil {
return err
}

View file

@ -230,6 +230,14 @@ var _ = Describe("UserRepository", func() {
})
})
Describe("Delete", func() {
It("returns not found for a missing user", func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
adminRepo := NewUserRepository(adminCtx, GetDBXBuilder()).(*userRepository)
Expect(adminRepo.Delete("does-not-exist")).To(MatchError(model.ErrNotFound))
})
})
Describe("ReadAll name filter", func() {
var adminRepo model.ResourceRepository

View file

@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/netguard"
)
const (
@ -53,6 +54,7 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Control: svc.dialControl,
Resolver: dialResolver,
}).DialContext
// No client timeout: it is set per-request via context deadline.
svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)}
@ -201,7 +203,7 @@ func isPrivateOrLoopback(hostname string) bool {
if ip == nil {
return false
}
return isPrivateIP(ip)
return netguard.IsPrivateIP(ip)
}
// Verify interface implementation

View file

@ -20,6 +20,10 @@ var _ = Describe("httpServiceImpl", func() {
ts *httptest.Server
)
BeforeEach(func() {
stubLocalhostDNS()
})
AfterEach(func() {
if ts != nil {
ts.Close()

View file

@ -4,8 +4,13 @@ import (
"fmt"
"net"
"slices"
"github.com/navidrome/navidrome/utils/netguard"
)
// dialResolver is nil in production (the system resolver); tests swap in a stub to avoid real DNS.
var dialResolver *net.Resolver
// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a
// literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it.
func checkPrivateDial(requiredHosts []string, address string) error {
@ -17,7 +22,7 @@ func checkPrivateDial(requiredHosts []string, address string) error {
return err
}
ip := net.ParseIP(host)
if ip == nil || !isPrivateIP(ip) {
if ip == nil || !netguard.IsPrivateIP(ip) {
return nil
}
for _, entry := range requiredHosts {
@ -51,7 +56,3 @@ func ipMatchesEntry(entry string, ip net.IP) bool {
}
return false
}
func isPrivateIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}

View file

@ -689,11 +689,12 @@ var _ = Describe("TaskQueueService", func() {
copy(times, dispatchTimes)
mu.Unlock()
// Consecutive dispatches should have at least ~160ms gap (80% of 200ms)
// Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps.
for i := 1; i < len(times); i++ {
gap := times[i].Sub(times[i-1])
Expect(gap).To(BeNumerically(">=", 160*time.Millisecond),
fmt.Sprintf("gap between dispatch %d and %d was %v, expected >= 160ms", i-1, i, gap))
offset := times[i].Sub(times[0])
minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond
Expect(offset).To(BeNumerically(">=", minOffset),
fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset))
}
})
})

View file

@ -114,7 +114,7 @@ func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, heade
// Establish WebSocket connection
dialer := websocket.Dialer{
HandshakeTimeout: 30 * time.Second,
NetDialContext: (&net.Dialer{Control: s.dialControl}).DialContext,
NetDialContext: (&net.Dialer{Control: s.dialControl, Resolver: dialResolver}).DialContext,
}
conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders)

View file

@ -505,6 +505,7 @@ var _ = Describe("WebSocketService", Ordered, func() {
var savedHosts []string
BeforeEach(func() {
stubLocalhostDNS()
savedHosts = testService.requiredHosts
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -46,6 +46,12 @@ func TestPlugins(t *testing.T) {
RunSpecs(t, "Plugins Suite")
}
// stubLocalhostDNS resolves "localhost." without real DNS: the trailing dot never matches /etc/hosts.
func stubLocalhostDNS() {
dialResolver = tests.StubResolver(map[string]string{"localhost.": "127.0.0.1"})
DeferCleanup(func() { dialResolver = nil })
}
// createTestManager creates a new plugin Manager with the given plugin config.
// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager.
// Returns the manager, temp directory path, and a cleanup function.

View file

@ -13,15 +13,16 @@ if [ ! -f /etc/navidrome/navidrome.toml ]; then
printf "MusicFolder = \"/opt/navidrome/music\"\n" >> /etc/navidrome/navidrome.toml
fi
# Older versions created these folders as root when this script ran `navidrome`. They were created empty,
# so no -R. Real dirs only, never following links: navidrome owns /var/lib/navidrome and could plant symlinks.
find /var/lib/navidrome/cache /var/lib/navidrome/artwork /var/lib/navidrome/plugins -maxdepth 0 -type d -user root -exec chown -h navidrome:navidrome {} + 2>/dev/null
postinstall_flag="/var/lib/navidrome/.installed"
if [ ! -f "$postinstall_flag" ]; then
# The primary reason why this would fail is if the service was already installed AND
# someone manually removed the .installed flag. In this case, ignore the error
navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || :
# Any `navidrome` command will make a cache. Make sure that this is properly owned by the Navidrome user
# and not by root
chown navidrome:navidrome /var/lib/navidrome/cache
touch "$postinstall_flag"
else
navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml

View file

@ -94,7 +94,7 @@
"download": "Herunterladen",
"info": "Mehr Informationen",
"share": "Freigabe erstellen",
"refresh": ""
"refresh": "Metadaten aktualisieren"
},
"lists": {
"all": "Alle",
@ -157,12 +157,12 @@
"token": "Token",
"lastAccessAt": "Letzter Zugriff am",
"libraries": "Bibliotheken",
"scrobbleFilter": ""
"scrobbleFilter": "Scrobble-Filter"
},
"helperTexts": {
"name": "Die Änderung wird erst nach dem nächsten Login gültig",
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken",
"scrobbleFilter": ""
"scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt."
},
"notifications": {
"created": "Benutzer erstellt",
@ -177,7 +177,7 @@
},
"validation": {
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein",
"invalidScrobbleFilter": ""
"invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt."
}
},
"player": {
@ -202,7 +202,7 @@
"command": "Befehl"
},
"choices": {
"noDefaultBitRate": ""
"noDefaultBitRate": "Keine"
}
},
"playlist": {
@ -398,6 +398,7 @@
"invalidJson": "Konfiguration muss valides JSON sein"
},
"messages": {
"idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.",
"configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.",
"clickPermissions": "Berechtigung anklicken für mehr Details",
"noConfig": "Keine Konfiguration gesetzt",
@ -411,8 +412,7 @@
"requiredHosts": "Benötigte Hosts",
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.",
"idHelp": ""
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
},
"placeholders": {
"configKey": "Schlüssel",
@ -573,7 +573,7 @@
"noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar",
"delete_user_title": "Benutzer '%{name}' löschen",
"delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?",
"notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert",
"notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert",
"notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen",
"lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert",
"lastfmLinkFailure": "Last.fm konnte nicht verbunden werden",
@ -609,7 +609,7 @@
"coverRemoved": "Cover entfernt",
"coverUploadError": "Fehler beim Hochladen des Covers",
"coverRemoveError": "Fehler beim Entfernen des Covers",
"metadataRefreshStarted": ""
"metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund"
},
"menu": {
"library": "Bibliothek",
@ -731,4 +731,4 @@
"empty": "Keine Wiedergabe",
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
}
}
}

View file

@ -202,7 +202,7 @@
"command": "Komento"
},
"choices": {
"noDefaultBitRate": ""
"noDefaultBitRate": "Ei mikään"
}
},
"playlist": {
@ -609,7 +609,10 @@
"coverRemoved": "Kansikuva poistettu",
"coverUploadError": "Virhe ladattaessa kansikuvaa",
"coverRemoveError": "Virhe poistettaessa kansikuvaa",
"metadataRefreshStarted": "Metatietoja päivitetään taustalla"
"metadataRefreshStarted": "Metatietoja päivitetään taustalla",
"quickConnectApproved": "",
"quickConnectInvalidCode": "",
"quickConnectError": ""
},
"menu": {
"library": "Kirjasto",
@ -645,7 +648,15 @@
"selectLibraries": "Valitse kirjastot",
"none": "Ei mitään"
},
"onlyFavourites": "Näytä vain suosikit"
"onlyFavourites": "Näytä vain suosikit",
"quickConnect": {
"name": "",
"code": "",
"help": "",
"confirm": "",
"continue": "",
"approve": ""
}
},
"player": {
"playListsText": "Jono",

View file

@ -93,7 +93,8 @@
"addToPlaylist": "Toevoegen aan afspeellijst",
"download": "Downloaden",
"info": "Meer info",
"share": "Delen"
"share": "Delen",
"refresh": "Metadata verversen"
},
"lists": {
"all": "Alle",
@ -155,11 +156,13 @@
"newPassword": "Nieuw wachtwoord",
"token": "Token",
"lastAccessAt": "Meest recente toegang",
"libraries": "Bibliotheken"
"libraries": "Bibliotheken",
"scrobbleFilter": "Scrobble filter"
},
"helperTexts": {
"name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
"libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken",
"scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten."
},
"notifications": {
"created": "Aangemaakt door gebruiker",
@ -173,7 +176,8 @@
"adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
},
"validation": {
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
"librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers",
"invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay"
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "Doelformaat",
"defaultBitRate": "Standaard bitrate",
"command": "Commando"
},
"choices": {
"noDefaultBitRate": "Geen"
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "Nummers",
"comment": "Commentaar",
"sync": "Auto-importeren",
"path": "Importeer vanuit"
"path": "Importeer vanuit",
"starred": "Favoriet"
},
"actions": {
"selectPlaylist": "Selecteer een afspeellijst:",
@ -403,7 +411,8 @@
"requiredHosts": "Benodigde hosts",
"configValidationError": "Configuratiecheck mislukt",
"schemaRenderError": "Kan het configuratieformulier niet verwerken. Het plugin schema is wellicht ongeldig.",
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen."
"allowWriteAccessHelp": "Met dit ingeschakeld, kan de plug-in bestanden bewerken in de bibliotheekmappen. Standaard kunnen plug-ins alleen lezen.",
"idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten."
},
"placeholders": {
"configKey": "Sleutel",
@ -599,7 +608,11 @@
"coverUploaded": "Albumhoes bijgewerkt",
"coverRemoved": "Albumhoes verwijderd",
"coverUploadError": "Fout bij het toevoegen albumhoes",
"coverRemoveError": "Fout bij verwijderen albumhoes"
"coverRemoveError": "Fout bij verwijderen albumhoes",
"metadataRefreshStarted": "Metadata verversen op de achtergrond",
"quickConnectApproved": "",
"quickConnectInvalidCode": "",
"quickConnectError": ""
},
"menu": {
"library": "Bibliotheek",
@ -634,6 +647,15 @@
"multipleLibraries": "%{selected} van %{total} bibliotheken",
"selectLibraries": "Selecteer bibliotheken",
"none": "Geen"
},
"onlyFavourites": "Toon alleen favorieten",
"quickConnect": {
"name": "",
"code": "",
"help": "",
"confirm": "",
"continue": "",
"approve": ""
}
},
"player": {

View file

@ -202,7 +202,7 @@
"command": "Comando"
},
"choices": {
"noDefaultBitRate": ""
"noDefaultBitRate": "Nenhum"
}
},
"playlist": {
@ -609,7 +609,10 @@
"coverRemoved": "Capa removida",
"coverUploadError": "Erro ao enviar capa",
"coverRemoveError": "Erro ao remover capa",
"metadataRefreshStarted": "Atualizando metadados em segundo plano"
"metadataRefreshStarted": "Atualizando metadados em segundo plano",
"quickConnectApproved": "%{app} em %{device} está conectado agora",
"quickConnectInvalidCode": "Código inválido ou expirado",
"quickConnectError": "Não foi possível aprovar o código"
},
"menu": {
"library": "Biblioteca",
@ -645,7 +648,15 @@
"selectLibraries": "Selecionar Bibliotecas",
"none": "Nenhuma"
},
"onlyFavourites": "Somente favoritas"
"onlyFavourites": "Somente favoritas",
"quickConnect": {
"name": "Conexão Rápida",
"code": "Código",
"help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta",
"confirm": "Conectar %{app} %{version} em %{device} à sua conta?",
"continue": "Continuar",
"approve": "Aprovar"
}
},
"player": {
"playListsText": "Fila de Execução",

View file

@ -11,22 +11,22 @@
"title": "Titel",
"artist": "Artist",
"album": "Album",
"path": "Sökväg",
"path": "Filsökväg",
"genre": "Genre",
"compilation": "Samling",
"compilation": "Samlingsalbum",
"year": "År",
"size": "Filstorlek",
"updatedAt": "Uppdaterad",
"bitRate": "Bitrate",
"discSubtitle": "Underrubrik",
"bitRate": "Bithastighet",
"discSubtitle": "Skivans undertitel",
"starred": "Favorit",
"comment": "Kommentar",
"rating": "Betyg",
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Senast spelad",
"channels": "Channels",
"createdAt": "Skapad",
"channels": "Kanaler",
"createdAt": "Tillagd",
"grouping": "Gruppering",
"mood": "Stämning",
"participants": "Ytterligare medverkande",
@ -35,19 +35,21 @@
"rawTags": "Omodifierade taggar",
"bitDepth": "Bitdjup",
"sampleRate": "Samplingsfrekvens",
"missing": "Saknade",
"missing": "Saknas",
"libraryName": "Bibliotek",
"composer": "Kompositör",
"disc": "Disc %{discNumber}"
"disc": "Skiva %{discNumber}",
"albumGain": "Volymjustering (album)",
"trackGain": "Volymjustering (låt)"
},
"actions": {
"addToQueue": "Lägg till i kön",
"playNow": "Spela nu",
"addToPlaylist": "Lägg till i spellista",
"shuffleAll": "Shuffle",
"shuffleAll": "Blanda alla",
"download": "Ladda ner",
"playNext": "Spela nästa",
"info": "Mer information",
"playNext": "Spela härnäst",
"info": "Visa information",
"showInPlaylist": "Visa i spellista",
"instantMix": "Direktmix"
}
@ -62,12 +64,12 @@
"playCount": "Spelningar",
"name": "Namn",
"genre": "Genre",
"compilation": "Samling",
"compilation": "Samlingsalbum",
"year": "År",
"updatedAt": "Uppdaterad",
"comment": "Kommentar",
"rating": "Betyg",
"createdAt": "Skapad",
"createdAt": "Tillagt",
"size": "Storlek",
"originalDate": "Originaldatum",
"releaseDate": "Utgivningsdatum",
@ -80,27 +82,28 @@
"media": "Media",
"mood": "Stämning",
"date": "Inspelningsdatum",
"missing": "Saknade",
"missing": "Saknas",
"libraryName": "Bibliotek"
},
"actions": {
"playAll": "Spela",
"playNext": "Spela härnäst",
"addToQueue": "Lägg till i kön",
"shuffle": "Shuffle",
"shuffle": "Blanda",
"addToPlaylist": "Lägg till i spellista",
"download": "Ladda ner",
"info": "Mer information",
"share": "Dela"
"info": "Visa information",
"share": "Dela",
"refresh": "Uppdatera metadata"
},
"lists": {
"all": "Alla",
"random": "Blanda",
"recentlyAdded": "Senast tillagda",
"recentlyPlayed": "Senast spelade",
"random": "Slumpmässiga",
"recentlyAdded": "Nyligen tillagda",
"recentlyPlayed": "Nyligen spelade",
"mostPlayed": "Mest spelade",
"starred": "Favoriter",
"topRated": "Bästa betyg"
"topRated": "Högst betygsatta"
}
},
"artist": {
@ -114,26 +117,26 @@
"genre": "Genre",
"size": "Storlek",
"role": "Roll",
"missing": "Saknade"
"missing": "Saknas"
},
"roles": {
"albumartist": "Albumartist |||| Albumartister",
"artist": "Artist |||| Artister",
"composer": "Kompositör |||| Kompositörer",
"composer": "Kompositör |||| Kompositörer",
"conductor": "Dirigent |||| Dirigenter",
"lyricist": "Textförfattare |||| Textförfattare",
"arranger": "Arrangör |||| Arrangörer",
"producer": "Producent |||| Producenter",
"director": "Inspelningsledare |||| Inspelningsledare",
"engineer": "Ljudtekniker |||| Ljudtekniker",
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
"performer": "Utövande artist |||| Utövande artister",
"maincredit": "Albumartister eller Artist |||| Albumartister eller Artister"
"mixer": "Mixare |||| Mixare",
"remixer": "Remixare |||| Remixare",
"djmixer": "DJ-mixare |||| DJ-mixare",
"performer": "Utövande artist |||| Utövande artister",
"maincredit": "Albumartist eller artist |||| Albumartister eller artister"
},
"actions": {
"shuffle": "Shuffle",
"shuffle": "Blanda",
"radio": "Radio",
"topSongs": "Topplåtar"
}
@ -142,7 +145,7 @@
"name": "Användare |||| Användare",
"fields": {
"userName": "Användarnamn",
"isAdmin": "Är admin",
"isAdmin": "Administratör",
"lastLoginAt": "Senaste inloggning",
"updatedAt": "Uppdaterad",
"name": "Namn",
@ -151,13 +154,15 @@
"changePassword": "Byt lösenord?",
"currentPassword": "Nuvarande lösenord",
"newPassword": "Nytt lösenord",
"token": "Token",
"token": "Åtkomsttoken",
"lastAccessAt": "Senaste åtkomst",
"libraries": "Bibliotek"
"libraries": "Bibliotek",
"scrobbleFilter": "Scrobblingsfilter"
},
"helperTexts": {
"name": "Ändringar av ditt namn syns först vid nästa inloggning",
"libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek"
"libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken",
"scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte."
},
"notifications": {
"created": "Användare skapad",
@ -165,13 +170,14 @@
"deleted": "Användare borttagen"
},
"message": {
"listenBrainzToken": "Ange din ListenBrainz användar-token.",
"listenBrainzToken": "Ange din användartoken för ListenBrainz.",
"clickHereForToken": "Klicka här för att hämta din token",
"selectAllLibraries": "Välj alla bibliotek",
"adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek"
"adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek"
},
"validation": {
"librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer"
"librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer",
"invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte."
}
},
"player": {
@ -179,12 +185,12 @@
"fields": {
"name": "Namn",
"transcodingId": "Omkodning",
"maxBitRate": "Max. bitrate",
"maxBitRate": "Högsta bithastighet",
"client": "Klient",
"userName": "Användarnamn",
"lastSeen": "Senast sedd",
"reportRealPath": "Visa hela sökvägen",
"scrobbleEnabled": "Scrobbla till extern tjänst"
"reportRealPath": "Rapportera den faktiska sökvägen",
"scrobbleEnabled": "Scrobbla till externa tjänster"
}
},
"transcoding": {
@ -192,8 +198,11 @@
"fields": {
"name": "Namn",
"targetFormat": "Målformat",
"defaultBitRate": "Standardbitrate",
"defaultBitRate": "Standardbithastighet",
"command": "Kommando"
},
"choices": {
"noDefaultBitRate": "Ingen"
}
},
"playlist": {
@ -207,8 +216,9 @@
"createdAt": "Skapad",
"songCount": "Låtar",
"comment": "Kommentar",
"sync": "Auto-import",
"path": "Importera från"
"sync": "Automatisk import",
"path": "Importera från",
"starred": "Favorit"
},
"actions": {
"selectPlaylist": "Välj en spellista:",
@ -216,24 +226,24 @@
"export": "Exportera",
"makePublic": "Gör offentlig",
"makePrivate": "Gör privat",
"saveQueue": "Spara kö till spellista",
"searchOrCreate": "Sök spellista eller skapa ny...",
"pressEnterToCreate": "Tryck Enter för att skapa ny spellista",
"removeFromSelection": "Ta bort från urval"
"saveQueue": "Spara kön i en spellista",
"searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...",
"pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista",
"removeFromSelection": "Ta bort från urvalet"
},
"message": {
"duplicate_song": "Lägg till dubletter",
"song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?",
"noPlaylistsFound": "Hittade inga spellistor",
"duplicate_song": "Lägg till dubbletter",
"song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?",
"noPlaylistsFound": "Inga spellistor hittades",
"noPlaylists": "Inga spellistor tillgängliga"
}
},
"radio": {
"name": "Radio |||| Radior",
"name": "Radiostation |||| Radiostationer",
"fields": {
"name": "Namn",
"streamUrl": "Stream-URL",
"homePageUrl": "Hemside-URL",
"streamUrl": "Strömmens URL",
"homePageUrl": "Webbplatsens URL",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad"
},
@ -242,7 +252,7 @@
}
},
"share": {
"name": "Dela |||| Delningar",
"name": "Delning |||| Delningar",
"fields": {
"username": "Delad av",
"url": "URL",
@ -252,11 +262,13 @@
"lastVisitedAt": "Senast besökt",
"visitCount": "Besök",
"format": "Format",
"maxBitRate": "Max. bitrate",
"maxBitRate": "Högsta bithastighet",
"updatedAt": "Uppdaterad",
"createdAt": "Skapad",
"downloadable": "Tillåt nedladdning?"
}
"downloadable": "Tillåt nedladdningar?"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Saknad fil |||| Saknade filer",
@ -267,11 +279,11 @@
"libraryName": "Bibliotek"
},
"actions": {
"remove": "Radera",
"remove_all": "Radera alla"
"remove": "Ta bort",
"remove_all": "Ta bort alla"
},
"notifications": {
"removed": "Saknade fil(er) borttagna"
"removed": "Saknade filer har tagits bort"
},
"empty": "Inga saknade filer"
},
@ -280,8 +292,8 @@
"fields": {
"name": "Namn",
"path": "Sökväg",
"remotePath": "Ta bort sökväg",
"lastScanAt": "Senaste scan",
"remotePath": "Fjärrsökväg",
"lastScanAt": "Senaste skanning",
"songCount": "Låtar",
"albumCount": "Album",
"artistCount": "Artister",
@ -302,33 +314,33 @@
"statistics": "Statistik"
},
"actions": {
"scan": "Scanna bibliotek",
"scan": "Skanna biblioteket",
"manageUsers": "Hantera användaråtkomst",
"viewDetails": "Se detaljer",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan"
"viewDetails": "Visa detaljer",
"quickScan": "Snabbskanning",
"fullScan": "Fullständig skanning"
},
"notifications": {
"created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats",
"scanStarted": "Biblioteksscan startad",
"scanCompleted": "Biblioteksscan avslutad",
"quickScanStarted": "Snabbscan startad",
"fullScanStarted": "Komplett scan startad",
"scanError": "Fel vid start av scan. Se loggarna"
"deleted": "Biblioteket har tagits bort",
"scanStarted": "Skanning av biblioteket har startat",
"scanCompleted": "Skanning av biblioteket är klar",
"quickScanStarted": "Snabbskanning har startat",
"fullScanStarted": "Fullständig skanning har startat",
"scanError": "Kunde inte starta skanningen. Kontrollera loggarna"
},
"validation": {
"nameRequired": "Biblioteksnamn krävs",
"pathRequired": "Bibliotekssökväg krävs",
"pathNotDirectory": "Bibliotekssökvägen måste vara en katalog",
"pathNotFound": "Bibliotekssökväg hittades inte",
"pathNotAccessible": "Bibliotekssökväg inte tillgänglig",
"pathInvalid": "Ogiltig bibliotekssökväg"
"nameRequired": "Ange ett biblioteksnamn",
"pathRequired": "Ange en sökväg till biblioteket",
"pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog",
"pathNotFound": "Bibliotekets sökväg hittades inte",
"pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig",
"pathInvalid": "Ogiltig sökväg till biblioteket"
},
"messages": {
"deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.",
"scanInProgress": "Scanning pågår...",
"deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.",
"scanInProgress": "Skanning pågår...",
"noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren"
}
},
@ -339,44 +351,44 @@
"name": "Namn",
"description": "Beskrivning",
"version": "Version",
"author": "Författare",
"website": "Website",
"author": "Upphovsperson",
"website": "Webbplats",
"permissions": "Behörigheter",
"enabled": "Aktiverad",
"enabled": "Aktiverat",
"status": "Status",
"path": "Sökväg",
"lastError": "Fel",
"hasError": "Fel",
"updatedAt": "Uppdaterad",
"createdAt": "Installerad",
"updatedAt": "Uppdaterat",
"createdAt": "Installerat",
"configKey": "Nyckel",
"configValue": "Värde",
"allUsers": "Tillåt alla användare",
"selectedUsers": "Valda användare",
"allLibraries": "Tillåt alla bibliotek",
"selectedLibraries": "Valda bibliotek",
"allowWriteAccess": "Tillåt skrivrättigheter"
"allowWriteAccess": "Tillåt skrivåtkomst"
},
"sections": {
"status": "Status",
"info": "Tilläggsinformation",
"info": "Information om tillägget",
"configuration": "Konfiguration",
"manifest": "Manifest",
"usersPermission": "Användarbehörigheter",
"libraryPermission": "Biblioteksbehörigheter"
"usersPermission": "Åtkomst till användare",
"libraryPermission": "Åtkomst till bibliotek"
},
"status": {
"enabled": "Aktiverad",
"disabled": "Inaktiverad"
"enabled": "Aktiverat",
"disabled": "Inaktiverat"
},
"actions": {
"enable": "Aktivera",
"disable": "Inaktivera",
"disabledDueToError": "Åtgärda felet innan aktivering",
"disabledDueToError": "Åtgärda felet före aktivering",
"disabledUsersRequired": "Välj användare före aktivering",
"disabledLibrariesRequired": "Välj bibliotek före aktivering",
"addConfig": "Lägg till konfiguration",
"rescan": "Scanna om"
"rescan": "Skanna om"
},
"notifications": {
"enabled": "Tillägg aktiverat",
@ -391,17 +403,18 @@
"configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.",
"clickPermissions": "Klicka på en behörighet för mer information",
"noConfig": "Ingen konfiguration angiven",
"allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.",
"allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.",
"noUsers": "Inga användare valda",
"permissionReason": "Orsak",
"usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.",
"allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.",
"usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.",
"allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.",
"noLibraries": "Inga bibliotek valda",
"librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
"requiredHosts": "Krävda värdar",
"librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.",
"requiredHosts": "Värdar som krävs",
"configValidationError": "Validering av konfigurationen misslyckades:",
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
"allowWriteAccessHelp": "När detta är aktiverat kan tillägget ändra filer i bibliotekets kataloger. Som standard har tillägget endast läsrättigheter."
"schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.",
"allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.",
"idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents."
},
"placeholders": {
"configKey": "nyckel",
@ -412,40 +425,40 @@
"ra": {
"auth": {
"welcome1": "Tack för att du installerade Navidrome!",
"welcome2": "Skapa först ett admin-konto",
"welcome2": "Börja med att skapa ett administratörskonto",
"confirmPassword": "Bekräfta lösenord",
"buttonCreateAdmin": "Skapa admin-konto",
"buttonCreateAdmin": "Skapa administratörskonto",
"auth_check_error": "Logga in för att fortsätta",
"user_menu": "Profil",
"username": "Användarnamn",
"password": "Lösenord",
"sign_in": "Logga in",
"sign_in_error": "Felaktig inloggning, försök igen",
"sign_in_error": "Inloggningen misslyckades. Försök igen",
"logout": "Logga ut",
"insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill"
"insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen"
},
"validation": {
"invalidChars": "Använd enbart bokstäver och siffror",
"passwordDoesNotMatch": "Lösenordet matchar inte",
"required": "Krävs",
"minLength": "Måste ha minst %{min} tecken",
"maxLength": "Får maximalt ha %{max} tecken",
"passwordDoesNotMatch": "Lösenorden stämmer inte överens",
"required": "Obligatoriskt",
"minLength": "Måste innehålla minst %{min} tecken",
"maxLength": "Får innehålla högst %{max} tecken",
"minValue": "Måste vara minst %{min}",
"maxValue": "Får maximalt vara %{max}",
"number": "Måste vara ett nummer",
"maxValue": "Får vara högst %{max}",
"number": "Måste vara ett tal",
"email": "Måste vara en giltig e-postadress",
"oneOf": "Måste vara en av: %{options}",
"regex": "Måste matcha ett specifikt format (regexp): %{pattern}",
"unique": "Måste vara unik",
"oneOf": "Måste vara något av: %{options}",
"regex": "Måste matcha det reguljära uttrycket: %{pattern}",
"unique": "Måste vara unikt",
"url": "Måste vara en giltig URL"
},
"action": {
"add_filter": "Lägg till filter",
"add": "Lägg till",
"back": "Tillbaka",
"bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda",
"bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda",
"cancel": "Avbryt",
"clear_input_value": "Rensa",
"clear_input_value": "Rensa värdet",
"clone": "Klona",
"confirm": "Bekräfta",
"create": "Skapa",
@ -454,8 +467,8 @@
"export": "Exportera",
"list": "Lista",
"refresh": "Uppdatera",
"remove_filter": "Ta bort filter",
"remove": "Radera",
"remove_filter": "Ta bort det här filtret",
"remove": "Ta bort",
"save": "Spara",
"search": "Sök",
"show": "Visa",
@ -477,15 +490,15 @@
},
"page": {
"create": "Skapa %{name}",
"dashboard": "Dashboard",
"dashboard": "Översikt",
"edit": "%{name} #%{id}",
"error": "Ett fel uppstod",
"list": "%{name}",
"loading": "Laddar",
"not_found": "Hittade inget",
"loading": "Läser in",
"not_found": "Hittades inte",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} ännu.",
"invite": "Vill du lägga till en?"
"empty": "%{name}: inga poster ännu.",
"invite": "Vill du lägga till något?"
},
"input": {
"file": {
@ -497,13 +510,13 @@
"upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild."
},
"references": {
"all_missing": "Hittade ingen referensdata.",
"many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.",
"single_missing": "Associerade referenser verkar inte längre vara tillgängliga."
"all_missing": "Kunde inte hitta referensdata.",
"many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.",
"single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig."
},
"password": {
"toggle_visible": "Dölj password",
"toggle_hidden": "Visa password"
"toggle_visible": "Dölj lösenord",
"toggle_hidden": "Visa lösenord"
}
},
"message": {
@ -511,16 +524,16 @@
"are_you_sure": "Är du säker?",
"bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?",
"bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}",
"delete_content": "Vill du verkligen ta bort detta innehåll?",
"delete_content": "Vill du verkligen ta bort det här objektet?",
"delete_title": "Ta bort %{name} #%{id}",
"details": "Detaljer",
"error": "Ett klientfel uppstod och begäran kunde inte slutföras.",
"invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel",
"loading": "Sidan läses in, var god vänta",
"invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna",
"loading": "Sidan läses in. Vänta ett ögonblick",
"no": "Nej",
"not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.",
"yes": "Ja",
"unsaved_changes": "Du har osparade ändringar. Ignorera dem?"
"unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?"
},
"navigation": {
"no_results": "Inga resultat hittades",
@ -529,23 +542,23 @@
"page_out_from_end": "Det finns inga fler sidor",
"page_out_from_begin": "Det finns ingen sida före sida 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Antal per sida:",
"page_rows_per_page": "Objekt per sida:",
"next": "Nästa",
"prev": "Föregående",
"skip_nav": "Hoppa till innehåll"
},
"notification": {
"updated": "Element uppdaterat |||| %{smart_count} element uppdaterade",
"created": "Element skapat",
"deleted": "Element borttaget |||| %{smart_count} element borttagna",
"bad_item": "Felaktigt element",
"item_doesnt_exist": "Element finns inte",
"updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade",
"created": "Objekt skapat",
"deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna",
"bad_item": "Felaktigt objekt",
"item_doesnt_exist": "Objektet finns inte",
"http_error": "Kommunikationsfel med servern",
"data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.",
"i18n_error": "Kunde inte läsa in översättningen av det valda språket",
"data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.",
"i18n_error": "Kunde inte läsa in översättningarna för det valda språket",
"canceled": "Åtgärden avbröts",
"logged_out": "Sessionen har avslutats, anslut på nytt.",
"new_version": "Det finns en ny version! Uppdatera detta fönster."
"logged_out": "Sessionen har avslutats. Logga in igen.",
"new_version": "En ny version finns tillgänglig! Ladda om det här fönstret."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolumner att visa",
@ -556,39 +569,39 @@
},
"message": {
"note": "OBSERVERA",
"transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).",
"transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.",
"songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan",
"noPlaylistsAvailable": "Ingen tillgänglig",
"delete_user_title": "Ta bort användare '%{name}'",
"delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?",
"notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar",
"notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https",
"lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt",
"lastfmLinkFailure": "Last.fm kunde inte länkas",
"lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat",
"lastfmUnlinkFailure": "Last.fm kunde inte avlänkas",
"transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.",
"transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.",
"songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan",
"noPlaylistsAvailable": "Inga tillgängliga",
"delete_user_title": "Ta bort användaren '%{name}'",
"delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?",
"notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar",
"notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS",
"lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats",
"lastfmLinkFailure": "Det gick inte att koppla Last.fm",
"lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats",
"lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm",
"openIn": {
"lastfm": "Öppna i Last.fm",
"musicbrainz": "Öppna i MusicBrainz"
},
"lastfmLink": "Läs mer...",
"listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat",
"listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas",
"listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}",
"listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats",
"listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz",
"downloadOriginalFormat": "Ladda ner i originalformat",
"shareOriginalFormat": "Dela i originalformat",
"shareDialogTitle": "Dela %{resource} '%{name}'",
"shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}",
"shareSuccess": "URL kopierades till urklipp: %{url}",
"shareFailure": "Fel vid kopiering av URL %{url} till urklipp",
"shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}",
"shareSuccess": "URL:en har kopierats till urklipp: %{url}",
"shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp",
"downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter",
"remove_missing_title": "Ta bort saknade filer",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.",
"remove_all_missing_title": "Ta bort alla saknade filer",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.",
"noSimilarSongsFound": "Hittade inga liknande låtar",
"noTopSongsFound": "Hittade inga topplåtar",
"startingInstantMix": "Laddar direktmix...",
@ -597,7 +610,11 @@
"coverUploaded": "Omslagsbild uppdaterad",
"coverRemoved": "Omslagsbild borttagen",
"coverUploadError": "Fel vid uppladdning av omslagsbild",
"coverRemoveError": "Fel vid borttagning av omslagsbild"
"coverRemoveError": "Fel vid borttagning av omslagsbild",
"metadataRefreshStarted": "Metadata uppdateras i bakgrunden",
"quickConnectApproved": "%{app} på %{device} är nu inloggad",
"quickConnectInvalidCode": "Koden är ogiltig eller har gått ut",
"quickConnectError": "Kunde inte godkänna koden"
},
"menu": {
"library": "Bibliotek",
@ -610,17 +627,17 @@
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvy",
"desktop_notifications": "Skrivbordsmeddelanden",
"desktop_notifications": "Skrivbordsaviseringar",
"lastfmScrobbling": "Scrobbla till Last.fm",
"listenBrainzScrobbling": "Scrobbla till ListenBrainz",
"replaygain": "ReplayGain-läge",
"preAmp": "ReplayGain PreAmp (dB)",
"preAmp": "Förförstärkning för ReplayGain (dB)",
"gain": {
"none": "Inaktiverad",
"album": "Använd gain för album",
"track": "Använd gain für låtar"
"album": "Använd albumets volymjustering",
"track": "Använd låtens volymjustering"
},
"lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad"
"lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad"
}
},
"albumList": "Album",
@ -632,10 +649,19 @@
"multipleLibraries": "%{selected} av %{total} bibliotek",
"selectLibraries": "Välj bibliotek",
"none": "Inga"
}
},
"quickConnect": {
"name": "Snabbanslutning",
"code": "Kod",
"help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen",
"confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?",
"continue": "Fortsätt",
"approve": "Godkänn"
},
"onlyFavourites": "Visa endast favoriter"
},
"player": {
"playListsText": "Spela kön",
"playListsText": "Uppspelningskö",
"openText": "Öppna",
"closeText": "Stäng",
"notContentText": "Ingen musik",
@ -645,26 +671,26 @@
"previousTrackText": "Föregående låt",
"reloadText": "Ladda om",
"volumeText": "Volym",
"toggleLyricText": "Låttext av/på",
"toggleLyricText": "Visa eller dölj låttexten",
"toggleMiniModeText": "Minimera",
"destroyText": "Radera",
"destroyText": "Stäng spelaren och rensa kön",
"downloadText": "Ladda ner",
"removeAudioListsText": "Ta bort audiolistor",
"removeAudioListsText": "Rensa kön",
"clickToDeleteText": "Klicka för att ta bort %{name}",
"emptyLyricText": "Ingen låttext",
"playModeText": {
"order": "I ordningsföljd",
"orderLoop": "Upprepa",
"singleLoop": "Upprepa en",
"shufflePlay": "Shuffle"
"orderLoop": "Upprepa alla",
"singleLoop": "Upprepa en låt",
"shufflePlay": "Blanda"
}
},
"about": {
"links": {
"homepage": "Hemsida",
"homepage": "Webbplats",
"source": "Källkod",
"featureRequests": "Funktionalitetförfrågan",
"lastInsightsCollection": "Senaste Insights-kollektion",
"featureRequests": "Förslag på nya funktioner",
"lastInsightsCollection": "Senaste insamling av användningsdata",
"insights": {
"disabled": "Inaktiverad",
"waiting": "Väntar"
@ -672,16 +698,16 @@
},
"tabs": {
"about": "Om",
"config": "Inställningar"
"config": "Konfiguration"
},
"config": {
"configName": "Inställningsnamn",
"configName": "Inställning",
"environmentVariable": "Miljövariabel",
"currentValue": "Nuvarande värde",
"configurationFile": "Inställningsfil",
"exportToml": "Exportera inställningar (TOML)",
"exportSuccess": "Inställningarna kopierade till urklippet i TOML-format",
"exportFailed": "Kopiering av inställningarna misslyckades",
"currentValue": "Aktuellt värde",
"configurationFile": "Konfigurationsfil",
"exportToml": "Exportera konfiguration (TOML)",
"exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format",
"exportFailed": "Kunde inte kopiera konfigurationen",
"devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)",
"devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner",
"downloadToml": "Ladda ner konfiguration (TOML)"
@ -689,28 +715,28 @@
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Genomsökta mappar",
"quickScan": "Snabbscan",
"fullScan": "Komplett scan",
"serverUptime": "Serverdrifttid",
"totalScanned": "Skannade mappar totalt",
"quickScan": "Snabb",
"fullScan": "Fullständig",
"serverUptime": "Serverns drifttid",
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
"elapsedTime": "Spelad tid",
"selectiveScan": "Urval"
"scanType": "Senaste skanning",
"status": "Skanningsfel",
"elapsedTime": "Förfluten tid",
"selectiveScan": "Selektiv"
},
"help": {
"title": "Navidrome kortkommandon",
"title": "Kortkommandon i Navidrome",
"hotkeys": {
"show_help": "Visa denna hjälp",
"toggle_menu": "Växla sidomeny",
"toggle_menu": "Visa eller dölj sidomenyn",
"toggle_play": "Spela / pausa",
"prev_song": "Föregående låt",
"next_song": "Nästa låt",
"vol_up": "Volym upp",
"vol_down": "Volym ner",
"toggle_love": "Lägg till låt i favoriter",
"current_song": "Hoppa till nuvarande låt"
"vol_up": "Höj volymen",
"vol_down": "Sänk volymen",
"toggle_love": "Lägg till låten i favoriter",
"current_song": "Gå till aktuell låt"
}
},
"nowPlaying": {
@ -718,4 +744,4 @@
"empty": "Inget spelas",
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
}
}
}

View file

@ -93,7 +93,8 @@
"shuffle": "随机播放",
"addToPlaylist": "添加到歌单",
"download": "下载",
"info": "查看信息"
"info": "查看信息",
"refresh": "刷新元数据"
},
"lists": {
"all": "全部",
@ -155,11 +156,13 @@
"currentPassword": "当前密码",
"newPassword": "新密码",
"token": "令牌",
"libraries": "媒体库"
"libraries": "媒体库",
"scrobbleFilter": "个性化记录过滤器"
},
"helperTexts": {
"name": "名称的更改将在下次登录时生效",
"libraries": "为此用户选择指定媒体库,留空则使用默认媒体库"
"libraries": "为此用户选择指定媒体库,留空则使用默认媒体库",
"scrobbleFilter": "符合这些智能歌单规则的歌曲不会发送到 Last.fm、ListenBrainz 或个性化记录插件。使用与智能歌单相同的 JSON 语法和行为。例如:{\"all\":[{\"lt\":{\"rating\":4}}]}。留空则记录所有播放。本地播放次数不受影响。"
},
"notifications": {
"created": "用户已创建",
@ -167,7 +170,8 @@
"deleted": "用户已删除"
},
"validation": {
"librariesRequired": "至少为非管理员用户选择一个媒体库"
"librariesRequired": "至少为非管理员用户选择一个媒体库",
"invalidScrobbleFilter": "必须是有效的智能歌单规则。不支持数量限制(limit)、偏移量(offset)和刷新延迟(refresh delay)。"
},
"message": {
"listenBrainzToken": "输入您的 ListenBrainz 用户令牌",
@ -196,6 +200,9 @@
"targetFormat": "目标格式",
"defaultBitRate": "默认比特率",
"command": "命令"
},
"choices": {
"noDefaultBitRate": "无"
}
},
"playlist": {
@ -393,6 +400,7 @@
"invalidJson": "配置必须是有效的 JSON"
},
"messages": {
"idHelp": "由插件文件名派生的插件 ID。在 Agents 等配置选项中引用此插件时使用。",
"configHelp": "使用键值对配置插件。如果插件无需配置则留空。",
"configValidationError": "配置验证失败:",
"schemaRenderError": "无法渲染配置表单。此插件的 schema 定义可能无效。",
@ -401,10 +409,10 @@
"allUsersHelp": "启用时,插件将可以访问所有用户,包括将来创建的。",
"noUsers": "未选择用户",
"permissionReason": "原因",
"usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户, 或启用 '允许所有用户'。",
"usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户,或启用 '允许所有用户'。",
"allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。",
"noLibraries": "未选择媒体库",
"librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库, 或启用 '允许所有媒体库'。",
"librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库,或启用 '允许所有媒体库'。",
"allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。",
"requiredHosts": "必需的主机"
},
@ -566,6 +574,7 @@
"coverRemoved": "封面已移除",
"coverUploadError": "上传封面时出错",
"coverRemoveError": "移除封面时出错",
"metadataRefreshStarted": "正在后台刷新元数据",
"note": "注意",
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。",
@ -598,7 +607,7 @@
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 %{smart_count} 项 %{resource}",
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter",
"shareCopyToClipboard": "复制到剪切板: Ctrl+C,Enter",
"shareSuccess": "URL 已复制: %{url}",
"shareFailure": "URL 复制失败: %{url}",
"downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})",

View file

@ -45,7 +45,9 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
jobs = append(jobs, job)
}
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state, imageChanges: &imageChangeCollector{ds: ds}}
walkCtx, stopWalk := context.WithCancelCause(ctx)
return &phaseFolders{jobs: jobs, ctx: ctx, walkCtx: walkCtx, stopWalk: stopWalk, ds: ds, state: state,
imageChanges: &imageChangeCollector{ds: ds}}
}
type scanJob struct {
@ -123,6 +125,8 @@ type phaseFolders struct {
jobs []*scanJob
ds model.DataStore
ctx context.Context
walkCtx context.Context // cancelled when a folder fails to persist, so the walk stops early
stopWalk context.CancelCauseFunc
state *scanState
prevAlbumPIDConf string
imageChanges *imageChangeCollector
@ -144,15 +148,15 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
var total int64
var totalChanged int64
for _, job := range p.jobs {
if utils.IsCtxDone(p.ctx) {
if utils.IsCtxDone(p.walkCtx) {
break
}
outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...)
outputChan, err := walkDirTree(p.walkCtx, job, job.targetFolders...)
if err != nil {
log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err)
}
for folder := range pl.ReadOrDone(p.ctx, outputChan) {
for folder := range pl.ReadOrDone(p.walkCtx, outputChan) {
job.numFolders.Add(1)
p.state.sendProgress(&ProgressInfo{
LibID: job.lib.ID,
@ -208,6 +212,9 @@ func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] {
func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
defer p.measure(entry)()
if err := context.Cause(p.walkCtx); err != nil {
return entry, err
}
// Load children mediafiles from DB
cursor, err := p.ds.MediaFile(p.ctx).GetCursor(model.QueryOptions{
@ -331,128 +338,129 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
defer p.measure(entry)()
p.state.changesDetected.Store(true)
// Collect artwork queue items for changed albums/artists, enqueued in the same transaction
var queueItems []model.ArtworkQueueItem
err := p.ds.WithTx(func(tx model.DataStore) error {
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(p.ctx)
tagRepo := tx.Tag(p.ctx)
artistRepo := tx.Artist(p.ctx)
libraryRepo := tx.Library(p.ctx)
albumRepo := tx.Album(p.ctx)
mfRepo := tx.MediaFile(p.ctx)
// A new folder's albums/artists are enqueued below; only pre-existing folders need the diff.
if !entry.isNew() {
if changed, artistImage := entry.imagesChanged(); changed {
p.imageChanges.record(entry.job.lib, imageChangedFolder{
id: entry.id, path: entry.path, artistImage: artistImage,
})
}
}
// Save folder to DB
folder := entry.toFolder()
err := folderRepo.Put(folder)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting folder to DB", "folder", entry.path, err)
return err
}
// Save all tags to DB
err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err)
return err
}
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.artists {
err = artistRepo.Put(&entry.artists[i], "name",
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at")
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err)
return err
}
err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID)
if err != nil {
log.Error(p.ctx, "Scanner: Error adding artist to library", "lib", entry.job.lib.ID, "artist", entry.artists[i].Name, err)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID))
}
}
// Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.albums {
err = p.persistAlbum(albumRepo, &entry.albums[i], entry.albumIDMap)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting album to DB", "folder", entry.path, "album", entry.albums[i], err)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID))
}
}
// Save all tracks to DB
for i := range entry.tracks {
err = mfRepo.Put(&entry.tracks[i])
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting mediafile to DB", "folder", entry.path, "track", entry.tracks[i], err)
return err
}
}
// A re-imported track returns to unresolved so new embedded art is picked up lazily.
if len(entry.tracks) > 0 {
trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID })
if err := tx.Artwork(p.ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil {
log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err)
}
}
// Mark all missing tracks as not available
if len(entry.missingTracks) > 0 {
err = mfRepo.MarkMissing(true, entry.missingTracks...)
if err != nil {
log.Error(p.ctx, "Scanner: Error marking missing tracks", "folder", entry.path, err)
return err
}
// Touch all albums that have missing tracks, so they get refreshed in later phases
groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) {
return mf.AlbumID, struct{}{}
})
albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks))
err = albumRepo.Touch(albumsToUpdate...)
if err != nil {
log.Error(p.ctx, "Scanner: Error touching album", "folder", entry.path, "albums", albumsToUpdate, err)
return err
}
}
// Enqueue artwork resolution for changed albums/artists. Never fails the scan.
// A full scan re-imports every track, so a re-import is no evidence the art changed.
if len(queueItems) > 0 {
queue := tx.ArtworkQueue(p.ctx)
enqueue := queue.Enqueue
if p.state.fullScan {
enqueue = queue.EnqueueIfMissing
}
if err := enqueue(queueItems...); err != nil {
log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err)
}
}
return nil
ctx := log.NewContext(p.ctx, "folder", entry.path)
err := p.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
return p.persistFolder(ctx, tx, entry)
}, "scanner: persist changes")
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
log.Error(ctx, "Scanner: Error persisting changes to DB", err)
p.stopWalk(err)
return entry, err
}
return entry, err
// A new folder's albums/artists were enqueued with it; only pre-existing folders need the diff.
if !entry.isNew() {
if changed, artistImage := entry.imagesChanged(); changed {
p.imageChanges.record(entry.job.lib, imageChangedFolder{
id: entry.id, path: entry.path, artistImage: artistImage,
})
}
}
return entry, nil
}
// persistFolder writes the folder in tx. WithTxRetry may rerun it after a rollback.
func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, entry *folderEntry) error {
// Collect artwork queue items for changed albums/artists, enqueued in the same transaction
var queueItems []model.ArtworkQueueItem
// persistAlbum consumes the map, so a rerun needs the original
albumIDMap := maps.Clone(entry.albumIDMap)
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(ctx)
tagRepo := tx.Tag(ctx)
artistRepo := tx.Artist(ctx)
libraryRepo := tx.Library(ctx)
albumRepo := tx.Album(ctx)
mfRepo := tx.MediaFile(ctx)
// Save folder to DB
folder := entry.toFolder()
err := folderRepo.Put(folder)
if err != nil {
return fmt.Errorf("persisting folder: %w", err)
}
// Save all tags to DB
err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
if err != nil {
return fmt.Errorf("persisting tags: %w", err)
}
// Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.artists {
err = artistRepo.Put(&entry.artists[i], "name",
"mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at")
if err != nil {
return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err)
}
err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID)
if err != nil {
return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err)
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID))
}
}
// Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later
for i := range entry.albums {
err = p.persistAlbum(albumRepo, &entry.albums[i], albumIDMap)
if err != nil {
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID))
}
}
// Save all tracks to DB
for i := range entry.tracks {
err = mfRepo.Put(&entry.tracks[i])
if err != nil {
return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err)
}
}
// A re-imported track returns to unresolved so new embedded art is picked up lazily.
if len(entry.tracks) > 0 {
trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID })
if err := tx.Artwork(ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil {
log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err)
}
}
// Mark all missing tracks as not available
if len(entry.missingTracks) > 0 {
err = mfRepo.MarkMissing(true, entry.missingTracks...)
if err != nil {
return fmt.Errorf("marking missing tracks: %w", err)
}
// Touch all albums that have missing tracks, so they get refreshed in later phases
groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) {
return mf.AlbumID, struct{}{}
})
albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks))
err = albumRepo.Touch(albumsToUpdate...)
if err != nil {
return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err)
}
}
// Enqueue artwork resolution for changed albums/artists. Never fails the scan.
// A full scan re-imports every track, so a re-import is no evidence the art changed.
if len(queueItems) > 0 {
queue := tx.ArtworkQueue(ctx)
enqueue := queue.Enqueue
if p.state.fullScan {
enqueue = queue.EnqueueIfMissing
}
if err := enqueue(queueItems...); err != nil {
log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err)
}
}
return nil
}
// persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID
@ -499,34 +507,32 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) {
}
func (p *phaseFolders) finalize(err error) error {
errF := p.ds.WithTx(func(tx model.DataStore) error {
p.stopWalk(nil)
defer p.imageChanges.enqueue(p.ctx)
// A failed phase may not have walked every folder, and unvisited ones must not be marked missing
if err != nil {
return err
}
return p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
for _, job := range p.jobs {
// Mark all folders that were not updated as missing
if len(job.lastUpdates) == 0 {
continue
}
folderIDs := slices.Collect(maps.Keys(job.lastUpdates))
err := tx.Folder(p.ctx).MarkMissing(true, folderIDs...)
if err != nil {
log.Error(p.ctx, "Scanner: Error marking missing folders", "lib", job.lib.Name, err)
return err
if err := tx.Folder(ctx).MarkMissing(true, folderIDs...); err != nil {
return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err)
}
err = tx.MediaFile(p.ctx).MarkMissingByFolder(true, folderIDs...)
if err != nil {
log.Error(p.ctx, "Scanner: Error marking tracks in missing folders", "lib", job.lib.Name, err)
return err
if err := tx.MediaFile(ctx).MarkMissingByFolder(true, folderIDs...); err != nil {
return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err)
}
// Touch all albums that have missing folders, so they get refreshed in later phases
_, err = tx.Album(p.ctx).TouchByMissingFolder()
if err != nil {
log.Error(p.ctx, "Scanner: Error touching albums with missing folders", "lib", job.lib.Name, err)
return err
if _, err := tx.Album(ctx).TouchByMissingFolder(); err != nil {
return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err)
}
}
return nil
}, "scanner: finalize phaseFolders")
p.imageChanges.enqueue(p.ctx)
return errors.Join(err, errF)
}
var _ phase[*folderEntry] = (*phaseFolders)(nil)

View file

@ -273,66 +273,68 @@ func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (mod
}
func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error {
return p.ds.WithTx(func(tx model.DataStore) error {
discardedID := target.ID
oldAlbumID := missing.AlbumID
newAlbumID := target.AlbumID
oldAlbumID := missing.AlbumID
newAlbumID := target.AlbumID
// Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target.
// Claimed before the transaction so a concurrent move skips it, and released if the move fails.
reassignAlbum := oldAlbumID != newAlbumID
if reassignAlbum {
p.annotationMutex.Lock()
reassignAlbum = !p.processedAlbumAnnotations[newAlbumID]
p.processedAlbumAnnotations[newAlbumID] = true
p.annotationMutex.Unlock()
if !reassignAlbum {
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
}
}
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
// A rerun must start from the original target, not the one the rolled-back attempt changed
moved := target
// Preserve the original created_at from the missing file, so moved tracks
// don't appear in "Recently Added"
target.CreatedAt = missing.CreatedAt
moved.CreatedAt = missing.CreatedAt
// Update the target media file with the missing file's ID. This effectively "moves" the track
// to the new location while keeping its annotations and references intact.
target.ID = missing.ID
err := tx.MediaFile(p.ctx).Put(&target)
if err != nil {
moved.ID = missing.ID
if err := tx.MediaFile(ctx).Put(&moved); err != nil {
return fmt.Errorf("update matched track: %w", err)
}
// Discard the new mediafile row (the one that was moved to)
err = tx.MediaFile(p.ctx).Delete(discardedID)
if err != nil {
if err := tx.MediaFile(ctx).Delete(target.ID); err != nil {
return fmt.Errorf("delete discarded track: %w", err)
}
// Handle album annotation reassignment if AlbumID changed
if oldAlbumID != newAlbumID {
// Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target
p.annotationMutex.RLock()
alreadyProcessed := p.processedAlbumAnnotations[newAlbumID]
p.annotationMutex.RUnlock()
if !alreadyProcessed {
p.annotationMutex.Lock()
// Double-check pattern to avoid race conditions
if !p.processedAlbumAnnotations[newAlbumID] {
// Reassign direct album annotations (starred, rating)
log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
// Keep created_at field from previous instance of the album, so moved albums
// don't appear in "Recently Added"
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
}
}
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
p.processedAlbumAnnotations[newAlbumID] = true
}
p.annotationMutex.Unlock()
} else {
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
if reassignAlbum {
// Reassign direct album annotations (starred, rating)
log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
if err := tx.Album(ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
log.Warn(ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
}
p.state.changesDetected.Store(true)
// Keep created_at field from previous instance of the album, so moved albums
// don't appear in "Recently Added"
if err := tx.Album(ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
if !errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
}
}
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
}
return nil
})
}, "scanner: move matched track")
if err != nil {
if reassignAlbum {
p.annotationMutex.Lock()
delete(p.processedAlbumAnnotations, newAlbumID)
p.annotationMutex.Unlock()
}
return err
}
p.state.changesDetected.Store(true)
return nil
}
func (p *phaseMissingTracks) finalize(err error) error {
@ -355,7 +357,12 @@ func (p *phaseMissingTracks) finalize(err error) error {
}
func (p *phaseMissingTracks) purgeMissing() error {
deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing()
var deletedCount int64
err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error {
var err error
deletedCount, err = tx.MediaFile(ctx).DeleteAllMissing()
return err
}, "scanner: purge missing")
if err != nil {
return fmt.Errorf("error deleting missing files: %w", err)
}

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