Compare commits

...

10 commits

Author SHA1 Message Date
Kendall Garner
ff58ca5d1f
fix(play tracker): record local play count even if external scrobbling is disabled on the player (#6387) 2026-11-11 14:15:15 -05:00
Deluan
2fecb718c0 ci: raise golangci-lint timeout to 3m
Package loading alone hit the 2m limit on a slow runner.
2026-11-11 08:16:03 -05:00
Junker der Provinz
514207faf1
fix(server): classify library path validation errors instead of a catch-all (#4595) (#5919) 2026-11-10 21:56:14 -05:00
Deluan Quintão
d22a31aee0
fix(subsonic): honour explicit download format over player transcoding (#6446) 2026-11-10 19:53:12 -05:00
Adrián Sánchez Zapico
1efd5c467b
fix(jellyfin): list the user's libraries at the /Items user root (#6214)
* fix(jellyfin): list the user's libraries at the /Items user root

An unfiltered, non-recursive /Items query with no ParentId is answered by
Jellyfin with the user's libraries (the user root folder's children), not
their contents. Navidrome defaulted to albums there, so Symfonium found no
music library and synced nothing.

Fixes #6199

Signed-off-by: zapisanchez <zapisanchez@gmail.com>

* fix(jellyfin): page the user-root /Items libraries by StartIndex and Limit

* fix(jellyfin): sort the user-root /Items libraries by SortBy and SortOrder before paging

* refactor(jellyfin): simplify user-root sorting and filter detection

Share the first-SortOrder rule between applySort and sortViews through a new
isDescending helper, and make sortViews compare string keys instead of building
comparator closures. hasFilters now compares itemFilters to its zero value, so
a filter added there later also counts at the user root. Read the Recursive
param once, fix the paginate comment that said it only served the multi-type
path, and cut comments that restated the code. The user-root specs share one
library fixture and one request helper.

---------

Signed-off-by: zapisanchez <zapisanchez@gmail.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-11-10 17:44:00 -05:00
Santhi Prakash
64cf86fae9
fix(jellyfin): clamp negative StartIndex to zero - #6341 (#6366) 2026-11-10 16:12:19 -05:00
ump45nose
27d041486c
fix(jellyfin): honour every value of repeated list params (#6414)
* fix(jellyfin): honour every value of repeated list params - #6389

Jellyfin's model binder accepts an array query param both as one
comma-separated value and as repeated keys, and @jellyfin/sdk clients
(Jellify) send the latter. parseItemsQuery read IncludeItemTypes with
StringOr, so IncludeItemTypes=MusicArtist&IncludeItemTypes=Audio&...
behaved as an artist-only query and a mixed search silently lost songs,
albums and playlists. Filters had the same single-value read.

knownItemKinds now takes all values of the param, and parseItemFilters
walks them too, keeping the comma splitting each already did. Absent or
all-unknown lists still mean "no type filter", so the inferred-type and
album-default paths are unchanged.

Signed-off-by: yuwk <1729065730@qq.com>

* fix(jellyfin): join repeated query params so every value is parsed

Jellyfin binds a list param as one comma-separated value or as repeated
keys. joinedStrings feeds every value into the parsers that already
split on commas, so IncludeItemTypes, Filters, SortBy, SortOrder, the
artist id lists and Container all accept both spellings.

ArtistIds, AlbumArtistIds and contributingArtistIds now match every
artist in the list instead of only the first. SortOrder is paired with
each SortBy key, and a key past the end of the list reuses the first
direction, which is what Jellyfin's GetOrderBy does. Playlist SearchTerm
is left for a follow-up.

Signed-off-by: Vincent <52391318+ump45nose@users.noreply.github.com>
Co-authored-by: ump45nose <ump45nose@users.noreply.github.com>

* fix(jellyfin): apply multi-id artist filters as a single slice

ORing one contributing-artist filter per id let an album whose album
artist is one of the ids still match as Featured On via another id in
the list. ParticipantIDFilter and NotParticipantIDFilter already accept
a slice, so contributingArtistIds now uses one IN/NOT IN pair over the
whole list. Album and song artist filters do the same, which also avoids
one subquery per id.

Signed-off-by: Vincent <52391318+ump45nose@users.noreply.github.com>
Co-authored-by: ump45nose <ump45nose@users.noreply.github.com>

* refactor(jellyfin): trim comments in repeated list params change

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: yuwk <1729065730@qq.com>
Signed-off-by: Vincent <52391318+ump45nose@users.noreply.github.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: ump45nose <ump45nose@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-11-10 12:41:46 -05:00
Deluan Quintão
9f0441352c
fix(transcoding): keep the DSD cap unless the client declares an upper rate limit (#6445)
* fix(transcoding): keep the DSD cap unless the client declares an upper rate limit

Any audioSamplerate limitation on the target codec turned off the 88.2kHz
DSD cap, including GreaterThanEqual, NotEquals, empty or non-numeric limits.
None of those lower the rate, so DSD64 still went out at 352.8kHz. Only
LessThanEqual with a number, or Equals with at least one number, now replaces
the cap.

Follow-up to #6367, addressing
https://github.com/navidrome/navidrome/pull/6367#discussion_r4526602338

* refactor(stream): share upper-bound parsing between the DSD cap check and limits

applyIntLimitation and the DSD cap check now read LessThanEqual and Equals values through one upperBounds helper, so the two can't disagree on which limits lower a rate.
2026-11-10 10:59:28 -05:00
Junker der Provinz
ecbd4b7d55
fix(transcoding): default DSD transcoded to lossless PCM to 88.2kHz
* fix(stream): cap DSD transcoded to lossless PCM at 88.2kHz

DSD64 was sent as 352.8kHz FLAC/WAV, which many clients cannot play.
Client and codec profile limits below 88.2kHz still apply.

ffprobe already reports DSD at rate/8 with 8 bits per sample, so only
rates read from tags are divided, and any DSD depth maps to 24-bit PCM.
Probed files used to come out at 44.1kHz with a bit depth of 8.

Fixes #6288

Co-authored-by: Olli Sulopuisto <1051255+ollisulopuisto@users.noreply.github.com>
Signed-off-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com>

* fix(stream): let a client's sample rate limit replace the DSD cap

The 88.2kHz cap on DSD transcoded to lossless PCM is meant for clients that
say nothing about sample rates, like the legacy stream endpoint. Applied
unconditionally, it also overrode clients that declared their own
audioSamplerate limit: a client accepting up to 192kHz got 88.2kHz, and one
accepting only 96kHz or 192kHz got no compatible profile at all.

The cap now applies only when the client has no audioSamplerate limitation
for the target codec. Otherwise the client's limit decides the rate.

* refactor(stream): share the audio codec profile matcher

Direct play checks, transcode limitations and the new DSD cap check each
looped over the client's codec profiles with the same audio-type and codec
alias filter. They now use one audioCodecProfiles iterator, so the DSD cap
decision cannot drift from the limitations that are actually applied.
targetSampleRate takes a single capDSD flag instead of two adjacent bools.

* test(stream): table-drive the DSD to FLAC sample rate specs

Six specs repeated the same DSD source and FLAC transcoding profile and
differed only in the source rate, the client's codec profiles and the
expected rate. A DescribeTable lists the cap policy case by case, and
every entry now also checks the 24-bit depth.

---------

Signed-off-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com>
Co-authored-by: Olli Sulopuisto <1051255+ollisulopuisto@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-11-10 10:14:37 -05:00
Alen Požun
818603436c
fix(smartplaylists): preserve empty nested groups in smart playlist criteria marshaling (#6438)
* fix: preserve empty nested groups in smart playlist criteria marshaling

Fixes #6316

Empty nested groups were serialized incorrectly because of omitempty.
Now only the active conjunction key is emitted, so empty groups become [].

* docs: add changelog entry for #6316

* fix: recover playlists previously saved with empty nested groups

Treats the old broken {} form as an empty All on unmarshal so existing
playlists load again.

* docs: mention recovery of previously broken playlists in changelog

* style: run gofmt

* fix: safer recovery of empty groups and nil slices

- only treat {} as empty All when the group has no other rules
- marshal nil slices as [] instead of null
- drop CHANGELOG.md (release notes are generated from commits)

* test: remove hand-built criteria test

* test: add Ginkgo round-trip specs for empty nested groups

* chore: remove CHANGELOG.md

* docs: add docstrings to criteria json helpers

* Revert "docs: add docstrings to criteria json helpers"

This reverts commit 9f74c604a1.

* docs: add docstrings to criteria json helpers

* test: fix criteria round-trip specs to use Criteria and gomega

* fix(criteria): keep only the marshal fix for empty nested groups

Remove the recovery that turned a group made only of {} elements into an
empty All. It lived in the parser shared by POST bodies, .nsp files and
scrobble filters, so it accepted inputs such as {"all":[{}]} that were
rejected before, and turned them into rules that match the whole library.
It also had to guess whether a saved {} was an empty all or an empty any.
Rows already saved broken are a one-time cleanup, not something the parser
should carry.

Also remove the {}/null short-circuit (null was already handled, and {}
only widened what the parser accepts), drop the doc comments that restated
function names, and fold the round-trip specs into one table that checks
the exact marshaled output for empty nested all and any groups.

---------

Co-authored-by: kukirin-ui <kukirin-ui@users.noreply.github.com>
Co-authored-by: Deluan <deluan@navidrome.org>
2026-11-10 09:26:16 -05:00
32 changed files with 882 additions and 212 deletions

View file

@ -79,7 +79,7 @@ jobs:
with: with:
version: ${{ steps.golangci-version.outputs.version }} version: ${{ steps.golangci-version.outputs.version }}
problem-matchers: true problem-matchers: true
args: --timeout 2m args: --timeout 3m
- name: Run go goimports - name: Run go goimports
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'` run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`

View file

@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
@ -367,25 +366,14 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, libr
fsys, err := fileStore.FS() fsys, err := fileStore.FS()
if err != nil { if err != nil {
log.Warn(ctx, "Error validating library.path", "path", library.Path, err) log.Warn(ctx, "Error validating library.path", "path", library.Path, err)
return fmt.Errorf("resources.library.validation.pathInvalid") return errors.New(classifyLibraryPathError(err))
} }
// Check if root directory exists // Check if root directory exists
info, err := fs.Stat(fsys, ".") info, err := fs.Stat(fsys, ".")
if err != nil { if err != nil {
// Parse the error message to check for "not a directory"
log.Warn(ctx, "Error stating library.path", "path", library.Path, err) log.Warn(ctx, "Error stating library.path", "path", library.Path, err)
errStr := err.Error() return errors.New(classifyLibraryPathError(err))
if strings.Contains(errStr, "not a directory") ||
strings.Contains(errStr, "The directory name is invalid.") {
return fmt.Errorf("resources.library.validation.pathNotDirectory")
} else if os.IsNotExist(err) {
return fmt.Errorf("resources.library.validation.pathNotFound")
} else if os.IsPermission(err) {
return fmt.Errorf("resources.library.validation.pathNotAccessible")
} else {
return fmt.Errorf("resources.library.validation.pathInvalid")
}
} }
if !info.IsDir() { if !info.IsDir() {
@ -395,6 +383,22 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, libr
return nil return nil
} }
// classifyLibraryPathError maps a path error to its i18n key. It uses errors.Is because storage FS() wraps with %w.
// Not-a-directory is matched by text: syscall.ENOTDIR means ERROR_PATH_NOT_FOUND on Windows, and ERROR_DIRECTORY is not exported.
func classifyLibraryPathError(err error) string {
switch {
case errors.Is(err, fs.ErrNotExist):
return "resources.library.validation.pathNotFound"
case errors.Is(err, fs.ErrPermission):
return "resources.library.validation.pathNotAccessible"
case strings.Contains(err.Error(), "not a directory") ||
strings.Contains(err.Error(), "The directory name is invalid."):
return "resources.library.validation.pathNotDirectory"
default:
return "resources.library.validation.pathInvalid"
}
}
func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error { func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
if len(libraryIDs) == 0 { if len(libraryIDs) == 0 {
return nil return nil

View file

@ -0,0 +1,29 @@
package core
import (
"errors"
"fmt"
"io/fs"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("classifyLibraryPathError", func() {
DescribeTable("maps a filesystem error to a validation message key",
func(err error, expected string) {
Expect(classifyLibraryPathError(err)).To(Equal(expected))
},
Entry("missing path", fs.ErrNotExist, "resources.library.validation.pathNotFound"),
Entry("wrapped missing path", fmt.Errorf("stat /music: %w", fs.ErrNotExist), "resources.library.validation.pathNotFound"),
Entry("permission denied", fs.ErrPermission, "resources.library.validation.pathNotAccessible"),
Entry("wrapped permission denied", fmt.Errorf("open /music: %w", fs.ErrPermission), "resources.library.validation.pathNotAccessible"),
Entry("file instead of a directory on Unix",
&fs.PathError{Op: "open", Path: "/music/song.mp3", Err: errors.New("not a directory")},
"resources.library.validation.pathNotDirectory"),
Entry("file instead of a directory on Windows",
&fs.PathError{Op: "open", Path: `C:\music\song.mp3`, Err: errors.New("The directory name is invalid.")},
"resources.library.validation.pathNotDirectory"),
Entry("any other error", errors.New("input/output error"), "resources.library.validation.pathInvalid"),
)
})

View file

@ -375,7 +375,7 @@ var _ = Describe("Library Service", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue()) Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid")) Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotFound"))
}) })
It("fails when path is a file instead of directory", func() { It("fails when path is a file instead of directory", func() {
@ -455,7 +455,7 @@ var _ = Describe("Library Service", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue()) Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid")) Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotFound"))
}) })
It("fails when updated path is a file instead of directory", func() { It("fails when updated path is a file instead of directory", func() {

View file

@ -363,24 +363,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP
case StateStopped: case StateStopped:
var loadedMF *model.MediaFile var loadedMF *model.MediaFile
haveVerdict := false haveVerdict := false
if !params.IgnoreScrobble && player.ScrobbleEnabled { if !params.IgnoreScrobble {
mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId)
if err != nil { if err != nil {
return err // If the track is not found, do not treat it as a fatal error.
} // Log it and move on so that the session can still be closed
log.Warn(ctx, "Cannot find track for scrobbling", "id", params.MediaId, "user", user.UserName, err)
} else {
loadedMF = mf loadedMF = mf
filtered = p.isFilteredOut(ctx, mf) filtered = p.isFilteredOut(ctx, mf)
haveVerdict = true haveVerdict = true
trackDurationMs := int64(mf.Duration * 1000) trackDurationMs := int64(mf.Duration * 1000)
threshold := min(trackDurationMs*50/100, 240_000) threshold := min(trackDurationMs*50/100, 240_000)
if params.PositionMs >= threshold { if params.PositionMs >= threshold {
// To be consistent with Submit (/rest/scrobble?submission=true), play should be incremented even if
// scrobbling is disabled
err = p.incPlay(ctx, mf, now) err = p.incPlay(ctx, mf, now)
if err != nil { if err != nil {
log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err)
} }
if player.ScrobbleEnabled {
p.dispatchScrobble(ctx, mf, now, filtered) p.dispatchScrobble(ctx, mf, now, filtered)
} }
} }
}
}
p.sessionsMu.Lock() p.sessionsMu.Lock()
info, getErr := p.playMap.Get(clientId) info, getErr := p.playMap.Get(clientId)
// A late stop for a previous track must not end the current session nor reach // A late stop for a previous track must not end the current session nor reach

View file

@ -315,12 +315,15 @@ var _ = Describe("PlayTracker", func() {
Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
}) })
It("does not send track to agent if player is not enabled to send scrobbles", func() { It("does not send track to agent if player is not enabled to send scrobbles (but should still track internally)", func() {
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false})
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
}) })
@ -811,7 +814,8 @@ var _ = Describe("PlayTracker", func() {
MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId,
}) })
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(track.PlayCount).To(Equal(int64(0))) Expect(track.PlayCount).To(Equal(int64(1)))
Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse())
}) })
It("scrobbles twice for two separate sessions of same song", func() { It("scrobbles twice for two separate sessions of same song", func() {

View file

@ -32,9 +32,10 @@ func isLosslessFormat(format string) bool {
return false return false
} }
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store // normalizeSourceSampleRate adjusts a sample rate read from tags for codecs that
// it differently than PCM. Currently handles DSD (÷8): // store it differently than PCM. Currently handles DSD (÷8):
// DSD64=2822400→352800, DSD128=5644800→705600, etc. // DSD64=2822400→352800, DSD128=5644800→705600, etc.
// ffprobe already reports DSD at this rate, so probe data needs no adjustment.
// For other codecs, returns the rate unchanged. // For other codecs, returns the rate unchanged.
func normalizeSourceSampleRate(sampleRate int, codec string) int { func normalizeSourceSampleRate(sampleRate int, codec string) int {
if strings.EqualFold(codec, "dsd") && sampleRate > 0 { if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
@ -43,14 +44,28 @@ func normalizeSourceSampleRate(sampleRate int, codec string) int {
return sampleRate return sampleRate
} }
// dsdMaxPCMSampleRate caps DSD transcoded to lossless PCM. It divides every
// 44.1kHz-based DSD rate exactly, DSD carries mostly its own shaped noise above
// it, and many clients cannot play 352.8kHz PCM.
const dsdMaxPCMSampleRate = 88200
// targetSampleRate returns the sample rate for a transcoded stream before codec
// and client limits are applied, with DSD capped when capDSD is set.
func targetSampleRate(srcSampleRate int, srcCodec string, capDSD bool) int {
if capDSD && strings.EqualFold(srcCodec, "dsd") {
return min(srcSampleRate, dsdMaxPCMSampleRate)
}
return srcSampleRate
}
// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy // targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy
// targets (they have no PCM bit depth), otherwise the source depth, with DSD // targets (they have no PCM bit depth), otherwise the source depth, with DSD
// adjusted to the 24-bit PCM that ffmpeg produces. // (1 bit in tags, 8 in ffprobe) adjusted to the 24-bit PCM that ffmpeg produces.
func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int { func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int {
if !targetIsLossless { if !targetIsLossless {
return 0 return 0
} }
if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 { if strings.EqualFold(srcCodec, "dsd") {
return 24 return 24
} }
return srcBitDepth return srcBitDepth

View file

@ -143,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta
} else { } else {
sd.Codec = mf.AudioCodec() sd.Codec = mf.AudioCodec()
sd.Bitrate = mf.BitRate sd.Bitrate = mf.BitRate
sd.SampleRate = mf.SampleRate sd.SampleRate = normalizeSourceSampleRate(mf.SampleRate, sd.Codec)
sd.BitDepth = gg.V(mf.BitDepth) sd.BitDepth = gg.V(mf.BitDepth)
sd.Channels = mf.Channels sd.Channels = mf.Channels
} }
@ -224,13 +224,11 @@ func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPla
} }
// Check codec-specific limitations // Check codec-specific limitations
for _, codecProfile := range clientInfo.CodecProfiles { for codecProfile := range audioCodecProfiles(clientInfo, src.Codec) {
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
if reason := checkLimitations(src, codecProfile.Limitations); reason != "" { if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
return reason return reason
} }
} }
}
return "" return ""
} }
@ -265,17 +263,20 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Detai
return nil, "" return nil, ""
} }
targetCodec := strings.ToLower(profile.AudioCodec)
if targetCodec == "" {
targetCodec = targetFormat
}
capDSD := targetIsLossless && !hasUpperBoundLimitation(clientInfo, targetCodec, LimitationAudioSamplerate)
ts := &Details{ ts := &Details{
Container: responseContainer, Container: responseContainer,
Codec: strings.ToLower(profile.AudioCodec), Codec: targetCodec,
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), SampleRate: targetSampleRate(src.SampleRate, src.Codec, capDSD),
Channels: src.Channels, Channels: src.Channels,
BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless), BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless),
IsLossless: targetIsLossless, IsLossless: targetIsLossless,
} }
if ts.Codec == "" {
ts.Codec = targetFormat
}
// Apply codec-intrinsic sample rate adjustments before codec profile limitations // Apply codec-intrinsic sample rate adjustments before codec profile limitations
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 { if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
@ -400,13 +401,7 @@ func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targe
// Returns false if the profile should be rejected. // Returns false if the profile should be rejected.
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
targetCodec := ts.Codec targetCodec := ts.Codec
for _, codecProfile := range clientInfo.CodecProfiles { for codecProfile := range audioCodecProfiles(clientInfo, targetCodec) {
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
continue
}
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
continue
}
for _, lim := range codecProfile.Limitations { for _, lim := range codecProfile.Limitations {
result := applyLimitation(sourceBitrate, &lim, ts) result := applyLimitation(sourceBitrate, &lim, ts)
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted { if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {

View file

@ -719,7 +719,7 @@ var _ = Describe("Decider", func() {
Context("DSD sample rate conversion", func() { Context("DSD sample rate conversion", func() {
It("converts DSD sample rate to PCM-equivalent in decision", func() { It("converts DSD sample rate to PCM-equivalent in decision", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 352800, BitDepth: new(8)})
ci := &ClientInfo{ ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320, MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{ TranscodingProfiles: []Profile{
@ -730,7 +730,7 @@ var _ = Describe("Decider", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("mp3")) Expect(decision.TargetFormat).To(Equal("mp3"))
// DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 // DSD64 probes as 352800, capped by MP3 max of 48000
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
Expect(decision.TargetSampleRate).To(Equal(48000)) Expect(decision.TargetSampleRate).To(Equal(48000))
// MP3 is lossy: no bit depth on the transcoded stream // MP3 is lossy: no bit depth on the transcoded stream
@ -738,54 +738,80 @@ var _ = Describe("Decider", func() {
Expect(decision.TargetBitDepth).To(BeZero()) Expect(decision.TargetBitDepth).To(BeZero())
}) })
It("converts DSD sample rate for FLAC target without codec limit", func() { sampleRateLimit := func(codec, comparison string, values ...string) CodecProfile {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) return CodecProfile{
Type: CodecProfileTypeAudio,
Name: codec,
Limitations: []Limitation{
{Name: LimitationAudioSamplerate, Comparison: comparison, Values: values, Required: true},
},
}
}
DescribeTable("DSD to FLAC sample rate",
func(srcSampleRate int, codecProfiles []CodecProfile, expectedRate int) {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: srcSampleRate, BitDepth: new(8)})
ci := &ClientInfo{ ci := &ClientInfo{
TranscodingProfiles: []Profile{ TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
}, },
CodecProfiles: codecProfiles,
} }
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.CanTranscode).To(BeTrue())
Expect(decision.TargetFormat).To(Equal("flac")) Expect(decision.TargetFormat).To(Equal("flac"))
// DSD64 2822400 / 8 = 352800, FLAC has no hard max Expect(decision.TranscodeStream.SampleRate).To(Equal(expectedRate))
Expect(decision.TranscodeStream.SampleRate).To(Equal(352800)) Expect(decision.TargetSampleRate).To(Equal(expectedRate))
Expect(decision.TargetSampleRate).To(Equal(352800))
// DSD 1-bit → 24-bit PCM
Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
Expect(decision.TargetBitDepth).To(Equal(24)) Expect(decision.TargetBitDepth).To(Equal(24))
}) },
Entry("caps DSD64 at 88.2kHz when the client declares no limit", 352800, nil, 88200),
Entry("caps DSD128 at 88.2kHz when the client declares no limit", 705600, nil, 88200),
Entry("uses a declared upper limit instead of the cap", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonLessThanEqual, "192000")}, 192000),
Entry("picks the closest rate from a declared list", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonEquals, "96000", "192000")}, 192000),
Entry("applies a declared limit below the cap", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonLessThanEqual, "48000")}, 48000),
Entry("keeps the cap when the declared limit is a lower bound", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonGreaterThanEqual, "44100")}, 88200),
Entry("keeps the cap when the declared limit excludes a rate", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonNotEquals, "48000")}, 88200),
Entry("keeps the cap when the declared limit has no values", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonLessThanEqual)}, 88200),
Entry("keeps the cap when the declared limit has no numeric values", 352800,
[]CodecProfile{sampleRateLimit("flac", ComparisonLessThanEqual, "high")}, 88200),
Entry("keeps the cap when the declared rate limit is for another codec", 352800,
[]CodecProfile{
sampleRateLimit("mp3", ComparisonLessThanEqual, "192000"),
{Type: CodecProfileTypeAudio, Name: "flac", Limitations: []Limitation{
{Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true},
}},
}, 88200),
)
It("applies codec profile limit to DSD-converted FLAC sample rate", func() { It("normalizes DSD sample rate from tags when there is no probe data", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) DeferCleanup(configtest.SetupConfig())
conf.Server.DevEnableMediaFileProbe = false
mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}
ci := &ClientInfo{ ci := &ClientInfo{
TranscodingProfiles: []Profile{ TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
}, },
CodecProfiles: []CodecProfile{
{
Type: CodecProfileTypeAudio,
Name: "flac",
Limitations: []Limitation{
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
},
},
},
} }
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.CanTranscode).To(BeTrue())
// DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 // Tags store the 1-bit DSD64 rate, ffprobe reports 352800
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) Expect(decision.SourceStream.SampleRate).To(Equal(352800))
Expect(decision.TargetSampleRate).To(Equal(48000)) Expect(decision.TargetSampleRate).To(Equal(88200))
// DSD 1-bit → 24-bit PCM
Expect(decision.TranscodeStream.BitDepth).To(Equal(24))
Expect(decision.TargetBitDepth).To(Equal(24)) Expect(decision.TargetBitDepth).To(Equal(24))
}) })
It("applies audioBitdepth limitation to DSD-converted bit depth", func() { It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 352800, BitDepth: new(8)})
ci := &ClientInfo{ ci := &ClientInfo{
TranscodingProfiles: []Profile{ TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -803,7 +829,7 @@ var _ = Describe("Decider", func() {
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.CanTranscode).To(BeTrue())
// DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit // DSD → 24-bit PCM, then capped by codec profile limit to 16-bit
Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
Expect(decision.TargetBitDepth).To(Equal(16)) Expect(decision.TargetBitDepth).To(Equal(16))
}) })
@ -1021,7 +1047,7 @@ var _ = Describe("Decider", func() {
Context("AAC max sample rate", func() { Context("AAC max sample rate", func() {
It("caps sample rate at 96000 for AAC", func() { It("caps sample rate at 96000 for AAC", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 352800, BitDepth: new(8)})
ci := &ClientInfo{ ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320, MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{ TranscodingProfiles: []Profile{
@ -1031,7 +1057,7 @@ var _ = Describe("Decider", func() {
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.CanTranscode).To(BeTrue())
// DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 // DSD64 probes as 352800, capped by AAC max of 96000
Expect(decision.TranscodeStream.SampleRate).To(Equal(96000)) Expect(decision.TranscodeStream.SampleRate).To(Equal(96000))
}) })
}) })

View file

@ -1,6 +1,8 @@
package stream package stream
import ( import (
"iter"
"slices"
"strconv" "strconv"
"strings" "strings"
) )
@ -48,6 +50,47 @@ func checkLimitations(src *Details, limitations []Limitation) string {
return "" return ""
} }
func audioCodecProfiles(clientInfo *ClientInfo, codec string) iter.Seq[CodecProfile] {
return func(yield func(CodecProfile) bool) {
for _, cp := range clientInfo.CodecProfiles {
if strings.EqualFold(cp.Type, CodecProfileTypeAudio) && matchesCodec(codec, []string{cp.Name}) {
if !yield(cp) {
return
}
}
}
}
}
func hasUpperBoundLimitation(clientInfo *ClientInfo, codec, name string) bool {
for cp := range audioCodecProfiles(clientInfo, codec) {
if slices.ContainsFunc(cp.Limitations, func(lim Limitation) bool {
return strings.EqualFold(lim.Name, name) && len(upperBounds(lim.Comparison, lim.Values)) > 0
}) {
return true
}
}
return false
}
// upperBounds returns the values a limitation can lower a number to, none for >=, != or non-numeric limits.
func upperBounds(comparison string, values []string) []int {
switch comparison {
case ComparisonLessThanEqual:
values = values[:min(len(values), 1)]
case ComparisonEquals:
default:
return nil
}
var bounds []int
for _, s := range values {
if v, ok := parseInt(s); ok {
bounds = append(bounds, v)
}
}
return bounds
}
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. // applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
// Returns the adjustment result. // Returns the adjustment result.
func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult { func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult {
@ -81,14 +124,11 @@ func applyIntLimitation(comparison string, values []string, current int, setter
switch comparison { switch comparison {
case ComparisonLessThanEqual: case ComparisonLessThanEqual:
limit, ok := parseInt(values[0]) bounds := upperBounds(comparison, values)
if !ok { if len(bounds) == 0 || current <= bounds[0] {
return adjustNone return adjustNone
} }
if current <= limit { setter(bounds[0])
return adjustNone
}
setter(limit)
return adjustAdjusted return adjustAdjusted
case ComparisonGreaterThanEqual: case ComparisonGreaterThanEqual:
limit, ok := parseInt(values[0]) limit, ok := parseInt(values[0])
@ -101,23 +141,19 @@ func applyIntLimitation(comparison string, values []string, current int, setter
// Cannot upscale // Cannot upscale
return adjustCannotFit return adjustCannotFit
case ComparisonEquals: case ComparisonEquals:
// Check if current value matches any allowed value bounds := upperBounds(comparison, values)
for _, v := range values { if slices.Contains(bounds, current) {
if limit, ok := parseInt(v); ok && current == limit {
return adjustNone return adjustNone
} }
}
// Find the closest allowed value below current (don't upscale) // Find the closest allowed value below current (don't upscale)
var closest int var closest int
found := false found := false
for _, v := range values { for _, limit := range bounds {
if limit, ok := parseInt(v); ok && limit < current { if limit < current && (!found || limit > closest) {
if !found || limit > closest {
closest = limit closest = limit
found = true found = true
} }
} }
}
if found { if found {
setter(closest) setter(closest)
return adjustAdjusted return adjustAdjusted

View file

@ -423,4 +423,16 @@ var _ = Describe("Criteria", func() {
gomega.Expect(ids).To(gomega.Equal([]string{sharedID})) gomega.Expect(ids).To(gomega.Equal([]string{sharedID}))
}) })
}) })
DescribeTable("empty nested groups",
func(input, expected string) {
var c Criteria
gomega.Expect(json.Unmarshal([]byte(input), &c)).To(gomega.Succeed())
out, err := json.Marshal(c)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(string(out)).To(gomega.Equal(expected))
},
Entry("keeps a nested empty group", `{"all":[{"all":[]}]}`, `{"all":[{"all":[]}]}`),
Entry("keeps a nested empty any group", `{"all":[{"any":[]}]}`, `{"all":[{"any":[]}]}`),
)
}) })

