Compare commits

..
Author SHA1 Message Date
MIguel LopesandDeluan Quintão 02c9816aec build(docker): add curl to container image (#6111) (#6116)
Signed-off-by: Miguel Lopes <miguel.lopes@miguelallopes.dev>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-09 11:38:59 -04:00
Deluan Quintão fe1c87c190 fix(ui): round the album grid hover overlay in the Nautiline theme (#6115)
The theme rounded the cover image directly and set a border radius on
albumContainer, which has no background or clipping, so it rounded
nothing. The hover overlay is a sibling of the image inside the same
link, so it kept square corners that poked out over the rounded cover.

Move the radius to that link and clip it, so both the image and the
overlay follow the same rounded box. This also covers the mobile bar,
which is always visible.

Fixes #6110
2026-09-09 10:52:15 -04:00
Deluan Quintão 043de7a86c docs(jellyfin): correct the rationale for the public image endpoint (#6114)
The comment justified anonymous access with "item ids are unguessable".
That is not true: an artist id is a deterministic, unsalted hash of the
artist name, id.NewHash(id.NewHash(str.Clear(lower(name)))), so it is
computable offline by anyone who knows the name.

The real reason the route is public is that upstream Jellyfin's is too.
ImageController.GetItemImage carries no [Authorize] attribute (verified on
v12.0, master/13.0.0, v10.11.9 and v10.10.7), and an anonymous request
reaches LibraryManager.ItemIsVisible with a null user, which returns true
unconditionally. Clients build cover URLs with no credentials at all, so
requiring auth here would break them.

No behavior change.
2026-09-09 10:42:54 -04:00
Deluan bea9715001 refactor(ui): replace icons in LibraryScanButton with react-icons 2026-09-08 18:51:49 -04:00
15 changed files with 57 additions and 448 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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) {
-35
View File
@@ -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() {
-66
View File
@@ -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))
}
-142
View File
@@ -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 }
-76
View File
@@ -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() {
-1
View File
@@ -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
View File
@@ -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
}
+2 -30
View File
@@ -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() {
+2 -2
View File
@@ -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 {
+9 -16
View File
@@ -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
+2 -43
View 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())
})
})
})
+3 -3
View File
@@ -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
+2 -4
View File
@@ -598,11 +598,9 @@ const NautilineTheme = {
},
},
NDAlbumGridView: {
albumContainer: {
link: {
borderRadius: radii.md,
'& img': {
borderRadius: radii.md,
},
overflow: 'hidden',
},
albumTitle: {
fontWeight: 600,
+22
View File
@@ -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')
},
)
})