Merge branch 'master' into quick-scan-does-not-detect-new-cover-art/5469

This commit is contained in:
Ayman Makroo 2026-07-24 20:34:54 +05:30 committed by GitHub
commit d4eca9cc9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 2125 additions and 416 deletions

6
.github/FUNDING.yml vendored
View File

@ -1,10 +1,10 @@
# These are supported funding model platforms
github: deluan
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: deluan
github: deluan
open_collective: navidrome
liberapay: deluan
patreon: # Replace with a single Patreon username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
issuehunt: # Replace with a single IssueHunt username

View File

@ -1,10 +1,12 @@
package deezer
import (
"cmp"
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"github.com/navidrome/navidrome/conf"
@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
}
}
// If the first one has the same name, that's the one
if !strings.EqualFold(artists[0].Name, name) {
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
// ahead of non-matches, prefer an exact-case match, then the most fans.
rank := func(a Artist) int {
switch {
case a.Name == name:
return 2
case strings.EqualFold(a.Name, name):
return 1
default:
return 0
}
}
slices.SortFunc(artists, func(a, b Artist) int {
return cmp.Or(
cmp.Compare(rank(b), rank(a)),
cmp.Compare(b.NbFan, a.NbFan),
cmp.Compare(a.ID, b.ID),
)
})
best := artists[0]
if !strings.EqualFold(best.Name, name) {
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
return &artists[0], err
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
return new(best), nil
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {

View File

@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() {
})
})
Describe("searchArtist", func() {
var agent *deezerAgent
var httpClient *fakeHttpClient
BeforeEach(func() {
httpClient = &fakeHttpClient{}
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
}
})
It("picks the exact-name match with the most fans when several share the name", func() {
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":61045802,"name":"Queen","nb_fan":75},
{"id":141954732,"name":"Queen","nb_fan":397},
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
{"id":183179807,"name":"Queen","nb_fan":53},
{"id":412,"name":"Queen","nb_fan":12744378}
],"total":5}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(412))
})
It("matches the name case-insensitively", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"QUEEN","nb_fan":10},
{"id":2,"name":"queen","nb_fan":20}
],"total":2}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(2))
})
It("returns ErrNotFound when no result matches the name exactly", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
],"total":1}`)),
})
_, err := agent.searchArtist(ctx, "Queen")
Expect(err).To(MatchError(agents.ErrNotFound))
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient

View File

@ -138,7 +138,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics)
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
return router
}

View File

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
)
type ffmpeg struct{}
@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP
return nil, err
}
if err := fileExists(filePath); err != nil {
return nil, err
return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err),
NotFound: errors.Is(err, fs.ErrNotExist), err: err}
}
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
log.Trace(ctx, "Executing ffprobe command", "args", args)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err}
}
return parseProbeOutput(output)
result, err := parseProbeOutput(output)
if err != nil {
return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err}
}
return result, nil
}
// ProbeError reports an ffprobe failure. Reason is a path-free message safe to
// expose to clients; the wrapped cause carries the full detail for logging.
// NotFound marks the media file itself as missing — a launch failure of a
// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer
// it from the error chain.
type ProbeError struct {
Path string
Reason string
NotFound bool
err error
}
func (e *ProbeError) Error() string {
if e.err == nil {
return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason)
}
return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err))
}
// Unwrap exposes the underlying cause so callers can test it with errors.Is
// (e.g. fs.ErrNotExist to detect a missing file).
func (e *ProbeError) Unwrap() error { return e.err }
// SafeReason returns the path-free reason, safe to send to clients.
func (e *ProbeError) SafeReason() string { return e.Reason }
// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved
// or unreadable file reads as "file not found" rather than a raw ffprobe message.
func fileAccessReason(err error) string {
switch {
case errors.Is(err, fs.ErrNotExist):
return "file not found"
case errors.Is(err, fs.ErrPermission):
return "permission denied"
default:
return "file not accessible"
}
}
// probeDetail returns the full diagnostic for logging (may contain paths):
// ffprobe's stderr when present, otherwise the raw error text.
func probeDetail(err error) string {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 {
return strings.TrimSpace(string(exitErr.Stderr))
}
return err.Error()
}
// probeClientReason returns a path-free reason for an ffprobe execution failure:
// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe
// couldn't run at all (its launch error may embed the binary path).
func probeClientReason(err error, path string) string {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || len(exitErr.Stderr) == 0 {
return "could not read file"
}
return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file"))
}
type probeOutput struct {

View File

@ -2,6 +2,7 @@ package ffmpeg
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() {
})
})
Describe("ProbeError", func() {
It("uses the underlying cause in Error() so logs keep the full detail", func() {
e := &ProbeError{Path: "/music/foo.flac",
err: errors.New("/music/foo.flac: Invalid data found when processing input")}
Expect(e.Error()).To(ContainSubstring("/music/foo.flac"))
Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input"))
})
It("returns the path-free reason from SafeReason()", func() {
e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"}
Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input"))
Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac"))
})
It("unwraps to the underlying cause so errors.Is detects a missing file", func() {
e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist}
Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue())
})
})
Describe("probeClientReason", func() {
It("strips the file path from ffprobe stderr", func() {
if runtime.GOOS == "windows" {
Skip("uses /bin/sh")
}
_, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output()
Expect(err).To(HaveOccurred())
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found"))
})
It("returns a generic reason for launch failures, without leaking the binary path", func() {
err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory")
Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file"))
})
})
Describe("probeDetail", func() {
It("surfaces ffprobe stderr for logging", func() {
if runtime.GOOS == "windows" {
Skip("uses /bin/sh")
}
_, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output()
Expect(err).To(HaveOccurred())
Expect(probeDetail(err)).To(Equal("boom detail"))
})
})
Describe("fileAccessReason", func() {
It("reports a missing file as 'file not found', not a raw stat message", func() {
_, err := os.Stat("/no/such/dir/really-missing.flac")
Expect(err).To(HaveOccurred())
Expect(fileAccessReason(err)).To(Equal("file not found"))
})
It("falls back to a generic reason for other access errors", func() {
Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible"))
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() {
}
})
It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() {
_, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac")
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue())
var pe *ProbeError
Expect(errors.As(err, &pe)).To(BeTrue())
Expect(pe.SafeReason()).To(Equal("file not found"))
Expect(pe.NotFound).To(BeTrue())
})
It("should interrupt transcoding when context is cancelled", func() {
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()

View File

@ -10,6 +10,30 @@ import (
"github.com/navidrome/navidrome/model/request"
)
const (
minRetryDelay = 5 * time.Second
maxRetryDelay = 4 * time.Minute
// maxRetryShift caps the exponent so the shift never overflows int64.
// minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling.
maxRetryShift = 6
)
// backoffDelay returns the delay for a zero-based retry index (0 = first retry):
// minRetryDelay doubled per prior failure, clamped to maxRetryDelay.
func backoffDelay(failures int) time.Duration {
if failures < 0 {
failures = 0
}
if failures >= maxRetryShift {
return maxRetryDelay
}
d := minRetryDelay << failures
if d > maxRetryDelay {
return maxRetryDelay
}
return d
}
// Loader is a function that loads a scrobbler by name.
// It returns the scrobbler and true if found, or nil and false if not available.
// This allows the buffered scrobbler to always get the current plugin instance.
@ -98,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() {
}
func (b *bufferedScrobbler) run(ctx context.Context) {
timer := time.NewTimer(time.Hour)
timer.Stop()
defer timer.Stop()
failures := 0
for {
if !b.processQueue(ctx) {
time.AfterFunc(5*time.Second, func() {
b.sendWakeSignal()
})
if b.processQueue(ctx) {
failures = 0
timer.Stop()
} else {
timer.Reset(backoffDelay(failures))
if failures < maxRetryShift {
failures++
}
}
select {
case <-b.wakeSignal:
continue
case <-timer.C:
case <-ctx.Done():
return
}

View File

@ -2,6 +2,9 @@ package scrobbler
import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/navidrome/navidrome/model"
@ -100,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() {
}
})
})
var _ = Describe("backoffDelay", func() {
DescribeTable("computes the exponential backoff curve clamped to the ceiling",
func(failures int, expected time.Duration) {
Expect(backoffDelay(failures)).To(Equal(expected))
},
Entry("first failure", 0, 5*time.Second),
Entry("second failure", 1, 10*time.Second),
Entry("third failure", 2, 20*time.Second),
Entry("fourth failure", 3, 40*time.Second),
Entry("fifth failure", 4, 80*time.Second),
Entry("sixth failure", 5, 160*time.Second),
Entry("reaches the ceiling", 6, 4*time.Minute),
Entry("stays clamped past the ceiling", 7, 4*time.Minute),
Entry("stays clamped for large values", 1000, 4*time.Minute),
Entry("negative is treated as zero", -1, 5*time.Second),
)
})
// Drives the real run loop and asserts the exact retry schedule + recovery. Plain
// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give.
func TestBufferedScrobblerBackoffSchedule(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
buffer := tests.CreateMockedScrobbleBufferRepo()
userRepo := tests.CreateMockUserRepo()
g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed())
ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo}
flaky := &recoveringScrobbler{}
flaky.fail(ErrRetryLater)
bs := newBufferedScrobbler(ds, flaky, "flaky")
defer func() { bs.Stop(); synctest.Wait() }()
// Let the loop settle on the empty buffer, then enqueue a scrobble.
synctest.Wait()
track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"}
g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed())
// First attempt fires immediately on the enqueue wake and is left buffered.
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(int32(1)))
g.Expect(buffer.Length()).To(Equal(int64(1)))
// Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s.
for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} {
want := int32(i + 2)
time.Sleep(gap - time.Nanosecond)
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap)
time.Sleep(time.Nanosecond)
synctest.Wait()
g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap)
}
// Once the service recovers, waking the loop drains the buffered entry.
flaky.succeed()
bs.sendWakeSignal()
synctest.Wait()
g.Expect(buffer.Length()).To(Equal(int64(0)))
})
}
// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while
// the buffered scrobbler's goroutine is draining, to exercise retry then recovery.
type recoveringScrobbler struct {
err atomic.Pointer[error]
count atomic.Int32
}
func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) }
func (f *recoveringScrobbler) succeed() { f.err.Store(nil) }
func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true }
func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error {
return nil
}
func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error {
f.count.Add(1)
if e := f.err.Load(); e != nil {
return *e
}
return nil
}
func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil }

View File

@ -0,0 +1,41 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain)
}
func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error {
// Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows
// into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album.
_, err := tx.ExecContext(ctx, `
ALTER TABLE album ADD COLUMN rg_album_gain real;
ALTER TABLE album ADD COLUMN rg_album_peak real;
CREATE TEMP TABLE _rg_backfill AS
SELECT album_id, rg_album_gain, rg_album_peak FROM media_file
WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL;
CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id);
UPDATE album SET
rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL
GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1),
rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL
GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1)
WHERE album.id IN (SELECT album_id FROM _rg_backfill);
DROP TABLE _rg_backfill;
`)
return err
}
func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

2
go.mod
View File

@ -3,7 +3,7 @@ module github.com/navidrome/navidrome
go 1.26
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3
require (
github.com/Masterminds/squirrel v1.5.4

4
go.sum
View File

@ -31,8 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs=
github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU=
github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/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/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=

View File

@ -1,7 +1,6 @@
package model
import (
"fmt"
"iter"
"math"
"sync"
@ -49,6 +48,8 @@ type Album struct {
MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"`
FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album
ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"`
RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"`
RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"`
// External metadata fields
Description string `structs:"description" json:"description,omitempty" hash:"ignore"`
@ -75,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID {
func (a Album) FullName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0])
}
return a.Name
}
@ -142,6 +143,7 @@ type AlbumRepository interface {
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetCursor(...QueryOptions) (AlbumCursor, error)
GetYears(libraryIDs ...int) ([]int, error)
// The following methods are used exclusively by the scanner:
Touch(ids ...string) error

View File

@ -24,6 +24,8 @@ var _ = Describe("Album", func() {
Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"),
Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"),
)
})

View File

