mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into quick-scan-does-not-detect-new-cover-art/5469
This commit is contained in:
commit
d4eca9cc9a
6
.github/FUNDING.yml
vendored
6
.github/FUNDING.yml
vendored
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
41
db/migrations/20260719005427_add_album_replaygain.go
Normal file
41
db/migrations/20260719005427_add_album_replaygain.go
Normal 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
2
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]"),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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
40
model/get_entity_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(`{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
26
server/jellyfin/dto/fields_test.go
Normal file
26
server/jellyfin/dto/fields_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
22
server/jellyfin/dto/filters_test.go
Normal file
22
server/jellyfin/dto/filters_test.go
Normal 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]`))
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
24
tests/mock_tag_repo.go
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
27
ui/src/common/List.test.jsx
Normal file
27
ui/src/common/List.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
62
ui/src/common/Pagination.test.jsx
Normal file
62
ui/src/common/Pagination.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
|
||||
11
ui/src/common/perPageStore.js
Normal file
11
ui/src/common/perPageStore.js
Normal 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))
|
||||
40
ui/src/common/perPageStore.test.js
Normal file
40
ui/src/common/perPageStore.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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]
|
||||
}
|
||||
|
||||
61
ui/src/common/useAlbumsPerPage.test.jsx
Normal file
61
ui/src/common/useAlbumsPerPage.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
180
ui/src/layout/PlaylistsSubMenu.test.jsx
Normal file
180
ui/src/layout/PlaylistsSubMenu.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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'}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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],
|
||||
)
|
||||
|
||||
34
ui/src/playlist/PlaylistList.test.jsx
Normal file
34
ui/src/playlist/PlaylistList.test.jsx
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
36
ui/src/reducers/settingsReducer.test.js
Normal file
36
ui/src/reducers/settingsReducer.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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 />
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user