View file

@ -174,14 +174,8 @@ func marshalExpression(name string, value map[string]any) ([]byte, error) {
} }
func marshalConjunction(name string, conj []Expression) ([]byte, error) { func marshalConjunction(name string, conj []Expression) ([]byte, error) {
aux := struct { if conj == nil {
All []Expression `json:"all,omitempty"` conj = []Expression{}
Any []Expression `json:"any,omitempty"`
}{}
if name == "any" {
aux.Any = conj
} else {
aux.All = conj
} }
return json.Marshal(aux) return json.Marshal(map[string][]Expression{name: conj})
} }

View file

@ -13,6 +13,9 @@ type QueryOptions struct {
Offset int Offset int
Filters squirrel.Sqlizer Filters squirrel.Sqlizer
Seed string // for random sorting Seed string // for random sorting
// SortOrders is one "asc"/"desc" per Sort key, kept on every column its mapping expands into.
// Empty means Order applies to every key; a key past the end reuses the first entry.
SortOrders []string
} }
type DataStore interface { type DataStore interface {

View file

@ -41,7 +41,10 @@ func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, err
func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions { func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions {
var base model.QueryOptions var base model.QueryOptions
if len(options) > 0 { if len(options) > 0 {
base = model.QueryOptions{Sort: options[0].Sort, Order: options[0].Order, Filters: options[0].Filters} base = model.QueryOptions{
Sort: options[0].Sort, Order: options[0].Order, SortOrders: options[0].SortOrders,
Filters: options[0].Filters,
}
} }
return func(chunk []string) model.QueryOptions { return func(chunk []string) model.QueryOptions {
opts := base opts := base

View file

@ -155,7 +155,14 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti
sq = sq.Offset(uint64(options[0].Offset)) sq = sq.Offset(uint64(options[0].Offset))
} }
if options[0].Sort != "" { if options[0].Sort != "" {
sq = sq.OrderBy(r.buildSortOrder(options[0].Sort, options[0].Order)) sort, order := options[0].Sort, options[0].Order
// Per-key directions are absolute, so buildSortOrder gets an ascending baseline.
// resetSeededRandom has already rewritten "random" to SEEDEDRAND by here.
if len(options[0].SortOrders) > 0 && sort != "random" && !strings.HasPrefix(sort, "SEEDEDRAND(") {
sort = r.sortWithKeyOrders(sort, options[0].SortOrders)
order = ""
}
sq = sq.OrderBy(r.buildSortOrder(sort, order))
} }
} }
return sq return sq
@ -196,6 +203,39 @@ func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased strin
return mapping, snakeCased, ok return mapping, snakeCased, ok
} }
// sortWithKeyOrders maps each logical sort key, then marks descending keys so buildSortOrder
// keeps that direction on every column the mapping expands into.
func (r sqlRepository) sortWithKeyOrders(sort string, orders []string) string {
keys := strings.FieldsFunc(sort, splitFunc(','))
out := make([]string, 0, len(keys))
for i, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
mapped := r.sortMapping(key)
if !descendingOrder(i, orders) {
out = append(out, mapped)
continue
}
for _, col := range strings.FieldsFunc(mapped, splitFunc(',')) {
out = append(out, strings.TrimSpace(col)+" desc")
}
}
return strings.Join(out, ", ")
}
func descendingOrder(i int, orders []string) bool {
if len(orders) == 0 {
return false
}
raw := orders[0]
if i < len(orders) {
raw = orders[i]
}
return raw == "desc"
}
func (r sqlRepository) buildSortOrder(sort, order string) string { func (r sqlRepository) buildSortOrder(sort, order string) string {
sort = r.sortMapping(sort) sort = r.sortMapping(sort)
order = strings.ToLower(strings.TrimSpace(order)) order = strings.ToLower(strings.TrimSpace(order))

View file

@ -62,6 +62,18 @@ var _ = Describe("sqlRepository", func() {
sql, _, _ := sq.ToSql() sql, _, _ := sq.ToSql()
Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2")) Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2"))
}) })
It("applies a per-key SortOrder after mappings expand", func() {
r.sortMappings["max_year"] = "coalesce(nullif(original_date,''), cast(max_year as text)), release_date"
sq = r.applyOptions(sq, model.QueryOptions{
Sort: "duration, name, max_year, play_count",
Order: "desc",
SortOrders: []string{"desc", "asc", "desc"},
})
sql, _, _ := sq.ToSql()
Expect(sql).To(Equal("SELECT * FROM test ORDER BY duration desc, title asc, " +
"coalesce(nullif(original_date,''), cast(max_year as text)) desc, release_date desc, play_count desc"))
})
}) })
Describe("toSQL", func() { Describe("toSQL", func() {

View file

@ -46,7 +46,7 @@ func AlbumsByArtist() Options {
return addDefaultFilters(Options{Sort: "artist"}) return addDefaultFilters(Options{Sort: "artist"})
} }
func AlbumsByArtistID(artistId string) Options { func AlbumsByArtistID(artistId any) Options {
roles := []model.Role{model.RoleAlbumArtist} roles := []model.Role{model.RoleAlbumArtist}
if conf.Server.Subsonic.ArtistParticipations { if conf.Server.Subsonic.ArtistParticipations {
roles = append(roles, model.RoleArtist) roles = append(roles, model.RoleArtist)
@ -57,10 +57,9 @@ func AlbumsByArtistID(artistId string) Options {
}) })
} }
// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the // AlbumsByContributingArtistID matches albums where the artist (an id or a slice of ids) performs on
// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an // a track but is not the album artist: Jellyfin's "Featured On", the complement of AlbumsByArtistID.
// artist's own discography never leaks into it. func AlbumsByContributingArtistID(artistId any) Options {
func AlbumsByContributingArtistID(artistId string) Options {
return addDefaultFilters(Options{ return addDefaultFilters(Options{
Sort: "max_year", Sort: "max_year",
Filters: And{ Filters: And{
@ -101,7 +100,7 @@ func SongsByAlbum(albumId string) Options {
// SongsByArtistID matches media files where the artist participates as album or track artist, in // SongsByArtistID matches media files where the artist participates as album or track artist, in
// album order. // album order.
func SongsByArtistID(artistId string) Options { func SongsByArtistID(artistId any) Options {
return addDefaultFilters(Options{ return addDefaultFilters(Options{
Sort: "album", Sort: "album",
Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist), Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist),

View file

@ -162,7 +162,9 @@ in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFol
`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; `IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
unrecognized keys are skipped, and `Random` always sorts alone); unrecognized keys are skipped, and `Random` always sorts alone);
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` `StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
returns direct children only (no tracks — no track is a library's direct child). returns direct children only (no tracks — no track is a library's direct child). With no `ParentId`,
no `Recursive=true` and none of the filters above, `/Items` lists the user's libraries (the same
views as `UserViews`), as Jellyfin does at the user root.
## Implemented endpoints ## Implemented endpoints

View file

@ -57,7 +57,8 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol
func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
p := req.Params(r) p := req.Params(r)
opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} offset, limit := pageParams(p)
opts := model.QueryOptions{Offset: offset, Max: limit}
res, err := api.listGenres(ctx, opts) res, err := api.listGenres(ctx, opts)
if err != nil { if err != nil {
api.internalError(w, r, err) api.internalError(w, r, err)
@ -83,8 +84,8 @@ func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) {
return return
} }
items := slice.Map(labels, dto.StudioToBaseItem) items := slice.Map(labels, dto.StudioToBaseItem)
offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) offset, limit := pageParams(p)
api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) api.ok(w, r, result(paginate(items, offset, limit), len(items), offset))
} }
// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's // getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's

View file

@ -206,6 +206,16 @@ var _ = Describe("Browsing", func() {
invoke(api.getGenres, w, r) invoke(api.getGenres, w, r)
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusOK))
}) })
It("treats a negative StartIndex as 0 instead of panicking", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Genres?StartIndex=-1&Limit=5", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getGenres, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.StartIndex).To(Equal(0))
})
}) })
Describe("getStudios", func() { Describe("getStudios", func() {
@ -238,6 +248,16 @@ var _ = Describe("Browsing", func() {
invoke(api.getStudios, w, r) invoke(api.getStudios, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound)) Expect(w.Code).To(Equal(http.StatusNotFound))
}) })
It("treats a negative StartIndex as 0 instead of panicking", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Studios?StartIndex=-1&Limit=5", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getStudios, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.StartIndex).To(Equal(0))
})
}) })
Describe("getQueryFiltersLegacy", func() { Describe("getQueryFiltersLegacy", func() {

View file

@ -31,6 +31,12 @@ func searchTerm(p *req.Values) string {
return strings.TrimSpace(p.StringOr("searchterm", "")) return strings.TrimSpace(p.StringOr("searchterm", ""))
} }
// joinedStrings joins repeated values of a query param with commas. Clients send lists either
// comma-separated (Finamp) or as repeated keys (@jellyfin/sdk), and the parsers split on commas.
func joinedStrings(p *req.Values, key string) string {
return strings.Join(p.Strings(key), ",")
}
// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params // itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params
// clients may send instead. A nil field means the client asked for no filtering on that dimension. // clients may send instead. A nil field means the client asked for no filtering on that dimension.
type itemFilters struct { type itemFilters struct {
@ -42,7 +48,7 @@ type itemFilters struct {
// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped. // Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped.
func parseItemFilters(p *req.Values) itemFilters { func parseItemFilters(p *req.Values) itemFilters {
f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")} f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")}
for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") { for token := range strings.SplitSeq(joinedStrings(p, "filters"), ",") {
switch strings.TrimSpace(token) { switch strings.TrimSpace(token) {
case "IsFavorite", "IsFavoriteOrLikes": case "IsFavorite", "IsFavoriteOrLikes":
f.favorite = new(true) f.favorite = new(true)
@ -294,25 +300,34 @@ type itemsQuery struct {
entityParent string entityParent string
isLibraryParent bool isLibraryParent bool
scopeIDs []int scopeIDs []int
// artistId selects that artist's own discography; contributingOnly means albums they merely // artistIds selects those artists' own discography (any of them). contributingOnly means albums
// appear on (Jellyfin's "Featured On"), which must exclude that discography. // they merely appear on (Jellyfin's "Featured On"), which must exclude that discography.
artistId string artistIds []string
contributingOnly bool contributingOnly bool
genreIds []string genreIds []string
albumIds []string albumIds []string
years []int years []int
studioIds []string studioIds []string
userRoot bool
}
// hasFilters reports the params that make a ParentId-less /Items a content query, not the user root.
func (q itemsQuery) hasFilters() bool {
return q.rawTypes != "" || len(q.ids) > 0 || q.search != "" || len(q.artistIds) > 0 ||
len(q.genreIds) > 0 || len(q.albumIds) > 0 || len(q.studioIds) > 0 || len(q.years) > 0 ||
q.filters != (itemFilters{})
} }
// listParams reads the itemsQuery fields that come straight from query params. // listParams reads the itemsQuery fields that come straight from query params.
func listParams(p *req.Values) itemsQuery { func listParams(p *req.Values) itemsQuery {
offset, limit := pageParams(p)
return itemsQuery{ return itemsQuery{
fields: dto.ParseFields(p.Strings("fields")...), fields: dto.ParseFields(p.Strings("fields")...),
search: searchTerm(p), search: searchTerm(p),
sortBy: p.StringOr("sortby", ""), sortBy: joinedStrings(p, "sortby"),
sortOrder: p.StringOr("sortorder", ""), sortOrder: joinedStrings(p, "sortorder"),
offset: p.IntOr("startindex", 0), offset: offset,
limit: p.IntOr("limit", 0), limit: limit,
filters: parseItemFilters(p), filters: parseItemFilters(p),
} }
} }
@ -349,7 +364,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ
} }
q := listParams(p) q := listParams(p)
q.ids = ids q.ids = ids
q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", "")) q.rawTypes = knownItemKinds(joinedStrings(p, "includeitemtypes"))
q.parentId = parentId q.parentId = parentId
q.genreIds = genreIds q.genreIds = genreIds
q.albumIds = albumIds q.albumIds = albumIds
@ -357,21 +372,25 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ
q.studioIds = studioIds q.studioIds = studioIds
// An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping
// plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist.
albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) albumArtistScope := joinedStrings(p, "albumartistids")
contributingScope := p.StringOr("contributingartistids", "") artistScope := joinedStrings(p, "artistids")
artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) contributingScope := joinedStrings(p, "contributingartistids")
artistIds, ok := decodedIDList(firstNonEmpty(albumArtistScope, artistScope, contributingScope))
if !ok { if !ok {
return itemsQuery{}, model.ErrNotFound return itemsQuery{}, model.ErrNotFound
} }
q.artistId = artistId q.artistIds = artistIds
q.contributingOnly = albumArtistScope == "" && contributingScope != "" q.contributingOnly = albumArtistScope == "" && artistScope == "" && contributingScope != ""
recursive := p.BoolOr("recursive", false)
q.userRoot = q.parentId == "" && !recursive && !q.hasFilters()
q.types = parseTypes(q.rawTypes) q.types = parseTypes(q.rawTypes)
q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId)
// Recursive=false asks for direct children only, and no track is a library's direct child. // Recursive=false asks for direct children only, and no track is a library's direct child.
// Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer.
if q.isLibraryParent && !p.BoolOr("recursive", false) { if q.isLibraryParent && !recursive {
q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" })
} }
@ -410,6 +429,14 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult
// A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items.
case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"): case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"):
return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
// Symfonium's sync reads the libraries this way; answering with albums makes it sync nothing.
case q.userRoot:
views, err := api.userViews(ctx)
if err != nil {
return itemsResult{}, err
}
sortViews(views, q.sortBy, q.sortOrder)
return materialized(result(paginate(views, q.offset, q.limit), len(views), q.offset)), nil
} }
if repo, ok := api.playlistTracksRepo(ctx, q); ok { if repo, ok := api.playlistTracksRepo(ctx, q); ok {
return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit) return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit)
@ -497,7 +524,8 @@ func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int
// Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window). // Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window).
window := offset + q.limit window := offset + q.limit
if q.search != "" { if q.search != "" {
window = min(window, maxSearchLimit) // Capping offset before the sum keeps a huge StartIndex from wrapping the window negative.
window = min(min(offset, maxSearchLimit)+q.limit, maxSearchLimit)
} }
lists := make([][]dto.BaseItemDto, len(q.types)) lists := make([][]dto.BaseItemDto, len(q.types))
totals := make([]int, len(q.types)) totals := make([]int, len(q.types))
@ -560,14 +588,25 @@ func firstNonEmpty(vals ...string) string {
return "" return ""
} }
// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting // decodedIDList decodes every id in a comma-joined Jellyfin id list. ok is false when any entry is
// whether it decoded successfully (see decodeFilterParam). // malformed, so a later bad id can't be dropped and widen the query (see decodeFilterParam).
func firstDecodedID(s string) (string, bool) { func decodedIDList(s string) ([]string, bool) {
if s == "" { if strings.TrimSpace(s) == "" {
return "", true return nil, true
} }
first, _, _ := strings.Cut(s, ",") var ids []string
return decodeFilterParam(strings.TrimSpace(first)) for part := range strings.SplitSeq(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, ok := decodeFilterParam(part)
if !ok {
return nil, false
}
ids = append(ids, id)
}
return ids, true
} }
// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if // decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if
@ -632,9 +671,16 @@ func parseTypes(types string) []string {
return slice.Unique(recognized) return slice.Unique(recognized)
} }
// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only // pageParams reads StartIndex/Limit; Jellyfin treats a negative StartIndex as 0.
// (single-type queries push Offset/Max down to SQL instead). func pageParams(p *req.Values) (offset, limit int) {
return max(p.IntOr("startindex", 0), 0), p.IntOr("limit", 0)
}
// paginate applies StartIndex/Limit to an in-memory item list (single-type queries push Offset/Max
// down to SQL instead).
func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto {
// A negative offset would panic the slice below; Jellyfin treats it as 0.
offset = max(offset, 0)
if offset >= len(items) { if offset >= len(items) {
return []dto.BaseItemDto{} return []dto.BaseItemDto{}
} }
@ -645,6 +691,40 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto {
return items return items
} }
// sortViews applies SortBy/SortOrder to the user-root libraries, sorted in memory since a user only
// has a handful; without a usable key they keep the repository order.
func sortViews(views []dto.BaseItemDto, sortBy, order string) {
type sortKey struct {
value func(dto.BaseItemDto) string
desc bool
}
dirs := listParts(order)
var keys []sortKey
for i, key := range listParts(sortBy) {
desc := sortDirection(i, dirs) == "desc"
switch strings.ToLower(key) {
case "sortname", "name":
keys = append(keys, sortKey{func(v dto.BaseItemDto) string { return strings.ToLower(v.SortName) }, desc})
case "datecreated":
keys = append(keys, sortKey{func(v dto.BaseItemDto) string { return v.DateCreated }, desc})
}
}
if len(keys) == 0 {
return
}
slices.SortStableFunc(views, func(a, b dto.BaseItemDto) int {
for _, k := range keys {
if r := strings.Compare(k.value(a), k.value(b)); r != 0 {
if k.desc {
r = -r
}
return r
}
}
return 0
})
}
// interleave merges per-type item lists round-robin: one item from each list in turn, preserving // interleave merges per-type item lists round-robin: one item from each list in turn, preserving
// each list's own order, so no single type dominates the head of a mixed-type result. // each list's own order, so no single type dominates the head of a mixed-type result.
func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto {
@ -707,10 +787,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it
// For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's
// albums"; contributingArtistIds means "albums they only appear on" (Featured On). // albums"; contributingArtistIds means "albums they only appear on" (Featured On).
switch { switch {
case q.contributingOnly && q.artistId != "": case q.contributingOnly && len(q.artistIds) > 0:
filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) filters = append(filters, filter.AlbumsByContributingArtistID(q.artistIds).Filters)
case firstNonEmpty(q.artistId, q.entityParent) != "": case len(q.artistIds) > 0:
filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) filters = append(filters, filter.AlbumsByArtistID(q.artistIds).Filters)
case q.entityParent != "":
filters = append(filters, filter.AlbumsByArtistID(q.entityParent).Filters)
default: default:
filters = append(filters, notMissing) filters = append(filters, notMissing)
} }
@ -753,8 +835,8 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite
filters := squirrel.And{} filters := squirrel.And{}
// For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's.
switch { switch {
case q.artistId != "": case len(q.artistIds) > 0:
filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) filters = append(filters, filter.SongsByArtistID(q.artistIds).Filters)
case q.entityParent != "": case q.entityParent != "":
filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters)
default: default:
@ -791,7 +873,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite
} }
// When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an
// explicit client SortBy still wins, since applySort already set opts.Sort. // explicit client SortBy still wins, since applySort already set opts.Sort.
if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { if len(q.artistIds) == 0 && q.entityParent != "" && opts.Sort == "" {
opts.Sort = filter.SongsByAlbum(q.entityParent).Sort opts.Sort = filter.SongsByAlbum(q.entityParent).Sort
} }
// A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows.
@ -1011,17 +1093,24 @@ func result(items []dto.BaseItemDto, total, start int) dto.QueryResult {
return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start}
} }
// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends. // applySort keeps every recognized SortBy key, paired with SortOrder by index as Jellyfin's GetOrderBy
// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY. // does. Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY.
func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
var cols []string dirs := listParts(order)
for key := range strings.SplitSeq(sortBy, ",") { var cols, colOrders []string
for i, key := range strings.Split(sortBy, ",") {
// An absent SortBy splits to one empty part; that is "no sort", not a key to count.
if sortBy == "" {
break
}
dir := sortDirection(i, dirs)
col, ok := sortColumn(itemType, strings.TrimSpace(key)) col, ok := sortColumn(itemType, strings.TrimSpace(key))
// The repo matches random by exact string equality, so it can only ever sort alone. // The repo matches random by exact string equality, so it can only ever sort alone.
if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) { if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) {
continue continue
} }
cols = append(cols, col) cols = append(cols, col)
colOrders = append(colOrders, dir)
if col == "random" { if col == "random" {
break break
} }
@ -1029,16 +1118,62 @@ func applySort(opts *model.QueryOptions, itemType, sortBy, order string) {
switch { switch {
case len(cols) > 0: case len(cols) > 0:
opts.Sort = strings.Join(cols, ", ") opts.Sort = strings.Join(cols, ", ")
case sortBy != "": case strings.TrimSpace(sortBy) != "":
log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order", log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order",
"itemType", itemType, "sortBy", sortBy) "itemType", itemType, "sortBy", sortBy)
} }
// Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value if !mixedDirections(colOrders) {
// for every key, as Jellyfin does for keys past the end of the list. // With no kept key, a lone SortOrder still sets Order.
first, _, _ := strings.Cut(order, ",") dir := sortDirection(0, dirs)
if strings.EqualFold(first, "Descending") { if len(colOrders) > 0 {
dir = colOrders[0]
}
if dir == "desc" {
opts.Order = "desc" opts.Order = "desc"
} }
return
}
opts.SortOrders = colOrders
}
// listParts splits a comma-joined query value. An absent value is nil, not a single empty part.
func listParts(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
for i, part := range parts {
parts[i] = strings.TrimSpace(part)
}
return parts
}
// sortDirection is "desc" only for Jellyfin's Descending. Keys past the end of dirs reuse the first
// entry, which is what Jellyfin does for a SortOrder list shorter than SortBy.
func sortDirection(i int, dirs []string) string {
if len(dirs) == 0 {
return "asc"
}
raw := dirs[0]
if i < len(dirs) {
raw = dirs[i]
}
if strings.EqualFold(raw, "Descending") {
return "desc"
}
return "asc"
}
func mixedDirections(orders []string) bool {
if len(orders) < 2 {
return false
}
for _, order := range orders[1:] {
if order != orders[0] {
return true
}
}
return false
} }
// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields // sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices" "slices"
@ -14,6 +15,7 @@ import (
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@ -50,6 +52,72 @@ var _ = Describe("Items", func() {
}) })
Describe("getItems", func() { Describe("getItems", func() {
Describe("at the user root", func() {
libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Audiobooks"}}
getRoot := func(query string, ctx context.Context) dto.QueryResult {
w := httptest.NewRecorder()
invoke(api.getItems, w, httptest.NewRequest("GET", "/Items?"+query, nil).WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
return res
}
BeforeEach(func() {
ds.Library().(*tests.MockLibraryRepo).SetData(libs)
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
})
It("lists the user's libraries for an unfiltered, non-recursive query", func() {
res := getRoot("UserId="+testID("u1"), ctxUser())
Expect(res.TotalRecordCount).To(Equal(1))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1)))
Expect(res.Items[0].Type).To(Equal("CollectionFolder"))
Expect(res.Items[0].CollectionType).To(Equal("music"))
})
It("pages the libraries by StartIndex and Limit", func() {
res := getRoot("StartIndex=1&Limit=1", ctxUserWithLibraries(libs))
Expect(res.TotalRecordCount).To(Equal(2))
Expect(res.StartIndex).To(Equal(1))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(2)))
})
DescribeTable("sorts the libraries before paging them",
func(query string, wantID int) {
res := getRoot(query, ctxUserWithLibraries(libs))
Expect(res.TotalRecordCount).To(Equal(2))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(wantID)))
},
Entry("by name ascending", "SortBy=SortName&Limit=1", 2),
Entry("by name descending", "SortBy=SortName&SortOrder=Descending&Limit=1", 1),
Entry("descending, second page", "SortBy=Name&SortOrder=Descending&StartIndex=1&Limit=1", 2),
Entry("unknown key keeps the repository order", "SortBy=Bogus&Limit=1", 1),
Entry("SortOrder pairs with SortBy by index", "SortBy=DateCreated,SortName&SortOrder=Ascending,Descending&Limit=1", 1),
)
It("treats an unknown IncludeItemTypes as absent", func() {
res := getRoot("IncludeItemTypes=music", ctxUser())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("CollectionFolder"))
})
DescribeTable("queries the library contents when the request is recursive or filtered",
func(query string) {
res := getRoot(query, ctxUser())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
},
Entry("Recursive=true", "Recursive=true"),
Entry("IncludeItemTypes", "IncludeItemTypes=MusicAlbum"),
Entry("SearchTerm", "SearchTerm=one"),
Entry("Filters", "Filters=IsUnplayed"),
Entry("Years", "Years=2020"),
)
})
It("lists albums when IncludeItemTypes=MusicAlbum", func() { It("lists albums when IncludeItemTypes=MusicAlbum", func() {
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -288,6 +356,37 @@ var _ = Describe("Items", func() {
Expect(res.TotalRecordCount).To(Equal(2)) Expect(res.TotalRecordCount).To(Equal(2))
}) })
// @jellyfin/sdk (Jellify) sends array params as repeated keys.
It("merges results from every repeated IncludeItemTypes key", func() {
ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Salt"}})
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Salt"}})
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "Salt"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?SearchTerm=Salt&IncludeItemTypes=MusicArtist&IncludeItemTypes=Audio&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(3))
types := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type}
Expect(types).To(ConsistOf("MusicArtist", "Audio", "MusicAlbum"))
Expect(res.TotalRecordCount).To(Equal(3))
})
It("mixes a repeated IncludeItemTypes key with a comma-separated one", func() {
ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Salt"}})
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Salt"}})
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "Salt"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
types := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type}
Expect(types).To(ConsistOf("MusicArtist", "Audio", "MusicAlbum"))
})
It("merges favorite songs, albums, and playlists", func() { It("merges favorite songs, albums, and playlists", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
@ -313,6 +412,19 @@ var _ = Describe("Items", func() {
Expect(playlistSQL).To(ContainSubstring("starred")) Expect(playlistSQL).To(ContainSubstring("starred"))
}) })
It("applies every repeated Filters key", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite&Filters=IsPlayed", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, _, err := albumRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("starred"))
Expect(sql).To(ContainSubstring("play_count"))
})
It("applies StartIndex/Limit to the merged multi-type result set", func() { It("applies StartIndex/Limit to the merged multi-type result set", func() {
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
@ -341,6 +453,39 @@ var _ = Describe("Items", func() {
Expect(albumRepo.Options.Max).To(Equal(3)) Expect(albumRepo.Options.Max).To(Equal(3))
}) })
It("treats a negative StartIndex as 0 on the multi-type merge instead of panicking", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}})
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=-1&Limit=2", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.StartIndex).To(Equal(0))
Expect(res.Items).To(HaveLen(2))
Expect(res.TotalRecordCount).To(Equal(4))
// The clamped offset must feed the per-type window too: 0+Limit, not -1+Limit.
Expect(mfRepo.Options.Max).To(Equal(2))
Expect(albumRepo.Options.Max).To(Equal(2))
})
It("clamps a negative StartIndex to 0 on the single-type path", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=-1&Limit=10", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Options.Offset).To(Equal(0))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.StartIndex).To(Equal(0))
Expect(res.Items).To(HaveLen(1))
})
DescribeTable("translates the Filters list and its standalone equivalents", DescribeTable("translates the Filters list and its standalone equivalents",
func(query string, wantSQL, notWantSQL []string) { func(query string, wantSQL, notWantSQL []string) {
albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo := ds.Album().(*tests.MockAlbumRepo)
@ -487,18 +632,22 @@ var _ = Describe("Items", func() {
Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) Expect(res.TotalRecordCount).To(BeNumerically(">", 10))
}) })
It("bounds the multi-type search window however large StartIndex is", func() { DescribeTable("bounds the multi-type search window however large StartIndex is",
func(startIndex int) {
albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder() w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", startIndex), nil).
WithContext(ctxUser()) WithContext(ctxUser())
invoke(api.getItems, w, r) invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusOK))
// Without the bound this asks each type for ~500001 rows.
Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1))
}) },
Entry("large", 500000),
// StartIndex+Limit would wrap negative and slice out of range in a worker goroutine.
Entry("near the int limit", math.MaxInt),
)
It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { It("stops a multi-type search at the ceiling rather than serving another type's rows", func() {
// Bounding the per-type window is what keeps StartIndex from driving it without limit, and // Bounding the per-type window is what keeps StartIndex from driving it without limit, and
@ -708,9 +857,8 @@ var _ = Describe("Items", func() {
Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""), Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""),
) )
// Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so // Jellyfin reuses the first SortOrder for keys past the end of the list.
// we honor the first value for all keys, matching Jellyfin's fallback for extra keys. DescribeTable("applies one SortOrder to every sort key",
DescribeTable("reads the first SortOrder value for the whole sort",
func(sortOrder, want string) { func(sortOrder, want string) {
albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
@ -719,13 +867,50 @@ var _ = Describe("Items", func() {
"/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser()) "/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser())
invoke(api.getItems, w, r) invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Options.Sort).To(Equal("duration, name"))
Expect(albumRepo.Options.Order).To(Equal(want)) Expect(albumRepo.Options.Order).To(Equal(want))
Expect(albumRepo.Options.SortOrders).To(BeEmpty())
}, },
Entry("ascending", "Ascending", ""), Entry("ascending", "Ascending", ""),
Entry("descending", "Descending", "desc"), Entry("descending", "Descending", "desc"),
Entry("descending leading a list", "Descending,Ascending", "desc"),
Entry("ascending leading a list", "Ascending,Descending", ""),
) )
It("pairs each SortOrder with its SortBy key", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName,PlayCount&SortOrder=Descending,Ascending", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Options.Sort).To(Equal("duration, name, play_count"))
Expect(albumRepo.Options.Order).To(BeEmpty())
Expect(albumRepo.Options.SortOrders).To(Equal([]string{"desc", "asc", "desc"}))
})
It("keeps every key of a repeated SortBy", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime&SortBy=SortName", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Options.Sort).To(Equal("duration, name"))
})
It("pairs a repeated SortOrder key with its SortBy key", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder=Ascending&SortOrder=Descending", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Options.Sort).To(Equal("duration, name"))
Expect(albumRepo.Options.Order).To(BeEmpty())
Expect(albumRepo.Options.SortOrders).To(Equal([]string{"asc", "desc"}))
})
}) })
Describe("library scoping", func() { Describe("library scoping", func() {
@ -849,6 +1034,56 @@ var _ = Describe("Items", func() {
Expect(w.Code).To(Equal(http.StatusNotFound)) Expect(w.Code).To(Equal(http.StatusNotFound))
}) })
It("404s when a later repeated ArtistIds value is malformed", func() {
ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=Audio&ArtistIds="+dto.EncodeID(testID("ar1"))+"&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("filters songs by every repeated ArtistIds key", func() {
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=Audio&ArtistIds="+dto.EncodeID(testID("ar1"))+"&ArtistIds="+dto.EncodeID(testID("ar2")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
_, args, err := mfRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(args).To(ContainElements(testID("ar1"), testID("ar2")))
})
It("filters albums by every artist in a comma-separated AlbumArtistIds", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("ar1"))+","+dto.EncodeID(testID("ar2")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
_, args, err := albumRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(args).To(ContainElements(testID("ar1"), testID("ar2")))
})
It("excludes an album artist from a multi-id contributingArtistIds Featured On filter", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET",
"/Items?IncludeItemTypes=MusicAlbum&contributingArtistIds="+dto.EncodeID(testID("ar1"))+","+dto.EncodeID(testID("ar2")), nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := albumRepo.Options.Filters.ToSql()
Expect(err).NotTo(HaveOccurred())
Expect(sql).To(ContainSubstring("NOT IN"))
Expect(args).To(ContainElements(testID("ar1"), testID("ar2")))
Expect(strings.Count(sql, "album_artists")).To(Equal(2))
})
It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() {
albumRepo := ds.Album().(*tests.MockAlbumRepo) albumRepo := ds.Album().(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
@ -1154,6 +1389,17 @@ var _ = Describe("Items", func() {
Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO")) Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO"))
}) })
It("keeps entries from every value of a repeated param", func() {
repeated := func(rawURL string) string {
r := httptest.NewRequest("GET", rawURL, nil)
return knownItemKinds(joinedStrings(req.Params(r), "includeitemtypes"))
}
Expect(repeated("/Items?includeitemtypes=MusicArtist&includeitemtypes=Audio&includeitemtypes=MusicAlbum")).
To(Equal("MusicArtist,Audio,MusicAlbum"))
Expect(repeated("/Items?includeitemtypes=Nonsense&includeitemtypes=music")).To(BeEmpty())
Expect(repeated("/Items")).To(BeEmpty())
})
It("defaults to albums only when IncludeItemTypes is absent", func() { It("defaults to albums only when IncludeItemTypes is absent", func() {
Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"}))
Expect(parseTypes("Nonsense")).To(BeEmpty()) Expect(parseTypes("Nonsense")).To(BeEmpty())

View file

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

View file

@ -270,6 +270,27 @@ var _ = Describe("Playlists", func() {
Expect(fp.tracksRepo.Options.Max).To(Equal(1)) Expect(fp.tracksRepo.Options.Max).To(Equal(1))
}) })
It("clamps a negative StartIndex to 0", func() {
fp.getPls = &model.Playlist{
ID: testID("pl1"),
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=-1&Limit=1", nil).
WithContext(context.Background())
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
invoke(api.getPlaylistItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.StartIndex).To(Equal(0))
Expect(res.Items).To(HaveLen(1))
Expect(fp.tracksRepo.Options.Offset).To(Equal(0))
})
It("returns 404 for a non-owned or absent playlist", func() { It("returns 404 for a non-owned or absent playlist", func() {
fp.getErr = model.ErrNotFound fp.getErr = model.ErrNotFound
w := httptest.NewRecorder() w := httptest.NewRecorder()

View file

@ -107,7 +107,7 @@ func (api *Router) streamUniversal(w http.ResponseWriter, r *http.Request) {
func universalClientInfo(p *req.Values) *stream.ClientInfo { func universalClientInfo(p *req.Values) *stream.ClientInfo {
ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)} ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)}
for entry := range strings.SplitSeq(p.StringOr("container", ""), ",") { for entry := range strings.SplitSeq(joinedStrings(p, "container"), ",") {
container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|") container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|")
if container == "" { if container == "" {
continue continue

View file

@ -299,6 +299,14 @@ var _ = Describe("Stream", func() {
Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128)) Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128))
}) })
It("accepts repeated Container query parameters", func() {
universal("Container=mp3&Container=m4a|aac&TranscodingContainer=m4a&AudioCodec=aac")
Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{
{Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}},
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}},
}))
})
It("uses AudioCodec as the target when no TranscodingContainer is given", func() { It("uses AudioCodec as the target when no TranscodingContainer is given", func() {
universal("Container=mp3&AudioCodec=aac") universal("Container=mp3&AudioCodec=aac")
Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{

View file

@ -1,6 +1,7 @@
package jellyfin package jellyfin
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
@ -13,14 +14,21 @@ import (
// getUserViews returns one CollectionFolder view per accessible library, so clients browse each // getUserViews returns one CollectionFolder view per accessible library, so clients browse each
// library as its own top-level view rather than one aggregate. // library as its own top-level view rather than one aggregate.
func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() views, err := api.userViews(r.Context())
if err != nil {
api.internalError(w, r, err)
return
}
api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0})
}
func (api *Router) userViews(ctx context.Context) ([]dto.BaseItemDto, error) {
u, _ := request.UserFrom(ctx) u, _ := request.UserFrom(ctx)
// u.Libraries comes from a projection without counts or stats, and clients hide a library that // u.Libraries comes from a projection without counts or stats, and clients hide a library that
// looks empty, so the rows are re-read in full here. // looks empty, so the rows are re-read in full here.
libs, err := api.ds.Library().GetAll(ctx) libs, err := api.ds.Library().GetAll(ctx)
if err != nil { if err != nil {
api.internalError(w, r, err) return nil, err
return
} }
views := make([]dto.BaseItemDto, 0, len(libs)) views := make([]dto.BaseItemDto, 0, len(libs))
for _, lib := range libs { for _, lib := range libs {
@ -28,7 +36,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
views = append(views, dto.LibraryToBaseItem(lib)) views = append(views, dto.LibraryToBaseItem(lib))
} }
} }
api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) return views, nil
} }
func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) {

View file

@ -53,7 +53,7 @@
// //
// - noopArtwork: returns [model.ErrNotFound] for all artwork requests. // - noopArtwork: returns [model.ErrNotFound] for all artwork requests.
// - noopStreamer: returns [model.ErrNotFound] for all stream requests. // - noopStreamer: returns [model.ErrNotFound] for all stream requests.
// - noopArchiver: returns [model.ErrNotFound] for all archive requests. // - core.NewArchiver: the real archiver, streaming tracks through the streamer spy.
// - noopProvider: returns empty results for all external metadata lookups. // - noopProvider: returns empty results for all external metadata lookups.
// - noopPlayTracker: silently discards all scrobble events. // - noopPlayTracker: silently discards all scrobble events.
// //

View file

@ -212,6 +212,23 @@ func buildTestFS() storagetest.FakeFS {
}) })
} }
// setPlayerForcedFormat forces the test-client player to transcode to format until the current spec ends.
func setPlayerForcedFormat(format string) {
doReq("ping")
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
Expect(err).ToNot(HaveOccurred())
trc, err := ds.Transcoding().FindByFormat(ctx, format)
Expect(err).ToNot(HaveOccurred())
player.TranscodingId = trc.ID
Expect(ds.Player().Put(ctx, player)).To(Succeed())
DeferCleanup(func() {
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
Expect(err).ToNot(HaveOccurred())
player.TranscodingId = ""
Expect(ds.Player().Put(ctx, player)).To(Succeed())
})
}
// createUser creates a user in the database with the given properties, assigns them to the test // createUser creates a user in the database with the given properties, assigns them to the test
// library, and returns the fully-loaded user (with Libraries populated). // library, and returns the fully-loaded user (with Libraries populated).
func createUser(id, username, name string, isAdmin bool) model.User { func createUser(id, username, name string, isAdmin bool) model.User {
@ -409,7 +426,7 @@ func setupTestDB() {
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds) auth.Init(ds)
// Create the Subsonic Router with real DS, streamer spy, and real Decider // Create the Subsonic Router with real DS, streamer spy, and real Decider and Archiver
streamerSpy = &harness.SpyStreamer{} streamerSpy = &harness.SpyStreamer{}
decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{})
s := scanner.New(ctx, ds, events.NoopBroker(), s := scanner.New(ctx, ds, events.NoopBroker(),
@ -418,7 +435,7 @@ func setupTestDB() {
ds, ds,
noopArtwork{}, noopArtwork{},
streamerSpy, streamerSpy,
noopArchiver{}, core.NewArchiver(streamerSpy, decider, ds, core.NewShare(ds), noopArtwork{}),
core.NewPlayers(ds), core.NewPlayers(ds),
noopProvider{}, noopProvider{},
s, s,

View file

@ -1,6 +1,8 @@
package e2e package e2e
import ( import (
"archive/zip"
"bytes"
"net/http" "net/http"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@ -106,7 +108,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() {
}) })
Describe("Download", func() { Describe("Download", func() {
var trackID, trackTitle string var trackID, trackTitle, albumID string
BeforeAll(func() { BeforeAll(func() {
// All test tracks are mp3 at 320kbps // All test tracks are mp3 at 320kbps
@ -115,6 +117,7 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() {
Expect(songs).ToNot(BeEmpty()) Expect(songs).ToNot(BeEmpty())
trackID = songs[0].ID trackID = songs[0].ID
trackTitle = songs[0].Title trackTitle = songs[0].Title
albumID = songs[0].AlbumID
}) })
It("returns error when id parameter is missing", func() { It("returns error when id parameter is missing", func() {
@ -155,6 +158,43 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() {
Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Status).To(Equal(responses.StatusFailed))
}) })
Context("when the player has a forced transcoding", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableDownloads = true
setPlayerForcedFormat("opus")
})
It("uses the explicitly requested format", func() {
w := doRawReq("download", "id", trackID, "format", "aac", "bitrate", "128")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Format).To(Equal("aac"))
Expect(streamerSpy.LastRequest.BitRate).To(Equal(128))
})
It("uses the explicitly requested format for album zips", func() {
w := doRawReq("download", "id", albumID, "format", "aac", "bitrate", "128")
Expect(w.Code).To(Equal(http.StatusOK))
z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
Expect(err).ToNot(HaveOccurred())
Expect(z.File).ToNot(BeEmpty())
for _, f := range z.File {
Expect(f.Name).To(HaveSuffix(".aac"))
}
})
It("uses the player's format when no format is requested and AutoTranscodeDownload is true", func() {
conf.Server.AutoTranscodeDownload = true
w := doRawReq("download", "id", trackID)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastRequest.Format).To(Equal("opus"))
})
})
}) })
Describe("GetCoverArt", func() { Describe("GetCoverArt", func() {

View file

@ -159,22 +159,11 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
Expect(ds.Player().Put(ctx, player)).To(Succeed()) Expect(ds.Player().Put(ctx, player)).To(Succeed())
} }
setPlayerForcedFormat := func(format string) {
doReq("ping")
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
Expect(err).ToNot(HaveOccurred())
trc, err := ds.Transcoding().FindByFormat(ctx, format)
Expect(err).ToNot(HaveOccurred())
player.TranscodingId = trc.ID
Expect(ds.Player().Put(ctx, player)).To(Succeed())
}
AfterEach(func() { AfterEach(func() {
// Reset player MaxBitRate to 0 after each test // Reset player MaxBitRate to 0 after each test
player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "")
if err == nil { if err == nil {
player.MaxBitRate = 0 player.MaxBitRate = 0
player.TranscodingId = ""
_ = ds.Player().Put(ctx, player) _ = ds.Player().Put(ctx, player)
} }
}) })
@ -340,8 +329,8 @@ var _ = Describe("Transcode Endpoints", Ordered, func() {
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("flac")) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("flac"))
// DSD sample rate normalized: 2822400 / 8 = 352800 // DSD sample rate normalized: 2822400 / 8 = 352800, capped at 88200
Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(352800))) Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(88200)))
// DSD 1-bit → 24-bit PCM // DSD 1-bit → 24-bit PCM
Expect(resp.TranscodeDecision.TranscodeStream.AudioBitdepth).To(Equal(int32(24))) Expect(resp.TranscodeDecision.TranscodeStream.AudioBitdepth).To(Equal(int32(24)))
}) })

View file

@ -90,6 +90,9 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
} else { } else {
format = "raw" format = "raw"
} }
} else {
// An explicit download format must win over the player's forced transcoding
ctx = request.WithTranscoding(ctx, model.Transcoding{})
} }
setHeaders := func(name string) { setHeaders := func(name string) {