@ -2,29 +2,26 @@ package model
import (
"context"
"errors"
)
// TODO: Should the type be encoded in the ID?
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
ar, err := ds.Artist(ctx).Get(id)
if err == nil {
return ar, nil
getters := []func() (any, error){
func() (any, error) { return ds.Artist(ctx).Get(id) },
func() (any, error) { return ds.Album(ctx).Get(id) },
func() (any, error) { return ds.Playlist(ctx).Get(id) },
func() (any, error) { return ds.MediaFile(ctx).Get(id) },
func() (any, error) { return ds.Radio(ctx).Get(id) },
}
al, err := ds.Album(ctx).Get(id)
if err == nil {
return al, nil
for _, get := range getters {
entity, err := get()
if err == nil {
return entity, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
}
pls, err := ds.Playlist(ctx).Get(id)
if err == nil {
return pls, nil
}
mf, err := ds.MediaFile(ctx).Get(id)
if err == nil {
return mf, nil
}
r, err := ds.Radio(ctx).Get(id)
if err == nil {
return r, nil
}
return nil, err
return nil, ErrNotFound
}

40
model/get_entity_test.go Normal file
View File

@ -0,0 +1,40 @@
package model_test
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GetEntityByID", func() {
var ds *tests.MockDataStore
var ctx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = GinkgoT().Context()
})
It("returns the entity matching the id", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
entity, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).ToNot(HaveOccurred())
Expect(entity).To(BeAssignableToTypeOf(&model.Album{}))
Expect(entity.(*model.Album).ID).To(Equal("a1"))
})
It("returns ErrNotFound when no entity matches", func() {
_, err := model.GetEntityByID(ctx, ds, "missing")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("propagates unexpected repository errors instead of reporting not-found", func() {
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
_, err := model.GetEntityByID(ctx, ds, "a1")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(model.ErrNotFound))
})
})

View File

