mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-11 20:58:39 +01:00
Compare commits
10 commits
apiv1-auth
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff58ca5d1f |
||
|
|
2fecb718c0 | ||
|
|
514207faf1 |
||
|
|
d22a31aee0 |
||
|
|
1efd5c467b |
||
|
|
64cf86fae9 |
||
|
|
27d041486c |
||
|
|
9f0441352c |
||
|
|
ecbd4b7d55 |
||
|
|
818603436c |
32 changed files with 882 additions and 212 deletions
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
|
|
@ -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$'`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
29
core/library_internal_test.go
Normal file
29
core/library_internal_test.go
Normal 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"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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":[]}]}`),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue