mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-09 12:12:49 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c9816aec | ||
|
|
fe1c87c190 | ||
|
|
043de7a86c | ||
|
|
bea9715001 |
No files matched your search
+1
-1
@@ -187,7 +187,7 @@ LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
|
||||
# otherwise the deleted bytes still ship in the image.
|
||||
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
for lib in libwebp libwebpdemux libwebpmux; do \
|
||||
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
||||
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
||||
|
||||
+6
-11
@@ -27,12 +27,11 @@ type TranscodeOptions struct {
|
||||
Command string // DB command template (used to detect custom vs default)
|
||||
Format string // Target format (mp3, opus, aac, flac)
|
||||
FilePath string
|
||||
BitRate int // kbps, 0 = codec default
|
||||
SampleRate int // 0 = no constraint
|
||||
Channels int // 0 = no constraint
|
||||
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||
Offset int // seconds
|
||||
Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
|
||||
BitRate int // kbps, 0 = codec default
|
||||
SampleRate int // 0 = no constraint
|
||||
Channels int // 0 = no constraint
|
||||
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
|
||||
@@ -87,11 +86,7 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
|
||||
} else {
|
||||
args = buildTemplateArgs(opts)
|
||||
}
|
||||
out, err := e.start(ctx, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
|
||||
return e.start(ctx, args)
|
||||
}
|
||||
|
||||
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package ffmpeg
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -685,40 +684,6 @@ var _ = Describe("ffmpeg", func() {
|
||||
})
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
})
|
||||
|
||||
It("fills in total_samples on a piped FLAC transcode", func() {
|
||||
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "tests/fixtures/test.flac",
|
||||
Duration: 1, // the fixture is exactly 1s at 44100Hz
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
out, err := io.ReadAll(stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(out[:4])).To(Equal("fLaC"))
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
|
||||
})
|
||||
|
||||
It("patches the duration net of the requested offset", func() {
|
||||
// The command has no %t, so ffmpeg still emits the whole fixture.
|
||||
// What is under test is the header arithmetic, not the audio.
|
||||
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
|
||||
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
|
||||
Format: "flac",
|
||||
FilePath: "tests/fixtures/test.flac",
|
||||
Duration: 3,
|
||||
Offset: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer stream.Close()
|
||||
|
||||
out, err := io.ReadAll(stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("stderr capture", func() {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
flacPrefixLen = 26 // through the last total_samples byte
|
||||
flacMaxTotalSamples = 1<<36 - 1
|
||||
)
|
||||
|
||||
// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
|
||||
// when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
|
||||
func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
|
||||
if duration <= 0 {
|
||||
return r
|
||||
}
|
||||
return &flacPatcher{ReadCloser: r, duration: duration}
|
||||
}
|
||||
|
||||
type flacPatcher struct {
|
||||
io.ReadCloser
|
||||
duration float32
|
||||
// Peeking here rather than in the constructor keeps Transcode from blocking
|
||||
// until ffmpeg has emitted its first bytes.
|
||||
stream io.Reader
|
||||
}
|
||||
|
||||
func (f *flacPatcher) Read(p []byte) (int, error) {
|
||||
if f.stream == nil {
|
||||
prefix := make([]byte, flacPrefixLen)
|
||||
n, err := io.ReadFull(f.ReadCloser, prefix)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return 0, err
|
||||
}
|
||||
prefix = prefix[:n]
|
||||
if err == nil {
|
||||
setFLACTotalSamples(prefix, f.duration)
|
||||
}
|
||||
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
|
||||
}
|
||||
return f.stream.Read(p)
|
||||
}
|
||||
|
||||
// setFLACTotalSamples takes the rate from the header rather than the transcode
|
||||
// options, so a resampled (-ar) output still gets the right count.
|
||||
func setFLACTotalSamples(prefix []byte, duration float32) {
|
||||
if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
|
||||
return
|
||||
}
|
||||
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples
|
||||
info := binary.BigEndian.Uint64(prefix[18:])
|
||||
rate := info >> 44
|
||||
if rate == 0 || info&flacMaxTotalSamples != 0 {
|
||||
return
|
||||
}
|
||||
total := math.Round(float64(duration) * float64(rate))
|
||||
if total > flacMaxTotalSamples {
|
||||
return
|
||||
}
|
||||
binary.BigEndian.PutUint64(prefix[18:], info|uint64(total))
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// Decoded independently so the specs do not mirror the production bit-twiddling.
|
||||
func readSampleRate(b []byte) int {
|
||||
return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4
|
||||
}
|
||||
|
||||
func readTotalSamples(b []byte) uint64 {
|
||||
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
|
||||
}
|
||||
|
||||
var _ = Describe("patchFLACDuration", func() {
|
||||
var fileFLAC []byte
|
||||
|
||||
// Zeroing total_samples reproduces what a piped transcode emits.
|
||||
pipedFLAC := func() []byte {
|
||||
b := bytes.Clone(fileFLAC)
|
||||
b[21] &= 0xF0
|
||||
clear(b[22:26])
|
||||
return b
|
||||
}
|
||||
|
||||
readAll := func(in []byte, duration float32) []byte {
|
||||
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return out
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate
|
||||
})
|
||||
|
||||
It("fills in total_samples from the duration", func() {
|
||||
out := readAll(pipedFLAC(), 1.0)
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
|
||||
})
|
||||
|
||||
It("takes the sample rate from the header, not from the source file", func() {
|
||||
in := pipedFLAC()
|
||||
// Rewrite the header's rate to 48000, as -ar would.
|
||||
in[18], in[19] = 0x0B, 0xB8
|
||||
in[20] &= 0x0F
|
||||
|
||||
out := readAll(in, 2.0)
|
||||
|
||||
Expect(readSampleRate(out)).To(Equal(48000))
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
|
||||
})
|
||||
|
||||
It("rounds to the nearest sample rather than truncating", func() {
|
||||
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
|
||||
out := readAll(pipedFLAC(), 0.7)
|
||||
Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
|
||||
})
|
||||
|
||||
It("passes through when the duration overflows the 36-bit field", func() {
|
||||
in := pipedFLAC()
|
||||
Expect(readAll(in, 2e6)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("leaves everything after the header untouched", func() {
|
||||
in := pipedFLAC()
|
||||
out := readAll(in, 1.0)
|
||||
Expect(out).To(HaveLen(len(in)))
|
||||
Expect(out[26:]).To(Equal(in[26:]))
|
||||
Expect(out[:18]).To(Equal(in[:18]))
|
||||
})
|
||||
|
||||
It("leaves an already-populated total_samples alone", func() {
|
||||
out := readAll(fileFLAC, 99.0)
|
||||
Expect(out).To(Equal(fileFLAC))
|
||||
})
|
||||
|
||||
It("passes through a stream that is not FLAC", func() {
|
||||
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
|
||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("passes through when the first metadata block is not STREAMINFO", func() {
|
||||
in := pipedFLAC()
|
||||
in[4] = 0x04 // VORBIS_COMMENT
|
||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
|
||||
in := pipedFLAC()[:20]
|
||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("passes through an empty stream", func() {
|
||||
Expect(readAll(nil, 1.0)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("passes through when the duration is zero or negative", func() {
|
||||
in := pipedFLAC()
|
||||
Expect(readAll(in, 0)).To(Equal(in))
|
||||
Expect(readAll(in, -5)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("passes through when the header declares no sample rate", func() {
|
||||
in := pipedFLAC()
|
||||
in[18], in[19] = 0, 0
|
||||
in[20] &= 0x0F
|
||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
||||
})
|
||||
|
||||
It("propagates a read error from the underlying stream", func() {
|
||||
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
|
||||
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
|
||||
Expect(err).To(MatchError("boom"))
|
||||
})
|
||||
|
||||
It("closes the underlying stream", func() {
|
||||
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
|
||||
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
|
||||
Expect(c.closed).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
|
||||
|
||||
type closeSpy struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *closeSpy) Close() error { c.closed = true; return nil }
|
||||
@@ -1144,82 +1144,6 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Player-forced format", func() {
|
||||
symfonium := func() *ClientInfo {
|
||||
return &ClientInfo{
|
||||
Name: "Symfonium",
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
It("direct plays a flac source forced to flac", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("flac")).To(BeTrue())
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("still transcodes a 24-bit flac when the client caps bit depth", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
|
||||
ci := symfonium()
|
||||
ci.CodecProfiles = []CodecProfile{{
|
||||
Type: CodecProfileTypeAudio, Name: "flac",
|
||||
Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}},
|
||||
}}
|
||||
Expect(ci.ForceFormat("flac")).To(BeTrue())
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.BitDepth).To(Equal(16))
|
||||
})
|
||||
|
||||
It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("mp3")).To(BeTrue())
|
||||
ci.CapBitrate(192)
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetBitrate).To(Equal(192))
|
||||
})
|
||||
|
||||
It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("mp3")).To(BeTrue())
|
||||
ci.CapBitrate(192)
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("transcodes a flac source forced to mp3", func() {
|
||||
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
|
||||
ci := symfonium()
|
||||
Expect(ci.ForceFormat("mp3")).To(BeTrue())
|
||||
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ensureProbed", func() {
|
||||
|
||||
@@ -268,7 +268,6 @@ func NewTranscodingCache() TranscodingCache {
|
||||
BitDepth: job.bitDepth,
|
||||
Channels: job.channels,
|
||||
Offset: job.offset,
|
||||
Duration: job.mf.Duration,
|
||||
})
|
||||
if err != nil {
|
||||
release()
|
||||
|
||||
+8
-18
@@ -59,38 +59,28 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
|
||||
return changed
|
||||
}
|
||||
|
||||
// ForceFormat narrows the client to transcoding to targetFormat, but only if the
|
||||
// client already declares a profile for it. All matching profiles are kept so
|
||||
// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt
|
||||
// from those profiles rather than dropped, since declaring a transcoding profile
|
||||
// for a format is proof the client can play it. Returns false when unsupported.
|
||||
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
|
||||
// direct play, but only if the client already declares a profile for that
|
||||
// format. All matching profiles are kept so negotiation can still pick among
|
||||
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
|
||||
// unsupported.
|
||||
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
|
||||
if targetFormat == "" {
|
||||
return false
|
||||
}
|
||||
var matched []Profile
|
||||
var directPlay []DirectPlayProfile
|
||||
for i := range ci.TranscodingProfiles {
|
||||
p := &ci.TranscodingProfiles[i]
|
||||
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
|
||||
// target_format) still matches a resolved "opus" profile.
|
||||
container, format := resolveTargetFormat(p)
|
||||
if !matchesContainer(format, []string{targetFormat}) {
|
||||
continue
|
||||
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
|
||||
matched = append(matched, ci.TranscodingProfiles[i])
|
||||
}
|
||||
matched = append(matched, *p)
|
||||
directPlay = append(directPlay, DirectPlayProfile{
|
||||
Containers: []string{container},
|
||||
AudioCodecs: []string{format},
|
||||
Protocols: []string{ProtocolHTTP},
|
||||
MaxAudioChannels: p.MaxAudioChannels,
|
||||
})
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return false
|
||||
}
|
||||
ci.TranscodingProfiles = matched
|
||||
ci.DirectPlayProfiles = directPlay
|
||||
ci.DirectPlayProfiles = nil
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
|
||||
})
|
||||
|
||||
Describe("ForceFormat", func() {
|
||||
It("restricts direct play to the forced format when supported", func() {
|
||||
It("restricts to the forced format and clears direct play when supported", func() {
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
|
||||
TranscodingProfiles: []Profile{
|
||||
@@ -71,35 +71,7 @@ var _ = Describe("ClientInfo", func() {
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
|
||||
Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP},
|
||||
}))
|
||||
})
|
||||
|
||||
It("keeps direct play for a source already in the forced format", func() {
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
ok := ci.ForceFormat("flac")
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{
|
||||
Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP},
|
||||
}))
|
||||
})
|
||||
|
||||
It("carries the channel limit of the forced profile into direct play", func() {
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
Expect(ci.ForceFormat("flac")).To(BeTrue())
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2))
|
||||
Expect(ci.DirectPlayProfiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("matches a container-only forced format (mp3)", func() {
|
||||
|
||||
@@ -34,8 +34,8 @@ func imageSize(maxWidth, maxHeight int) int {
|
||||
}
|
||||
|
||||
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
|
||||
// Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials
|
||||
// and item ids are unguessable, so resolution runs elevated to bypass the visibility filter.
|
||||
// Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and
|
||||
// upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user).
|
||||
ctx := request.WithUser(r.Context(), model.User{IsAdmin: true})
|
||||
itemId, ok := itemIDParam(w, r, "itemId")
|
||||
if !ok {
|
||||
|
||||
@@ -280,19 +280,12 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
|
||||
return stream.IsAACCodec(p.Container)
|
||||
})
|
||||
|
||||
player, hasPlayer := request.PlayerFrom(ctx)
|
||||
|
||||
// Honor the player's forced transcoding format, falling back to normal
|
||||
// negotiation when the client can't play it (issue #5583).
|
||||
maxBitRate := 0
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||
if clientInfo.ForceFormat(trc.TargetFormat) {
|
||||
// DirectPlayProfile carries no bitrate, so this ceiling is the only
|
||||
// thing keeping an over-bitrate source out of direct play.
|
||||
maxBitRate = trc.DefaultBitRate
|
||||
} else {
|
||||
if !clientInfo.ForceFormat(trc.TargetFormat) {
|
||||
clientName := clientInfo.Name
|
||||
if hasPlayer && player.Client != "" {
|
||||
if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
|
||||
clientName = player.Client
|
||||
}
|
||||
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
|
||||
@@ -300,13 +293,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// The player's own MaxBitRate outranks the forced-format default (issue #5583).
|
||||
if hasPlayer && player.MaxBitRate > 0 {
|
||||
maxBitRate = player.MaxBitRate
|
||||
}
|
||||
if clientInfo.CapBitrate(maxBitRate) {
|
||||
log.Debug(ctx, "Applied bitrate ceiling to transcode decision",
|
||||
"maxBitRate", maxBitRate, "client", clientInfo.Name)
|
||||
// Apply the player's MaxBitRate as a ceiling on the client's declared
|
||||
// limits (issue #5583). Both fields are capped because the client sends
|
||||
// them independently here; capping only MaxAudioBitrate would let an
|
||||
// independent MaxTranscodingAudioBitrate slip through computeBitrate.
|
||||
if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) {
|
||||
log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision",
|
||||
"playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||
}
|
||||
|
||||
// Get media file
|
||||
|
||||
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
|
||||
mockTD.token = "token"
|
||||
})
|
||||
|
||||
It("forces a supported format and narrows direct play to it", func() {
|
||||
It("forces a supported format and clears direct play", func() {
|
||||
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
|
||||
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
|
||||
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
@@ -380,11 +380,7 @@ var _ = Describe("Transcode endpoints", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
|
||||
Containers: []string{"ogg"},
|
||||
AudioCodecs: []string{"opus"},
|
||||
Protocols: []string{"http"},
|
||||
}))
|
||||
Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("falls back to negotiation when the forced format is unsupported", func() {
|
||||
@@ -420,43 +416,6 @@ var _ = Describe("Transcode endpoints", func() {
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128))
|
||||
})
|
||||
|
||||
withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request {
|
||||
ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate})
|
||||
ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate})
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
It("applies the transcoding default bitrate when the player sets no maxBitRate", func() {
|
||||
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0)
|
||||
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192))
|
||||
Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192))
|
||||
})
|
||||
|
||||
It("prefers the player maxBitRate over the transcoding default bitrate", func() {
|
||||
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320)
|
||||
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320))
|
||||
})
|
||||
|
||||
It("ignores the transcoding default bitrate when the forced format is unsupported", func() {
|
||||
body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
|
||||
r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0)
|
||||
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
useUnselectAll,
|
||||
} from 'react-admin'
|
||||
import { useSelector } from 'react-redux'
|
||||
import SyncIcon from '@material-ui/icons/Sync'
|
||||
import CachedIcon from '@material-ui/icons/Cached'
|
||||
import { GiMagnifyingGlass } from 'react-icons/gi'
|
||||
import { VscSync } from 'react-icons/vsc'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
|
||||
@@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
|
||||
? translate('resources.library.actions.fullScan')
|
||||
: translate('resources.library.actions.quickScan')
|
||||
|
||||
const icon = fullScan ? <CachedIcon /> : <SyncIcon />
|
||||
const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync />
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -598,11 +598,9 @@ const NautilineTheme = {
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumContainer: {
|
||||
link: {
|
||||
borderRadius: radii.md,
|
||||
'& img': {
|
||||
borderRadius: radii.md,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
},
|
||||
albumTitle: {
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -12,3 +12,25 @@ describe('NDPlaylistDetails styles', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('NDAlbumGridView styles', () => {
|
||||
const themeEntries = Object.entries(themes)
|
||||
|
||||
// The hover overlay is a sibling of the image, so it keeps square corners.
|
||||
it.each(themeEntries)(
|
||||
'%s should not round the grid cover image on its own',
|
||||
(themeName, theme) => {
|
||||
const container = theme.overrides?.NDAlbumGridView?.albumContainer
|
||||
expect(container?.['& img']?.borderRadius).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(themeEntries)(
|
||||
'%s should clip the grid cover link when it is rounded',
|
||||
(themeName, theme) => {
|
||||
const link = theme.overrides?.NDAlbumGridView?.link
|
||||
if (!link?.borderRadius) return
|
||||
expect(link.overflow).toBe('hidden')
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user