@ -100,18 +100,28 @@ type MediaFile struct {
func (mf MediaFile) FullTitle() string {
if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0])
}
return mf.Title
}
func (mf MediaFile) FullAlbumName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0])
}
return mf.Album
}
var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'}
func appendSuffix(base, suffix string) string {
suffix = strings.TrimSpace(suffix)
if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] {
return base + " " + suffix
}
return base + " (" + suffix + ")"
}
func (mf MediaFile) ContentType() string {
return mime.TypeByExtension("." + mf.Suffix)
}
@ -314,6 +324,8 @@ func (mfs MediaFiles) ToAlbum() Album {
originalYears := make([]int, 0, len(mfs))
originalDates := make([]string, 0, len(mfs))
releaseDates := make([]string, 0, len(mfs))
rgAlbumGains := make([]*float64, 0, len(mfs))
rgAlbumPeaks := make([]*float64, 0, len(mfs))
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
a.Missing = true
@ -344,6 +356,8 @@ func (mfs MediaFiles) ToAlbum() Album {
originalYears = append(originalYears, m.OriginalYear)
originalDates = append(originalDates, m.OriginalDate)
releaseDates = append(releaseDates, m.ReleaseDate)
rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain)
rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak)
comments = append(comments, m.Comment)
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
@ -378,6 +392,8 @@ func (mfs MediaFiles) ToAlbum() Album {
a.Comment, _ = allOrNothing(comments)
a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds)
a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds)
a.RGAlbumGain = mostFrequentPtr(rgAlbumGains)
a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks)
fixAlbumArtist(&a)
return a
@ -407,6 +423,32 @@ func minMax(items []int) (int, int) {
return mn, mx
}
// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if
// none. It counts by dereferenced value so a genuine 0.0 is a real candidate
// (slice.MostFrequent skips the zero value and compares pointers by identity).
func mostFrequentPtr(items []*float64) *float64 {
var counts map[float64]int
var best float64
var bestCount int
for _, it := range items {
if it == nil {
continue
}
if counts == nil {
counts = map[float64]int{}
}
counts[*it]++
if counts[*it] > bestCount {
bestCount = counts[*it]
best = *it
}
}
if bestCount == 0 {
return nil
}
return &best
}
func newer(t1, t2 time.Time) time.Time {
if t1.After(t2) {
return t1

View File

@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() {
})
})
})
Context("ReplayGain", func() {
It("picks the most frequent non-nil album gain and peak", func() {
mfs := MediaFiles{
{Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
{Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)},
{Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)},
}
album := mfs.ToAlbum()
Expect(album.RGAlbumGain).ToNot(BeNil())
Expect(*album.RGAlbumGain).To(Equal(-8.0))
Expect(album.RGAlbumPeak).ToNot(BeNil())
Expect(*album.RGAlbumPeak).To(Equal(0.9))
})
It("keeps a genuine 0.0 gain instead of dropping it", func() {
mfs := MediaFiles{
{Path: "a", RGAlbumGain: new(0.0)},
{Path: "b", RGAlbumGain: new(0.0)},
}
album := mfs.ToAlbum()
Expect(album.RGAlbumGain).ToNot(BeNil())
Expect(*album.RGAlbumGain).To(Equal(0.0))
})
It("leaves gain and peak nil when no track has a value", func() {
mfs := MediaFiles{{Path: "a"}, {Path: "b"}}
album := mfs.ToAlbum()
Expect(album.RGAlbumGain).To(BeNil())
Expect(album.RGAlbumPeak).To(BeNil())
})
})
Context("Participants", func() {
var album Album
BeforeEach(func() {
@ -504,6 +533,13 @@ var _ = Describe("MediaFile", func() {
Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
Entry("returns just title when tag is absent", true, Tags{}, "Song"),
Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"),
Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"),
Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"),
Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{"<Live>"}}, "Song <Live>"),
Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"),
Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"),
Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"),
)
DescribeTable("FullAlbumName",
func(enabled bool, tags Tags, expected string) {
@ -515,6 +551,8 @@ var _ = Describe("MediaFile", func() {
Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"),
Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"),
)
Describe("CoverArtId", func() {
It("returns its own id if it HasCoverArt", func() {

View File

@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) {
type TagRepository interface {
Add(libraryID int, tags ...Tag) error
UpdateCounts() error
GetAll(name TagName, options ...QueryOptions) (TagList, error)
}
type TagName string

View File

@ -3,6 +3,7 @@ package persistence
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"maps"
@ -31,6 +32,10 @@ type dbAlbum struct {
Participants string `structs:"-" json:"-"`
Tags string `structs:"-" json:"-"`
FolderIDs string `structs:"-" json:"-"`
// dbx maps columns to fields by name; RGAlbumGain doesn't convert to
// rg_album_gain, so shim fields carry the read and PostScan copies them over.
RgAlbumGain *float64 `structs:"-" json:"-"`
RgAlbumPeak *float64 `structs:"-" json:"-"`
}
func (a *dbAlbum) PostScan() error {
@ -58,6 +63,8 @@ func (a *dbAlbum) PostScan() error {
}
a.Album.FolderIDs = ids
}
a.Album.RGAlbumGain = a.RgAlbumGain
a.Album.RGAlbumPeak = a.RgAlbumPeak
return nil
}
@ -256,6 +263,20 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC
return wrapAlbumCursor(cursor), nil
}
func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) {
cond := And{Gt{"max_year": 0}, Eq{"missing": false}}
if len(libraryIDs) > 0 {
cond = append(cond, Eq{"library_id": libraryIDs})
}
sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year"))
years := []int{}
err := r.queryAllSlice(sq, &years)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
return years, nil
}
func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error {
var from dbx.NullStringMap
err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from)

View File

@ -3,6 +3,7 @@ package persistence
import (
"errors"
"fmt"
"sort"
"time"
"github.com/Masterminds/squirrel"
@ -851,6 +852,65 @@ var _ = Describe("AlbumRepository", func() {
})
})
Describe("GetYears", func() {
It("returns distinct album years ascending, excluding zero", func() {
years, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
// Sorted ascending, no duplicates, no zero-year entries.
Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue())
Expect(years).ToNot(ContainElement(0))
for i := 1; i < len(years); i++ {
Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct
}
})
It("deduplicates repeated years", func() {
// Regression test: verify that DISTINCT is applied in the SQL.
// Insert two albums with the same non-zero max_year (2005).
album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005}
album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005}
Expect(albumRepo.Put(album1)).To(Succeed())
Expect(albumRepo.Put(album2)).To(Succeed())
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}}))
})
years, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
// Count occurrences of 2005 in the result
count := 0
for _, y := range years {
if y == 2005 {
count++
}
}
Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it")
})
It("scopes years to the given libraries", func() {
all, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
// A library with no albums yields no years.
scoped, err := albumRepo.GetYears(99999)
Expect(err).ToNot(HaveOccurred())
Expect(scoped).To(BeEmpty())
Expect(all).ToNot(BeEmpty())
})
It("excludes years that belong only to missing albums", func() {
gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true}
Expect(albumRepo.Put(gone)).To(Succeed())
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"}))
})
years, err := albumRepo.GetYears()
Expect(err).ToNot(HaveOccurred())
Expect(years).ToNot(ContainElement(1911))
})
})
Describe("wrapAlbumCursor", func() {
It("does not panic when the cursor yields a dbAlbum with nil Album", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:
@ -890,6 +950,33 @@ var _ = Describe("AlbumRepository", func() {
Expect(albums[0].ID).To(Equal("a1"))
})
})
Describe("ReplayGain", func() {
BeforeEach(func() {
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}}))
})
})
It("round-trips album ReplayGain gain and peak", func() {
Expect(albumRepo.Put(&model.Album{
ID: "rg-1", Name: "rg", LibraryID: 1,
RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98),
})).To(Succeed())
got, err := albumRepo.Get("rg-1")
Expect(err).ToNot(HaveOccurred())
Expect(got.RGAlbumGain).ToNot(BeNil())
Expect(*got.RGAlbumGain).To(Equal(-7.5))
Expect(got.RGAlbumPeak).ToNot(BeNil())
Expect(*got.RGAlbumPeak).To(Equal(0.98))
})
It("reads nil when ReplayGain is unset", func() {
Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed())
got, err := albumRepo.Get("rg-2")
Expect(err).ToNot(HaveOccurred())
Expect(got.RGAlbumGain).To(BeNil())
Expect(got.RGAlbumPeak).To(BeNil())
})
})
})
func _p(id, name string, sortName ...string) model.Participant {

View File

@ -51,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
r.ctx = ctx
r.db = db
r.registerModel(&model.Playlist{}, map[string]filterFunc{
"q": playlistFilter,
"smart": smartPlaylistFilter,
"id": idFilter("playlist"),
"q": playlistFilter,
"smart": smartPlaylistFilter,
"starred": annotationBoolFilter("starred"),
})
r.setSortMappings(map[string]string{
"owner_name": "owner_name",

View File

@ -4,6 +4,7 @@ import (
"slices"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -155,6 +156,34 @@ var _ = Describe("PlaylistRepository", func() {
Expect(count).To(Equal(int64(len(starred))))
})
It("filters starred playlists through the registered REST filter", func() {
Expect(repo.SetStar(true, plsID)).To(Succeed())
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "true"},
})
Expect(err).ToNot(HaveOccurred())
starred := res.(model.Playlists)
Expect(starred).To(ContainElement(HaveField("ID", plsID)))
for _, p := range starred {
Expect(p.Starred).To(BeTrue())
}
res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "false"},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID)))
})
It("reads a playlist by id through the REST id filter without ambiguity", func() {
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"id": plsID},
})
Expect(err).ToNot(HaveOccurred())
Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID)))
})
It("does not leak an annotation row of another item_type sharing the playlist id", func() {
// Older builds (and the star fallthrough) can leave a media_file-typed row
// under a playlist id; the item_type-scoped join must not surface or dupe it.

View File

@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
return nil
}
func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) {
sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name})
res := model.TagList{}
err := r.queryAll(sq, &res)
return res, err
}
func (r *tagRepository) purgeUnused() error {
del := Delete(r.tableName).Where(`
del := Delete(r.tableName).Where(`
id not in (select jt.value
from album left join json_tree(album.tags, '$') as jt
where atom is not null
and key = 'id'
UNION
UNION
select jt.value
from media_file left join json_tree(media_file.tags, '$') as jt
where atom is not null

View File

@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example:
**Required fields:** `name`, `author`, `version`
**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental`
**Optional fields:** `description`, `website`, `config`, `permissions`
#### Config Definition
@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema
}
```
#### Experimental Features
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
- **`threads`** Enables WebAssembly threads support (for plugins compiled with multi-threading)
```json
{
"experimental": {
"threads": {
"reason": "Required for concurrent audio processing"
}
}
}
```
> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary.
---
## Capabilities

View File

@ -13,8 +13,6 @@ import (
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"golang.org/x/sync/errgroup"
)
@ -377,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
WithCompilationCache(m.cache).
WithCloseOnContextDone(true)
// Enable experimental threads if requested in manifest
if pkg.Manifest.HasExperimentalThreads() {
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
log.Debug(ctx, "Enabling experimental threads support")
}
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: runtimeConfig,

View File

@ -34,9 +34,6 @@
"permissions": {
"$ref": "#/$defs/Permissions"
},
"experimental": {
"$ref": "#/$defs/Experimental"
},
"config": {
"$ref": "#/$defs/ConfigDefinition"
}
@ -58,27 +55,6 @@
}
}
},
"Experimental": {
"type": "object",
"description": "Experimental features that may change or be removed in future versions",
"additionalProperties": false,
"properties": {
"threads": {
"$ref": "#/$defs/ThreadsFeature"
}
}
},
"ThreadsFeature": {
"type": "object",
"description": "Enable experimental WebAssembly threads support",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why threads support is needed"
}
}
},
"Permissions": {
"type": "object",
"description": "Permissions required by the plugin",

View File

@ -117,11 +117,6 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
return nil
}
// HasExperimentalThreads returns true if the manifest requests experimental threads support.
func (m *Manifest) HasExperimentalThreads() bool {
return m.Experimental != nil && m.Experimental.Threads != nil
}
// HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries.
func (m *Manifest) HasLibraryFilesystemPermission() bool {
return m.Permissions != nil &&

View File

@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
return nil
}
// Experimental features that may change or be removed in future versions
type Experimental struct {
// Threads corresponds to the JSON schema field "threads".
Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"`
}
// HTTP access permissions for a plugin
type HTTPPermission struct {
// Explanation for why HTTP access is needed
@ -109,9 +103,6 @@ type Manifest struct {
// A brief description of what the plugin does
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`
// Experimental corresponds to the JSON schema field "experimental".
Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"`
// The display name of the plugin
Name string `json:"name" yaml:"name" mapstructure:"name"`
@ -242,12 +233,6 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error {
return nil
}
// Enable experimental WebAssembly threads support
type ThreadsFeature struct {
// Explanation for why threads support is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Users service permissions for accessing user information
type UsersPermission struct {
// Explanation for why users access is needed

View File

@ -117,76 +117,6 @@ var _ = Describe("Manifest", func() {
})
})
Describe("HasExperimentalThreads", func() {
It("returns false when no experimental section", func() {
m := &Manifest{}
Expect(m.HasExperimentalThreads()).To(BeFalse())
})
It("returns false when experimental section has no threads", func() {
m := &Manifest{
Experimental: &Experimental{},
}
Expect(m.HasExperimentalThreads()).To(BeFalse())
})
It("returns true when threads feature is present", func() {
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{},
},
}
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
It("returns true when threads feature has a reason", func() {
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{
Reason: new("Required for concurrent processing"),
},
},
}
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
It("parses experimental.threads from JSON", func() {
data := []byte(`{
"name": "Threaded Plugin",
"author": "Test Author",
"version": "1.0.0",
"experimental": {
"threads": {
"reason": "To use multi-threaded WASM module"
}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.HasExperimentalThreads()).To(BeTrue())
Expect(m.Experimental.Threads.Reason).ToNot(BeNil())
Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module"))
})
It("parses experimental.threads without reason from JSON", func() {
data := []byte(`{
"name": "Threaded Plugin",
"author": "Test Author",
"version": "1.0.0",
"experimental": {
"threads": {}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
})
Describe("ParseManifest", func() {
It("parses a valid manifest with users permission", func() {
data := []byte(`{

View File

@ -194,6 +194,16 @@ func ByAlbumID(albumIds []string) Sqlizer {
return Eq{"album_id": albumIds}
}
// AlbumsByYears matches albums whose production year (max_year) is in years.
func AlbumsByYears(years []int) Sqlizer {
return Eq{"max_year": years}
}
// SongsByYears matches media files whose year is in years.
func SongsByYears(years []int) Sqlizer {
return Eq{"year": years}
}
// ArtistsByGenreID matches artists credited as album artist on an album with any of the given
// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row.
func ArtistsByGenreID(genreIds []string) Sqlizer {
@ -204,10 +214,17 @@ func ArtistsByGenreID(genreIds []string) Sqlizer {
)
}
// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry
// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups.
func genreTagFilter(cond Sqlizer) Sqlizer {
return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond})
// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each
// entry against cond (its name via Like, or its tag id via Eq/IN).
func tagIDFilter(tagName string, cond Sqlizer) Sqlizer {
return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond})
}
func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) }
// ByStudioID matches items (albums or songs) whose record-label tag id is in ids.
func ByStudioID(ids []string) Sqlizer {
return tagIDFilter("recordlabel", Eq{"value": ids})
}
func filterByGenre(genre string) Sqlizer {

View File

@ -8,53 +8,41 @@ import (
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
)
// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't
// access their library; artists span libraries (library_artist), so have no single LibraryID to
// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility.
// When ok is false the response has already been written, so callers must return without writing
// the annotation.
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) {
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
// already been written, so callers must return without writing the annotation.
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
ctx := r.Context()
entity, err := model.GetEntityByID(ctx, api.ds, id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, ""
}
u, _ := request.UserFrom(ctx)
if al, err := api.ds.Album(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(al.LibraryID) {
http.Error(w, "Not Found", http.StatusNotFound)
return nil, false
switch e := entity.(type) {
case *model.Album:
if u.HasLibraryAccess(e.LibraryID) {
return api.ds.Album(ctx), "album"
}
return api.ds.Album(ctx), true
} else if !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, false
}
if _, err := api.ds.Artist(ctx).Get(id); err == nil {
return api.ds.Artist(ctx), true
} else if !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, false
}
if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(mf.LibraryID) {
http.Error(w, "Not Found", http.StatusNotFound)
return nil, false
case *model.Artist:
return api.ds.Artist(ctx), "artist"
case *model.MediaFile:
if u.HasLibraryAccess(e.LibraryID) {
return api.ds.MediaFile(ctx), "song"
}
return api.ds.MediaFile(ctx), true
} else if !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, false
}
playlistRepo := api.ds.Playlist(ctx)
if _, err := playlistRepo.Get(id); err == nil {
return playlistRepo, true
} else if !errors.Is(err, model.ErrNotFound) {
api.internalError(w, r, err)
return nil, false
case *model.Playlist:
return api.ds.Playlist(ctx), "playlist"
}
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
http.Error(w, "Not Found", http.StatusNotFound)
return nil, false
return nil, ""
}
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
@ -77,14 +65,15 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
repo, ok := api.resolveAnnotated(w, r, id)
if !ok {
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return
}
if err := repo.SetStar(starred, id); err != nil {
api.internalError(w, r, err)
return
}
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
encodedID := dto.EncodeID(id)
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
}
@ -96,14 +85,15 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
repo, ok := api.resolveAnnotated(w, r, id)
if !ok {
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return
}
if err := repo.SetRating(rating, id); err != nil {
api.internalError(w, r, err)
return
}
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
encodedID := dto.EncodeID(id)
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
if rating > 0 {

View File

@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -17,6 +18,7 @@ import (
var _ = Describe("Annotations", func() {
var api *Router
var ds *tests.MockDataStore
var broker *fakeEventBroker
// alice has access to library 1 only.
ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
@ -24,7 +26,8 @@ var _ = Describe("Annotations", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
api = &Router{ds: ds}
broker = &fakeEventBroker{}
api = &Router{ds: ds, broker: broker}
})
Describe("markFavorite / unmarkFavorite", func() {
@ -134,6 +137,39 @@ var _ = Describe("Annotations", func() {
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("emits a refreshResource event when starring a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
})
It("emits a refreshResource event when starring an album", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`))
})
It("does not emit an event when the item is not accessible", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(broker.Events).To(BeEmpty())
})
})
Describe("setRating / removeRating", func() {
@ -253,5 +289,31 @@ var _ = Describe("Annotations", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
})
It("emits a refreshResource event when rating a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
invoke(api.setRating, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
})
})
})
type fakeEventBroker struct {
http.Handler
Events []events.Event
}
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
var _ events.Broker = (*fakeEventBroker)(nil)

View File

@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/cache"
)
@ -38,6 +39,7 @@ type Router struct {
provider external.Provider
sonic sonic.Engine
lyrics lyrics.Lyrics
broker events.Broker
lyricsCache cache.SimpleCache[string, model.LyricList]
similarFlight singleflight.Group
serverIDMu sync.Mutex
@ -47,11 +49,11 @@ type Router struct {
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
transcodeDecider stream.TranscodeDecider, players core.Players,
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router {
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router {
r := &Router{
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
sonic: sonicSvc, lyrics: lyricsSvc,
sonic: sonicSvc, lyrics: lyricsSvc, broker: broker,
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
SizeLimit: 1000,
DefaultTTL: 5 * time.Minute,
@ -144,6 +146,8 @@ func (api *Router) routes() http.Handler {
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
r.Get("/genres", api.getGenres)
r.Get("/musicgenres", api.getGenres)
r.Get("/studios", api.getStudios)
r.Get("/items/filters", api.getQueryFiltersLegacy)
r.Post("/playlists", api.createPlaylist)
r.Get("/playlists/{playlistId}", api.getPlaylist)

View File

@ -18,7 +18,7 @@ import (
var _ = Describe("Router", func() {
It("serves the public handshake through the mounted handler", func() {
ds := &tests.MockDataStore{}
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -26,7 +26,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for unknown routes", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Nonexistent/Route", nil)
api.ServeHTTP(w, r)
@ -36,7 +36,7 @@ var _ = Describe("Router", func() {
})
It("returns 404 JSON for a known path with an unsupported method", func() {
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("PATCH", "/System/Info/Public", nil)
api.ServeHTTP(w, r)
@ -53,7 +53,7 @@ var _ = Describe("Router", func() {
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil)
api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -70,7 +70,7 @@ var _ = Describe("Router", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 2
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
login := func() int {
w := httptest.NewRecorder()

View File

@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists
@ -27,7 +28,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol
opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)}
applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", ""))
scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", "")))
scopeIDs, _ := parentIDScope(ctx, r)
// Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false.
// Finamp's artist tab sends GenreIds when a genre filter is active.
q := itemsQuery{
@ -59,3 +60,44 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) {
}
api.ok(w, r, res)
}
// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as
// Studio items, scoped to ParentId's library when accessible.
func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
scope, _ := parentIDScope(ctx, r)
opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)}
labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts)
if err != nil {
api.internalError(w, r, err)
return
}
items := slice.Map(labels, dto.StudioToBaseItem)
offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0)
api.ok(w, r, result(paginate(items, offset, max), len(items), offset))
}
// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's
// library when accessible. Tags/OfficialRatings have no music source, so they are always empty.
func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
scope, _ := parentIDScope(ctx, r)
genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)}
genres, err := api.ds.Genre(ctx).GetAll(genreOpts)
if err != nil {
api.internalError(w, r, err)
return
}
years, err := api.ds.Album(ctx).GetYears(scope...)
if err != nil {
api.internalError(w, r, err)
return
}
api.ok(w, r, dto.QueryFiltersLegacy{
Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }),
Tags: []string{},
OfficialRatings: []string{},
Years: years,
})
}

View File

@ -175,4 +175,52 @@ var _ = Describe("Browsing", func() {
Expect(w.Code).To(Equal(http.StatusOK))
})
})
Describe("getStudios", func() {
It("scopes results to the user's accessible libraries", func() {
tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}}))
invoke(api.getStudios, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := tagRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("library_tag.library_id"))
Expect(args).To(ContainElements(1, 2))
})
// An empty scope (admin, or a non-admin with no explicit library grants) must be treated
// as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing".
It("does not restrict results for an admin user", func() {
tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin())
invoke(api.getStudios, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(tagRepo.Options.Filters).To(BeNil())
})
})
Describe("getQueryFiltersLegacy", func() {
It("scopes genres to the user's accessible libraries", func() {
genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}}))
invoke(api.getQueryFiltersLegacy, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := genreRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("library_tag.library_id"))
Expect(args).To(ContainElements(1, 2))
})
It("does not restrict genres for an admin user", func() {
genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin())
invoke(api.getQueryFiltersLegacy, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(genreRepo.Options.Filters).To(BeNil())
})
})
})

