Compare commits

..
Author SHA1 Message Date
YorkandDeluan Quintão 2aa7a5c466 fix(ui): prevent Safari album grid resize when top menus open (#6125)
* fix(ui): prevent Safari album grid resize when top menus open

* fix(ui): disable scroll lock for all popovers

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-09-10 20:48:33 -04:00
Deluan Quintão 4067e36a06 Merge pull request #6126 from navidrome/t3code/update-go-dependencies
chore(deps): update direct Go dependencies and taglib fork
2026-09-10 15:08:21 -04:00
Deluan 964d3c778b build(deps): update direct Go dependencies and taglib fork
Bumps all 13 direct dependencies that had newer releases, plus the
go-taglib fork pin. No source changes were needed.

The jwx bump to v3.3.0 carries a security fix (GHSA-4cf7-xm37-g63h):
custom claim, header and JWK names were written unescaped, so a name
containing a quote could inject extra members. Navidrome is not
affected - every claim name we emit is a hardcoded literal - but the
fix is worth taking. cascadia v1.3.5 similarly limits selector nesting
to avoid a stack overflow, and our only selector is a constant.

go-sqlite3 v1.14.52 is the only bump with real behavior change: it
flushes the statement cache on schema changes, steps cached statements
eagerly, and drops the per-row goroutine used for query cancellation.
goose v3.28.0 raises its minimum to Go 1.26 and otherwise only touches
MySQL, ClickHouse and Azure SQL, which we do not use. The golang.org/x
bumps are routine. govulncheck reports no reachable vulnerabilities.

The taglib fork pin picks up two fixes. Audio properties are now
clamped with std::max(0, ...) before the unsigned conversion, so a
malformed file no longer reports a duration of ~49 days; this ports
upstream sentriz/go-taglib 0524e91 and additionally covers
bitsPerSample, which is specific to this fork. Bit depth is also now
reported for DSDIFF, TrueAudio and Shorten, which previously returned
0. Both values reach media_file only on re-extraction, so existing
libraries need a full scan to pick them up.
2026-09-10 15:00:01 -04:00
Deluan Quintão c14b598a01 Merge pull request #6124 from navidrome/fix/login-rate-limit-ip-spoofing
fix(server): key the login rate limit on a trust-aware client IP
2026-09-10 13:29:02 -04:00
Deluan Quintão 25e7b5b20d Merge branch 'master' into fix/login-rate-limit-ip-spoofing 2026-09-10 13:28:00 -04:00
Deluan Quintão bb386b13bf Merge pull request #6105 from navidrome/fix-forceformat-directplay
fix(transcoding): don't re-encode a source already in the forced format, and make piped FLAC seekable
2026-09-10 13:27:08 -04:00
Deluan Quintão 08eb46c8ad Merge branch 'master' into fix-forceformat-directplay 2026-09-10 13:26:45 -04:00
Deluan 055fbde3cf fix(server): key the login rate limit on a trust-aware client IP
The RealIP middleware rewrote RemoteAddr from the True-Client-IP, X-Real-IP
and X-Forwarded-For headers on every request, including when no trusted
reverse proxy was configured. The login rate limiters on /auth/login and the
Jellyfin /Users/AuthenticateByName derived their bucket from that value, so
an unauthenticated client could rotate a forwarding header and get a fresh
bucket for every password attempt, defeating the brute-force protection.

Resolve the client IP with chi's ClientIPFrom* middlewares instead. The
forwarding headers are only honoured when ExtAuth.TrustedSources is set and
the connecting peer is in that list, reusing the trust check that external
authentication already applies; otherwise the peer address is used. The
X-Forwarded-For chain is now walked against the trusted CIDRs rather than
taking its leftmost entry, so a spoofed value prepended by the client is
skipped.

Both limiters now key on the resolved address. The resolved address is still
mirrored into RemoteAddr, so request logging, player registration and the
Jellyfin local-network check keep reporting the client rather than the proxy.

Reported by gehan-psbc.
2026-09-10 08:59:01 -04:00
Deluan Quintão 72975a95fb fix(subsonic): honor DefaultDownloadableShare in createShare (#6121)
* fix(subsonic): honor DefaultDownloadableShare in createShare

The DefaultDownloadableShare option was only sent to the web UI, which used
it to pre-tick the "Allow Downloads?" checkbox. The Subsonic createShare
handler built the model.Share without touching Downloadable, so it fell back
to the Go zero value and every share created through the API was stored as
non-downloadable, regardless of the configured default.

createShare now reads an optional downloadable parameter and falls back to
conf.Server.DefaultDownloadableShare when the client omits it, matching the
web UI. Fixes #6119.

updateShare had a related problem: core's share repository wrapper always
writes the downloadable column, but the handler never set the field, so any
updateShare call silently reset the share to non-downloadable. It now loads
the current share and uses its value as the fallback.

* refactor(subsonic): trim the share downloadable lookup and align with the UI

updateShare fetched the share with Get to recover the stored downloadable
flag, which also runs loadMedia and materializes every album and track the
share points at, just to read one boolean. It now uses Read, which skips
loadMedia, and only queries at all when the client omitted the parameter.

createShare now ANDs the default with EnableDownloads, matching what the web
UI already computes, so both paths apply the same rule.

The specs collapse the create-path matrix into a DescribeTable, reuse the
existing albumIDByName helper, and set the request-time config after
setupTestDB so it does not leak into the config snapshot.

* fix(subsonic): keep the share description on a downloadable-only update

updateShare read the description straight from the request, so a client that
sent only id and downloadable got an empty string written over the stored
description. shareRepositoryWrapper.Update always writes that column, so the
description was silently erased.

This predates the downloadable parameter added earlier in this branch: any
updateShare that omitted description already cleared it. Adding the parameter
just made it easy to hit, since toggling downloads is a natural reason to call
updateShare without touching the description.

Both fields now use the presence-aware accessors and fall back to the stored
share, which still costs at most one read and none when the client sends both.
An explicitly empty description still clears the field.
2026-09-09 20:29:00 -04:00
Deluan Quintão e7b449b805 Merge branch 'master' into fix-forceformat-directplay 2026-09-09 17:38:56 -04:00
Deluan 8d77a49b31 fix(ui): allow setting a transcoding Default Bit Rate of 0
The Default Bit Rate dropdown on the Transcoding create/edit forms was fed
BITRATE_CHOICES, which starts at 32. There was no way to pick 0, and the
SelectInput was not resettable, so an admin could neither create nor restore a
transcoding with no default bit rate, such as the default FLAC one (seeded with
0 in consts.DefaultTranscodings). Editing that row also rendered a blank
dropdown, since its stored value matched no choice.

Adds TRANSCODING_BITRATE_CHOICES, which prepends a 0 entry labelled 'None' to
the shared list. The forms use it as SelectInput choices, and the list and
read-only show view render it through SelectField, so all four screens resolve
the label from the same array and cannot drift. The shared BITRATE_CHOICES is
left untouched, because 0 is not a meaningful option for the player Max. Bit
Rate or the share dialog.

Reported in discussion #6107, where a user had deleted the default
transcodings and could not recreate the FLAC one.
2026-09-09 17:35:03 -04:00
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
Deluan 89026012ab fix(transcoding): make piped FLAC transcodes seekable
The FLAC muxer writes STREAMINFO before it knows the stream length, then
rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout
(-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC
header" and the field stays 0. A decoder needs total_samples to turn a
timestamp into a byte offset, so it reports an unknown duration and refuses to
seek. Online playback hides this because the client re-requests with a new
offset each time, but an offline copy is permanently unseekable, the symptom
reported against Symfonium where seeking a downloaded track jumps back to the
start.

Transcode now wraps its own output and rewrites total_samples as the first
bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that
package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream
only learns a target format and hands back an io.ReadCloser, so compensating
there leaked a transcoder implementation detail one layer up. TranscodeOptions
grows a Duration field alongside the existing Offset, which also puts the
duration-minus-offset arithmetic in the same function that emits -ss.

The wrapper runs on every transcode rather than only FLAC targets: the format
on a transcoding row is a declared target that nothing validates against the
command's actual -f, so a custom command can emit FLAC under any target_format.
The magic-byte check inside the wrapper is the authoritative test and costs a
26-byte peek. The output sample rate is read back out of the header ffmpeg just
wrote rather than taken from the transcode options, so a resampled (-ar) output
still gets the right count. Anything that is not a FLAC stream with an unset
total_samples passes through byte for byte.

Measured on a 177s source: before, total_samples=0 and ffprobe reported
duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio
payload byte-identical. This affects every piped FLAC regardless of the source
format; only FLAC stores an authoritative "unknown", which is why mp3, opus
and aac survive the same pipe.

No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and
decoders binary-search using total_samples alone.
2026-09-07 16:47:42 -04:00
Deluan 404837799b fix(subsonic): don't re-encode a source already in the player's forced format
When a player has a forced transcoding format, ClientInfo.ForceFormat cleared
DirectPlayProfiles unconditionally. A FLAC source on a player configured to
transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth
for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the
resulting FLAC has total_samples=0 and no seek table -- an offline copy of it
can never be seeked. Reported against getTranscodeDecision by the Symfonium
author.

ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding
profiles instead of dropping them: a client declaring a transcoding profile for
a format is proof it can consume that format, so a source already in it is
served as-is. Container and codec come from resolveTargetFormat, so a legacy
"oga" target_format yields an ogg/opus profile, and the profile's
MaxAudioChannels is carried across.

DirectPlayProfile has no bitrate field, so restoring direct play needs a
ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now
seeds that ceiling from the transcoding row's DefaultBitRate when a format was
successfully forced, with the player's own MaxBitRate still taking precedence.
This also closes a gap where the new endpoint ignored DefaultBitRate entirely:
an mp3 320 source on a player forced to mp3@192 was served at 320, while the
legacy /rest/stream path correctly gave 192.

Applied via CapBitrate, which only ever lowers, so a client declaring a
stricter limit keeps it. The legacy path (applyServerOverride) is untouched --
ForceFormat has no other callers.
2026-09-07 14:56:41 -04:00
Deluan 48af781b82 fix(reflex): exclude .worktrees from the reflex configuration regex 2026-09-07 14:43:18 -04:00
69 changed files with 1263 additions and 1633 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; \
+212 -66
View File
@@ -8,7 +8,9 @@ import (
"io"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
@@ -74,7 +76,7 @@ var artworkExplainCmd = &cobra.Command{
Short: "Explain why an item's artwork resolved the way it did",
Long: "Explain why an item's artwork resolved the way it did.\n\n" +
"The item can be given as a bare id, a full artwork id (e.g. al-<id>), or a <kind> <id> pair.\n" +
"<kind> is one of: " + kindPrefixes(artwork.ExplainKinds) + ".\n" +
"<kind> is one of: " + kindPrefixes(explainKinds) + ".\n" +
"A disc artwork id is the album id and the disc number, joined by a colon: <albumID>:2",
Args: cobra.RangeArgs(1, 2),
Run: func(cmd *cobra.Command, args []string) {
@@ -262,7 +264,7 @@ func configState(rep statusReport) string {
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
for _, s := range stats {
fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), artwork.PriorityName(s.Priority), s.Count)
fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count)
}
fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total)
}
@@ -274,14 +276,37 @@ func kindName(prefix string) string {
return prefix
}
type artworkPriority struct {
name string
value int
}
// knownPriorities is the one listing behind both the name and the parse, so they cannot drift.
var knownPriorities = []artworkPriority{
{"bump", model.ArtworkPriorityBump},
{"scan", model.ArtworkPriorityScan},
{"recheck", model.ArtworkPriorityRecheck},
{"backfill", model.ArtworkPriorityBackfill},
}
// priorityName falls back to the number: a row written by a newer version still has to print.
func priorityName(p int) string {
for _, ap := range knownPriorities {
if ap.value == p {
return ap.name
}
}
return strconv.Itoa(p)
}
func priorityNames() string {
return strings.Join(slice.Map(artwork.KnownPriorities, func(ap artwork.Priority) string { return ap.Name }), ", ")
return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ")
}
func parseArtworkPriority(s string) (int, error) {
for _, ap := range artwork.KnownPriorities {
if ap.Name == s {
return ap.Value, nil
for _, ap := range knownPriorities {
if ap.name == s {
return ap.value, nil
}
}
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
@@ -653,6 +678,13 @@ func refreshItems(ctx context.Context, ds model.DataStore, targets []model.Artwo
return failed
}
// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind
// with no chain to walk still has something to answer with.
var explainKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork,
model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
func kindPrefixes(kinds []model.Kind) string {
return strings.Join(model.KindPrefixes(kinds), ", ")
}
@@ -714,15 +746,106 @@ func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (mode
return model.ArtworkID{Kind: kind, ID: arg}, nil
}
// cliUnavailableNote marks agents the CLI cannot construct; a running server loads them all.
const cliUnavailableNote = " (* not available to the CLI)"
// cliAgents words the CLI's own legend for the starred agents FormatAgents reports.
func cliAgents(rep artwork.ExplainReport) string {
if rep.AgentsIncomplete {
return rep.Agents + cliUnavailableNote
// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a
// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it.
func explainAgents(configured string, available []string) string {
if strings.TrimSpace(configured) == "" {
return "(none)"
}
return rep.Agents
var unavailable bool
names := slice.Map(strings.Split(configured, ","), func(name string) string {
name = strings.TrimSpace(name)
if slices.Contains(available, name) {
return name
}
unavailable = true
return name + "*"
})
line := strings.Join(names, ", ")
if unavailable {
line += " (* not available to the CLI)"
}
return line
}
// availableImageAgents names the agents that can actually supply an image for kind.
func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string {
ag := agents.GetAgents(ds, mgr)
if kind == model.KindArtistArtwork {
return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name })
}
return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name })
}
// explainResult states the verdict of the walk. A skipped or failed external tier, or a local
// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork.
func explainResult(source string, steps []artwork.TraceStep) string {
if source != "" {
for _, s := range steps {
if s.Outcome == artwork.OutcomeHit {
break
}
// An external winner discards the earlier error, so the resolver settles it with no retry.
if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) &&
!strings.HasPrefix(source, artwork.ExternalPrefix) {
return "resolved from " + source +
" (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)"
}
}
return "resolved from " + source
}
for _, s := range steps {
switch {
case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix):
return "indeterminate (an external lookup failed; the item may resolve on a later attempt)"
// A stage error or an unreadable candidate means a source was found but not processed; the
// worker retries rather than settling absent, so neither reads as a clean miss.
case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable:
return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)"
}
}
return "not resolved"
}
// explainConfig names the setting that decides where a kind's artwork comes from, and its value.
func explainConfig(kind model.Kind) (name, value string) {
switch kind {
case model.KindArtistArtwork:
return "ArtistArtPriority", conf.Server.ArtistArtPriority
case model.KindAlbumArtwork:
return "CoverArtPriority", conf.Server.CoverArtPriority
case model.KindDiscArtwork:
return "DiscArtPriority", conf.Server.DiscArtPriority
case model.KindMediaFileArtwork:
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt)
}
return "", ""
}
type explainReport struct {
kind model.Kind
id string
name string
stored *model.ItemArtwork
queued *model.ArtworkQueueItem
agents string
// steps is the chain walk: recorded when the item was resolved, or performed just now when walked.
steps []artwork.TraceStep
source string
walked bool
resolveErr error
}
// explainChainOrigin says whether the operator is reading history or a walk performed just now,
// since the two can disagree after a config change.
func explainChainOrigin(rep explainReport) string {
if rep.walked {
return "walked now"
}
if rep.stored != nil {
return "recorded " + formatTime(rep.stored.AttemptedAt)
}
return "not recorded"
}
// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and
@@ -743,100 +866,107 @@ func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) {
writeSteps(w, " ", steps)
}
func formatExplain(rep artwork.ExplainReport) string {
func formatExplain(rep explainReport) string {
var sb strings.Builder
w := newTabWriter(&sb)
explainable := artwork.Explainable(rep.Kind)
stateful := artwork.KeepsState(rep.Kind)
unrecorded := !rep.Walked && rep.Stored == nil
explainable := artwork.Explainable(rep.kind)
stateful := artwork.KeepsState(rep.kind)
unrecorded := !rep.walked && rep.stored == nil
fmt.Fprintln(w, "Item")
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.Kind, rep.Kind.Prefix())
fmt.Fprintf(w, " ID:\t%s\n", rep.ID)
fmt.Fprintf(w, " Name:\t%s\n", rep.Name)
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix())
fmt.Fprintf(w, " ID:\t%s\n", rep.id)
fmt.Fprintf(w, " Name:\t%s\n", rep.name)
fmt.Fprintln(w, "\nStored")
switch {
case !stateful:
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.Kind)
case rep.Stored == nil:
fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind)
case rep.stored == nil:
fmt.Fprintln(w, " (no artwork state recorded)")
default:
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.Stored.Source))
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.Stored.Hash, "(absent)"))
if rep.Stored.SourcePath != "" {
fmt.Fprintf(w, " Source path:\t%s\n", rep.Stored.SourcePath)
fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source))
fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)"))
if rep.stored.SourcePath != "" {
fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath)
}
fmt.Fprintf(w, " Attempted at:\t%s\n", artwork.FormatTime(rep.Stored.AttemptedAt))
fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt))
}
fmt.Fprintln(w, "\nQueue")
switch {
case !stateful:
fmt.Fprintln(w, " (never queued)")
case rep.Queued == nil:
case rep.queued == nil:
fmt.Fprintln(w, " (not queued)")
default:
fmt.Fprintf(w, " Priority:\t%s (%d)\n", artwork.PriorityName(rep.Queued.Priority), rep.Queued.Priority)
fmt.Fprintf(w, " Attempts:\t%d\n", rep.Queued.Attempts)
fmt.Fprintf(w, " Retry at:\t%s\n", artwork.FormatTime(rep.Queued.RetryAt))
fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority)
fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts)
fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt))
}
if rep.Queued != nil {
writeStepTable(w, "Last attempt failed", rep.LastAttemptFailed())
if rep.queued != nil {
writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, ""))
}
if rep.Stored != nil {
writeStepTable(w, "Gave up after", rep.GaveUpAfter())
if rep.stored != nil {
writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, ""))
}
fmt.Fprintln(w, "\nConfig")
if setting, value := artwork.ConfigFor(rep.Kind); setting == "" {
if setting, value := explainConfig(rep.kind); setting == "" {
fmt.Fprintln(w, " (no artwork source configuration applies)")
} else {
fmt.Fprintf(w, " %s:\t%s\n", setting, value)
if rep.Agents != "" {
fmt.Fprintf(w, " Agents:\t%s\n", cliAgents(rep))
if rep.agents != "" {
fmt.Fprintf(w, " Agents:\t%s\n", rep.agents)
}
}
fmt.Fprintf(w, "\nChain (%s)\n", rep.ChainOrigin())
fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep))
switch {
case !explainable:
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.Kind)
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind)
case unrecorded:
fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)")
case !rep.Walked && len(rep.Steps) == 0 && rep.Stored.Hash != "":
case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "":
// A stored image with no chain can only predate trace recording: a recorded resolution that
// found an image always records its winning candidate.
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
case !rep.Walked && len(rep.Steps) == 0:
case !rep.walked && len(rep.steps) == 0:
// Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row.
fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)")
default:
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
writeSteps(w, " ", rep.Steps)
writeSteps(w, " ", rep.steps)
}
fmt.Fprintln(w, "\nResult")
switch {
case rep.ResolveErr != nil:
fmt.Fprintf(w, " resolution failed: %s\n", rep.ResolveErr)
case rep.resolveErr != nil:
fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr)
case !explainable:
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
case unrecorded:
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
default:
fmt.Fprintf(w, " %s\n", rep.Result())
fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps))
}
w.Flush()
return sb.String()
}
func formatTime(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format(time.RFC3339)
}
func runExplain(ctx context.Context, args []string) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.ExplainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds)
if err != nil {
log.Fatal(ctx, err)
}
@@ -848,29 +978,45 @@ func runExplain(ctx context.Context, args []string) {
}
kind, id := targets[0].Kind, targets[0].ID
var opts artwork.ExplainOptions
// Only artist and album reach an agent, and the load must precede the resolver, which reads the
// same manager. Leaving ag nil elsewhere avoids handing agents.GetAgents a not-yet-loaded manager.
var ag *agents.Agents
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
mgr := loadPluginAgents(ctx, explainLive)
defer func() { _ = mgr.Stop() }()
ag = agents.GetAgents(ds, mgr)
name, err := artwork.ItemName(ctx, ds, kind, id)
if err != nil {
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
}
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
if explainLive || !artwork.KeepsState(kind) {
opts.Walk = func(t *artwork.ChainTrace) *artwork.TracingResolver {
return CreateArtworkResolver(t, explainLive)
rep := explainReport{kind: kind, id: id, name: name}
if artwork.KeepsState(kind) {
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
}
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
}
}
rep, err := artwork.Explain(ctx, ds, ag, kind, id, opts)
if err != nil {
log.Fatal(ctx, "Failed to explain artwork", "kind", kind, "id", id, err)
}
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
rep.walked = explainLive || !artwork.KeepsState(kind)
if artwork.Explainable(kind) {
// Only artist and album reach an agent, and the load must precede the resolver, which reads
// the same manager.
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
mgr := loadPluginAgents(ctx, explainLive)
defer func() { _ = mgr.Stop() }()
rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind))
}
switch {
case rep.walked:
trace := &artwork.ChainTrace{}
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
rep.steps = trace.Steps()
case rep.stored != nil:
rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath)
rep.source = rep.stored.Source
}
}
fmt.Print(formatExplain(rep))
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
if rep.ResolveErr != nil {
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.ResolveErr)
if rep.resolveErr != nil {
log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr)
}
}
+165 -56
View File
@@ -41,8 +41,8 @@ var _ = Describe("parseArtworkKind", func() {
_, err := parseArtworkKind(prefix, valid)
Expect(err).ToNot(HaveOccurred())
},
Entry("explain reads disc artwork", "dc", artwork.ExplainKinds),
Entry("explain reads media file artwork", "mf", artwork.ExplainKinds),
Entry("explain reads disc artwork", "dc", explainKinds),
Entry("explain reads media file artwork", "mf", explainKinds),
// Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not
// accept it: the queue row would be rejected on every drain.
Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds),
@@ -65,7 +65,7 @@ var _ = Describe("resolveArtworkTargets", func() {
})
It("accepts the explicit <kind> <id> leader shared by every id", func() {
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, artwork.ExplainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(failures).To(BeEmpty())
Expect(targets).To(Equal([]model.ArtworkID{
@@ -78,20 +78,20 @@ var _ = Describe("resolveArtworkTargets", func() {
})
It("resolves a bare id by looking it up across tables", func() {
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, artwork.ExplainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(failures).To(BeEmpty())
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}}))
})
It("reads the kind from a full artwork id prefix without a database lookup", func() {
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, artwork.ExplainKinds)
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
})
It("strips the hash suffix from a full artwork id", func() {
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, artwork.ExplainKinds)
targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
})
@@ -105,7 +105,7 @@ var _ = Describe("resolveArtworkTargets", func() {
})
It("collects an id that matches nothing and has no kind prefix", func() {
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, artwork.ExplainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(BeEmpty())
Expect(failures).To(HaveLen(1))
@@ -113,7 +113,7 @@ var _ = Describe("resolveArtworkTargets", func() {
})
It("resolves the valid ids and collects the unresolvable ones", func() {
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, artwork.ExplainKinds)
targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds)
Expect(err).ToNot(HaveOccurred())
Expect(targets).To(Equal([]model.ArtworkID{
{Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}}))
@@ -122,36 +122,126 @@ var _ = Describe("resolveArtworkTargets", func() {
})
})
var _ = Describe("explainResult", func() {
It("reports the winning source", func() {
steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}}
Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder"))
})
It("reports not resolved when every candidate was tried and missed", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "miss"},
{Candidate: "external:deezer", Outcome: "miss"},
}
Expect(explainResult("", steps)).To(Equal("not resolved"))
})
It("reports indeterminate when a local candidate exists but could not be read", func() {
steps := []artwork.TraceStep{
{Candidate: "cover.*", Outcome: "miss"},
{Candidate: "embedded", Outcome: "unreadable"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss")
})
It("reports indeterminate when a processing stage errored after a candidate was found", func() {
steps := []artwork.TraceStep{
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
{Candidate: "store", Outcome: "error", Detail: "disk full"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"a stage error is a processing failure the worker retries, not a definitive miss")
})
It("does not qualify a hit that an earlier unreadable candidate preceded", func() {
// chainState.try stamps only the external error onto a hit and drops the local one, so the
// worker settles this as found; warning about it would be a false alarm.
steps := []artwork.TraceStep{
{Candidate: "embedded", Outcome: "unreadable"},
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
}
Expect(explainResult("folder", steps)).To(Equal("resolved from folder"))
})
It("reports indeterminate when an external lookup failed transiently", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "miss"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"a failed network call is not evidence that the item has no artwork")
})
It("qualifies a win a failed higher-priority external lookup could have taken", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
}
res := explainResult("artist.*", steps)
Expect(res).To(ContainSubstring("resolved from artist.*"))
Expect(res).To(ContainSubstring("indeterminate"),
"the resolver serves this hit but retries later, so the winner is provisional")
})
It("does not qualify an external win that followed a failed external lookup", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
{Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"},
}
Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"),
"a later agent supplying the image discards the earlier error, so there is no retry to warn about")
})
It("does not qualify a win that outranked the failed external lookup", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "hit"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
}
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
})
})
var _ = Describe("explainAgents", func() {
It("accounts for every configured agent, marking the ones the CLI could not use", func() {
out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"})
for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} {
Expect(out).To(ContainSubstring(name),
"a configured agent missing from this line reads as if it had never been configured")
}
Expect(out).To(ContainSubstring("not available to the CLI"))
})
It("does not mark anything when every configured agent is available", func() {
out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"})
Expect(out).To(Equal("deezer, lastfm"))
})
It("reports an empty configuration as none, not as an unavailable agent", func() {
Expect(explainAgents("", nil)).To(Equal("(none)"))
})
})
var _ = Describe("formatExplain", func() {
var rep artwork.ExplainReport
var rep explainReport
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external, artist.*"
rep = artwork.ExplainReport{
Kind: model.KindArtistArtwork,
ID: "ar-1",
Name: "Radiohead",
Agents: "lastfm,spotify",
Walked: true,
Steps: []artwork.TraceStep{
rep = explainReport{
kind: model.KindArtistArtwork,
id: "ar-1",
name: "Radiohead",
agents: "lastfm,spotify",
walked: true,
steps: []artwork.TraceStep{
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
},
Source: "",
source: "",
}
})
It("explains the star on an agent the CLI could not construct", func() {
rep.AgentsIncomplete = true
Expect(formatExplain(rep)).To(ContainSubstring("(* not available to the CLI)"))
})
It("leaves the agent line unadorned when every agent is available", func() {
Expect(formatExplain(rep)).ToNot(ContainSubstring("not available to the CLI"))
})
It("reports the item, its config and the chain it walked", func() {
out := formatExplain(rep)
Expect(out).To(ContainSubstring("Radiohead"))
@@ -170,11 +260,11 @@ var _ = Describe("formatExplain", func() {
It("prints the stored state and the queue row when they exist", func() {
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc123",
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123",
SourcePath: "/music/cover.jpg", AttemptedAt: attempted}
rep.Queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
RetryAt: attempted.Add(time.Hour)}
rep.Source = "folder"
rep.source = "folder"
out := formatExplain(rep)
Expect(out).To(ContainSubstring("abc123"))
@@ -185,12 +275,12 @@ var _ = Describe("formatExplain", func() {
})
It("marks a known-absent stored state instead of printing an empty hash", func() {
rep.Stored = &model.ItemArtwork{AttemptedAt: time.Now()}
rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()}
Expect(formatExplain(rep)).To(ContainSubstring("absent"))
})
It("reports a failed walk as failed, not as unresolved", func() {
rep.ResolveErr = errors.New("no such directory")
rep.resolveErr = errors.New("no such directory")
out := formatExplain(rep)
Expect(out).To(ContainSubstring("resolution failed: no such directory"))
@@ -200,9 +290,9 @@ var _ = Describe("formatExplain", func() {
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
conf.Server.CoverArtPriority = "cover.*, embedded"
rep.Kind = model.KindPlaylistArtwork
rep.Steps = nil
rep.Agents = ""
rep.kind = model.KindPlaylistArtwork
rep.steps = nil
rep.agents = ""
out := formatExplain(rep)
Expect(out).To(ContainSubstring("does not walk a priority chain"))
@@ -216,11 +306,11 @@ var _ = Describe("formatExplain", func() {
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
conf.Server.DiscArtPriority = "cover.jpg, embedded"
rep = artwork.ExplainReport{
Kind: model.KindDiscArtwork, ID: "al-1:2", Name: "OK Computer (disc 2)",
Steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
Source: "folder",
Walked: true,
rep = explainReport{
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
source: "folder",
walked: true,
}
out := formatExplain(rep)
@@ -235,15 +325,15 @@ var _ = Describe("formatExplain", func() {
Context("stored traces", func() {
BeforeEach(func() {
rep.Walked = false
rep.Steps = nil
rep.walked = false
rep.steps = nil
})
It("labels a recorded chain with when it was recorded, not as a walk done now", func() {
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
rep.Steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
rep.Source = "folder"
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
rep.source = "folder"
out := formatExplain(rep)
Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)"))
@@ -260,7 +350,7 @@ var _ = Describe("formatExplain", func() {
})
It("distinguishes a row written before traces existed from one with an empty chain", func() {
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()}
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()}
Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded"))
})
@@ -268,7 +358,7 @@ var _ = Describe("formatExplain", func() {
It("does not call an absent row with an empty recorded chain a pre-tracing row", func() {
// An empty priority list records a real but empty chain and resolves absent; that is not a
// legacy row, so it must not be reported as resolved before tracing existed.
rep.Stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
out := formatExplain(rep)
Expect(out).ToNot(ContainSubstring("resolved before traces were recorded"))
@@ -277,9 +367,9 @@ var _ = Describe("formatExplain", func() {
})
It("prints why the last attempt failed and why it gave up", func() {
rep.Queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3,
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3,
Trace: `[{"c":"decode","o":"error","d":"bad header"}]`}
rep.Stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(),
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(),
LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`}
out := formatExplain(rep)
@@ -298,10 +388,10 @@ var _ = Describe("formatExplain", func() {
It("reports the setting that governs media file artwork", func() {
conf.Server.EnableMediaFileCoverArt = false
rep = artwork.ExplainReport{
Kind: model.KindMediaFileArtwork, ID: "mf-1", Name: "Airbag",
Walked: true,
Steps: []artwork.TraceStep{
rep = explainReport{
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
walked: true,
steps: []artwork.TraceStep{
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
},
}
@@ -314,6 +404,25 @@ var _ = Describe("formatExplain", func() {
})
})
var _ = Describe("explainConfig", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DiscArtPriority = "cover.jpg"
conf.Server.EnableMediaFileCoverArt = true
})
DescribeTable("names the setting that decides where a kind's artwork comes from",
func(kind model.Kind, setting, value string) {
gotSetting, gotValue := explainConfig(kind)
Expect(gotSetting).To(Equal(setting))
Expect(gotValue).To(Equal(value))
},
Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"),
Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"),
Entry("playlist has none", model.KindPlaylistArtwork, "", ""),
)
})
var _ = Describe("artwork refresh command", func() {
It("requires at least one argument", func() {
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred())
@@ -386,8 +495,8 @@ var _ = Describe("explain/reprocess source round trip", func() {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
shown := storedSource(formatExplain(artwork.ExplainReport{Kind: model.KindArtistArtwork, ID: "ar-1",
Stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
q := ds.ArtworkQueue(ctx)
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
@@ -950,7 +1059,7 @@ var _ = Describe("parseArtworkPriority", func() {
It("accepts every name status prints", func() {
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
Expect(parseArtworkPriority(artwork.PriorityName(p))).To(Equal(p))
Expect(parseArtworkPriority(priorityName(p))).To(Equal(p))
}
})
+1 -1
View File
@@ -79,7 +79,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
agentsAgents := agents.GetAgents(dataStore, manager)
matcherMatcher := matcher.New(dataStore)
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, agentsAgents)
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
return router
}
-215
View File
@@ -1,215 +0,0 @@
package artwork
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
)
// Result states the verdict of the walk. A skipped or failed external tier, or a local candidate
// that would not open, leaves the outcome unknown: nothing observed that there is no artwork.
func Result(source string, steps []TraceStep) string {
if source != "" {
for _, s := range steps {
if s.Outcome == OutcomeHit {
break
}
// An external winner discards the earlier error, so the resolver settles it with no retry.
if s.Outcome == OutcomeError && strings.HasPrefix(s.Candidate, ExternalPrefix) &&
!strings.HasPrefix(source, ExternalPrefix) {
return "resolved from " + source +
" (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)"
}
}
return "resolved from " + source
}
for _, s := range steps {
switch {
case s.Outcome == OutcomeError && strings.HasPrefix(s.Candidate, ExternalPrefix):
return "indeterminate (an external lookup failed; the item may resolve on a later attempt)"
case s.Outcome == OutcomeError, s.Outcome == OutcomeUnreadable:
return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)"
}
}
return "not resolved"
}
// ConfigFor names the setting that decides where a kind's artwork comes from, and its value.
func ConfigFor(kind model.Kind) (setting, value string) {
switch kind {
case model.KindArtistArtwork:
return "ArtistArtPriority", conf.Server.ArtistArtPriority
case model.KindAlbumArtwork:
return "CoverArtPriority", conf.Server.CoverArtPriority
case model.KindDiscArtwork:
return "DiscArtPriority", conf.Server.DiscArtPriority
case model.KindMediaFileArtwork:
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt)
}
return "", ""
}
// ImageAgentNames names the agents that can actually supply an image for kind.
func ImageAgentNames(ag *agents.Agents, kind model.Kind) []string {
if kind == model.KindArtistArtwork {
return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name })
}
return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name })
}
// FormatAgents accounts for every configured agent: one that cannot be constructed never reaches
// the chain, so the raw list alone overstates it. Those are starred, and the bool lets each
// caller word its own legend.
func FormatAgents(configured string, available []string) (string, bool) {
if strings.TrimSpace(configured) == "" {
return "(none)", false
}
var unavailable bool
names := slice.Map(strings.Split(configured, ","), func(name string) string {
name = strings.TrimSpace(name)
if slices.Contains(available, name) {
return name
}
unavailable = true
return name + "*"
})
return strings.Join(names, ", "), unavailable
}
// ExplainOptions configures a single explain. The zero value reads history and never
// touches the network.
type ExplainOptions struct {
// Walk builds a resolver that records into the trace; nil reads the recorded trace instead.
Walk func(*ChainTrace) *TracingResolver
}
// ExplainReport is everything known about how one item's artwork resolved.
type ExplainReport struct {
Kind model.Kind
ID string
Name string
Stored *model.ItemArtwork
Queued *model.ArtworkQueueItem
Steps []TraceStep
Source string
Agents string
// AgentsIncomplete reports that some configured agent is starred in Agents.
AgentsIncomplete bool
Walked bool
ResolveErr error
}
// Explain gathers everything known about how kind/id's artwork resolved: stored state, the
// queue row, and either the recorded trace or a fresh walk, depending on opts.Walk.
func Explain(ctx context.Context, ds model.DataStore, ag *agents.Agents, kind model.Kind, id string,
opts ExplainOptions) (ExplainReport, error) {
name, err := ItemName(ctx, ds, kind, id)
if err != nil {
return ExplainReport{}, err
}
rep := ExplainReport{Kind: kind, ID: id, Name: name}
if KeepsState(kind) {
rep.Stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return ExplainReport{}, fmt.Errorf("reading artwork state: %w", err)
}
rep.Queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return ExplainReport{}, fmt.Errorf("reading the artwork queue: %w", err)
}
}
// Set even for a kind the chain never walks, so a caller that asked to walk (e.g. --live on a
// playlist) is reported as having tried.
rep.Walked = opts.Walk != nil
if !Explainable(kind) {
return rep, nil
}
if ag != nil && (kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork) {
rep.Agents, rep.AgentsIncomplete = FormatAgents(conf.Server.Agents, ImageAgentNames(ag, kind))
}
switch {
case rep.Walked:
trace := &ChainTrace{}
rep.Source, rep.ResolveErr = opts.Walk(trace).Resolve(ctx, kind, id)
rep.Steps = trace.Steps()
case rep.Stored != nil:
rep.Steps = DecodeTrace(rep.Stored.Trace, rep.Stored.SourcePath)
rep.Source = rep.Stored.Source
}
return rep, nil
}
// Result reports this report's verdict; see the package-level Result for the rules.
func (r ExplainReport) Result() string { return Result(r.Source, r.Steps) }
// ChainOrigin says whether the report reads history or a walk performed just now, since the two
// can disagree after a config change.
func (r ExplainReport) ChainOrigin() string {
if r.Walked {
return "walked now"
}
if r.Stored != nil {
return "recorded " + FormatTime(r.Stored.AttemptedAt)
}
return "not recorded"
}
// FormatTime renders a timestamp for the report: a zero time reads as unset, not as year 1.
func FormatTime(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Format(time.RFC3339)
}
// LastAttemptFailed decodes why the queued row's last attempt failed, if there is one queued.
func (r ExplainReport) LastAttemptFailed() []TraceStep {
if r.Queued == nil {
return nil
}
return DecodeTrace(r.Queued.Trace, "")
}
// GaveUpAfter decodes the trace of the attempt that exhausted the retry budget, if the stored
// state recorded one.
func (r ExplainReport) GaveUpAfter() []TraceStep {
if r.Stored == nil {
return nil
}
return DecodeTrace(r.Stored.LastFailure, "")
}
// Priority names one of the queue's fixed priority levels.
type Priority struct {
Name string
Value int
}
// KnownPriorities is the one listing behind both the name and the parse, so they cannot drift.
var KnownPriorities = []Priority{
{"bump", model.ArtworkPriorityBump},
{"scan", model.ArtworkPriorityScan},
{"recheck", model.ArtworkPriorityRecheck},
{"backfill", model.ArtworkPriorityBackfill},
}
// PriorityName falls back to the number: a row written by a newer version still has to print.
func PriorityName(p int) string {
for _, ap := range KnownPriorities {
if ap.Value == p {
return ap.Name
}
}
return strconv.Itoa(p)
}
-213
View File
@@ -1,213 +0,0 @@
package artwork_test
import (
"context"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Result", func() {
It("reports the source when one was found", func() {
steps := []artwork.TraceStep{{Candidate: "cover.*", Outcome: artwork.OutcomeHit}}
Expect(artwork.Result("folder", steps)).To(Equal("resolved from folder"))
})
It("flags a local win that a failed external lookup could have outranked", func() {
steps := []artwork.TraceStep{
{Candidate: artwork.ExternalPrefix + "deezer", Outcome: artwork.OutcomeError, Detail: "timeout"},
{Candidate: "cover.*", Outcome: artwork.OutcomeHit},
}
Expect(artwork.Result("folder", steps)).To(ContainSubstring("indeterminate"))
})
It("does not flag an external winner", func() {
steps := []artwork.TraceStep{
{Candidate: artwork.ExternalPrefix + "lastfm", Outcome: artwork.OutcomeError},
{Candidate: artwork.ExternalPrefix + "deezer", Outcome: artwork.OutcomeHit},
}
Expect(artwork.Result(artwork.ExternalPrefix+"deezer", steps)).
To(Equal("resolved from external:deezer"))
})
It("is indeterminate when an external lookup failed and nothing resolved", func() {
steps := []artwork.TraceStep{{Candidate: artwork.ExternalPrefix + "deezer", Outcome: artwork.OutcomeError}}
Expect(artwork.Result("", steps)).To(ContainSubstring("an external lookup failed"))
})
It("is indeterminate when a candidate could not be processed", func() {
steps := []artwork.TraceStep{{Candidate: "cover.*", Outcome: artwork.OutcomeUnreadable}}
Expect(artwork.Result("", steps)).To(ContainSubstring("could not be processed"))
})
It("reports a clean miss", func() {
steps := []artwork.TraceStep{{Candidate: "cover.*", Outcome: artwork.OutcomeMiss}}
Expect(artwork.Result("", steps)).To(Equal("not resolved"))
})
})
var _ = Describe("ConfigFor", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external"
})
It("names the setting for a kind that has one", func() {
setting, value := artwork.ConfigFor(model.KindArtistArtwork)
Expect(setting).To(Equal("ArtistArtPriority"))
Expect(value).To(Equal("external"))
})
It("names the setting for the album kind", func() {
conf.Server.CoverArtPriority = "cover.*, embedded"
setting, value := artwork.ConfigFor(model.KindAlbumArtwork)
Expect(setting).To(Equal("CoverArtPriority"))
Expect(value).To(Equal("cover.*, embedded"))
})
It("returns nothing for a kind with no source configuration", func() {
setting, _ := artwork.ConfigFor(model.KindPlaylistArtwork)
Expect(setting).To(BeEmpty())
})
})
var _ = Describe("FormatAgents", func() {
It("reports none when nothing is configured", func() {
line, incomplete := artwork.FormatAgents(" ", nil)
Expect(line).To(Equal("(none)"))
Expect(incomplete).To(BeFalse())
})
It("keeps the configured order and marks what is unavailable", func() {
got, incomplete := artwork.FormatAgents("spotify, lastfm", []string{"lastfm"})
Expect(incomplete).To(BeTrue())
Expect(got).To(Equal("spotify*, lastfm"))
})
It("reports complete when every configured agent is available", func() {
line, incomplete := artwork.FormatAgents("lastfm", []string{"lastfm"})
Expect(line).To(Equal("lastfm"))
Expect(incomplete).To(BeFalse())
})
})
var _ = Describe("PriorityName", func() {
It("names a known priority", func() {
Expect(artwork.PriorityName(model.ArtworkPriorityScan)).To(Equal("scan"))
})
It("falls back to the number for an unknown priority", func() {
Expect(artwork.PriorityName(999)).To(Equal("999"))
})
})
var _ = Describe("Explain", func() {
var ds *tests.MockDataStore
var artRepo *tests.MockArtworkRepo
var queueRepo *tests.MockArtworkQueueRepo
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo}
Expect(ds.Artist(ctx).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
})
It("returns an error when the item does not exist", func() {
_, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "nope", artwork.ExplainOptions{})
Expect(err).To(MatchError(model.ErrNotFound))
})
It("reads the recorded trace when no walker is supplied", func() {
// Storage shape mirrors storedStep in trace.go: c=candidate, o=outcome, d=detail.
trace := `[{"c":"external:deezer","o":"hit","d":"https://cdn/x.jpg"}]`
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Hash: "abc", Source: "external:deezer",
Trace: trace,
AttemptedAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC),
})).To(Succeed())
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", artwork.ExplainOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(rep.Name).To(Equal("Radiohead"))
Expect(rep.Walked).To(BeFalse())
Expect(rep.Steps).To(Equal([]artwork.TraceStep{
{Candidate: "external:deezer", Outcome: artwork.OutcomeHit, Detail: "https://cdn/x.jpg"},
}))
Expect(rep.Source).To(Equal("external:deezer"))
Expect(rep.Result()).To(Equal("resolved from external:deezer"))
Expect(rep.ChainOrigin()).To(ContainSubstring("recorded"))
})
It("renders a zero attempted-at as unset, not as year 1", func() {
rep := artwork.ExplainReport{Stored: &model.ItemArtwork{Source: "folder"}}
Expect(rep.ChainOrigin()).To(Equal("recorded -"))
})
It("reports nothing recorded when there is no stored state", func() {
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", artwork.ExplainOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(rep.Stored).To(BeNil())
Expect(rep.Steps).To(BeEmpty())
Expect(rep.ChainOrigin()).To(Equal("not recorded"))
})
It("includes the queue row and its failure trace", func() {
failure := `[{"c":"external:lastfm","o":"error","d":"429"}]`
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
})).To(Succeed())
queueRepo.SetTrace(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary, failure)
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", artwork.ExplainOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(rep.Queued).ToNot(BeNil())
Expect(rep.Queued.Priority).To(Equal(model.ArtworkPriorityScan))
Expect(rep.LastAttemptFailed()).To(Equal([]artwork.TraceStep{
{Candidate: "external:lastfm", Outcome: artwork.OutcomeError, Detail: "429"},
}))
})
It("records nothing for a kind that keeps no state and has no walker", func() {
Expect(ds.Album(ctx).Put(&model.Album{ID: "al-1", Name: "OK Computer"})).To(Succeed())
rep, err := artwork.Explain(ctx, ds, nil, model.KindDiscArtwork, "al-1:2", artwork.ExplainOptions{})
Expect(err).ToNot(HaveOccurred())
Expect(rep.Stored).To(BeNil())
Expect(rep.Steps).To(BeEmpty())
})
It("performs a fresh walk and reports its outcome, including a failed one, when a walker is supplied", func() {
// GetAll erroring mid-walk is a deterministic way to force ResolveErr without a real library.
ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true)
opts := artwork.ExplainOptions{Walk: func(t *artwork.ChainTrace) *artwork.TracingResolver {
return artwork.NewTracingResolver(ds, nil, nil, t, false)
}}
rep, err := artwork.Explain(ctx, ds, nil, model.KindArtistArtwork, "ar-1", opts)
Expect(err).ToNot(HaveOccurred())
Expect(rep.Walked).To(BeTrue())
Expect(rep.ResolveErr).To(HaveOccurred())
})
It("still reports Walked for a kind with no chain to walk, when a walker is supplied", func() {
Expect(ds.Playlist(ctx).Put(&model.Playlist{ID: "pl-1", Name: "Favorites"})).To(Succeed())
opts := artwork.ExplainOptions{Walk: func(*artwork.ChainTrace) *artwork.TracingResolver {
panic("a kind that cannot walk must never be handed to the walker")
}}
rep, err := artwork.Explain(ctx, ds, nil, model.KindPlaylistArtwork, "pl-1", opts)
Expect(err).ToNot(HaveOccurred())
Expect(rep.Walked).To(BeTrue())
})
})
-7
View File
@@ -25,13 +25,6 @@ var ReprocessKinds = []model.Kind{
// artwork is read through on every request and cached by content key, so it has neither.
func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork }
// ExplainKinds is every kind Explain accepts: it reports stored state and config too, so a kind
// with no chain to walk still has something to answer with.
var ExplainKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork,
model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker
// resolves them, it just never enumerates them in bulk.
+11 -6
View File
@@ -27,11 +27,12 @@ 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
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.
}
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
@@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
} else {
args = buildTemplateArgs(opts)
}
return e.start(ctx, args)
out, err := e.start(ctx, args)
if err != nil {
return nil, err
}
return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
}
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
+35
View File
@@ -3,6 +3,7 @@ package ffmpeg
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
@@ -684,6 +685,40 @@ 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
@@ -0,0 +1,66 @@
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
@@ -0,0 +1,142 @@
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,6 +1144,82 @@ 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,6 +268,7 @@ func NewTranscodingCache() TranscodingCache {
BitDepth: job.bitDepth,
Channels: job.channels,
Offset: job.offset,
Duration: job.mf.Duration,
})
if err != nil {
release()
+18 -8
View File
@@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
return changed
}
// 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.
// 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.
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.
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
matched = append(matched, ci.TranscodingProfiles[i])
container, format := resolveTargetFormat(p)
if !matchesContainer(format, []string{targetFormat}) {
continue
}
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 = nil
ci.DirectPlayProfiles = directPlay
return true
}
+30 -2
View File
@@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() {
})
Describe("ForceFormat", func() {
It("restricts to the forced format and clears direct play when supported", func() {
It("restricts direct play to the forced format when supported", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
@@ -71,7 +71,35 @@ 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(BeEmpty())
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))
})
It("matches a container-only forced format (mp3)", func() {
+17 -17
View File
@@ -3,11 +3,11 @@ module github.com/navidrome/navidrome
go 1.27
// Fork to implement raw tags support
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec
require (
github.com/Masterminds/squirrel v1.5.4
github.com/andybalholm/cascadia v1.3.4
github.com/andybalholm/cascadia v1.3.5
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -26,7 +26,7 @@ require (
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-viper/encoding/ini v0.1.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/gohugoio/hashstructure v1.0.0
github.com/gohugoio/hashstructure v1.1.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
@@ -35,16 +35,16 @@ require (
github.com/jellydator/ttlcache/v3 v3.4.1
github.com/kardianos/service v1.3.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v3 v3.2.0
github.com/mattn/go-sqlite3 v1.14.50
github.com/lestrrat-go/jwx/v3 v3.3.0
github.com/mattn/go-sqlite3 v1.14.52
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.32.1
github.com/onsi/ginkgo/v2 v2.32.2
github.com/onsi/gomega v1.43.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pmezard/go-difflib v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.27.3
github.com/pressly/goose/v3 v3.28.0
github.com/prometheus/client_golang v1.24.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
@@ -60,13 +60,13 @@ require (
github.com/zeebo/xxh3 v1.1.0
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.45.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
golang.org/x/time v0.16.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -114,7 +114,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanity-io/litter v1.5.8 // indirect
@@ -131,8 +131,8 @@ require (
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/mod v0.40.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
+44 -44
View File
@@ -6,8 +6,8 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70=
github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec h1:3VyOFsbsRtCQqdq/+fcmD3D6zlRvKSG7RCixgrdWfEo=
github.com/deluan/go-taglib v0.0.0-20260910183509-2ca9506dd7ec/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -94,8 +94,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gohugoio/hashstructure v1.0.0 h1:vWYuyzs1n0LdI0F54TJQeYAiB44fHX7H9hCp9X6gHKg=
github.com/gohugoio/hashstructure v1.0.0/go.mod h1:FSbTK4QwxucJ2bC4Lvrs9a6x0DbQDXNoyBO+h4nlCgE=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -134,8 +134,8 @@ github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFd
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -159,16 +159,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.2.0 h1:Jb3zBASTSZXz7gzzSAfYqxXF8KejvKC4xWoePLQqXCA=
github.com/lestrrat-go/jwx/v3 v3.2.0/go.mod h1:38vQ8iWKq3qRSbilbzvzdQPuywhowwuR03lhkYskyrw=
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
@@ -185,8 +185,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
@@ -199,16 +199,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -304,34 +304,34 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
@@ -350,11 +350,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
+1 -1
View File
@@ -1 +1 @@
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 .
-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 .
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"golang.org/x/sync/singleflight"
"github.com/navidrome/navidrome/conf"
@@ -78,9 +77,9 @@ func (api *Router) routes() http.Handler {
inner.Post("/system/ping", api.ping)
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
// brute-force surface, so it must share the same per-IP throttle when one is configured.
// brute-force surface, so it must share the same per-client throttle when one is configured.
if conf.Server.AuthRequestLimit > 0 {
limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/users/authenticatebyname", api.authenticateByName)
+23
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
@@ -84,4 +85,26 @@ var _ = Describe("Router", func() {
Expect(login()).To(Equal(http.StatusUnauthorized))
Expect(login()).To(Equal(http.StatusTooManyRequests))
})
It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.AuthRequestLimit = 1
conf.Server.AuthWindowLength = time.Minute
api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Every request arrives on the same proxy connection, so only the resolved client IP
// can separate the buckets.
handler := middleware.ClientIPFromHeader("X-Real-IP")(api)
login := func(clientIP string) int {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`))
r.RemoteAddr = "10.0.0.1:1234"
r.Header.Set("X-Real-IP", clientIP)
handler.ServeHTTP(w, r)
return w.Code
}
Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized))
Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized))
})
})
+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 {
+1 -1
View File
@@ -136,7 +136,7 @@ func isSameMachine(r *http.Request, remote netip.Addr) bool {
return parseIP(local.String()) == remote
}
// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP.
// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP.
func remoteIP(r *http.Request) netip.Addr {
return parseIP(r.RemoteAddr)
}
+71 -11
View File
@@ -7,7 +7,9 @@ import (
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
@@ -165,20 +168,77 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
})
}
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
// realIPMiddleware resolves the request's client IP into the context, where it can be read with
// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration.
// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that
// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept
// in the context as request.ReverseProxyIp.
func realIPMiddleware(next http.Handler) http.Handler {
if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
).Handler(next)
trusted := conf.Server.ExtAuth.TrustedSources
fromPeer := middleware.ClientIPFromRemoteAddr(next)
if trusted == "" {
return fromPeer
}
// The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients
// behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security
// impact of reading the headers from untrusted sources is limited.
return middleware.RealIP(next)
// Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP,
// X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list.
fromProxy := chi.Chain(
middleware.ClientIPFromRemoteAddr,
middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...),
middleware.ClientIPFromHeader("X-Real-IP"),
middleware.ClientIPFromHeader("True-Client-IP"),
).Handler(mirrorClientIP(next))
dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if validateIPAgainstList(r.RemoteAddr, trusted) {
fromProxy.ServeHTTP(w, r)
return
}
log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr)
fromPeer.ServeHTTP(w, r)
})
return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch)
}
// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer.
func mirrorClientIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
// peerHost returns the host part of RemoteAddr, which may already be a bare IP.
func peerHost(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR
// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust
// exactly one hop.
func trustedProxyPrefixes(list string) []string {
var prefixes []string
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if _, err := netip.ParsePrefix(entry); err == nil {
prefixes = append(prefixes, entry)
}
}
return prefixes
}
// ClientIPRateLimiter returns a rate limiter keyed by the client IP resolved by realIPMiddleware,
// so spoofed forwarding headers cannot be rotated for a fresh bucket. It falls back to the peer
// address, so that a missing middleware degrades to per-peer limiting rather than one shared bucket.
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))), nil
})
}
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
+97
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -435,4 +436,100 @@ var _ = Describe("middlewares", func() {
})
})
})
Describe("realIPMiddleware", func() {
var resolved, remoteAddr string
var proxyIP any
next := func(w http.ResponseWriter, r *http.Request) {
resolved = middleware.GetClientIP(r.Context())
remoteAddr = r.RemoteAddr
proxyIP = r.Context().Value(request.ReverseProxyIp)
}
call := func(peer string, headers map[string]string) {
resolved, remoteAddr, proxyIP = "", "", nil
r := httptest.NewRequest("POST", "/auth/login", nil)
r.RemoteAddr = peer
for k, v := range headers {
r.Header.Set(k, v)
}
realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r)
}
Context("without a trusted proxy", func() {
It("ignores client-supplied forwarding headers", func() {
call("10.0.0.1:1234", map[string]string{
"X-Forwarded-For": "203.0.113.5",
"X-Real-IP": "203.0.113.6",
"True-Client-IP": "203.0.113.7",
})
Expect(resolved).To(Equal("10.0.0.1"))
})
It("leaves RemoteAddr untouched", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(remoteAddr).To(Equal("10.0.0.1:1234"))
})
})
Context("with a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
It("uses the forwarded client IP when the peer is a trusted proxy", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"})
Expect(resolved).To(Equal("203.0.113.5"))
Expect(remoteAddr).To(Equal("203.0.113.5"))
})
It("honours X-Real-IP from a trusted proxy", func() {
call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"})
Expect(resolved).To(Equal("203.0.113.6"))
})
It("ignores forwarding headers when the peer is not a trusted proxy", func() {
call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(resolved).To(Equal("198.51.100.9"))
Expect(remoteAddr).To(Equal("198.51.100.9:1234"))
})
It("keeps the peer address in the context for external auth", func() {
call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"})
Expect(proxyIP).To(Equal("10.0.0.1:1234"))
})
})
})
Describe("ClientIPRateLimiter", func() {
var handler http.Handler
JustBeforeEach(func() {
handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })))
})
attempt := func(peer string, header, value string) int {
r := httptest.NewRequest("POST", "/auth/login", nil)
r.RemoteAddr = peer
r.Header.Set(header, value)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w.Code
}
DescribeTable("keeps one bucket per peer when the forwarding header is rotated",
func(header string) {
Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK))
Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests))
},
Entry("X-Forwarded-For", "X-Forwarded-For"),
Entry("X-Real-IP", "X-Real-IP"),
Entry("True-Client-IP", "True-Client-IP"),
)
Context("behind a trusted proxy", func() {
BeforeEach(func() {
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
})
It("gives each real client its own bucket", func() {
Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK))
Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests))
Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK))
})
})
})
})
-133
View File
@@ -1,133 +0,0 @@
package nativeapi
import (
"encoding/json"
"errors"
"net/http"
"slices"
"time"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type traceStepDTO struct {
Candidate string `json:"candidate"`
Outcome string `json:"outcome"`
Detail string `json:"detail,omitempty"`
}
type storedDTO struct {
Source string `json:"source"`
SourcePath string `json:"sourcePath,omitempty"`
AttemptedAt string `json:"attemptedAt,omitempty"`
}
type queuedDTO struct {
PriorityName string `json:"priorityName"`
Attempts int `json:"attempts"`
RetryAt string `json:"retryAt,omitempty"`
}
type configDTO struct {
Setting string `json:"setting"`
Value string `json:"value"`
}
type explainDTO struct {
Name string `json:"name"`
Result string `json:"result"`
Steps []traceStepDTO `json:"steps"`
Stored *storedDTO `json:"stored,omitempty"`
Queued *queuedDTO `json:"queued,omitempty"`
LastAttemptFailed []traceStepDTO `json:"lastAttemptFailed,omitempty"`
GaveUpAfter []traceStepDTO `json:"gaveUpAfter,omitempty"`
Config *configDTO `json:"config,omitempty"`
Agents string `json:"agents,omitempty"`
AgentsIncomplete bool `json:"agentsIncomplete,omitempty"`
}
func (api *Router) addArtworkExplainRoute(r chi.Router) {
r.Get("/artwork/explain", api.explainArtwork())
}
func (api *Router) explainArtwork() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
kind, ok := model.ParseKind(r.URL.Query().Get("kind"))
if !ok || !slices.Contains(artwork.ExplainKinds, kind) {
http.Error(w, "invalid artwork kind", http.StatusBadRequest)
return
}
id := r.URL.Query().Get("id")
// No Walk: the endpoint reports history only, so it can never reach the network.
rep, err := artwork.Explain(ctx, api.ds, api.agents, kind, id, artwork.ExplainOptions{})
if err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
log.Error(ctx, "Error explaining artwork", "kind", kind, "id", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(toExplainDTO(rep)); err != nil {
log.Error(ctx, "Error encoding artwork explain response", "kind", kind, "id", id, err)
}
}
}
func rfc3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func toStepDTOs(steps []artwork.TraceStep) []traceStepDTO {
// Never nil: the UI maps over this unconditionally.
out := make([]traceStepDTO, 0, len(steps))
for _, s := range steps {
out = append(out, traceStepDTO{Candidate: s.Candidate, Outcome: string(s.Outcome), Detail: s.Detail})
}
return out
}
func toExplainDTO(rep artwork.ExplainReport) explainDTO {
dto := explainDTO{
Name: rep.Name,
Result: rep.Result(),
Steps: toStepDTOs(rep.Steps),
Agents: rep.Agents,
AgentsIncomplete: rep.AgentsIncomplete,
}
if rep.Stored != nil {
dto.Stored = &storedDTO{
Source: rep.Stored.Source,
SourcePath: rep.Stored.SourcePath,
AttemptedAt: rfc3339(rep.Stored.AttemptedAt),
}
}
if rep.Queued != nil {
dto.Queued = &queuedDTO{
PriorityName: artwork.PriorityName(rep.Queued.Priority),
Attempts: rep.Queued.Attempts,
RetryAt: rfc3339(rep.Queued.RetryAt),
}
}
if steps := rep.LastAttemptFailed(); len(steps) > 0 {
dto.LastAttemptFailed = toStepDTOs(steps)
}
if steps := rep.GaveUpAfter(); len(steps) > 0 {
dto.GaveUpAfter = toStepDTOs(steps)
}
if setting, value := artwork.ConfigFor(rep.Kind); setting != "" {
dto.Config = &configDTO{Setting: setting, Value: value}
}
return dto
}
-152
View File
@@ -1,152 +0,0 @@
package nativeapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GET /artwork/explain", func() {
var router http.Handler
var ds *tests.MockDataStore
var artRepo *tests.MockArtworkRepo
var queueRepo *tests.MockArtworkQueueRepo
var adminToken, userToken string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableSharing = false
conf.Server.ArtistArtPriority = "external"
artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo}
Expect(ds.Artist(context.Background()).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"}
Expect(ds.User(context.Background()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.Background()).Put(&regularUser)).To(Succeed())
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("returns 403 for a non-admin", func() {
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
It("returns 400 for an unknown kind", func() {
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=zz&id=ar-1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
It("returns 404 for an unknown id", func() {
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=missing", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns the report for an admin", func() {
// Storage shape from core/artwork/trace.go's storedStep: single-letter keys, "d" optional.
trace := `[{"c":"external:deezer","o":"hit","d":"https://cdn/x.jpg"}]`
gaveUpTrace := `[{"c":"external:deezer","o":"error","d":"connection reset"}]`
attemptedAt := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Hash: "abc", Source: "external:deezer", SourcePath: "/music/Radiohead/folder.jpg", Trace: trace,
LastFailure: gaveUpTrace, AttemptedAt: attemptedAt,
})).To(Succeed())
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var got map[string]any
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
Expect(got["name"]).To(Equal("Radiohead"))
Expect(got["result"]).To(Equal("resolved from external:deezer"))
Expect(got["config"]).To(HaveKeyWithValue("setting", "ArtistArtPriority"))
Expect(got["stored"]).To(Equal(map[string]any{
"source": "external:deezer",
"sourcePath": "/music/Radiohead/folder.jpg",
"attemptedAt": attemptedAt.Format(time.RFC3339),
}))
Expect(got["steps"]).To(Equal([]any{
map[string]any{"candidate": "external:deezer", "outcome": "hit", "detail": "https://cdn/x.jpg"},
}))
Expect(got["gaveUpAfter"]).To(Equal([]any{
map[string]any{"candidate": "external:deezer", "outcome": "error", "detail": "connection reset"},
}))
})
It("returns the queue state and the last-attempt trace", func() {
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
ItemKind: model.KindArtistArtwork.Prefix(), ItemID: "ar-1", ImageType: model.ImageTypePrimary,
Priority: model.ArtworkPriorityScan,
})).To(Succeed())
seen, err := queueRepo.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
retryAt := time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC)
failTrace := `[{"c":"external:deezer","o":"error","d":"timeout"}]`
Expect(queueRepo.MarkFailedIfUnchanged(model.KindArtistArtwork.Prefix(), "ar-1", model.ImageTypePrimary,
seen.RetryAt, retryAt, failTrace)).To(Succeed())
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var got map[string]any
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
Expect(got["queued"]).To(Equal(map[string]any{
"priorityName": "scan",
"attempts": float64(1),
"retryAt": retryAt.Format(time.RFC3339),
}))
Expect(got["lastAttemptFailed"]).To(Equal([]any{
map[string]any{"candidate": "external:deezer", "outcome": "error", "detail": "timeout"},
}))
})
It("omits empty sections", func() {
req := createAuthenticatedRequest("GET", "/artwork/explain?kind=ar&id=ar-1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var got map[string]any
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
Expect(got).ToNot(HaveKey("stored"))
Expect(got).ToNot(HaveKey("queued"))
Expect(got).ToNot(HaveKey("lastAttemptFailed"))
Expect(got).ToNot(HaveKey("gaveUpAfter"))
Expect(got).ToNot(HaveKey("chainOrigin"))
})
})
+1 -1
View File
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
+1 -1
View File
@@ -31,7 +31,7 @@ var _ = Describe("Library API", func() {
conf.Server.EnableSharing = false
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
+1 -1
View File
@@ -66,7 +66,7 @@ var _ = Describe("Metadata API", func() {
}
auth.Init(ds)
provider = &fakeProvider{}
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider)
router = server.JWTVerifier(nativeRouter)
adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"}
+2 -5
View File
@@ -13,7 +13,6 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
@@ -49,11 +48,10 @@ type Router struct {
pluginManager PluginManager
imgUpload artwork.Uploader
provider external.Provider
agents *agents.Agents
}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, ag *agents.Agents) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, agents: ag}
func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider}
r.Handler = r.routes()
return r
}
@@ -97,7 +95,6 @@ func (api *Router) routes() http.Handler {
api.addUserLibraryRoute(r)
api.addPluginRoute(r)
api.addMetadataRoute(r)
api.addArtworkExplainRoute(r)
api.RX(r, "/library", api.libs.NewRepository, true)
})
})
+1 -1
View File
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
+1 -1
View File
@@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() {
err := userRepo.Put(&testUser)
Expect(err).ToNot(HaveOccurred())
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)
nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
+1 -1
View File
@@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() {
ds = &tests.MockDataStore{}
mockManager = &tests.MockPluginManager{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
@@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func()
auth.Init(ds)
userService := core.NewUser(ds, noopPluginUnloader{})
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil)
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil)
router = server.JWTVerifier(nativeRouter)
})
+1 -2
View File
@@ -17,7 +17,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
@@ -209,7 +208,7 @@ func (s *Server) mountAuthenticationRoutes() chi.Router {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", conf.Server.AuthWindowLength)
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
r.With(rateLimiter).Post("/login", login(s.ds))
} else {
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
@@ -205,3 +205,76 @@ var _ = Describe("Sharing Cross-User Isolation", Ordered, func() {
Expect(check.Shares.Share[0].ID).To(Equal(shareID))
})
})
var _ = Describe("Sharing Downloadable Default", func() {
var albumID string
BeforeEach(func() {
conf.Server.EnableSharing = true
setupTestDB()
conf.Server.EnableDownloads = true
albumID = albumIDByName("Abbey Road")
})
createShare := func(params ...string) *model.Share {
GinkgoHelper()
resp := doReq("createShare", append([]string{"id", albumID}, params...)...)
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares.Share).To(HaveLen(1))
share, err := ds.Share(ctx).Get(resp.Shares.Share[0].ID)
Expect(err).ToNot(HaveOccurred())
return share
}
DescribeTable("createShare resolves downloadable",
func(defaultDownloadable, enableDownloads bool, params []string, expected bool) {
conf.Server.DefaultDownloadableShare = defaultDownloadable
conf.Server.EnableDownloads = enableDownloads
Expect(createShare(params...).Downloadable).To(Equal(expected))
},
Entry("applies the default when the param is absent", true, true, nil, true),
Entry("stays off when the default is off", false, true, nil, false),
Entry("ignores the default when downloads are disabled", true, false, nil, false),
Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false),
Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true),
)
It("updateShare keeps the current downloadable when the param is absent", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare()
Expect(share.Downloadable).To(BeTrue())
resp := doReq("updateShare", "id", share.ID, "description", "Updated")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(Equal("Updated"))
Expect(updated.Downloadable).To(BeTrue())
})
It("updateShare applies an explicit downloadable and keeps the description", func() {
conf.Server.DefaultDownloadableShare = true
share := createShare("description", "Keep me")
resp := doReq("updateShare", "id", share.ID, "downloadable", "false")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Downloadable).To(BeFalse())
Expect(updated.Description).To(Equal("Keep me"))
})
It("updateShare clears the description when it is sent empty", func() {
share := createShare("description", "Clear me")
resp := doReq("updateShare", "id", share.ID, "description", "")
Expect(resp.Status).To(Equal(responses.StatusOK))
updated, err := ds.Share(ctx).Get(share.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.Description).To(BeEmpty())
})
})
+25 -7
View File
@@ -1,11 +1,13 @@
package subsonic
import (
"cmp"
"net/http"
"strings"
"time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -60,9 +62,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) {
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
share := &model.Share{
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
Description: description,
Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads),
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ResourceIDs: strings.Join(ids, ","),
}
id, err := repo.(rest.Persistable).Save(share)
@@ -87,12 +90,27 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
description, _ := p.String("description")
repo := api.share.NewRepository(r.Context())
// The update always writes description and downloadable, so read back the
// stored value for whichever one the client omitted.
description := p.StringPtr("description")
downloadable := p.BoolPtr("downloadable")
if description == nil || downloadable == nil {
current, err := repo.Read(id)
if err != nil {
return nil, err
}
cur := current.(*model.Share)
description = cmp.Or(description, &cur.Description)
downloadable = cmp.Or(downloadable, &cur.Downloadable)
}
share := &model.Share{
ID: id,
Description: description,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
ID: id,
Description: *description,
Downloadable: *downloadable,
ExpiresAt: new(p.TimeOr("expires", time.Time{})),
}
err = repo.(rest.Persistable).Update(id, share)
+16 -9
View File
@@ -280,12 +280,19 @@ 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) {
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 {
clientName := clientInfo.Name
if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" {
if hasPlayer && player.Client != "" {
clientName = player.Client
}
log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation",
@@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request)
}
}
// 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)
// 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)
}
// Get media file
+43 -2
View File
@@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() {
mockTD.token = "token"
})
It("forces a supported format and clears direct play", func() {
It("forces a supported format and narrows direct play to it", func() {
body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}],
"transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"},
{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}`
@@ -380,7 +380,11 @@ 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(BeEmpty())
Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{
Containers: []string{"ogg"},
AudioCodecs: []string{"opus"},
Protocols: []string{"http"},
}))
})
It("falls back to negotiation when the forced format is unsupported", func() {
@@ -416,6 +420,43 @@ 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())
})
})
})
-12
View File
@@ -39,18 +39,6 @@ func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*mode
return &it, nil
}
// SetTrace stores a queue row's trace directly, bypassing the retry-token check
// MarkFailedIfUnchanged enforces; tests use it to seed a failure trace outright.
func (m *MockArtworkQueueRepo) SetTrace(kind model.Kind, id, imageType, trace string) {
m.mu.Lock()
defer m.mu.Unlock()
k := iaKey(kind.Prefix(), id, imageType)
if it, ok := m.Data[k]; ok {
it.Trace = trace
m.Data[k] = it
}
}
func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error {
m.mu.Lock()
defer m.mu.Unlock()
+1 -2
View File
@@ -60,11 +60,10 @@ export const closeDuplicateSongDialog = () => ({
type: DUPLICATE_SONG_WARNING_CLOSE,
})
export const openExtendedInfoDialog = (record, resource) => {
export const openExtendedInfoDialog = (record) => {
return {
type: EXTENDED_INFO_OPEN,
record,
resource,
}
}
-2
View File
@@ -18,7 +18,6 @@ import {
import { makeStyles } from '@material-ui/core/styles'
import {
ArtistLinkField,
ArtworkInfo,
MultiLineTextField,
ParticipantsInfo,
RangeField,
@@ -140,7 +139,6 @@ const AlbumInfo = (props) => {
)
})}
<ParticipantsInfo record={record} classes={classes} />
<ArtworkInfo resource="album" id={record.id} />
</TableBody>
</Table>
</TableContainer>
-82
View File
@@ -1,82 +0,0 @@
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import { humanize, underscore } from 'inflection'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import {
DateField,
TextField,
useRecordContext,
useTranslate,
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import { ArtworkInfo, SizeField } from '../common'
const useStyles = makeStyles({
tableCell: {
width: '17.5%',
},
value: {
whiteSpace: 'pre-line',
},
})
const ArtistInfo = (props) => {
const classes = useStyles()
const translate = useTranslate()
const record = useRecordContext(props)
const data = {
name: <TextField source={'name'} />,
sortArtistName: <TextField source={'sortArtistName'} />,
mbzArtistId: <TextField source={'mbzArtistId'} />,
albumCount: <TextField source={'albumCount'} />,
songCount: <TextField source={'songCount'} />,
size: <SizeField source={'size'} />,
playCount: <TextField source={'playCount'} />,
externalInfoUpdatedAt: (
<DateField source={'externalInfoUpdatedAt'} showTime />
),
updatedAt: <DateField source={'updatedAt'} showTime />,
}
const optionalFields = [
'sortArtistName',
'mbzArtistId',
'externalInfoUpdatedAt',
]
optionalFields.forEach((field) => {
!record[field] && delete data[field]
})
return (
<TableContainer>
<Table aria-label="artist details" size="small">
<TableBody>
{Object.keys(data).map((key) => {
return (
<TableRow key={`${record.id}-${key}`}>
<TableCell
component="th"
scope="row"
className={classes.tableCell}
>
{translate(`resources.artist.fields.${key}`, {
_: humanize(underscore(key)),
})}
:
</TableCell>
<TableCell align="left" className={classes.value}>
{data[key]}
</TableCell>
</TableRow>
)
})}
<ArtworkInfo resource="artist" id={record.id} />
</TableBody>
</Table>
</TableContainer>
)
}
export default ArtistInfo
-39
View File
@@ -1,39 +0,0 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { RecordContextProvider } from 'react-admin'
import ArtistInfo from './ArtistInfo'
vi.mock('../common', async (importOriginal) => ({
...(await importOriginal()),
ArtworkInfo: () => (
<tr>
<td>artwork-section</td>
</tr>
),
}))
const renderWith = (record) =>
render(
<RecordContextProvider value={record}>
<ArtistInfo />
</RecordContextProvider>,
)
describe('<ArtistInfo />', () => {
it('renders the artist fields', () => {
renderWith({ id: 'ar-1', name: 'Radiohead', albumCount: 9, songCount: 120 })
expect(screen.getByText('Radiohead')).toBeInTheDocument()
expect(screen.getByText('9')).toBeInTheDocument()
})
it('drops optional fields that are empty', () => {
renderWith({ id: 'ar-1', name: 'Radiohead' })
expect(screen.queryByText(/MusicBrainz/i)).toBeNull()
})
it('renders the artwork section', () => {
renderWith({ id: 'ar-1', name: 'Radiohead' })
expect(screen.getByText('artwork-section')).toBeInTheDocument()
})
})
-3
View File
@@ -35,8 +35,6 @@ import ArtistSimpleList from './ArtistSimpleList'
import { DraggableTypes } from '../consts'
import en from '../i18n/en.json'
import { formatBytes } from '../utils/index.js'
import ArtistInfo from './ArtistInfo'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
const useStyles = makeStyles({
contextHeader: {
@@ -221,7 +219,6 @@ const ArtistList = (props) => {
>
<ArtistListView {...props} />
</List>
<ExpandInfoDialog content={<ArtistInfo />} />
</>
)
}
+1 -3
View File
@@ -9,7 +9,6 @@ import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import ArtistInfo from './ArtistInfo'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
import { Artwork } from '../common/Artwork'
@@ -161,8 +160,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
/>
)}
</Card>
<ExpandInfoDialog resource="album" content={<AlbumInfo />} />
<ExpandInfoDialog resource="artist" content={<ArtistInfo />} />
<ExpandInfoDialog content={<AlbumInfo />} />
</div>
)
}
-183
View File
@@ -1,183 +0,0 @@
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { Chip, Link, TableCell, TableRow } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx'
import { useDataProvider, usePermissions, useTranslate } from 'react-admin'
import { DateField } from './DateField'
const useStyles = makeStyles((theme) => ({
chip: { color: theme.palette.common.white, height: 20 },
hit: { backgroundColor: theme.palette.success.main },
error: { backgroundColor: theme.palette.error.main },
neutral: { backgroundColor: theme.palette.grey[500] },
toggle: { cursor: 'pointer' },
}))
const OUTCOME_CLASS = { hit: 'hit', error: 'error', unreadable: 'error' }
const OutcomeChip = ({ outcome }) => {
const classes = useStyles()
return (
<Chip
size="small"
label={outcome}
className={clsx(
classes.chip,
classes[OUTCOME_CLASS[outcome] || 'neutral'],
)}
/>
)
}
OutcomeChip.propTypes = {
outcome: PropTypes.string.isRequired,
}
const StepTable = ({ title, steps }) => {
if (!steps?.length) return null
return (
<>
<TableRow>
<TableCell colSpan={2}>
<strong>{title}</strong>
</TableCell>
</TableRow>
{steps.map((s, i) => (
<TableRow key={`${s.candidate}-${i}`}>
<TableCell>{s.candidate}</TableCell>
<TableCell>
<OutcomeChip outcome={s.outcome} /> {s.detail}
</TableCell>
</TableRow>
))}
</>
)
}
StepTable.propTypes = {
title: PropTypes.string.isRequired,
steps: PropTypes.array,
}
const Row = ({ label, children }) => (
<TableRow>
<TableCell component="th" scope="row">
{label}:
</TableCell>
<TableCell align="left">{children}</TableCell>
</TableRow>
)
Row.propTypes = {
label: PropTypes.node.isRequired,
children: PropTypes.node,
}
export const ArtworkInfo = ({ resource, id }) => {
const classes = useStyles()
const translate = useTranslate()
const dataProvider = useDataProvider()
const { permissions } = usePermissions()
const [report, setReport] = useState(null)
const [expanded, setExpanded] = useState(false)
const isAdmin = permissions === 'admin'
useEffect(() => {
if (!isAdmin) return undefined
let live = true
dataProvider
.explainArtwork(resource, id)
.then(({ data }) => live && setReport(data))
.catch(() => live && setReport(null))
return () => {
live = false
}
}, [dataProvider, resource, id, isAdmin])
if (!isAdmin || !report) return null
const recorded = !!report.stored
return (
<>
<TableRow>
<TableCell colSpan={2}>
<strong>{translate('artwork.title')}</strong>
</TableCell>
</TableRow>
<Row label={translate('artwork.result')}>{report.result}</Row>
{recorded ? (
<>
<Row label={translate('artwork.source')}>{report.stored?.source}</Row>
<Row label={translate('artwork.attemptedAt')}>
<DateField record={report.stored} source="attemptedAt" showTime />
</Row>
</>
) : (
<Row label={translate('artwork.source')}>
{translate('artwork.notRecorded')}
</Row>
)}
<TableRow>
<TableCell colSpan={2}>
<Link
component="button"
className={classes.toggle}
onClick={() => setExpanded(!expanded)}
>
{translate(
expanded ? 'artwork.hideDetails' : 'artwork.showDetails',
)}
</Link>
</TableCell>
</TableRow>
{expanded && (
<>
<StepTable title={translate('artwork.chain')} steps={report.steps} />
<StepTable
title={translate('artwork.lastAttemptFailed')}
steps={report.lastAttemptFailed}
/>
<StepTable
title={translate('artwork.gaveUpAfter')}
steps={report.gaveUpAfter}
/>
{report.stored?.sourcePath && (
<Row label={translate('artwork.sourcePath')}>
{report.stored.sourcePath}
</Row>
)}
{report.queued && (
<>
<Row label={translate('artwork.priority')}>
{report.queued.priorityName}
</Row>
<Row label={translate('artwork.attempts')}>
{report.queued.attempts}
</Row>
<Row label={translate('artwork.retryAt')}>
<DateField record={report.queued} source="retryAt" showTime />
</Row>
</>
)}
{report.config && (
<Row label={report.config.setting}>{report.config.value}</Row>
)}
{report.agents && (
<Row label={translate('artwork.agents')}>
{report.agents}
{report.agentsIncomplete && (
<div>{translate('artwork.agentsIncomplete')}</div>
)}
</Row>
)}
</>
)}
</>
)
}
ArtworkInfo.propTypes = {
resource: PropTypes.oneOf(['album', 'artist']).isRequired,
id: PropTypes.string.isRequired,
}
-108
View File
@@ -1,108 +0,0 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ArtworkInfo } from './ArtworkInfo'
const explainArtwork = vi.fn()
const { mockPermissions } = vi.hoisted(() => ({
mockPermissions: { value: 'admin' },
}))
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useDataProvider: () => ({ explainArtwork }),
usePermissions: () => ({ permissions: mockPermissions.value }),
useTranslate: () => (key) => key,
}
})
const report = {
result: 'resolved from external:deezer',
chainOrigin: 'recorded 2026-09-01T10:00:00Z',
stored: {
source: 'external:deezer',
sourcePath: '/music/Radiohead/folder.jpg',
attemptedAt: '2026-09-01T10:00:00Z',
},
steps: [
{ candidate: 'cover.*', outcome: 'miss' },
{
candidate: 'external:deezer',
outcome: 'hit',
detail: 'https://cdn/x.jpg',
},
],
queued: { priority: 20, priorityName: 'scan', attempts: 1, retryAt: '' },
config: { setting: 'ArtistArtPriority', value: 'external' },
}
// ArtworkInfo renders bare TableRows to slot into the caller's TableBody, so it needs a
// table/tbody ancestor or React logs DOM-nesting warnings.
const renderInTable = (ui) =>
render(
<table>
<tbody>{ui}</tbody>
</table>,
)
describe('<ArtworkInfo />', () => {
beforeEach(() => {
mockPermissions.value = 'admin'
explainArtwork.mockReset()
explainArtwork.mockResolvedValue({ data: report })
})
it('renders nothing for a non-admin', () => {
mockPermissions.value = 'regular'
const { container } = renderInTable(
<ArtworkInfo resource="artist" id="ar-1" />,
)
expect(container.querySelector('tbody')).toBeEmptyDOMElement()
expect(explainArtwork).not.toHaveBeenCalled()
})
it('shows the summary for an admin', async () => {
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
expect(
await screen.findByText('resolved from external:deezer'),
).toBeInTheDocument()
expect(screen.queryByText('external:deezer')).not.toBeNull()
expect(screen.queryByText('cover.*')).toBeNull()
})
it('reveals the step table when details are expanded', async () => {
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
await screen.findByText('resolved from external:deezer')
await userEvent.click(screen.getByText('artwork.showDetails'))
expect(screen.getByText('cover.*')).toBeInTheDocument()
expect(screen.getByText('ArtistArtPriority:')).toBeInTheDocument()
expect(screen.getByText('/music/Radiohead/folder.jpg')).toBeInTheDocument()
expect(screen.getByText('scan')).toBeInTheDocument()
})
it('shows the not-recorded state', async () => {
explainArtwork.mockResolvedValue({
data: { result: 'not resolved', chainOrigin: 'not recorded', steps: [] },
})
renderInTable(<ArtworkInfo resource="artist" id="ar-1" />)
expect(await screen.findByText('artwork.notRecorded')).toBeInTheDocument()
// stored/queued/config/agents are all omitted by the endpoint here, so the optional
// blocks' guards must not throw when expanded.
await userEvent.click(screen.getByText('artwork.showDetails'))
expect(screen.queryByText('artwork.priority')).toBeNull()
expect(screen.queryByText('artwork.agents')).toBeNull()
})
it('renders nothing when the fetch fails', async () => {
explainArtwork.mockRejectedValue(new Error('boom'))
const { container } = renderInTable(
<ArtworkInfo resource="artist" id="ar-1" />,
)
await waitFor(() => expect(explainArtwork).toHaveBeenCalled())
expect(container.querySelector('tbody')).toBeEmptyDOMElement()
})
})
+3 -2
View File
@@ -146,9 +146,9 @@ const ContextMenu = ({
...(!hideInfo && {
info: {
enabled: true,
needData: false,
needData: true,
label: translate('resources.album.actions.info'),
action: (record) => dispatch(openExtendedInfoDialog(record, resource)),
action: () => dispatch(openExtendedInfoDialog(record)),
},
}),
}
@@ -272,6 +272,7 @@ export const ArtistContextMenu = (props) =>
props.record ? (
<ContextMenu
{...props}
hideInfo={true}
resource={'artist'}
songQueryParams={{
pagination: { page: 1, perPage: 200 },
-25
View File
@@ -4,7 +4,6 @@ import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus'
import { EXTENDED_INFO_OPEN } from '../actions'
const mockDispatch = vi.fn()
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
@@ -131,28 +130,4 @@ describe('ContextMenus', () => {
expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1')
})
})
describe('info dialog', () => {
it('dispatches the record and resource when opening the info dialog', () => {
const record = { id: 'al1', name: 'Album', songCount: 1 }
renderMenu(AlbumContextMenu, record)
fireEvent.click(screen.getByText('resources.album.actions.info'))
expect(mockDispatch).toHaveBeenCalledWith({
type: EXTENDED_INFO_OPEN,
record,
resource: 'album',
})
})
it('dispatches the record and resource when opening the artist info dialog', () => {
const record = { id: 'ar1', name: 'Artist', stats: {} }
renderMenu(ArtistContextMenu, record)
fireEvent.click(screen.getByText('resources.album.actions.info'))
expect(mockDispatch).toHaveBeenCalledWith({
type: EXTENDED_INFO_OPEN,
record,
resource: 'artist',
})
})
})
})
+1 -1
View File
@@ -165,7 +165,7 @@ export const SongContextMenu = ({
}
}
dispatch(openExtendedInfoDialog(fullRecord, 'song'))
dispatch(openExtendedInfoDialog(fullRecord))
},
},
}
-21
View File
@@ -4,7 +4,6 @@ import { TestContext } from 'ra-test'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { SongContextMenu } from './SongContextMenu'
import subsonic from '../subsonic'
import { EXTENDED_INFO_OPEN } from '../actions'
vi.mock('../dataProvider', () => ({
httpClient: vi.fn(),
@@ -131,26 +130,6 @@ describe('SongContextMenu', () => {
expect(mockOnClick).not.toHaveBeenCalled()
})
it('dispatches the record and resource when opening the info dialog', async () => {
const record = { id: 'song1', size: 1 }
render(
<TestContext>
<SongContextMenu record={record} resource="song" />
</TestContext>,
)
fireEvent.click(screen.getAllByRole('button')[1])
await waitFor(() => screen.getByText(/resources\.song\.actions\.info/))
fireEvent.click(screen.getByText(/resources\.song\.actions\.info/))
await waitFor(() =>
expect(mockDispatch).toHaveBeenCalledWith({
type: EXTENDED_INFO_OPEN,
record,
resource: 'song',
}),
)
})
describe('Instant Mix action', () => {
it('calls getSimilarSongs2 with song id and shows loading notification', async () => {
render(
-1
View File
@@ -1,7 +1,6 @@
export * from './AddToPlaylistButton'
export * from './artist'
export * from './ArtistLinkField'
export * from './ArtworkInfo'
export * from './BatchPlayButton'
export * from './BitrateField'
export * from './CollapsibleComment'
+6
View File
@@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [
32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320,
].map((b) => ({ id: b, name: b.toString() }))
// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express.
export const TRANSCODING_BITRATE_CHOICES = [
{ id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' },
...BITRATE_CHOICES,
]
+2 -6
View File
@@ -4,7 +4,7 @@ import { REST_URL } from '../consts'
const dataProvider = jsonServerProvider(REST_URL, httpClient)
const ARTWORK_KIND = { album: 'al', artist: 'ar' }
const REFRESH_KIND = { album: 'al', artist: 'ar' }
const isAdmin = () => {
const role = localStorage.getItem('role')
@@ -226,13 +226,9 @@ const wrapperDataProvider = {
// The endpoint answers 204 with no body, but react-admin rejects any response without a
// `data` key, so the id stands in for one.
refreshMetadata: (resource, id) =>
httpClient(`${REST_URL}/metadata/${ARTWORK_KIND[resource]}/${id}/refresh`, {
httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, {
method: 'POST',
}).then(() => ({ data: { id } })),
explainArtwork: (resource, id) =>
httpClient(
`${REST_URL}/artwork/explain?kind=${ARTWORK_KIND[resource]}&id=${encodeURIComponent(id)}`,
).then(({ json }) => ({ data: json })),
}
export default wrapperDataProvider
@@ -120,23 +120,4 @@ describe('wrapperDataProvider', () => {
).resolves.toEqual({ data: { id: 'al-1' } })
})
})
describe('explainArtwork', () => {
it('requests the artist kind prefix', async () => {
mockHttpClient.mockResolvedValue({ json: { name: 'Radiohead' } })
const result = await wrapperDataProvider.explainArtwork('artist', 'ar-1')
expect(mockHttpClient).toHaveBeenCalledWith(
expect.stringContaining('/artwork/explain?kind=ar&id=ar-1'),
)
expect(result).toEqual({ data: { name: 'Radiohead' } })
})
it('requests the album kind prefix', async () => {
mockHttpClient.mockResolvedValue({ json: {} })
await wrapperDataProvider.explainArtwork('album', 'al-1')
expect(mockHttpClient).toHaveBeenCalledWith(
expect.stringContaining('kind=al&id=al-1'),
)
})
})
})
+5 -12
View File
@@ -11,16 +11,10 @@ import {
} from '@material-ui/core'
import { closeExtendedInfoDialog } from '../actions'
const ExpandInfoDialog = ({ title, content, resource }) => {
const {
open,
record,
resource: openFor,
} = useSelector((state) => state.expandInfoDialog)
const ExpandInfoDialog = ({ title, content }) => {
const { open, record } = useSelector((state) => state.expandInfoDialog)
const dispatch = useDispatch()
const translate = useTranslate()
// One page may mount several of these; each claims the resource it was given.
const mine = !resource || resource === openFor
const handleClose = (e) => {
dispatch(closeExtendedInfoDialog())
@@ -29,7 +23,7 @@ const ExpandInfoDialog = ({ title, content, resource }) => {
return (
<Dialog
open={open && mine}
open={open}
onClose={handleClose}
aria-labelledby="info-dialog-album"
fullWidth={true}
@@ -39,7 +33,7 @@ const ExpandInfoDialog = ({ title, content, resource }) => {
{translate(title || 'resources.song.actions.info')}
</DialogTitle>
<DialogContent>
{record && mine && (
{record && (
<RecordContextProvider value={record}>
{content}
</RecordContextProvider>
@@ -56,8 +50,7 @@ const ExpandInfoDialog = ({ title, content, resource }) => {
ExpandInfoDialog.propTypes = {
title: PropTypes.string,
content: PropTypes.element.isRequired,
resource: PropTypes.string,
content: PropTypes.object.isRequired,
}
export default ExpandInfoDialog
-54
View File
@@ -1,54 +0,0 @@
import * as React from 'react'
import { TestContext } from 'ra-test'
import { render, screen, cleanup } from '@testing-library/react'
import { describe, afterEach, it, expect } from 'vitest'
import ExpandInfoDialog from './ExpandInfoDialog'
const renderDialogs = (openFor, dialogs) =>
render(
<TestContext
initialState={{
expandInfoDialog: {
open: true,
record: { id: 'r1', name: 'Record' },
resource: openFor,
},
}}
>
{dialogs}
</TestContext>,
)
describe('ExpandInfoDialog', () => {
afterEach(cleanup)
it('renders an unclaimed dialog for any resource', () => {
renderDialogs('song', <ExpandInfoDialog content={<div>Song Info</div>} />)
expect(screen.getByText('Song Info')).toBeInTheDocument()
})
// Guards the artist detail page, which mounts both dialogs: an album card's Get Info
// must open AlbumInfo, and the page's own ArtistInfo must stay shut.
it.each([
['artist', 'Artist Info', 'Album Info'],
['album', 'Album Info', 'Artist Info'],
])('opens only the %s dialog', (openFor, shown, hidden) => {
renderDialogs(
openFor,
<>
<ExpandInfoDialog resource="album" content={<div>Album Info</div>} />
<ExpandInfoDialog resource="artist" content={<div>Artist Info</div>} />
</>,
)
expect(screen.getByText(shown)).toBeInTheDocument()
expect(screen.queryByText(hidden)).not.toBeInTheDocument()
})
it('stays shut when no mounted dialog claims the resource', () => {
renderDialogs(
'artist',
<ExpandInfoDialog resource="album" content={<div>Album Info</div>} />,
)
expect(screen.queryByText('Album Info')).not.toBeInTheDocument()
})
})
+3 -22
View File
@@ -110,14 +110,10 @@
"name": "Artist |||| Artists",
"fields": {
"name": "Name",
"sortArtistName": "Sort Name",
"mbzArtistId": "MusicBrainz Artist Id",
"albumCount": "Album Count",
"songCount": "Song Count",
"size": "Size",
"playCount": "Plays",
"externalInfoUpdatedAt": "External Info Updated At",
"updatedAt": "Updated At",
"rating": "Rating",
"genre": "Genre",
"role": "Role",
@@ -204,6 +200,9 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
},
"choices": {
"noDefaultBitRate": "None"
}
},
"playlist": {
@@ -650,24 +649,6 @@
"sharedPlaylists": "Shared Playlists",
"about": "About"
},
"artwork": {
"title": "Artwork",
"chain": "Chain",
"result": "Result",
"source": "Source",
"attemptedAt": "Attempted at",
"sourcePath": "Source path",
"showDetails": "Show details",
"hideDetails": "Hide details",
"priority": "Priority",
"attempts": "Attempts",
"retryAt": "Retry at",
"lastAttemptFailed": "Last attempt failed",
"gaveUpAfter": "Gave up after",
"agents": "Agents",
"agentsIncomplete": "* This agent is configured but cannot supply images",
"notRecorded": "No resolution recorded yet"
},
"player": {
"playListsText": "Play Queue",
"openText": "Open",
+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
-3
View File
@@ -127,7 +127,6 @@ export const expandInfoDialogReducer = (
previousState = {
open: false,
record: undefined,
resource: undefined,
},
payload,
) => {
@@ -138,14 +137,12 @@ export const expandInfoDialogReducer = (
...previousState,
open: true,
record: payload.record,
resource: payload.resource,
}
case EXTENDED_INFO_CLOSE:
return {
...previousState,
open: false,
record: undefined,
resource: undefined,
}
default:
return previousState
-45
View File
@@ -1,45 +0,0 @@
import { describe, it, expect } from 'vitest'
import { expandInfoDialogReducer } from './dialogReducer'
import { EXTENDED_INFO_OPEN, EXTENDED_INFO_CLOSE } from '../actions'
describe('expandInfoDialogReducer', () => {
it('stores the record and resource on EXTENDED_INFO_OPEN', () => {
const record = { id: 'al1', name: 'Album' }
const result = expandInfoDialogReducer(
{ open: false, record: undefined, resource: undefined },
{ type: EXTENDED_INFO_OPEN, record, resource: 'album' },
)
expect(result).toEqual({ open: true, record, resource: 'album' })
})
it('clears the record and resource on EXTENDED_INFO_CLOSE', () => {
const previousState = {
open: true,
record: { id: 'al1', name: 'Album' },
resource: 'album',
}
const result = expandInfoDialogReducer(previousState, {
type: EXTENDED_INFO_CLOSE,
})
expect(result).toEqual({
open: false,
record: undefined,
resource: undefined,
})
})
it('returns previous state for unknown action', () => {
const previousState = {
open: false,
record: undefined,
resource: undefined,
}
const result = expandInfoDialogReducer(previousState, {
type: 'UNKNOWN_ACTION',
})
expect(result).toBe(previousState)
})
})
+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')
},
)
})
+5 -1
View File
@@ -59,7 +59,11 @@ const useCurrentTheme = () => {
return useMemo(
() => ({
...theme,
props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
props: {
...theme.props,
MuiUseMediaQuery: { noSsr: true },
MuiPopover: { disableScrollLock: true },
},
}),
[theme],
)
+2 -2
View File
@@ -8,7 +8,7 @@ import {
useTranslate,
} from 'react-admin'
import { Title } from '../common'
import { BITRATE_CHOICES } from '../consts'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = () => {
const translate = useTranslate()
@@ -28,7 +28,7 @@ const TranscodingCreate = (props) => (
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput
source="defaultBitRate"
choices={BITRATE_CHOICES}
choices={TRANSCODING_BITRATE_CHOICES}
defaultValue={192}
/>
<TextInput
+5 -2
View File
@@ -9,7 +9,7 @@ import {
} from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { BITRATE_CHOICES } from '../consts'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
const translate = useTranslate()
@@ -28,7 +28,10 @@ const TranscodingEdit = (props) => {
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<TextInput source="targetFormat" validate={[required()]} />
<SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} />
<SelectInput
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextInput source="command" fullWidth validate={[required()]} />
</SimpleForm>
</Edit>
+13 -3
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { Datagrid, TextField } from 'react-admin'
import { Datagrid, SelectField, TextField } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, List } from '../common'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import config from '../config'
const TranscodingList = (props) => {
@@ -16,13 +17,22 @@ const TranscodingList = (props) => {
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => `format: ${r.targetFormat}`}
tertiaryText={(r) => r.defaultBitRate}
tertiaryText={(r) => (
<SelectField
record={r}
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
)}
/>
) : (
<Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}>
<TextField source="name" />
<TextField source="targetFormat" />
<TextField source="defaultBitRate" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="command" />
</Datagrid>
)}
+6 -2
View File
@@ -1,7 +1,8 @@
import React from 'react'
import { Show, SimpleShowLayout, TextField } from 'react-admin'
import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
return <Title subTitle={`Transcoding ${record ? record.name : ''}`} />
@@ -16,7 +17,10 @@ const TranscodingShow = (props) => {
<SimpleShowLayout>
<TextField source="name" />
<TextField source="targetFormat" />
<TextField source="defaultBitRate" />
<SelectField
source="defaultBitRate"
choices={TRANSCODING_BITRATE_CHOICES}
/>
<TextField source="command" />
</SimpleShowLayout>
</Show>