mirror of
https://github.com/navidrome/navidrome.git
synced 2026-09-11 13:08:28 -04:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa7a5c466 | ||
|
|
4067e36a06 | ||
|
|
964d3c778b | ||
|
|
c14b598a01 | ||
|
|
25e7b5b20d | ||
|
|
bb386b13bf | ||
|
|
08eb46c8ad | ||
|
|
055fbde3cf | ||
|
|
72975a95fb | ||
|
|
e7b449b805 | ||
|
|
8d77a49b31 | ||
|
|
02c9816aec | ||
|
|
fe1c87c190 | ||
|
|
043de7a86c | ||
|
|
bea9715001 | ||
|
|
89026012ab | ||
|
|
404837799b | ||
|
|
48af781b82 |
No files matched your search
+1
-1
@@ -187,7 +187,7 @@ LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
|
||||
# otherwise the deleted bytes still ship in the image.
|
||||
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
for lib in libwebp libwebpdemux libwebpmux; do \
|
||||
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
||||
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
||||
|
||||
+212
-66
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 .
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(®ularUser)).To(Succeed())
|
||||
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
userToken, err = auth.CreateToken(®ularUser)
|
||||
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"))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 />} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -165,7 +165,7 @@ export const SongContextMenu = ({
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(openExtendedInfoDialog(fullRecord, 'song'))
|
||||
dispatch(openExtendedInfoDialog(fullRecord))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
export * from './AddToPlaylistButton'
|
||||
export * from './artist'
|
||||
export * from './ArtistLinkField'
|
||||
export * from './ArtworkInfo'
|
||||
export * from './BatchPlayButton'
|
||||
export * from './BitrateField'
|
||||
export * from './CollapsibleComment'
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -598,11 +598,9 @@ const NautilineTheme = {
|
||||
},
|
||||
},
|
||||
NDAlbumGridView: {
|
||||
albumContainer: {
|
||||
link: {
|
||||
borderRadius: radii.md,
|
||||
'& img': {
|
||||
borderRadius: radii.md,
|
||||
},
|
||||
overflow: 'hidden',
|
||||
},
|
||||
albumTitle: {
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -12,3 +12,25 @@ describe('NDPlaylistDetails styles', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('NDAlbumGridView styles', () => {
|
||||
const themeEntries = Object.entries(themes)
|
||||
|
||||
// The hover overlay is a sibling of the image, so it keeps square corners.
|
||||
it.each(themeEntries)(
|
||||
'%s should not round the grid cover image on its own',
|
||||
(themeName, theme) => {
|
||||
const container = theme.overrides?.NDAlbumGridView?.albumContainer
|
||||
expect(container?.['& img']?.borderRadius).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(themeEntries)(
|
||||
'%s should clip the grid cover link when it is rounded',
|
||||
(themeName, theme) => {
|
||||
const link = theme.overrides?.NDAlbumGridView?.link
|
||||
if (!link?.borderRadius) return
|
||||
expect(link.overflow).toBe('hidden')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user