View File

@ -61,19 +61,23 @@ type BaseItemDto struct {
PremiereDate *string `json:"PremiereDate,omitempty"`
// DateCreated is the ISO 8601 date the item was added to the library; clients show it as
// "Date Added" and sort "Recently Added" by it.
DateCreated string `json:"DateCreated,omitempty"`
Album string `json:"Album,omitempty"`
AlbumId string `json:"AlbumId,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
Artists []string `json:"Artists,omitempty"`
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
Genres []string `json:"Genres,omitempty"`
ChildCount *int `json:"ChildCount,omitempty"`
SongCount *int `json:"SongCount,omitempty"`
AlbumCount *int `json:"AlbumCount,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
DateCreated string `json:"DateCreated,omitempty"`
Album string `json:"Album,omitempty"`
AlbumId string `json:"AlbumId,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
Artists []string `json:"Artists,omitempty"`
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
Genres []string `json:"Genres,omitempty"`
GenreItems []NameGuidPair `json:"GenreItems,omitempty"`
Studios []NameGuidPair `json:"Studios,omitempty"`
NormalizationGain *float64 `json:"NormalizationGain,omitempty"`
AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"`
ChildCount *int `json:"ChildCount,omitempty"`
SongCount *int `json:"SongCount,omitempty"`
AlbumCount *int `json:"AlbumCount,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
// ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a
// de-dup key for image downloads (and a placeholder); absent, it warns the server isn't
// calculating blurhashes.
@ -285,3 +289,12 @@ type LyricLineCue struct {
Start int64 `json:"Start"`
End *int64 `json:"End,omitempty"`
}
// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present;
// clients (jellyfin-web) render each unconditionally.
type QueryFiltersLegacy struct {
Genres []string `json:"Genres"`
Tags []string `json:"Tags"`
OfficialRatings []string `json:"OfficialRatings"`
Years []int `json:"Years"`
}

View File

@ -7,12 +7,15 @@ import "strings"
// omits those unless the client asks for them.
type Fields map[string]struct{}
// ParseFields splits the comma-separated Fields param into a lowercased set.
func ParseFields(csv string) Fields {
// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated
// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors.
func ParseFields(values ...string) Fields {
f := Fields{}
for name := range strings.SplitSeq(csv, ",") {
if name = strings.TrimSpace(strings.ToLower(name)); name != "" {
f[name] = struct{}{}
for _, csv := range values {
for name := range strings.SplitSeq(csv, ",") {
if name = strings.TrimSpace(strings.ToLower(name)); name != "" {
f[name] = struct{}{}
}
}
}
return f

View File

@ -0,0 +1,26 @@
package dto
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseFields", func() {
It("parses a single comma-separated value", func() {
f := ParseFields("Genres,MediaSources")
Expect(f.Has("Genres")).To(BeTrue())
Expect(f.Has("MediaSources")).To(BeTrue())
})
It("parses fields spread across repeated params", func() {
f := ParseFields("Genres", "MediaSources", "SortName")
Expect(f.Has("Genres")).To(BeTrue())
Expect(f.Has("MediaSources")).To(BeTrue())
Expect(f.Has("SortName")).To(BeTrue())
})
It("returns an empty set for no values", func() {
Expect(ParseFields()).To(BeEmpty())
Expect(ParseFields("")).To(BeEmpty())
})
})

View File

@ -0,0 +1,22 @@
package dto
import (
"encoding/json"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("QueryFiltersLegacy", func() {
It("marshals all four keys, empty ones as [] not null", func() {
b, err := json.Marshal(QueryFiltersLegacy{
Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999},
})
Expect(err).ToNot(HaveOccurred())
j := string(b)
Expect(j).To(ContainSubstring(`"Genres":["Rock"]`))
Expect(j).To(ContainSubstring(`"Tags":[]`))
Expect(j).To(ContainSubstring(`"OfficialRatings":[]`))
Expect(j).To(ContainSubstring(`"Years":[1999]`))
})
})

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
@ -139,7 +140,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
Album: mf.Album,
AlbumId: EncodeID(mf.AlbumID),
AlbumArtist: mf.AlbumArtist,
Artists: []string{mf.Artist},
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
@ -153,15 +153,27 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
if fields.Has("SortName") {
item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title)
}
// Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown
// Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist;
// AlbumArtists the album artist.
if mf.ArtistID != "" {
item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}}
// Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit).
// Participants holds the per-artist list; fall back to the flattened display fields when absent.
if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 {
item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name })
item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair {
return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)}
})
} else {
if mf.Artist != "" {
item.Artists = []string{mf.Artist}
}
if mf.ArtistID != "" {
item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}}
}
}
if mf.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}}
}
// dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion.
item.NormalizationGain = mf.RGTrackGain
item.AlbumNormalizationGain = mf.RGAlbumGain
if mf.Year > 0 {
item.ProductionYear = new(mf.Year)
}
@ -175,6 +187,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
if len(mf.Genres) > 0 {
for _, g := range mf.Genres {
item.Genres = append(item.Genres, g.Name)
item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name})
}
} else if mf.Genre != "" {
item.Genres = []string{mf.Genre}
@ -187,7 +200,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
return item
}
func AlbumToBaseItem(al model.Album) BaseItemDto {
func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
item := BaseItemDto{
Name: al.Name,
Id: EncodeID(al.ID),
@ -216,8 +229,20 @@ func AlbumToBaseItem(al model.Album) BaseItemDto {
if len(al.Genres) > 0 {
for _, g := range al.Genres {
item.Genres = append(item.Genres, g.Name)
item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name})
}
}
// Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios
// list and StudioIds= filter, so a client can display and click through to filter by label.
if fields.Has("Studios") {
for _, label := range al.Tags.Values(model.TagRecordLabel) {
id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID)
item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id})
}
}
// The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same
// convention as tracks; clients read it off the album item as NormalizationGain.
item.NormalizationGain = al.RGAlbumGain
return item
}
@ -247,6 +272,15 @@ func GenreToBaseItem(g model.Genre) BaseItemDto {
}
}
func StudioToBaseItem(t model.Tag) BaseItemDto {
return BaseItemDto{
Name: t.TagValue,
Id: EncodeID(t.ID),
Type: "Studio",
BackdropImageTags: []string{},
}
}
// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto.
func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
// Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover.

View File

@ -15,6 +15,7 @@ var _ = Describe("mappers", func() {
ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1",
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}},
}
mf.PlayCount = 2
mf.Starred = true
@ -35,6 +36,8 @@ var _ = Describe("mappers", func() {
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag))
Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
})
Describe("Fields gating (matches real Jellyfin)", func() {
@ -96,6 +99,48 @@ var _ = Describe("mappers", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
})
It("omits Artists when the track has no artist name or participants", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: "s1", Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul",
AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul",
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}},
{Artist: model.Artist{ID: "ar-redman", Name: "Redman"}},
},
}
item := SongToBaseItem(mf, nil)
Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{
{Name: "De La Soul", Id: EncodeID("ar-delasoul")},
{Name: "Redman", Id: EncodeID("ar-redman")},
}))
// AlbumArtists stays single, matching real Jellyfin.
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}}))
})
It("serializes normalization gains with Jellyfin's exact key casing", func() {
mf := model.MediaFile{ID: "s1", Title: "Song",
RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)}
b, err := json.Marshal(SongToBaseItem(mf, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`))
Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`))
})
It("omits normalization gains when the file has no ReplayGain tags", func() {
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil))
Expect(err).ToNot(HaveOccurred())
// Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain).
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
It("builds a MediaSourceInfo from a media file", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
@ -198,8 +243,8 @@ var _ = Describe("mappers", func() {
})
It("maps an album to a MusicAlbum folder item", func() {
al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10}
item := AlbumToBaseItem(al)
al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}}
item := AlbumToBaseItem(al, nil)
Expect(item.Type).To(Equal("MusicAlbum"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("alb-1")))
@ -211,6 +256,36 @@ var _ = Describe("mappers", func() {
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"]))
Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
})
It("populates album Studios from record-label tags only when Fields=Studios", func() {
al := model.Album{ID: "alb-2", Name: "Alb2"}
al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}}
Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty())
item := AlbumToBaseItem(al, ParseFields("Studios"))
Expect(item.Studios).To(Equal([]NameGuidPair{
{Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)},
{Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)},
}))
})
It("sets NormalizationGain on the album from its ReplayGain", func() {
al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)}
b, err := json.Marshal(AlbumToBaseItem(al, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`))
// Real Jellyfin never sets AlbumNormalizationGain on an album item.
Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain"))
})
It("omits NormalizationGain when the album has no ReplayGain", func() {
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
It("maps an artist to a MusicArtist folder item", func() {
@ -231,6 +306,13 @@ var _ = Describe("mappers", func() {
Expect(item.Name).To(Equal("Rock"))
})
It("maps a tag to a Studio BaseItemDto", func() {
item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"})
Expect(item.Type).To(Equal("Studio"))
Expect(item.Name).To(Equal("Blue Note"))
Expect(item.Id).To(Equal(EncodeID("t1")))
})
Describe("premiereDate", func() {
// Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily.
It("serializes a full date", func() {
@ -259,9 +341,9 @@ var _ = Describe("mappers", func() {
})
It("is set on albums from their date, falling back to MaxYear", func() {
Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil())
Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil())
})
})

View File

@ -2,6 +2,7 @@ package e2e
import (
"net/http"
"sort"
"time"
"github.com/navidrome/navidrome/server/jellyfin/dto"
@ -62,6 +63,16 @@ var _ = Describe("Browsing", func() {
}
})
// Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated
// value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect.
It("honors MediaSources when Fields is sent as repeated params", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources"))
Expect(q.Items).ToNot(BeEmpty())
for _, it := range q.Items {
Expect(it.MediaSources).To(HaveLen(1))
}
})
It("lists all album artists", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(4))
@ -200,6 +211,44 @@ var _ = Describe("Browsing", func() {
})
})
Describe("year filtering (Years=)", func() {
It("filters items by Years=", func() {
albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959"))
Expect(names(albums.Items)).To(ConsistOf("Kind of Blue"))
songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959"))
for _, it := range songs.Items {
Expect(it.ProductionYear).ToNot(BeNil())
Expect(*it.ProductionYear).To(Equal(1959))
}
Expect(songs.Items).ToNot(BeEmpty())
})
})
Describe("studio filtering (StudioIds=)", func() {
It("filters items by StudioIds=", func() {
studios := queryResult(get("/Studios"))
var columbiaID string
for _, it := range studios.Items {
if it.Name == "Columbia" {
columbiaID = it.Id
}
}
Expect(columbiaID).ToNot(BeEmpty())
albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID))
Expect(names(albums.Items)).To(ConsistOf("Kind of Blue"))
})
It("returns filter lists scoped to a ParentId library", func() {
var filters dto.QueryFiltersLegacy
parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters)
Expect(filters.Years).To(ContainElements(1959, 1965))
studios := queryResult(get("/Studios?ParentId=" + enc("1")))
Expect(names(studios.Items)).To(ContainElement("Columbia"))
})
})
// Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>.
Describe("genre filtering (GenreIds)", func() {
lib1 := enc("1")
@ -405,6 +454,22 @@ var _ = Describe("Browsing", func() {
Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis"))
})
It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item)
Expect(item.NormalizationGain).ToNot(BeNil())
Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001))
Expect(item.AlbumNormalizationGain).ToNot(BeNil())
Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001))
})
It("omits normalization gains for files without ReplayGain tags", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(songID("So What"))), &item)
Expect(item.NormalizationGain).To(BeNil())
Expect(item.AlbumNormalizationGain).To(BeNil())
})
It("resolves an artist", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item)
@ -456,5 +521,31 @@ var _ = Describe("Browsing", func() {
Expect(q.Items).To(HaveLen(1))
Expect(q.TotalRecordCount).To(Equal(3))
})
It("returns record labels as Studio items", func() {
q := queryResult(get("/Studios"))
names := make([]string, 0, len(q.Items))
for _, it := range q.Items {
Expect(it.Type).To(Equal("Studio"))
names = append(names, it.Name)
}
Expect(names).To(ContainElement("Columbia"))
})
})
Describe("GET /Items/Filters", func() {
It("returns legacy query filters with genres, years, and empty tags/ratings", func() {
var filters dto.QueryFiltersLegacy
parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters)
Expect(filters.Genres).To(ContainElements("Rock", "Jazz"))
Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971))
// Verify ascending sort by checking it equals itself sorted.
sorted := make([]int, len(filters.Years))
copy(sorted, filters.Years)
sort.Ints(sorted)
Expect(filters.Years).To(Equal(sorted))
Expect(filters.Tags).To(BeEmpty())
Expect(filters.OfficialRatings).To(BeEmpty())
})
})
})

View File

@ -111,7 +111,7 @@ func buildTestFS() storagetest.FakeFS {
abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"})
singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"})
return harness.CreateFS(fstest.MapFS{
@ -121,7 +121,9 @@ func buildTestFS() storagetest.FakeFS {
"Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")),
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
"replaygain_track_gain": "-3.50 dB",
"replaygain_album_gain": "-4.25 dB",
})),
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
"Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")),
@ -329,6 +331,7 @@ func setupTestDB() {
providerFake,
sonicSvc,
lyrics.NewLyrics(ds, nil),
events.NoopBroker(),
)
}

View File

@ -28,9 +28,9 @@ var _ = Describe("Item images", func() {
Expect(artworkSpy.lastID).To(ContainSubstring(id))
})
It("resolves a private playlist's cover for its owner under an elevated context", func() {
// The route carries no user in ctx (public); the owner is identified by the request token,
// and resolution then runs elevated so the visibility filter doesn't eat the cover.
It("resolves a private playlist's cover under an elevated context", func() {
// The route carries no user in ctx (public); resolution runs elevated so the visibility
// filter doesn't eat the cover.
plID := createPlaylist("Private Mix", nil)
w := get("/Items/" + enc(plID) + "/Images/Primary")
Expect(w.Code).To(Equal(http.StatusOK))
@ -48,27 +48,11 @@ var _ = Describe("Item images", func() {
Expect(w.Body.String()).To(Equal("IMG"))
})
Describe("private playlist covers", func() {
It("does not resolve a private playlist's cover for an unauthenticated caller", func() {
plID := createPlaylist("Secret Mix", nil) // owned by admin, private
w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "")
Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error
Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID))
})
It("does not resolve a private playlist's cover for another user", func() {
plID := createPlaylist("Secret Mix", nil)
w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID))
})
It("resolves a public playlist's cover for anyone", func() {
plID := createPlaylist("Shared Mix", nil)
Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(artworkSpy.lastID).To(ContainSubstring(plID))
})
// Covers are served regardless of playlist visibility — see getItemImage for the rationale.
It("resolves a private playlist's cover for an unauthenticated caller", func() {
plID := createPlaylist("Secret Mix", nil) // owned by admin, private
w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(artworkSpy.lastID).To(ContainSubstring(plID))
})
})

View File

@ -25,14 +25,13 @@ import (
)
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
// Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs
// under an elevated context to bypass the persistence visibility filter; playlist access is
// gated inside resolveArtworkID.
// Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials
// and item ids are unguessable, so resolution runs elevated to bypass the visibility filter.
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId")))
size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth"))
artID := api.resolveArtworkID(ctx, r, itemId)
artID := api.resolveArtworkID(ctx, itemId)
reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false)
switch {
case errors.Is(err, context.Canceled):
@ -49,7 +48,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing
// album -> artist -> media file -> playlist.
func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string {
func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string {
if al, err := api.ds.Album(ctx).Get(itemId); err == nil {
return al.CoverArtID().String()
}
@ -60,12 +59,7 @@ func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId
return mf.CoverArtID().String()
}
if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil {
// Playlist covers are user-scoped: serve a private one only for a public playlist or a
// token identifying its owner/an admin, so this public route can't probe others' covers.
u, ok := api.userFromToken(r)
if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) {
return pl.CoverArtID().String()
}
return pl.CoverArtID().String()
}
return (model.ArtworkID{}).String()
}

View File

@ -85,20 +85,7 @@ var _ = Describe("Images", func() {
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
})
It("resolves a public playlist id to its cover artwork", func() {
ds := &tests.MockDataStore{}
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}})
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("pl1"))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.recvId).To(ContainSubstring("pl1"))
})
It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() {
It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() {
ds := &tests.MockDataStore{}
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}})
fa := &fakeArtwork{}
@ -108,7 +95,7 @@ var _ = Describe("Images", func() {
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.recvId).ToNot(ContainSubstring("pl1"))
Expect(fa.recvId).To(ContainSubstring("pl1"))
})
// This endpoint is public (no user in the request), so artwork must be resolved under an

View File

@ -222,6 +222,8 @@ type itemsQuery struct {
contributingOnly bool
genreIds []string
albumIds []string
years []int
studioIds []string
}
// parseItemsQuery also resolves the entity types (inferring them from the parent when
@ -230,7 +232,7 @@ type itemsQuery struct {
func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery {
p := req.Params(r)
q := itemsQuery{
fields: dto.ParseFields(p.StringOr("fields", "")),
fields: dto.ParseFields(p.Strings("fields")...),
ids: decodedQueryIDs(r, "ids"),
rawTypes: p.StringOr("includeitemtypes", ""),
search: searchTerm(p),
@ -245,7 +247,9 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu
// Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre.
genreIds: decodedQueryIDs(r, "genreids"),
// Feishin fetches an album's tracks with AlbumIds instead of ParentId.
albumIds: decodedQueryIDs(r, "albumids"),
albumIds: decodedQueryIDs(r, "albumids"),
years: parseYears(r),
studioIds: decodedQueryIDs(r, "studioids"),
}
// An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping
// plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist.
@ -414,6 +418,17 @@ func decodedQueryIDs(r *http.Request, key string) []string {
return slice.Map(queryIDs(r, key), dto.DecodeID)
}
// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params.
func parseYears(r *http.Request) []int {
var years []int
for _, v := range queryIDs(r, "years") {
if y, err := strconv.Atoi(v); err == nil && y > 0 {
years = append(years, y)
}
}
return years
}
// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to
// {"MusicAlbum"} when none are recognized (so ParentId=<artistId> browses that artist's albums).
func parseTypes(types string) []string {
@ -481,6 +496,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO
}
func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) {
toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) }
repo := api.ds.Album(ctx)
filters := squirrel.And{}
// For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's
@ -496,6 +512,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it
if len(q.genreIds) > 0 {
filters = append(filters, filter.ByGenreID(q.genreIds))
}
if len(q.years) > 0 {
filters = append(filters, filter.AlbumsByYears(q.years))
}
if len(q.studioIds) > 0 {
filters = append(filters, filter.ByStudioID(q.studioIds))
}
if q.favOnly {
filters = append(filters, filter.ByStarred().Filters)
}
@ -509,12 +531,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it
if err != nil {
return itemsResult{}, err
}
return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil
return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil
}
total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters})
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
return repo.GetCursor(opts)
}, dto.AlbumToBaseItem)
}, toItem)
return streamed(open, int(total), opts.Offset), nil
}
@ -537,6 +559,12 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite
if len(q.genreIds) > 0 {
filters = append(filters, filter.ByGenreID(q.genreIds))
}
if len(q.years) > 0 {
filters = append(filters, filter.SongsByYears(q.years))
}
if len(q.studioIds) > 0 {
filters = append(filters, filter.ByStudioID(q.studioIds))
}
if q.favOnly {
filters = append(filters, filter.ByStarred().Filters)
}
@ -665,7 +693,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi
if !u.HasLibraryAccess(al.LibraryID) {
return dto.BaseItemDto{}, false
}
return dto.AlbumToBaseItem(*al), true
return dto.AlbumToBaseItem(*al, fields), true
}
if ar, err := api.ds.Artist(ctx).Get(id); err == nil {
// TODO: an artist spans multiple libraries (library_artist), so there's no single
@ -730,7 +758,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel
func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
fields := dto.ParseFields(req.Params(r).StringOr("fields", ""))
fields := dto.ParseFields(req.Params(r).Strings("fields")...)
if item, ok := api.resolveItemByID(r.Context(), id, fields); ok {
api.ok(w, r, item)
return
@ -754,13 +782,15 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) {
// /Items/Latest, and why it writes directly instead of going through api.ok.
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
fields := dto.ParseFields(p.Strings("fields")...)
opts := filter.AlbumsByNewest()
opts.Max = req.Params(r).IntOr("limit", 20)
opts.Max = p.IntOr("limit", 20)
opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx))
repo := api.ds.Album(ctx)
open := streamCursor(func() (func(func(model.Album, error) bool), error) {
return repo.GetCursor(opts)
}, dto.AlbumToBaseItem)
}, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) })
api.writeItemsArray(w, r, streamed(open, 0, 0))
}

View File

@ -2,11 +2,14 @@ package jellyfin
import (
"context"
"net/http"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
)
// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty
@ -30,6 +33,20 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int,
return accessibleLibraryIDs(ctx), false
}
// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope).
func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) {
return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", "")))
}
// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means
// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN ().
func libraryScopeFilter(scope []int) squirrel.Sqlizer {
if len(scope) == 0 {
return nil
}
return squirrel.Eq{"library_tag.library_id": scope}
}
// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node.
// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item.
func libraryView(lib model.Library) dto.BaseItemDto {

View File

@ -152,7 +152,7 @@ func tokenFromRequest(r *http.Request) string {
}
// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token
// or unknown subject. Used by authenticate and by public routes that optionally identify the caller.
// or unknown subject.
func (api *Router) userFromToken(r *http.Request) (model.User, bool) {
token := tokenFromRequest(r)
if token == "" {

View File

@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) {
return
}
p := req.Params(r)
fields := dto.ParseFields(p.StringOr("fields", ""))
fields := dto.ParseFields(p.Strings("fields")...)
res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0))
if err != nil {
api.internalError(w, r, err)

View File

@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() {
var api *Router
BeforeEach(func() {
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("serves a fully lowercase path directly", func() {

View File

@ -177,7 +177,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.
}
seen[s.AlbumID] = true
if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) {
items = append(items, dto.AlbumToBaseItem(*al))
items = append(items, dto.AlbumToBaseItem(*al, nil))
if len(items) >= limit {
break
}

View File

@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() {
Expect(err).ToNot(HaveOccurred())
token = t
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil)
api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("upgrades when authenticated via the api_key query parameter", func() {

View File

@ -68,7 +68,7 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
ctx := r.Context()
p := req.Params(r)
ids, _ := p.Strings("id")
ids := p.Strings("id")
var err error
if len(ids) == 0 {

View File

@ -102,7 +102,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := req.Params(r)
playlistId, _ := p.String(":playlistId")
ids, _ := p.Strings("id")
ids := p.Strings("id")
err := pls.RemoveTracks(r.Context(), playlistId, ids)
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])

View File

@ -103,7 +103,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
ids := p.Strings("id")
currentID, _ := p.String("current")
position := p.Int64Or("position", 0)
@ -176,7 +176,7 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
ids := p.Strings("id")
position := p.Int64Or("position", 0)

View File

@ -155,6 +155,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter))
})
})

View File

@ -109,6 +109,7 @@ var _ = Describe("Sharing Endpoints", Ordered, func() {
Expect(resp.Status).To(Equal(responses.StatusFailed))
Expect(resp.Error).ToNot(BeNil())
Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter))
})
It("updateShare returns error when id parameter is missing", func() {

View File

@ -68,7 +68,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error)
case ActionStatus:
return createResponse(pb.Status(ctx))
case ActionSet:
ids, _ := p.Strings("id")
ids := p.Strings("id")
return createResponse(pb.Set(ctx, ids))
case ActionStart:
return createResponse(pb.Start(ctx))
@ -82,7 +82,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error)
offset := p.IntOr("offset", 0)
return createResponse(pb.Skip(ctx, index, offset))
case ActionAdd:
ids, _ := p.Strings("id")
ids := p.Strings("id")
return createResponse(pb.Add(ctx, ids))
case ActionClear:
return createResponse(pb.Clear(ctx))

View File

@ -45,7 +45,8 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
// Parse optional target parameters for selective scanning
var targets []model.ScanTarget
if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 {
if targetParams := p.Strings("target"); len(targetParams) > 0 {
var err error
targets, err = model.ParseTargets(targetParams)
if err != nil {
return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err))

View File

@ -71,9 +71,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error {
func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
albumIds, _ := p.Strings("albumId")
artistIds, _ := p.Strings("artistId")
ids := p.Strings("id")
albumIds := p.Strings("albumId")
artistIds := p.Strings("artistId")
if len(ids)+len(albumIds)+len(artistIds) == 0 {
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
}
@ -90,9 +90,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) {
func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, _ := p.Strings("id")
albumIds, _ := p.Strings("albumId")
artistIds, _ := p.Strings("artistId")
ids := p.Strings("id")
albumIds := p.Strings("albumId")
artistIds := p.Strings("artistId")
if len(ids)+len(albumIds)+len(artistIds) == 0 {
return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
}
@ -163,9 +163,9 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error
func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, err := p.Strings("id")
if err != nil {
return nil, err
ids := p.Strings("id")
if len(ids) == 0 {
return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'")
}
times, _ := p.Times("time")
if len(times) > 0 && len(times) != len(ids) {

View File

@ -62,7 +62,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context()
p := req.Params(r)
songIds, _ := p.Strings("songId")
songIds := p.Strings("songId")
playlistId, _ := p.String("playlistId")
name, _ := p.String("name")
if playlistId == "" && name == "" {
@ -99,7 +99,7 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error)
if err != nil {
return nil, err
}
songsToAdd, _ := p.Strings("songIdToAdd")
songsToAdd := p.Strings("songIdToAdd")
songIndexesToRemove, _ := p.Ints("songIndexToRemove")
var plsName *string
if s, err := p.String("name"); err == nil {

View File

@ -52,9 +52,9 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar
func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
ids, err := p.Strings("id")
if err != nil {
return nil, err
ids := p.Strings("id")
if len(ids) == 0 {
return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'")
}
description, _ := p.String("description")

View File

@ -8,6 +8,7 @@ import (
"slices"
"strconv"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -315,7 +316,8 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{})
if err != nil {
log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err)
return nil, newError(responses.ErrorGeneric, "failed to make transcode decision")
code, reason := transcodeFailure(err)
return nil, newError(code, "failed to make transcode decision: %s", reason)
}
// Only create a token when there is a valid playback path
@ -346,6 +348,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
return response, nil
}
// transcodeFailure maps a decision error to a Subsonic error code and a reason
// safe to send to clients, omitting server file paths.
func transcodeFailure(err error) (int32, string) {
pe, ok := errors.AsType[*ffmpeg.ProbeError](err)
if !ok {
return responses.ErrorGeneric, "internal error"
}
if pe.NotFound {
return responses.ErrorDataNotFound, pe.SafeReason()
}
return responses.ErrorGeneric, pe.SafeReason()
}
// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint.
// It streams media using the decision encoded in the transcodeParams JWT token.
// All errors are returned as proper HTTP status codes (not Subsonic error responses).

View File

@ -4,12 +4,16 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -77,6 +81,47 @@ var _ = Describe("Transcode endpoints", func() {
Expect(err.Error()).To(ContainSubstring("error retrieving media file"))
})
It("enriches the decision error with the reason, without leaking the file path", func() {
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w",
&ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"})
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to make transcode decision"))
Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input"))
Expect(err.Error()).ToNot(ContainSubstring("/music/secret"))
var subErr subError
Expect(errors.As(err, &subErr)).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
It("returns ErrorDataNotFound when the source file is missing on disk", func() {
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w",
&ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true})
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("file not found"))
var subErr subError
Expect(errors.As(err, &subErr)).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorDataNotFound))
})
It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() {
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}})
pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"}
mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist)
Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue())
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}")
_, err := router.GetTranscodeDecision(w, r)
Expect(err).To(HaveOccurred())
var subErr subError
Expect(errors.As(err, &subErr)).To(BeTrue())
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
})
It("returns error when body is empty", func() {
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
_, err := router.GetTranscodeDecision(w, r)
@ -516,6 +561,7 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
// mockTranscodeDecision is a test double for stream.TranscodeDecider
type mockTranscodeDecision struct {
decision *stream.TranscodeDecision
decisionErr error
token string
tokenErr error
resolvedReq stream.Request
@ -525,6 +571,9 @@ type mockTranscodeDecision struct {
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) {
m.capturedClient = ci
if m.decisionErr != nil {
return nil, m.decisionErr
}
if m.decision != nil {
return m.decision, nil
}

View File

@ -209,4 +209,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error {
return nil
}
func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) {
if m.Err {
return nil, errors.New("error")
}
return []int{}, nil
}
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)

View File

@ -65,7 +65,7 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository {
if db.RealDS != nil {
return db.RealDS.Tag(ctx)
}
db.MockedTag = struct{ model.TagRepository }{}
db.MockedTag = &MockTagRepo{}
return db.MockedTag
}

View File

@ -5,8 +5,9 @@ import (
)
type MockedGenreRepo struct {
Error error
Data map[string]model.Genre
Error error
Data map[string]model.Genre
Options model.QueryOptions
}
func (r *MockedGenreRepo) init() {
@ -15,7 +16,10 @@ func (r *MockedGenreRepo) init() {
}
}
func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) {
func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) {
if len(options) > 0 {
r.Options = options[0]
}
if r.Error != nil {
return nil, r.Error
}

24
tests/mock_tag_repo.go Normal file
View File

@ -0,0 +1,24 @@
package tests
import (
"github.com/navidrome/navidrome/model"
)
// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can
// assert on which filters a caller attached (e.g. a library scope).
type MockTagRepo struct {
model.TagRepository
Data model.TagList
Options model.QueryOptions
Err error
}
func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) {
if len(options) > 0 {
r.Options = options[0]
}
if r.Err != nil {
return nil, r.Err
}
return r.Data, nil
}

View File

@ -1,6 +1,8 @@
export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE'
export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS'
export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS'
export const SET_SIDEBAR_PLAYLISTS_FAVOURITES =
'SET_SIDEBAR_PLAYLISTS_FAVOURITES'
export const setNotificationsState = (enabled) => ({
type: SET_NOTIFICATIONS_STATE,
@ -16,3 +18,8 @@ export const setOmittedFields = (obj) => ({
type: SET_OMITTED_FIELDS,
data: obj,
})
export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({
type: SET_SIDEBAR_PLAYLISTS_FAVOURITES,
data: enabled,
})

View File

@ -6,7 +6,6 @@ import {
Filter,
NullableBooleanInput,
NumberInput,
Pagination,
ReferenceArrayInput,
ReferenceInput,
SearchInput,
@ -20,6 +19,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite'
import { withWidth } from '@material-ui/core'
import {
List,
Pagination,
Title,
useAlbumsPerPage,
useResourceRefresh,

View File

@ -100,6 +100,7 @@ const ArtistShowLayout = (props) => {
const rowsPerPageOptions = [1, 2, 3].map((option) =>
Math.trunc(option * (perPage / 3)),
)
// react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key
pagination = <Pagination rowsPerPageOptions={rowsPerPageOptions} />
}

View File

@ -2,6 +2,7 @@ import React from 'react'
import { List as RAList } from 'react-admin'
import config from '../config'
import { Pagination } from './Pagination'
import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore'
import { Title } from './index'
export const List = (props) => {
@ -15,7 +16,7 @@ export const List = (props) => {
/>
}
debounce={config.uiSearchDebounceMs}
perPage={15}
perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)}
pagination={<Pagination />}
{...props}
/>

View File

@ -0,0 +1,27 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { List } from './List'
// Only stub the heavy react-admin List controller (data fetching, router sync);
// everything else, including our own Pagination/perPageStore wiring, stays real
// so a bad import (the bug this test guards against) throws on render.
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
List: ({ children }) => <div data-testid="ra-list">{children}</div>,
}
})
describe('List', () => {
it('renders without throwing and shows its children', () => {
render(
<List resource="song">
<div>list content</div>
</List>,
)
expect(screen.getByTestId('ra-list')).toBeInTheDocument()
expect(screen.getByText('list content')).toBeInTheDocument()
})
})

View File

@ -1,6 +1,29 @@
import React from 'react'
import { Pagination as RAPagination } from 'react-admin'
import React, { useCallback } from 'react'
import {
Pagination as RAPagination,
useListPaginationContext,
} from 'react-admin'
import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore'
export const Pagination = (props) => (
<RAPagination rowsPerPageOptions={[15, 25, 50]} {...props} />
)
export const Pagination = ({
rowsPerPageOptions = defaultRowsPerPageOptions,
...props
}) => {
const { resource, setPerPage } = useListPaginationContext()
// Persist only a selector-driven change: mount, URL params and responsive
// fallbacks never call setPerPage, so they can't overwrite the preference.
const handleSetPerPage = useCallback(
(value) => {
if (resource) setStoredPerPage(resource, value)
setPerPage(value)
},
[resource, setPerPage],
)
return (
<RAPagination
rowsPerPageOptions={rowsPerPageOptions}
{...props}
setPerPage={handleSetPerPage}
/>
)
}

View File

@ -0,0 +1,62 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { Pagination } from './Pagination'
// stub RA's Pagination so a test can invoke the injected setPerPage, i.e.
// simulate an actual rows-per-page selection
vi.mock('react-admin', async () => {
const React = await vi.importActual('react')
return {
Pagination: ({ setPerPage }) =>
React.createElement(
'button',
{ onClick: () => setPerPage(50) },
'select 50',
),
useListPaginationContext: vi.fn(),
}
})
describe('Pagination', () => {
let mockContext
let setPerPage
beforeEach(async () => {
vi.clearAllMocks()
localStorage.clear()
setPerPage = vi.fn()
const { useListPaginationContext } = await import('react-admin')
mockContext = vi.mocked(useListPaginationContext)
})
const selectPerPage = () => fireEvent.click(screen.getByText('select 50'))
it('persists the page size chosen in the selector', () => {
mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage })
render(<Pagination />)
selectPerPage()
expect(localStorage.getItem('perPage.song')).toEqual('50')
})
it('still applies the change to the list', () => {
mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage })
render(<Pagination />)
selectPerPage()
expect(setPerPage).toHaveBeenCalledWith(50)
})
it('does not persist a page size the user did not select', () => {
mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage })
render(<Pagination />)
expect(localStorage.getItem('perPage.song')).toBeNull()
})
it('does not persist without a resource in context', () => {
mockContext.mockReturnValue({ perPage: 15, setPerPage })
render(<Pagination />)
selectPerPage()
expect(localStorage.getItem('perPage.undefined')).toBeNull()
expect(setPerPage).toHaveBeenCalledWith(50)
})
})

View File

@ -10,6 +10,7 @@ export * from './DurationField'
export * from './List'
export * from './MultiLineTextField'
export * from './Pagination'
export * from './perPageStore'
export * from './PlayButton'
export * from './QuickFilter'
export * from './RangeField'

View File

@ -0,0 +1,11 @@
export const defaultRowsPerPageOptions = [15, 25, 50]
const key = (resource) => `perPage.${resource}`
export const getStoredPerPage = (resource, options, fallback = options[0]) => {
const stored = parseInt(localStorage.getItem(key(resource)), 10)
return options.includes(stored) ? stored : fallback
}
export const setStoredPerPage = (resource, perPage) =>
localStorage.setItem(key(resource), String(perPage))

View File

@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { getStoredPerPage, setStoredPerPage } from './perPageStore'
const options = [15, 25, 50]
describe('perPageStore', () => {
beforeEach(() => {
localStorage.clear()
})
it('round-trips a stored value', () => {
setStoredPerPage('song', 25)
expect(getStoredPerPage('song', options, 15)).toEqual(25)
})
it('keys values per resource', () => {
setStoredPerPage('song', 25)
setStoredPerPage('playlist', 50)
expect(getStoredPerPage('song', options, 15)).toEqual(25)
expect(getStoredPerPage('playlist', options, 15)).toEqual(50)
})
it('returns the fallback when nothing is stored', () => {
expect(getStoredPerPage('song', options, 15)).toEqual(15)
})
it('returns the fallback for garbage values', () => {
localStorage.setItem('perPage.song', 'bogus')
expect(getStoredPerPage('song', options, 15)).toEqual(15)
})
it('returns the fallback when the stored value is not a valid option', () => {
setStoredPerPage('album', 90)
expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18)
})
it('defaults the fallback to the first option', () => {
expect(getStoredPerPage('song', options)).toEqual(15)
})
})

View File

@ -1,4 +1,5 @@
import { useSelector } from 'react-redux'
import { getStoredPerPage } from './perPageStore'
const getPerPage = (width) => {
if (width === 'xs') return 12
@ -17,10 +18,15 @@ const getPerPageOptions = (width) => {
}
export const useAlbumsPerPage = (width) => {
const perPage =
useSelector(
(state) => state?.admin.resources?.album?.list?.params?.perPage,
) || getPerPage(width)
const options = getPerPageOptions(width)
const sessionPerPage = useSelector(
(state) => state?.admin.resources?.album?.list?.params?.perPage,
)
// Use the session value only when it's valid for the current width, so a
// size picked at a wider breakpoint can't leave an out-of-range selector.
const perPage = options.includes(sessionPerPage)
? sessionPerPage
: getStoredPerPage('album', options, getPerPage(width))
return [perPage, getPerPageOptions(width)]
return [perPage, options]
}

View File

@ -0,0 +1,61 @@
import { renderHook } from '@testing-library/react-hooks'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAlbumsPerPage } from './useAlbumsPerPage'
import { setStoredPerPage } from './perPageStore'
vi.mock('react-redux', () => ({
useSelector: vi.fn(),
}))
describe('useAlbumsPerPage', () => {
let mockUseSelector
beforeEach(async () => {
vi.clearAllMocks()
localStorage.clear()
const { useSelector } = await import('react-redux')
mockUseSelector = vi.mocked(useSelector)
})
const setReduxPerPage = (value) =>
mockUseSelector.mockImplementation((selector) =>
selector({
admin: {
resources: { album: { list: { params: { perPage: value } } } },
},
}),
)
it('prefers the redux session value over the stored one', () => {
setReduxPerPage(36)
setStoredPerPage('album', 72)
const { result } = renderHook(() => useAlbumsPerPage('lg'))
expect(result.current[0]).toEqual(36)
})
it('falls back to the stored value on fresh load', () => {
setReduxPerPage(undefined)
setStoredPerPage('album', 72)
const { result } = renderHook(() => useAlbumsPerPage('lg'))
expect(result.current[0]).toEqual(72)
})
it('ignores stored values invalid for the current width', () => {
setReduxPerPage(undefined)
setStoredPerPage('album', 72) // valid for lg, not for md
const { result } = renderHook(() => useAlbumsPerPage('md'))
expect(result.current[0]).toEqual(12)
})
it('returns the responsive default when nothing is stored', () => {
setReduxPerPage(undefined)
const { result } = renderHook(() => useAlbumsPerPage('xl'))
expect(result.current).toEqual([36, [18, 36, 72]])
})
it('ignores a redux value invalid for the current width', () => {
setReduxPerPage(72) // valid for lg, not for md
const { result } = renderHook(() => useAlbumsPerPage('md'))
expect(result.current[0]).toEqual(12)
})
})

View File

@ -210,7 +210,8 @@
"songCount": "Songs",
"comment": "Comment",
"sync": "Auto-import",
"path": "Import from"
"path": "Import from",
"starred": "Favourite"
},
"actions": {
"selectPlaylist": "Select a playlist:",
@ -635,6 +636,7 @@
},
"albumList": "Albums",
"playlists": "Playlists",
"onlyFavourites": "Only show favourites",
"sharedPlaylists": "Shared Playlists",
"about": "About"
},

View File

@ -1,19 +1,24 @@
import React, { useCallback } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
MenuItemLink,
useDataProvider,
useNotify,
useQueryWithStore,
useTranslate,
} from 'react-admin'
import { useHistory } from 'react-router-dom'
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
import { Typography } from '@material-ui/core'
import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined'
import { BiCog } from 'react-icons/bi'
import FavoriteIcon from '@material-ui/icons/Favorite'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { BiListUl } from 'react-icons/bi'
import { useDrop } from 'react-dnd'
import SubMenu from './SubMenu'
import { canChangeTracks, OverflowTooltip } from '../common'
import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common'
import { DraggableTypes } from '../consts'
import { setSidebarPlaylistsOnlyFavourites } from '../actions'
import config from '../config'
const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => {
@ -53,6 +58,37 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => {
const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
const history = useHistory()
const dispatch = useDispatch()
const translate = useTranslate()
const onlyFavourites = useSelector(
(state) => state.settings.sidebarPlaylistsOnlyFavourites,
)
// Ignore a persisted preference when the feature is off, so disabling it later
// (with the toggle now hidden) doesn't strand the user on a filtered sidebar
const showFavouritesOnly = config.enableFavourites && onlyFavourites
const playlistData = useSelector(
(state) => state.admin.resources.playlist?.data,
)
// Fingerprint of local star state; changes only when a playlist is (un)starred,
// so a local toggle refetches the sidebar without the SSE echo the actor never gets
const starFingerprint = useMemo(() => {
const data = playlistData || {}
return Object.keys(data)
.filter((id) => data[id]?.starred)
.sort()
.join(',')
}, [playlistData])
const [refreshCount, setRefreshCount] = useState(0)
// Only the favourites-only view depends on star state changing elsewhere;
// when showing all playlists a star event from another client changes nothing
// async because useRefreshOnEvents calls .catch() on the returned value
const onRefresh = useCallback(async () => {
if (showFavouritesOnly) setRefreshCount((count) => count + 1)
}, [showFavouritesOnly])
useRefreshOnEvents({ events: ['playlist'], onRefresh })
// A changed payload signature makes useQueryWithStore refetch
const { data, loaded } = useQueryWithStore({
type: 'getList',
resource: 'playlist',
@ -62,6 +98,11 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
perPage: config.maxSidebarPlaylists,
},
sort: { field: 'name' },
...(showFavouritesOnly && {
filter: { starred: true },
starFingerprint,
refresh: refreshCount,
}),
},
})
@ -98,6 +139,10 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
[history],
)
const handleToggleFavourites = useCallback(() => {
dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites))
}, [dispatch, onlyFavourites])
return (
<>
<SubMenu
@ -107,8 +152,20 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
name={'menu.playlists'}
icon={<QueueMusicIcon />}
dense={dense}
actionIcon={<BiCog />}
actionIcon={<BiListUl />}
onAction={onPlaylistConfig}
onSecondaryAction={
config.enableFavourites ? handleToggleFavourites : undefined
}
secondaryActionIcon={
onlyFavourites ? (
<FavoriteIcon fontSize={'small'} />
) : (
<FavoriteBorderIcon fontSize={'small'} />
)
}
secondaryActionTitle={translate('menu.onlyFavourites')}
secondaryActionActive={onlyFavourites}
>
{myPlaylists.map(renderPlaylistMenuItemLink)}
</SubMenu>

View File

@ -0,0 +1,180 @@
import React from 'react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { settingsReducer, activityReducer } from '../reducers'
import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions'
import PlaylistsSubMenu from './PlaylistsSubMenu'
const mockUseQueryWithStore = vi.fn()
vi.mock('../config', () => ({
// losslessFormats is read at module-load time by common/QualityInfo.jsx,
// pulled in transitively via the '../common' barrel file
default: {
enableFavourites: true,
maxSidebarPlaylists: 100,
losslessFormats: '',
},
}))
vi.mock('react-dnd', () => ({
useDrop: () => [{}, () => {}],
}))
vi.mock('react-router-dom', () => ({
useHistory: () => ({ push: vi.fn() }),
}))
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useTranslate: () => (x) => x,
useDataProvider: () => ({ addToPlaylist: vi.fn() }),
useNotify: () => vi.fn(),
useQueryWithStore: (query) => mockUseQueryWithStore(query),
MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>,
}
})
const playlists = {
'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' },
'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' },
}
const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA'
const adminReducer = (state = { resources: {} }, action) =>
action.type === SET_PLAYLIST_DATA
? { resources: { playlist: { data: action.data } } }
: state
const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => {
const store = createStore(
combineReducers({
settings: settingsReducer,
activity: activityReducer,
admin: adminReducer,
}),
{
settings: preloadedSettings,
activity: {},
admin: {
resources: preloadedPlaylistData
? { playlist: { data: preloadedPlaylistData } }
: {},
},
},
)
const theme = createTheme()
render(
<Provider store={store}>
<ThemeProvider theme={theme}>
<PlaylistsSubMenu
state={{ menuPlaylists: true, menuSharedPlaylists: true }}
setState={vi.fn()}
sidebarIsOpen={true}
dense={false}
/>
</ThemeProvider>
</Provider>,
)
return store
}
const lastQuery = () =>
mockUseQueryWithStore.mock.calls[
mockUseQueryWithStore.mock.calls.length - 1
][0]
describe('<PlaylistsSubMenu />', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.setItem('userId', 'user-1')
mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true })
// SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom
window.matchMedia = (query) => ({
matches: false,
media: query,
addListener: () => {},
removeListener: () => {},
})
// OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
})
it('queries without a starred filter by default', () => {
renderMenu()
expect(lastQuery().payload.filter).toBeUndefined()
expect(screen.getByText('Mine')).not.toBeNull()
expect(screen.getByText('Theirs')).not.toBeNull()
})
it('adds the starred filter when favourites-only is enabled', () => {
renderMenu({ sidebarPlaylistsOnlyFavourites: true })
expect(lastQuery().payload.filter).toEqual({ starred: true })
})
it('toggles the setting when the heart action is clicked', () => {
const store = renderMenu()
fireEvent.click(screen.getByTitle('menu.onlyFavourites'))
expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true)
expect(lastQuery().payload.filter).toEqual({ starred: true })
})
it('refetches on a playlist SSE event when favourites-only is on', async () => {
const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true })
const before = lastQuery().payload.refresh
// useRefreshOnEvents compares Date.now() timestamps; make sure it advances
await act(() => new Promise((resolve) => setTimeout(resolve, 5)))
act(() => {
store.dispatch(
processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }),
)
})
expect(lastQuery().payload.refresh).toBe(before + 1)
})
it('does not change the query signature on an SSE event when favourites-only is off', async () => {
const store = renderMenu()
const before = JSON.stringify(lastQuery().payload)
await act(() => new Promise((resolve) => setTimeout(resolve, 5)))
act(() => {
store.dispatch(
processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }),
)
})
// Signature unchanged useQueryWithStore dedupes, no wasted refetch
expect(lastQuery().payload.refresh).toBeUndefined()
expect(JSON.stringify(lastQuery().payload)).toBe(before)
})
it('refetches when a playlist is starred locally (no SSE echo)', () => {
const store = renderMenu(
{ sidebarPlaylistsOnlyFavourites: true },
{ 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } },
)
const before = lastQuery().payload.starFingerprint
act(() => {
store.dispatch({
type: SET_PLAYLIST_DATA,
data: {
'pl-1': {
id: 'pl-1',
name: 'Mine',
ownerId: 'user-1',
starred: true,
},
},
})
})
expect(lastQuery().payload.starFingerprint).not.toBe(before)
expect(lastQuery().payload.starFingerprint).toContain('pl-1')
})
})

View File

@ -33,6 +33,9 @@ const useStyles = makeStyles(
menuHeader: {
width: '100%',
},
headerText: {
flexGrow: 1,
},
headerWrapper: {
display: 'flex',
'&:hover $actionIcon': {
@ -55,6 +58,10 @@ const SubMenu = ({
dense,
onAction,
actionIcon,
onSecondaryAction,
secondaryActionIcon,
secondaryActionTitle,
secondaryActionActive,
}) => {
const translate = useTranslate()
const classes = useStyles()
@ -70,6 +77,11 @@ const SubMenu = ({
}
}
const handleSecondaryClick = (e) => {
e.stopPropagation()
onSecondaryAction(e)
}
const header = (
<div className={classes.headerWrapper}>
<MenuItem
@ -81,9 +93,26 @@ const SubMenu = ({
<ListItemIcon className={classes.icon}>
{isOpen ? <ExpandMore /> : icon}
</ListItemIcon>
<Typography variant="inherit" color="textSecondary">
<Typography
variant="inherit"
color="textSecondary"
className={classes.headerText}
>
{translate(name)}
</Typography>
{onSecondaryAction && sidebarIsOpen && (
<IconButton
size={'small'}
title={secondaryActionTitle}
aria-label={secondaryActionTitle}
className={
isDesktop && !secondaryActionActive ? classes.actionIcon : null
}
onClick={handleSecondaryClick}
>
{secondaryActionIcon}
</IconButton>
)}
{onAction && sidebarIsOpen && (
<IconButton
size={'small'}

View File

@ -1,10 +1,15 @@
import { List, SizeField, useResourceRefresh } from '../common/index'
import {
List,
Pagination,
SizeField,
getStoredPerPage,
useResourceRefresh,
} from '../common/index'
import {
Datagrid,
DateField,
TextField,
downloadCSV,
Pagination,
Filter,
ReferenceInput,
useTranslate,
@ -49,8 +54,10 @@ const BulkActionButtons = (props) => (
</>
)
const missingPerPageOptions = [50, 100, 200]
const MissingPagination = (props) => (
<Pagination rowsPerPageOptions={[50, 100, 200]} {...props} />
<Pagination rowsPerPageOptions={missingPerPageOptions} {...props} />
)
const MissingFilesList = (props) => {
@ -63,7 +70,7 @@ const MissingFilesList = (props) => {
actions={<MissingListActions />}
filters={<MissingFilesFilter />}
bulkActionButtons={<BulkActionButtons />}
perPage={50}
perPage={getStoredPerPage('missing', missingPerPageOptions)}
pagination={<MissingPagination />}
>
<Datagrid>

View File

@ -13,6 +13,7 @@ import {
CollapsibleComment,
DurationField,
ImageUploadOverlay,
LoveButton,
SizeField,
isWritable,
OverflowTooltip,
@ -81,6 +82,15 @@ const useStyles = makeStyles(
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
minWidth: 0,
},
titleRow: {
display: 'flex',
alignItems: 'center',
},
loveButton: {
marginLeft: theme.spacing(0.5),
flexShrink: 0,
},
stats: {
marginTop: '1em',
@ -139,14 +149,24 @@ const PlaylistDetails = (props) => {
</div>
<div className={classes.details}>
<CardContent className={classes.content}>
<OverflowTooltip title={record.name || ''}>
<Typography
variant={isDesktop ? 'h5' : 'h6'}
className={classes.title}
>
{record.name || translate('ra.page.loading')}
</Typography>
</OverflowTooltip>
<div className={classes.titleRow}>
<OverflowTooltip title={record.name || ''}>
<Typography
variant={isDesktop ? 'h5' : 'h6'}
className={classes.title}
>
{record.name || translate('ra.page.loading')}
</Typography>
</OverflowTooltip>
<LoveButton
className={classes.loveButton}
record={record}
resource={'playlist'}
size={isDesktop ? 'default' : 'small'}
aria-label="love"
color="primary"
/>
</div>
<Typography component="p" className={classes.stats}>
{record.songCount ? (
<span>

View File

@ -4,6 +4,7 @@ import {
DateField,
EditButton,
Filter,
NullableBooleanInput,
NumberField,
ReferenceInput,
SearchInput,
@ -22,11 +23,14 @@ import {
CoverArtAvatar,
DurationField,
List,
LoveButton,
Writable,
isWritable,
useSelectedFields,
useResourceRefresh,
} from '../common'
import FavoriteIcon from '@material-ui/icons/Favorite'
import config from '../config'
import PlaylistListActions from './PlaylistListActions'
import ChangePublicStatusButton from './ChangePublicStatusButton'
@ -53,6 +57,12 @@ const PlaylistFilter = (props) => {
<SelectInput optionText="name" />
</ReferenceInput>
)}
{config.enableFavourites && (
<NullableBooleanInput
source="starred"
label={<FavoriteIcon fontSize={'small'} />}
/>
)}
</Filter>
)
}
@ -139,6 +149,13 @@ const PlaylistListBulkActions = (props) => {
)
}
// Datagrid reads `source`/`sortable`/`label` off this element for the column
// header; only record/resource are forwarded so they never leak onto the button.
export const PlaylistLove = ({ record, className }) => (
<LoveButton record={record} resource={'playlist'} className={className} />
)
PlaylistLove.defaultProps = { source: 'starred', sortable: false }
const PlaylistList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
@ -159,6 +176,7 @@ const PlaylistList = (props) => {
sync: !isXsmall && (
<ToggleAutoImport source="sync" sortByOrder={'DESC'} />
),
starred: config.enableFavourites && <PlaylistLove />,
}),
[isDesktop, isXsmall],
)

View File

@ -0,0 +1,34 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { PlaylistLove } from './PlaylistList'
vi.mock('../config', () => ({
default: { enableFavourites: true },
}))
vi.mock('../common', () => ({
LoveButton: ({ record, resource }) => (
<button data-testid="love" data-resource={resource}>
{record?.starred ? 'starred' : 'not-starred'}
</button>
),
}))
describe('<PlaylistLove />', () => {
it('renders a LoveButton bound to the playlist resource', () => {
render(<PlaylistLove record={{ id: 'pl-1', starred: true }} />)
const btn = screen.getByTestId('love')
expect(btn.getAttribute('data-resource')).toBe('playlist')
expect(btn.textContent).toBe('starred')
})
it('exposes datagrid header props so the column renders unsorted', () => {
// The Datagrid reads these off the element; the wrapper body must not
// forward them to the button (which would leak onto the DOM).
expect(PlaylistLove.defaultProps).toEqual({
source: 'starred',
sortable: false,
})
})
})

View File

@ -4,14 +4,21 @@ import {
ShowContextProvider,
useShowContext,
useShowController,
Pagination,
Title as RaTitle,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import PlaylistDetails from './PlaylistDetails'
import PlaylistSongs from './PlaylistSongs'
import PlaylistActions from './PlaylistActions'
import { Title, canChangeTracks, useResourceRefresh } from '../common'
import {
Pagination,
Title,
canChangeTracks,
getStoredPerPage,
useResourceRefresh,
} from '../common'
const playlistTrackPerPageOptions = [100, 250, 500]
const useStyles = makeStyles(
(theme) => ({
@ -41,7 +48,10 @@ const PlaylistShowLayout = (props) => {
reference="playlistTrack"
target="playlist_id"
sort={{ field: 'id', order: 'ASC' }}
perPage={100}
perPage={getStoredPerPage(
'playlistTrack',
playlistTrackPerPageOptions,
)}
filter={{ playlist_id: props.id }}
>
<PlaylistSongs
@ -56,7 +66,9 @@ const PlaylistShowLayout = (props) => {
}
resource={'playlistTrack'}
exporter={false}
pagination={<Pagination rowsPerPageOptions={[100, 250, 500]} />}
pagination={
<Pagination rowsPerPageOptions={playlistTrackPerPageOptions} />
}
/>
</ReferenceManyField>
)}

View File

@ -16,6 +16,8 @@ import {
} from 'react-admin'
import {
List,
defaultRowsPerPageOptions,
getStoredPerPage,
useImageUrl,
ToggleFieldsMenu,
useSelectedFields,
@ -135,7 +137,11 @@ const RadioList = ({ permissions, ...props }) => {
hasCreate={isAdmin}
actions={<RadioListActions isAdmin={isAdmin} />}
filters={<RadioFilter />}
perPage={isXsmall ? 25 : 10}
perPage={getStoredPerPage(
'radio',
defaultRowsPerPageOptions,
isXsmall ? 25 : 10,
)}
>
{isXsmall ? (
<SimpleList

View File

@ -1,6 +1,7 @@
import {
SET_NOTIFICATIONS_STATE,
SET_OMITTED_FIELDS,
SET_SIDEBAR_PLAYLISTS_FAVOURITES,
SET_TOGGLEABLE_FIELDS,
} from '../actions'
@ -8,6 +9,7 @@ const initialState = {
notifications: false,
toggleableFields: {},
omittedFields: {},
sidebarPlaylistsOnlyFavourites: false,
}
export const settingsReducer = (previousState = initialState, payload) => {
@ -34,6 +36,11 @@ export const settingsReducer = (previousState = initialState, payload) => {
...data,
},
}
case SET_SIDEBAR_PLAYLISTS_FAVOURITES:
return {
...previousState,
sidebarPlaylistsOnlyFavourites: data,
}
default:
return previousState
}

View File

@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { settingsReducer } from './settingsReducer'
import {
SET_SIDEBAR_PLAYLISTS_FAVOURITES,
setSidebarPlaylistsOnlyFavourites,
} from '../actions'
describe('settingsReducer', () => {
it('defaults sidebarPlaylistsOnlyFavourites to false', () => {
const state = settingsReducer(undefined, { type: 'UNKNOWN' })
expect(state.sidebarPlaylistsOnlyFavourites).toBe(false)
})
it('enables the flag via the action creator', () => {
const state = settingsReducer(
undefined,
setSidebarPlaylistsOnlyFavourites(true),
)
expect(state.sidebarPlaylistsOnlyFavourites).toBe(true)
})
it('disables the flag and preserves other settings', () => {
const initial = settingsReducer(undefined, { type: 'UNKNOWN' })
const on = settingsReducer(initial, {
type: SET_SIDEBAR_PLAYLISTS_FAVOURITES,
data: true,
})
const off = settingsReducer(on, {
type: SET_SIDEBAR_PLAYLISTS_FAVOURITES,
data: false,
})
expect(off.sidebarPlaylistsOnlyFavourites).toBe(false)
expect(off.notifications).toEqual(initial.notifications)
expect(off.toggleableFields).toEqual(initial.toggleableFields)
})
})

View File

@ -26,6 +26,8 @@ import {
useResourceRefresh,
ArtistLinkField,
PathField,
defaultRowsPerPageOptions,
getStoredPerPage,
} from '../common'
import { useDispatch } from 'react-redux'
import { makeStyles } from '@material-ui/core/styles'
@ -215,7 +217,11 @@ const SongList = (props) => {
bulkActionButtons={<SongBulkActions />}
actions={<SongListActions />}
filters={<SongFilter />}
perPage={isXsmall ? 50 : 15}
perPage={getStoredPerPage(
'song',
defaultRowsPerPageOptions,
isXsmall ? 50 : 15,
)}
>
{isXsmall ? (
<SongSimpleList />

View File

@ -60,12 +60,10 @@ func (r *Values) StringOr(param, def string) string {
return v
}
func (r *Values) Strings(param string) ([]string, error) {
values := r.URL.Query()[param]
if len(values) == 0 {
return nil, newError(ErrMissingParam, param)
}
return values, nil
// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that
// require the param should check for emptiness themselves.
func (r *Values) Strings(param string) []string {
return r.URL.Query()[param]
}
func (r *Values) TimeOr(param string, def time.Time) time.Time {
@ -85,9 +83,9 @@ func (r *Values) TimeOr(param string, def time.Time) time.Time {
}
func (r *Values) Times(param string) ([]time.Time, error) {
pStr, err := r.Strings(param)
if err != nil {
return nil, err
pStr := r.Strings(param)
if len(pStr) == 0 {
return nil, newError(ErrMissingParam, param)
}
times := make([]time.Time, len(pStr))
for i, t := range pStr {
@ -139,9 +137,9 @@ func (r *Values) Int64Or(param string, def int64) int64 {
}
func (r *Values) Ints(param string) ([]int, error) {
pStr, err := r.Strings(param)
if err != nil {
return nil, err
pStr := r.Strings(param)
if len(pStr) == 0 {
return nil, newError(ErrMissingParam, param)
}
ints := make([]int, 0, len(pStr))
for _, s := range pStr {

View File

@ -60,9 +60,7 @@ var _ = Describe("Request Helpers", func() {
})
It("returns empty array if param does not exist", func() {
v, err := r.Strings("xx")
Expect(err).To(MatchError(req.ErrMissingParam))
Expect(v).To(BeEmpty())
Expect(r.Strings("xx")).To(BeEmpty())
})
})