Merge branch 'master' into msi-property-fix

This commit is contained in:
Rob Emery 2026-08-15 17:57:42 +01:00 committed by GitHub
commit 013986e4b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
135 changed files with 8348 additions and 1577 deletions

819
cmd/artwork.go Normal file
View File

@ -0,0 +1,819 @@
package cmd
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins"
"github.com/navidrome/navidrome/utils/slice"
"github.com/spf13/cobra"
)
var explainLive bool
var (
reprocessKinds []string
reprocessSources []string
reprocessAll bool
reprocessDryRun bool
reprocessYes bool
)
func init() {
artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false,
"perform real external lookups instead of reporting what would be tried; "+
"also initializes plugin agents, which may open external connections")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil,
"kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable")
artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil,
"only items currently resolved from these sources (e.g. folder, external:deezer, absent)")
artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind")
artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false,
"report what would be queued and exit without queueing")
artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt")
artworkCmd.AddCommand(artworkExplainCmd)
artworkCmd.AddCommand(artworkRefreshCmd)
artworkCmd.AddCommand(artworkReprocessCmd)
artworkCmd.AddCommand(artworkStatusCmd)
rootCmd.AddCommand(artworkCmd)
}
var artworkCmd = &cobra.Command{
Use: "artwork",
Short: "Inspect and re-resolve artwork",
}
var artworkExplainCmd = &cobra.Command{
Use: "explain <kind> <id>",
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" +
"<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.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
kind, err := parseArtworkKind(args[0], explainKinds)
if err != nil {
log.Fatal(cmd.Context(), err)
}
runExplain(cmd.Context(), kind, args[1])
},
}
var artworkRefreshCmd = &cobra.Command{
Use: "refresh <kind> <id>...",
Short: "Clear an item's artwork state and re-resolve it",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
kind, err := parseArtworkKind(args[0], artwork.RefreshableKinds)
if err != nil {
log.Fatal(cmd.Context(), err)
}
runRefresh(cmd.Context(), kind, args[1:])
},
}
var artworkReprocessCmd = &cobra.Command{
Use: "reprocess",
Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runReprocess(cmd.Context())
},
}
var artworkStatusCmd = &cobra.Command{
Use: "status",
Short: "Report the artwork queue, where artwork resolves from, and the backfill state",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
runStatus(cmd.Context())
},
}
func runStatus(ctx context.Context) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
rep, err := collectStatus(ctx, ds)
if err != nil {
log.Fatal(ctx, err)
}
fmt.Print(formatStatus(rep))
}
type sourceCount struct {
kind model.Kind
source string
count int64
}
type absentCount struct {
kind model.Kind
model.ArtworkAbsentStat
}
type statusReport struct {
queue []model.ArtworkQueueStat
sources []sourceCount
absent []absentCount
inputs []artwork.FingerprintInput
stored string
current string
}
func (r statusReport) queueTotal() int64 {
var n int64
for _, s := range r.queue {
n += s.Count
}
return n
}
func (r statusReport) backfillQueued() int64 {
var n int64
for _, s := range r.queue {
if s.Priority == model.ArtworkPriorityBackfill {
n += s.Count
}
}
return n
}
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
q := ds.ArtworkQueue(ctx)
var rep statusReport
var err error
if rep.queue, err = q.CountByKindAndPriority(); err != nil {
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
}
cutoff := time.Now().Add(-artwork.StaleAbsentAge)
for _, k := range artwork.RecheckKinds {
sources, err := q.SourcesInUse(k)
if err != nil {
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
}
slices.Sort(sources)
for _, s := range sources {
n, err := q.CountBySource(k, []string{s})
if err != nil {
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
}
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
}
stat, err := q.CountAbsent(k, cutoff)
if err != nil {
return rep, fmt.Errorf("counting absent %s artwork: %w", k, err)
}
rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat})
}
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
}
return rep, nil
}
func formatStatus(rep statusReport) string {
var sb strings.Builder
w := newTabWriter(&sb)
fmt.Fprintln(w, "Queue")
if len(rep.queue) == 0 {
fmt.Fprintln(w, " (empty)")
} else {
fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS")
for _, s := range rep.queue {
fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count)
}
fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal())
}
fmt.Fprintln(w, "\nSources")
fmt.Fprintln(w, " KIND\tSOURCE\tITEMS")
for _, s := range rep.sources {
fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count)
}
fmt.Fprintln(w, "\nAbsent (resolved, no image found)")
fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK")
for _, a := range rep.absent {
fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale)
}
fmt.Fprintf(w, " (rechecked once the last attempt is older than %gh)\n", artwork.StaleAbsentAge.Hours())
fmt.Fprintln(w, "\nBackfill")
fmt.Fprintf(w, " State:\t%s\n", backfillState(rep))
fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)"))
fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current)
if len(rep.inputs) > 0 {
fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):")
for _, in := range rep.inputs {
fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value)
}
}
w.Flush()
return sb.String()
}
// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually
// already stored the new fingerprint, and "up to date" would bury the flood it is still working through.
func backfillState(rep statusReport) string {
pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup"
if n := rep.backfillQueued(); n > 0 {
if rep.stored != rep.current {
return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending)
}
return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n)
}
if rep.stored != rep.current {
return pending
}
return "up to date"
}
func kindName(prefix string) string {
if k, ok := model.ParseKind(prefix); ok {
return k.String()
}
return prefix
}
func priorityName(p int) string {
switch p {
case model.ArtworkPriorityRecheck:
return "recheck"
case model.ArtworkPriorityBackfill:
return "backfill"
case model.ArtworkPriorityScan:
return "scan"
case model.ArtworkPriorityBump:
return "bump"
}
return strconv.Itoa(p)
}
func runReprocess(ctx context.Context) {
kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll)
if err != nil {
log.Fatal(ctx, err)
}
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
// Only a kind that can reach an agent needs the count, and loading a plugin creates its
// services. A preview must not reach the network, so init never runs here.
var imageAgents artwork.ImageAgentCount
if needsImageAgents(kinds) {
mgr := loadPluginAgents(ctx, false)
defer func() { _ = mgr.Stop() }()
imageAgents = imageAgentCount(ds, mgr)
}
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents,
reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil {
log.Fatal(ctx, err)
}
}
func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
// A source filter on its own is already a complete selection, so it does not also need a kind.
if all || (len(kinds) == 0 && len(sources) > 0) {
return artwork.RecheckKinds, nil
}
if len(kinds) == 0 {
return nil, fmt.Errorf("no selector given: pass --kind, --source or --all")
}
out := make([]model.Kind, 0, len(kinds))
for _, k := range kinds {
kind, err := parseArtworkKind(k, artwork.RecheckKinds)
if err != nil {
return nil, err
}
out = append(out, kind)
}
// A repeated kind would be counted twice, overstating the cost the operator confirms.
return slice.Unique(out), nil
}
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI.
const absentSource = "absent"
func repositorySources(sources []string) []string {
return slice.Map(sources, func(s string) string {
if s == absentSource {
return ""
}
return s
})
}
func displaySource(s string) string { return cmp.Or(s, absentSource) }
type confirmFunc func(out io.Writer, total, external int64) bool
func reprocessConfirm(yes bool, in io.Reader) confirmFunc {
if yes {
return func(io.Writer, int64, int64) bool { return true }
}
return promptConfirm(in)
}
// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the
// plugin agents it counts are only the ones this process managed to load.
func externalEstimate(n int64) string {
if n == 0 {
return "none"
}
return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n)
}
func externalLookupLine(n int64) string {
return fmt.Sprintf("External lookups: %s.", externalEstimate(n))
}
func imageAgentCount(ds model.DataStore, mgr *plugins.Manager) artwork.ImageAgentCount {
ag := agents.GetAgents(ds, mgr)
return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())}
}
// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a
// running server would. A load failure is reported, not fatal: the built-in agents still answer.
func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager {
mgr := getPluginManager()
if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil {
log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err)
}
return mgr
}
// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with
// the estimate it guards. Playlists count: their generated grid resolves album art through agents.
func needsImageAgents(kinds []model.Kind) bool {
return slices.ContainsFunc(kinds, artwork.MayFetchExternal)
}
// configuredAgents names the agents in priority order; one absent from it can never supply an image.
func configuredAgents() []string {
var names []string
for name := range strings.SplitSeq(conf.Server.Agents, ",") {
if name = strings.TrimSpace(name); name != "" {
names = append(names, name)
}
}
return names
}
func promptConfirm(in io.Reader) confirmFunc {
return func(out io.Writer, total, external int64) bool {
var cost string
if external > 0 {
cost = fmt.Sprintf(" %s", externalLookupLine(external))
}
fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost)
var answer string
if _, err := fmt.Fscanln(in, &answer); err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
}
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
if len(sources) == 0 {
return nil
}
var inUse []string
for _, k := range artwork.RecheckKinds {
found, err := q.SourcesInUse(k)
if err != nil {
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
}
inUse = slice.Unique(append(inUse, found...))
}
var unknown []string
for _, s := range sources {
if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent
unknown = append(unknown, displaySource(s))
}
}
if len(unknown) == 0 {
return nil
}
valid := slice.Map(inUse, displaySource)
slices.Sort(valid)
return fmt.Errorf("no artwork resolves from %s; sources in use: %s",
strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)"))
}
// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource
// actually inserted; the two differ because an already-queued row is left untouched.
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
q := ds.ArtworkQueue(ctx)
if err := validateSources(q, sources); err != nil {
return err
}
matched := make([]int64, len(kinds))
var total, external int64
for i, k := range kinds {
n, err := q.CountBySource(k, sources)
if err != nil {
return fmt.Errorf("counting %s artwork: %w", k, err)
}
matched[i] = n
total += n
external += n * artwork.ExternalLookupsPerItem(k, imageAgents)
}
printReprocessPreview(out, kinds, matched, total, external, sources)
switch {
case dryRun:
fmt.Fprintln(out, "\nDry run: nothing was queued.")
return nil
case total == 0:
fmt.Fprintln(out, "Nothing was queued.")
return nil
case !confirm(out, total, external):
fmt.Fprintln(out, "Aborted: nothing was queued.")
return nil
}
var queued int64
for i, k := range kinds {
if matched[i] == 0 {
continue
}
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
if err != nil {
return fmt.Errorf("queueing %s artwork: %w", k, err)
}
queued += n
fmt.Fprintf(out, "%s: %d queued\n", k, n)
}
fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total)
if skipped := total - queued; skipped > 0 {
fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped)
}
return nil
}
// printReprocessPreview also states the external estimate, which --dry-run must show because it
// skips the prompt that would otherwise carry it.
func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) {
w := newTabWriter(out)
shown := slice.Map(sources, displaySource)
fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)"))
fmt.Fprintln(w, "KIND\tMATCHED")
for i, k := range kinds {
fmt.Fprintf(w, "%s\t%d\n", k, matched[i])
}
fmt.Fprintf(w, "TOTAL\t%d\n", total)
w.Flush()
fmt.Fprintf(out, "\n%s\n", externalLookupLine(external))
if total == 0 {
fmt.Fprintln(out, "\nNothing matches this selection.")
}
}
func runRefresh(ctx context.Context, kind model.Kind, ids []string) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
if failed := refreshItems(ctx, ds, kind, ids, os.Stdout); failed > 0 {
log.Fatal(ctx, "Failed to refresh artwork", "kind", kind, "failed", failed, "total", len(ids))
}
}
// refreshItems keeps going after a failure — the ids are independent — and returns how many failed.
func refreshItems(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string, out io.Writer) int {
var failed int
for _, id := range ids {
// artwork.Refresh would happily queue an id that does not exist, orphaning a queue row.
if _, err := artworkItemName(ctx, ds, kind, id); err != nil {
log.Error(ctx, "Item not found", "kind", kind, "id", id, err)
failed++
continue
}
if err := artwork.Refresh(ctx, ds, kind, id); err != nil {
log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err)
failed++
continue
}
fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id)
}
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(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ")
}
func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) {
kind, ok := model.ParseKind(s)
if ok && slices.Contains(valid, kind) {
return kind, nil
}
return kind, fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid))
}
// 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)"
}
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
}
if s.Outcome == artwork.OutcomeWouldTry {
return "resolved from " + source +
" (offline: a higher-priority external candidate was not tried; re-run with --live)"
}
// 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.OutcomeWouldTry:
return "indeterminate (external agents not called; re-run with --live)"
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)"
// The worker treats an unreadable local candidate exactly as it treats a failed external one:
// it retries instead of settling absent, so the verdict must not read as a clean miss.
case s.Outcome == artwork.OutcomeUnreadable:
return "indeterminate (a candidate exists but could not be read; 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 []artwork.TraceStep
source string
resolveErr error
}
func formatExplain(rep explainReport) string {
var sb strings.Builder
w := newTabWriter(&sb)
explainable := artwork.Explainable(rep.kind)
stateful := artwork.KeepsState(rep.kind)
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.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.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, " 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:
fmt.Fprintln(w, " (not queued)")
default:
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))
}
fmt.Fprintln(w, "\nConfig")
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", rep.agents)
}
}
fmt.Fprintln(w, "\nChain")
if !explainable {
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind)
} else {
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
for _, s := range rep.steps {
// A row with an empty last cell would end tabwriter's column block, breaking alignment.
fmt.Fprintf(w, " %s\t%s\t%s\n", s.Candidate, s.Outcome, cmp.Or(s.Detail, "-"))
}
}
fmt.Fprintln(w, "\nResult")
switch {
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)")
default:
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, kind model.Kind, id string) {
defer db.Init(ctx)()
ds, ctx := getAdminContext(ctx)
name, err := artworkItemName(ctx, ds, kind, id)
if err != nil {
log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err)
}
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)
}
}
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))
}
trace := &artwork.ChainTrace{}
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
rep.steps = trace.Steps()
}
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)
}
}
// artworkItemName looks the entity up under its own kind, so a mismatched kind/id pair is
// reported as not found instead of silently explaining another entity's artwork.
func artworkItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
switch kind {
case model.KindArtistArtwork:
ar, err := ds.Artist(ctx).Get(id)
if err != nil {
return "", err
}
return ar.Name, nil
case model.KindAlbumArtwork:
al, err := ds.Album(ctx).Get(id)
if err != nil {
return "", err
}
return al.Name, nil
case model.KindPlaylistArtwork:
pls, err := ds.Playlist(ctx).Get(id)
if err != nil {
return "", err
}
return pls.Name, nil
case model.KindRadioArtwork:
rd, err := ds.Radio(ctx).Get(id)
if err != nil {
return "", err
}
return rd.Name, nil
case model.KindMediaFileArtwork:
mf, err := ds.MediaFile(ctx).Get(id)
if err != nil {
return "", err
}
return mf.Title, nil
case model.KindDiscArtwork:
return discArtworkName(ctx, ds, id)
}
return "", fmt.Errorf("unsupported kind %q", kind.Prefix())
}
func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) {
albumID, discNumber, err := model.ParseDiscArtworkID(id)
if err != nil {
return "", err
}
al, err := ds.Album(ctx).Get(albumID)
if err != nil {
return "", err
}
name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber)
// The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it.
if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" {
name += ": " + subtitle
}
return name, nil
}

946
cmd/artwork_test.go Normal file
View File

@ -0,0 +1,946 @@
package cmd
import (
"context"
"errors"
"io"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"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("parseArtworkKind", func() {
It("accepts a supported kind", func() {
k, err := parseArtworkKind("ar", artwork.RecheckKinds)
Expect(err).ToNot(HaveOccurred())
Expect(k).To(Equal(model.KindArtistArtwork))
})
It("rejects an unknown kind and lists the valid ones", func() {
_, err := parseArtworkKind("zz", artwork.RecheckKinds)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ar"))
Expect(err.Error()).To(ContainSubstring("al"))
})
It("rejects a known kind the command does not accept", func() {
_, err := parseArtworkKind("mf", artwork.RecheckKinds)
Expect(err).To(HaveOccurred())
})
DescribeTable("accepts the kinds each command supports",
func(prefix string, valid []model.Kind) {
_, err := parseArtworkKind(prefix, valid)
Expect(err).ToNot(HaveOccurred())
},
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),
)
It("rejects disc artwork for refresh", func() {
_, err := parseArtworkKind("dc", artwork.RefreshableKinds)
Expect(err).To(HaveOccurred())
})
})
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("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 skipped higher-priority external candidate could have taken", func() {
steps := []artwork.TraceStep{
{Candidate: "external:deezer", Outcome: "would-try"},
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
}
res := explainResult("artist.*", steps)
Expect(res).To(ContainSubstring("resolved from artist.*"))
Expect(res).To(ContainSubstring("--live"),
"offline, the winner is only the winner because the external tier was skipped")
})
It("does not qualify a win that no skipped candidate outranked", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "hit"},
{Candidate: "external:deezer", Outcome: "would-try"},
}
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
})
It("reports indeterminate when external agents were never called", func() {
steps := []artwork.TraceStep{
{Candidate: "artist.*", Outcome: "miss"},
{Candidate: "external:deezer", Outcome: "would-try"},
}
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
"an offline run must not claim an item is unresolvable when external agents were skipped")
})
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 explainReport
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.ArtistArtPriority = "external, artist.*"
rep = explainReport{
kind: model.KindArtistArtwork,
id: "ar-1",
name: "Radiohead",
agents: "lastfm,spotify",
steps: []artwork.TraceStep{
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
{Candidate: "external:deezer", Outcome: "would-try"},
},
source: "",
}
})
It("reports the item, its config and the chain it walked", func() {
out := formatExplain(rep)
Expect(out).To(ContainSubstring("Radiohead"))
Expect(out).To(ContainSubstring("ar-1"))
Expect(out).To(ContainSubstring("ArtistArtPriority"))
Expect(out).To(ContainSubstring("lastfm,spotify"))
Expect(out).To(ContainSubstring("external:deezer"))
Expect(out).To(ContainSubstring("would-try"))
Expect(out).To(ContainSubstring("indeterminate"))
})
It("reports the absence of stored state and of a queue row", func() {
out := formatExplain(rep)
Expect(out).To(ContainSubstring("no artwork state recorded"))
Expect(out).To(ContainSubstring("not queued"))
})
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",
SourcePath: "/music/cover.jpg", AttemptedAt: attempted}
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2,
RetryAt: attempted.Add(time.Hour)}
rep.source = "folder"
out := formatExplain(rep)
Expect(out).To(ContainSubstring("abc123"))
Expect(out).To(ContainSubstring("/music/cover.jpg"))
Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z"))
Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up")
Expect(out).To(ContainSubstring("resolved from folder"))
})
It("marks a known-absent stored state instead of printing an empty hash", func() {
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")
out := formatExplain(rep)
Expect(out).To(ContainSubstring("resolution failed: no such directory"))
Expect(out).ToNot(ContainSubstring("indeterminate"))
Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print")
})
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 = ""
out := formatExplain(rep)
Expect(out).To(ContainSubstring("does not walk a priority chain"))
Expect(out).ToNot(ContainSubstring("CANDIDATE"),
"an empty chain table reads as 'nothing was tried', which is false")
Expect(out).ToNot(ContainSubstring("not resolved"),
"nothing was resolved because nothing was attempted")
Expect(out).ToNot(ContainSubstring("CoverArtPriority"),
"the priority chain config does not govern this kind")
})
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
conf.Server.DiscArtPriority = "cover.jpg, embedded"
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",
}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("never recorded"))
Expect(out).To(ContainSubstring("never queued"))
Expect(out).ToNot(ContainSubstring("no artwork state recorded"),
"a missing row would read as a lookup that failed, when disc artwork has no row by design")
Expect(out).To(ContainSubstring("DiscArtPriority"))
Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent")
Expect(out).To(ContainSubstring("resolved from folder"))
})
It("reports the setting that governs media file artwork", func() {
conf.Server.EnableMediaFileCoverArt = false
rep = explainReport{
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
steps: []artwork.TraceStep{
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
},
}
out := formatExplain(rep)
Expect(out).To(ContainSubstring("EnableMediaFileCoverArt"))
Expect(out).To(ContainSubstring("false"))
Expect(out).To(ContainSubstring("not resolved"))
Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state")
})
})
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("discArtworkName", func() {
var ds *tests.MockDataStore
BeforeEach(func() {
albumRepo := tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}})
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
})
It("names the album, the disc and its subtitle", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 2): Side Three"))
})
It("omits the subtitle when the disc has none", func() {
name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1")
Expect(err).ToNot(HaveOccurred())
Expect(name).To(Equal("Sandinista! (disc 1)"))
})
It("rejects an id that is not <albumID>:<disc>", func() {
_, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("artwork refresh command", func() {
It("requires at least a kind and one id", func() {
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar"})).To(HaveOccurred())
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred())
Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1", "id2"})).ToNot(HaveOccurred())
})
})
var _ = Describe("artwork reprocess selection", func() {
It("errors when no selector is given", func() {
_, err := selectedKinds(nil, nil, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("--all"))
})
It("returns every kind for --all", func() {
ks, err := selectedKinds(nil, nil, true)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(ConsistOf(artwork.RecheckKinds))
})
It("returns every kind for a source filter given without a kind", func() {
ks, err := selectedKinds(nil, []string{"folder"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection")
})
It("returns only the named kinds", func() {
ks, err := selectedKinds([]string{"ar"}, nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork}))
})
It("keeps a named kind selection alongside a source filter", func() {
ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork}))
})
It("rejects an unknown kind", func() {
_, err := selectedKinds([]string{"zz"}, nil, false)
Expect(err).To(HaveOccurred())
})
It("counts a repeated kind once", func() {
ks, err := selectedKinds([]string{"ar", "ar"}, nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork}))
})
})
var _ = Describe("explain/reprocess source round trip", func() {
ctx := context.Background()
// storedSource reads back the Source line explain printed, as an operator would copy it.
storedSource := func(out string) string {
GinkgoHelper()
for line := range strings.SplitSeq(out, "\n") {
if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok {
return strings.TrimSpace(after)
}
}
Fail("explain printed no Source line")
return ""
}
It("names the absent state as reprocess --source accepts it", func() {
ds := &tests.MockDataStore{}
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
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(),
"explain's spelling of a source must be pasteable into --source")
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
"a parenthesised name would be rejected, so explain must not print one")
})
})
var _ = Describe("repositorySources", func() {
It("maps the user-facing absent name onto the stored empty source", func() {
Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"}))
})
It("keeps an empty selection empty, meaning every source", func() {
Expect(repositorySources(nil)).To(BeEmpty())
})
})
var _ = Describe("promptConfirm", func() {
var out strings.Builder
BeforeEach(func() { out.Reset() })
It("states the external cost and accepts an explicit yes", func() {
Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("re-resolve 42 items"))
Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated"))
})
It("defaults to no on anything else", func() {
Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse())
Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse())
})
It("drops the external clause when no lookup will be made", func() {
Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("re-resolve 3 items."))
Expect(out.String()).ToNot(ContainSubstring("External lookups"))
})
})
var _ = Describe("reprocessConfirm", func() {
var out strings.Builder
BeforeEach(func() { out.Reset() })
It("prompts when --yes was not given", func() {
Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("Continue?"))
})
It("bypasses the prompt only for --yes", func() {
Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue())
Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads")
})
})
var _ = Describe("reprocessArtwork", func() {
var ds *tests.MockDataStore
var art *tests.MockArtworkRepo
var queue *tests.MockArtworkQueueRepo
var out strings.Builder
var imageAgents artwork.ImageAgentCount
ctx := context.Background()
kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork}
accept := func(io.Writer, int64, int64) bool { return true }
decline := func(io.Writer, int64, int64) bool { return false }
put := func(kind model.Kind, id, source string) {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
ds = &tests.MockDataStore{}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
out.Reset()
put(model.KindArtistArtwork, "ar-1", "external:deezer")
put(model.KindArtistArtwork, "ar-2", "")
put(model.KindAlbumArtwork, "al-1", "external:deezer")
put(model.KindAlbumArtwork, "al-2", "folder")
})
It("previews the per-kind breakdown and queues nothing on a dry run", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("external:deezer"))
Expect(out.String()).To(ContainSubstring("artist"))
Expect(out.String()).To(ContainSubstring("album"))
Expect(out.String()).To(ContainSubstring("TOTAL"))
Expect(out.String()).To(ContainSubstring("Dry run"))
Expect(queue.Count()).To(BeZero())
})
It("queues nothing when the operator declines", func() {
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Aborted"))
Expect(queue.Count()).To(BeZero())
})
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(Equal(int64(2)))
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
})
It("targets the absent state", func() {
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
Expect(queue.Count()).To(Equal(int64(1)))
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
})
It("reports matched and queued separately when part of the set is already queued", func() {
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
"an already-queued row keeps its priority and backoff")
})
It("stops at a selection that matches nothing instead of prompting", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false,
func(io.Writer, int64, int64) bool {
Fail("must not prompt when there is nothing to queue")
return true
}, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing"))
Expect(queue.Count()).To(BeZero())
})
It("reports an empty selection as a dry run when one was asked for", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches"))
Expect(out.String()).To(ContainSubstring("Dry run"))
})
It("shows the external estimate on a dry run, which never reaches the prompt", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated"))
})
It("bills every agent per item, not one lookup per item", func() {
imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3}
var external int64
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents")
Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated"))
})
It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() {
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed())
// The count includes plugin agents once they load, so the caveat is about a failed load,
// not about plugins being invisible to the CLI.
Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load"))
Expect(out.String()).To(ContainSubstring("local hits may need fewer"))
Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false")
Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false")
})
It("says so when the selection needs no external lookup", func() {
conf.Server.CoverArtPriority = "cover.*"
put(model.KindPlaylistArtwork, "pl-1", "playlist")
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("External lookups: none"))
})
It("counts playlists as external cost when the m3u image fetch is enabled", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.EnableM3UExternalAlbumArt = true
put(model.KindPlaylistArtwork, "pl-1", "playlist")
var external int64
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(external).To(Equal(int64(1)))
Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated"))
})
It("bills a playlist for every album its grid samples, at every agent", func() {
imageAgents = artwork.ImageAgentCount{Album: 3}
put(model.KindPlaylistArtwork, "pl-1", "playlist")
var external int64
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)),
"one playlist samples 4 albums, each walking all 3 album agents")
})
It("counts only the kinds that call an external agent as external cost", func() {
put(model.KindRadioArtwork, "ra-1", "upload")
var total, external int64
capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false }
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork},
nil, imageAgents, false, capture, &out)).To(Succeed())
Expect(total).To(Equal(int64(3)))
Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent")
})
It("rejects an unknown source and names the ones in use", func() {
err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("externa:deezer"))
Expect(err.Error()).To(ContainSubstring("external:deezer"))
Expect(err.Error()).To(ContainSubstring("folder"))
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
Expect(queue.Count()).To(BeZero())
})
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
put(model.KindArtistArtwork, "ar-2", "folder")
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}),
imageAgents, false, accept, &out)).To(Succeed(),
"a reserved source must stay valid once the library has none of it")
Expect(out.String()).To(ContainSubstring("Nothing matches"))
Expect(queue.Count()).To(BeZero())
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
})
It("accepts a source another kind uses, letting the empty selection report itself", func() {
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"},
imageAgents, false, decline, &out)).To(Succeed())
Expect(out.String()).To(ContainSubstring("Nothing matches"),
"a well-formed filter must not be reported as a typo because of the kinds selected")
Expect(queue.Count()).To(BeZero())
})
})
var _ = Describe("artwork status command", func() {
It("takes no arguments", func() {
Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred())
Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred())
})
})
var _ = Describe("collectStatus", func() {
var ds *tests.MockDataStore
var art *tests.MockArtworkRepo
var queue *tests.MockArtworkQueueRepo
ctx := context.Background()
BeforeEach(func() {
ds = &tests.MockDataStore{}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
}
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour))
put(model.KindArtistArtwork, "ar-3", "", "", time.Now())
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
})
It("reports the queue, the source distribution and the absent ages", func() {
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar",
Priority: model.ArtworkPriorityBackfill, Count: 1}))
Expect(rep.sources).To(ContainElements(
sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1},
sourceCount{kind: model.KindArtistArtwork, source: "", count: 2},
sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1},
))
Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork,
ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}))
})
It("compares the stored fingerprint against the current one", func() {
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
rep, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(rep.stored).To(Equal("old-fingerprint"))
Expect(rep.current).To(Equal(artwork.ConfigFingerprint()),
"the CLI must report the value backfill itself compares")
})
It("queues nothing", func() {
_, err := collectStatus(ctx, ds)
Expect(err).ToNot(HaveOccurred())
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
})
})
var _ = Describe("formatStatus", func() {
var rep statusReport
BeforeEach(func() {
rep = statusReport{
queue: []model.ArtworkQueueStat{
{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
},
sources: []sourceCount{
{kind: model.KindArtistArtwork, source: "external:deezer", count: 5},
{kind: model.KindArtistArtwork, source: "", count: 2},
},
absent: []absentCount{
{kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}},
},
inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}},
stored: "abc123",
current: "abc123",
}
})
// block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere.
block := func(out, header string) string {
GinkgoHelper()
_, after, found := strings.Cut(out, header+"\n")
Expect(found).To(BeTrue(), "the %q block must be printed", header)
body, _, _ := strings.Cut(after, "\n\n")
return body
}
It("names the kind and the priority of every queued row", func() {
queue := block(formatStatus(rep), "Queue")
Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`))
Expect(queue).To(MatchRegexp(`album\s+scan\s+1`))
})
It("totals the queue", func() {
Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`))
})
It("counts each source, naming the empty one absent", func() {
sources := block(formatStatus(rep), "Sources")
Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`))
Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`))
})
It("prints the absent total and how many are due for recheck", func() {
absent := block(formatStatus(rep), "Absent (resolved, no image found)")
Expect(absent).To(MatchRegexp(`artist\s+2\s+1`))
})
It("states the recheck window the absent counts are bucketed against", func() {
Expect(formatStatus(rep)).To(ContainSubstring("24h"))
})
It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() {
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`),
"an operator scanning for trouble must not read 'up to date' while 2 items churn")
Expect(out).To(ContainSubstring("fingerprint up to date"))
})
It("keeps the re-enqueue warning while a backfill is already running", func() {
rep.stored = "older"
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`))
Expect(out).To(ContainSubstring("re-enqueued"),
"the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one")
})
It("reports up to date only once the backfill has drained", func() {
rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}}
Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`))
})
It("echoes the config inputs a fingerprint change would have come from", func() {
out := block(formatStatus(rep), "Backfill")
Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`))
Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed")
})
It("reports a changed fingerprint as a pending re-resolve of everything", func() {
rep.stored = "older"
rep.queue = nil
out := formatStatus(rep)
Expect(out).To(ContainSubstring("fingerprint changed"))
Expect(out).ToNot(ContainSubstring("up to date"))
})
It("reports a never-recorded fingerprint without printing an empty value", func() {
rep.stored = ""
out := formatStatus(rep)
Expect(out).To(ContainSubstring("(none)"))
Expect(out).To(ContainSubstring("fingerprint changed"))
})
It("says the queue is empty instead of printing a headless table", func() {
rep.queue = nil
out := formatStatus(rep)
Expect(out).To(ContainSubstring("empty"))
Expect(out).ToNot(ContainSubstring("PRIORITY"))
})
})
var _ = Describe("refreshItems", func() {
var ds *tests.MockDataStore
var queue *tests.MockArtworkQueueRepo
var art *tests.MockArtworkRepo
var out strings.Builder
ctx := context.Background()
BeforeEach(func() {
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
ds = &tests.MockDataStore{MockedAlbum: albums}
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
out.Reset()
})
It("clears the stored state and queues each id at Bump priority", func() {
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-1", "al-3"}, &out)).To(BeZero())
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
})
It("skips an id that does not exist instead of queuing it", func() {
Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-2"}, &out)).To(Equal(1))
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
Expect(out.String()).To(BeEmpty())
})
It("continues past a failing id and counts the failures", func() {
Expect(refreshItems(ctx, ds, model.KindAlbumArtwork,
[]string{"al-1", "al-2", "al-3"}, &out)).To(Equal(1))
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"),
"the ids after a failure are still refreshed")
})
})
var _ = Describe("needsImageAgents", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("is false for a selection no agent can serve", func() {
Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse())
})
// The generated playlist grid resolves album art through the image agents, so a playlist
// selection needs the count even though no agent is asked for a playlist image directly.
It("is true for playlists, whose grid tiles resolve through the album chain", func() {
Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue())
})
It("is true when any one of several kinds can reach an agent", func() {
Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue())
})
It("is false once the chains no longer reach an agent", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.ArtistArtPriority = "artist.*"
Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse())
})
})
var _ = Describe("configuredAgents", func() {
BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) })
It("splits and trims the configured list", func() {
conf.Server.Agents = "lastfm, spotify ,deezer"
Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"}))
})
// An empty name would match no plugin, but it also must not make the list look non-empty:
// LoadPlugins treats an empty list as "load nothing".
It("drops empty entries rather than passing a name nothing can match", func() {
conf.Server.Agents = " , ,"
Expect(configuredAgents()).To(BeEmpty())
})
})

View File

@ -9,7 +9,6 @@ import (
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/navidrome/navidrome/conf"
@ -314,7 +313,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
return sb.String(), w.Error()
case "table":
var sb strings.Builder
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
w := newTabWriter(&sb)
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
for _, p := range list {
name, version := manifestSummary(p)

View File

@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"io"
"text/tabwriter"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
@ -13,6 +15,11 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// newTabWriter keeps every CLI table on the same column settings.
func newTabWriter(out io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
}
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)

View File

@ -223,6 +223,18 @@ func CreateArtworkWorker() *artwork.Worker {
return worker
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
fFmpeg := ffmpeg.New()
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
return tracingResolver
}
func getPluginManager() *plugins.Manager {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)

View File

@ -144,6 +144,13 @@ func CreateArtworkWorker() *artwork.Worker {
))
}
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
panic(wire.Build(
allProviders,
artwork.NewTracingResolver,
))
}
func getPluginManager() *plugins.Manager {
panic(wire.Build(
allProviders,

View File

@ -5,6 +5,7 @@ import (
"encoding"
"encoding/json"
"fmt"
"math"
"net/url"
"os"
"path/filepath"
@ -90,6 +91,7 @@ type configOptions struct {
EnableUserEditing bool
EnableArtworkUpload bool
MaxImageUploadSize string
MaxImageSize string
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
@ -421,7 +423,8 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateMaxImageUploadSize,
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
validateByteSize("MaxImageSize", Server.MaxImageSize),
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
@ -481,6 +484,14 @@ func Load(noConfigDump bool) {
Server.UICoverArtSize = newValue
}
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
if imgSize < uploadSize {
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
Server.MaxImageSize = Server.MaxImageUploadSize
}
// Call init hooks
for _, hook := range hooks {
hook()
@ -806,11 +817,20 @@ func validatePurgeMissingOption() error {
return nil
}
func validateMaxImageUploadSize() error {
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
func validateByteSize(name, value string) func() error {
return func() error {
size, err := humanize.ParseBytes(value)
if err != nil {
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
}
if size == 0 {
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
}
if size > math.MaxInt64 {
return fmt.Errorf("invalid %s %q: value is too large", name, value)
}
return nil
}
return nil
}
func validateEnforceNonRootUser() error {
@ -980,6 +1000,7 @@ func setViperDefaults() {
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
viper.SetDefault("enableartworkupload", true)
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
viper.SetDefault("enablesharing", true)
viper.SetDefault("shareurl", "")
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)

View File

@ -336,19 +336,10 @@ var _ = Describe("Configuration", func() {
})
Describe("ValidateMaxImageUploadSize", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
Describe("ValidateByteSize", func() {
DescribeTable("accepts valid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
},
Entry("megabytes", "10MB"),
Entry("gigabytes", "1GB"),
@ -359,14 +350,39 @@ var _ = Describe("Configuration", func() {
DescribeTable("rejects invalid size values",
func(input string) {
conf.Server.MaxImageUploadSize = input
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
},
Entry("garbage string", "not-a-size"),
Entry("negative-looking", "-10MB"),
Entry("zero", "0"),
Entry("zero with unit", "0MB"),
Entry("overflows int64", "9223372036854775808"),
)
})
Describe("MaxImageSize floor", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("is raised to MaxImageUploadSize when configured lower", func() {
viper.SetDefault("maximagesize", "5MB")
viper.SetDefault("maximageuploadsize", "50MB")
conf.Load(true)
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
})
It("keeps a larger MaxImageSize unchanged", func() {
viper.SetDefault("maximagesize", "30MB")
conf.Load(true)
Expect(conf.Server.MaxImageSize).To(Equal("30MB"))
})
})
Describe("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)

View File

@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend
var ToPascalCase = toPascalCase
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
var ValidateByteSize = validateByteSize
func SetRuntimeInfoForTest(goos string, euid int) func() {
oldGOOS := currentGOOS

View File

@ -112,6 +112,7 @@ const (
const (
DefaultUICoverArtSize = 300
DefaultMaxImageUploadSize = "10MB"
DefaultMaxImageSize = "20MB"
)
// Prometheus options

View File

@ -34,10 +34,10 @@ var _ = Describe("Agents", func() {
})
It("calls the placeholder GetArtistImages", func() {
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
})
})

View File

@ -53,6 +53,8 @@ func (s Song) Equals(other Song) bool {
}
var (
// ErrNotFound means the provider answered and had nothing. Return the underlying error
// for a fault instead, or callers that back off on faults will treat it as definitive.
ErrNotFound = errors.New("not found")
)

View File

@ -5,6 +5,8 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/slice"
)
const LocalAgentName = "local"
@ -37,14 +39,51 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
if err != nil {
return nil, err
}
var result []Song
for _, s := range top {
result = append(result, Song{
Name: s.Title,
MBID: s.MbzReleaseTrackID,
})
return songsFrom(top), nil
}
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
seed, err := p.ds.MediaFile(ctx).Get(id)
if err != nil {
return nil, err
}
return result, nil
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
if len(genreIDs) == 0 {
return nil, nil
}
// Ask for extra so we can drop the seed itself and still fill the count.
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
Filters: squirrel.And{
persistence.SongGenres.ByID(genreIDs),
squirrel.Eq{"missing": false},
},
Max: count + 1,
})
if err != nil {
return nil, err
}
filtered := make(model.MediaFiles, 0, len(candidates))
for _, s := range candidates {
if s.ID == id {
continue
}
filtered = append(filtered, s)
if len(filtered) >= count {
break
}
}
return songsFrom(filtered), nil
}
func songsFrom(mfs model.MediaFiles) []Song {
if len(mfs) == 0 {
return nil
}
return slice.Map(mfs, func(mf model.MediaFile) Song {
return Song{ID: mf.ID, Name: mf.Title}
})
}
func init() {

View File

@ -0,0 +1,96 @@
package agents
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
var ds *tests.MockDataStore
var mfRepo *tests.MockMediaFileRepo
var agent *localAgent
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
mfRepo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
agent = &localAgent{ds: ds}
})
It("excludes the seed track from its own similars", func() {
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
mfRepo.SetData(model.MediaFiles{seed, related})
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
Expect(err).ToNot(HaveOccurred())
names := slice.Map(songs, func(s Song) string { return s.Name })
Expect(names).ToNot(ContainElement("Seed"))
})
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
// would pass just as well with no genre filter at all.
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
rock := model.NewTag(model.TagGenre, "Rock")
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
mfRepo.SetData(model.MediaFiles{seed})
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
Expect(err).ToNot(HaveOccurred())
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
Expect(sqlErr).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
Expect(sql).To(ContainSubstring("missing"))
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
})
It("returns the library id so the matcher can resolve the song", func() {
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
// so the local fallback silently returns an empty mix.
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
mfRepo.SetData(model.MediaFiles{seed, related})
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
})
It("asks for one extra candidate so dropping the seed still fills the count", func() {
// The mock returns rows sorted by id, so the seed comes first and would consume the only
// slot if the query did not over-fetch.
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
mfRepo.SetData(model.MediaFiles{seed, related})
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].Name).To(Equal("Related"))
})
It("returns nil when the seed track has no genres", func() {
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
mfRepo.SetData(model.MediaFiles{seed})
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
// Without the early return an empty tag filter would scan the whole library.
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
})
})

View File

@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
)
@ -40,7 +41,13 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitra
}
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id})
// Match by album-artist participation, not the deprecated album_artist_id
// column (first album artist only), so co-album-artists are included too.
filter := squirrel.And{
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
}
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
}
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {

View File

@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/stream"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
@ -69,8 +70,11 @@ var _ = Describe("Archiver", func() {
mfRepo := &mockMediaFileRepository{}
mfRepo.On("GetAll", []model.QueryOptions{{
Filters: squirrel.Eq{"album_artist_id": "1"},
Sort: "album",
Filters: squirrel.And{
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
squirrel.Eq{"missing": false},
},
Sort: "album",
}}).Return(mfs, nil)
ds.On("MediaFile", mock.Anything).Return(mfRepo)

View File

@ -47,10 +47,17 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
// Synthetic artists would otherwise get an unrelated agent result assigned to them.
switch ar.ID {
case consts.UnknownArtistID, consts.VariousArtistsID:
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"})
return nil, "", false
}
name := externalName(ar.Name)
for _, a := range ag.ArtistImageAgents() {
imageAgents := ag.ArtistImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides artist images"})
return nil, "", false
}
for _, a := range imageAgents {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
if err != nil {
@ -76,7 +83,13 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
// fetchAlbumImage is the album counterpart of fetchArtistImage.
func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) {
name, artist := externalName(al.Name), externalName(al.AlbumArtist)
for _, a := range ag.AlbumImageAgents() {
imageAgents := ag.AlbumImageAgents()
if len(imageAgents) == 0 {
traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"})
return nil, "", false
}
for _, a := range imageAgents {
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
if err != nil {

View File

@ -173,6 +173,30 @@ var _ = Describe("agent images", func() {
Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents")
})
It("records a skipped external candidate when no agent provides artist images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"})
Expect(r).To(BeNil())
Expect(extErr).To(BeFalse())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
Detail: "no enabled agent provides artist images"}}),
"a configured external token must never be silently absent from the chain")
})
It("records a skipped external candidate for synthetic artists", func() {
a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}}
ag := imageAgents(a)
t := &ChainTrace{}
_, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate,
model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"})
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped))
Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic"))
})
It("clears typographic characters from the query name unless preserving unicode", func() {
conf.Server.DevPreserveUnicodeInExternalCalls = false
a := &fakeImageAgent{name: "agentA"}
@ -231,6 +255,18 @@ var _ = Describe("agent images", func() {
Expect(a.albumCalls).To(Equal(1))
})
It("records a skipped external candidate when no agent provides album images", func() {
ag := imageAgents()
t := &ChainTrace{}
r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"})
Expect(r).To(BeNil())
Expect(extErr).To(BeFalse())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped,
Detail: "no enabled agent provides album images"}}),
"a configured external token must never be silently absent from the chain")
})
It("reports extErr when the only agent fails transiently", func() {
a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded}
ag := imageAgents(a)

View File

@ -11,6 +11,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@ -118,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't
// hammer external services; below staleAbsentAge to catch younger absences.
// hammer external services; below StaleAbsentAge to catch younger absences.
const requestRecheckAge = time.Hour
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
@ -317,16 +318,15 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
return nil, err
}
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
selectImage := func() (io.ReadCloser, string, error) {
funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority)
return selectImageReader(ctx, artID, funcs...)
selectImage := func() (io.ReadCloser, error) {
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
return res.reader, err
}
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
// Disc art has no state row, hence no content hash: keying on id, album mtime and
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square,
func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err })
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, err
@ -386,6 +386,54 @@ func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkI
return model.ArtworkID{}, model.ErrNotFound
}
// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records
// the walk and reports the winning source, without ever writing artwork state.
type TracingResolver struct {
inner *resolver
trace *ChainTrace
}
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. With live
// false the external tier is reported but never called.
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
gate := offlineGate(t)
if live {
// A diagnostic must show the provider's real answer, and one item is at most one call
// per agent, so --live deliberately bypasses the rate limiter and circuit breaker.
gate = tracingGate(t, passthroughGate)
}
return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), trace: t}
}
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
// ("" when none produced an image).
func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) {
switch kind {
case model.KindArtistArtwork:
return r.explain(ctx, r.inner.resolveArtist, id)
case model.KindAlbumArtwork:
return r.explain(ctx, r.inner.resolveAlbum, id)
case model.KindDiscArtwork:
return r.explain(ctx, r.inner.resolveDisc, id)
case model.KindMediaFileArtwork:
return r.explain(ctx, r.inner.resolveMediaFile, id)
}
return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind)
}
// explain discards the bytes: nothing downstream persists this resolution, so nothing else
// would close the reader either.
func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) {
res, err := resolve(withTrace(ctx, r.trace), id)
if err != nil {
return "", err
}
if res.reader != nil {
_ = res.reader.Close()
}
return res.source, nil
}
func unixMtime(mtime int64) time.Time {
if mtime <= 0 {
return time.Time{}

View File

@ -113,27 +113,71 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
}, nil
}
func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
var ff []sourceFunc
// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at
// all, so a chain walk can say why instead of leaving a configured entry unaccounted for.
type discCandidate struct {
pattern string
resolve func() (resolution, bool)
skip string
}
func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate {
folder := func(sf sourceFunc) func() (resolution, bool) {
return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) }
}
var cc []discCandidate
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
c := discCandidate{pattern: pattern}
switch {
case pattern == "embedded":
ff = append(ff,
fromTag(ctx, d.lib.FS, d.firstTrackRel),
fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)),
)
case pattern == "external":
// Not supported for disc art, silently ignore
case pattern == "discsubtitle":
if subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]); subtitle != "" {
ff = append(ff, d.fromDiscSubtitle(ctx, subtitle))
c.resolve = func() (resolution, bool) {
return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel)
}
case len(d.imgFiles) > 0:
ff = append(ff, d.fromExternalFile(ctx, pattern))
case pattern == externalCandidate:
c.skip = "external sources are not supported for disc artwork"
case pattern == "discsubtitle":
subtitle := strings.TrimSpace(d.album.Discs[d.discNumber])
if subtitle == "" {
c.skip = "disc has no subtitle"
} else {
c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle))
}
case len(d.imgFiles) == 0:
c.skip = "no images in album folder"
default:
c.resolve = folder(d.fromExternalFile(ctx, pattern))
}
cc = append(cc, c)
}
return cc
}
// selectImage walks the DiscArtPriority entries and returns the first that yields an image.
// chain records the walk; the serving path passes an untraced one and pays nothing for it.
func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string,
chain *chainState) (resolution, error) {
for _, c := range d.discCandidates(ctx, ffmpeg, priority) {
if err := ctx.Err(); err != nil {
return resolution{}, err
}
if c.skip != "" {
chain.record(c.pattern, OutcomeSkipped, c.skip)
continue
}
start := time.Now()
res, ok := c.resolve()
log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID,
"disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath,
"elapsed", time.Since(start))
if res, ok = chain.try(c.pattern, res, ok); ok {
return res, nil
}
}
return ff
return chain.exhausted(), nil
}
// fromDiscSubtitle returns a sourceFunc that matches image files whose stem

View File

@ -6,6 +6,8 @@ import (
"path/filepath"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -179,19 +181,19 @@ var _ = Describe("Disc Artwork Reader", func() {
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*")
Expect(ff).To(HaveLen(2))
r, path, err := ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f2))
r.Close()
cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*")
Expect(cc).To(HaveLen(2))
res, ok := cc[0].resolve()
Expect(ok).To(BeTrue())
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2)))
res.reader.Close()
ff = reader.fromDiscArtPriority(ctx, nil, "cover.*, disc*.*")
Expect(ff).To(HaveLen(2))
r, path, err = ff[0]()
Expect(err).ToNot(HaveOccurred())
Expect(path).To(Equal(f1))
r.Close()
cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*")
Expect(cc).To(HaveLen(2))
res, ok = cc[0].resolve()
Expect(ok).To(BeTrue())
Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1)))
res.reader.Close()
})
DescribeTable("numbered match wins over shared fallback within a pattern",
@ -428,64 +430,109 @@ var _ = Describe("Disc Artwork Reader", func() {
})
Describe("discArtworkReader", func() {
Describe("fromDiscArtPriority", func() {
var (
reader *discArtworkReader
tmpDir string
var (
reader *discArtworkReader
tmpDir string
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
Describe("selectImage", func() {
It("abandons the walk when the context is cancelled", func() {
ctx, cancel := context.WithCancel(context.Background())
cancel()
res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{})
Expect(err).To(MatchError(context.Canceled))
Expect(res.reader).To(BeNil())
})
// "the track has no embedded art" and "the track is there but unreadable" are the two
// answers a wrong-artwork report needs told apart; only the second is worth retrying.
It("reports a track it cannot parse as unreadable, not as a miss", func() {
trace := &ChainTrace{}
track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel))
Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed())
Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed())
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
&chainState{trace: trace})
Expect(err).ToNot(HaveOccurred())
Expect(res.localError).To(BeTrue())
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}}))
})
It("reports a disc with no tracks to read as a miss", func() {
trace := &ChainTrace{}
reader.firstTrackRel = ""
res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded",
&chainState{trace: trace})
Expect(err).ToNot(HaveOccurred())
Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read")
Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}}))
})
})
Describe("discCandidates", func() {
It("returns a resolvable candidate for glob patterns", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*")
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).ToNot(BeNil())
})
It("returns one candidate per entry, in order", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })).
To(Equal([]string{"disc*.*", "cd*.*", "embedded"}))
})
It("skips an empty entry rather than building a glob that matches nothing", func() {
cc := reader.discCandidates(context.Background(), nil, "disc*.*,")
Expect(cc).To(HaveLen(1))
})
// The skip reasons below are what `artwork explain` prints, so an entry that maps to no
// source must say why instead of vanishing from the walk.
DescribeTable("keeps an entry that maps to no source, with its reason",
func(setup func(), priority, reason string) {
setup()
cc := reader.discCandidates(context.Background(), nil, priority)
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).To(BeNil())
Expect(cc[0].skip).To(Equal(reason))
},
Entry("external is unsupported", func() {}, "external",
"external sources are not supported for disc artwork"),
Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*",
"no images in album folder"),
Entry("the disc has no subtitle",
func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle",
"disc has no subtitle"),
)
BeforeEach(func() {
tmpDir = GinkgoT().TempDir()
reader = &discArtworkReader{
discNumber: 2,
isMultiFolder: true,
discFoldersRel: map[string]bool{"music/album/cd2": true},
imgFiles: []string{
"music/album/cd1/disc.jpg",
"music/album/cd2/disc.jpg",
"music/album/cd2/disc2.jpg",
},
firstTrackRel: "music/album/cd2/track1.flac",
lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir},
}
})
It("returns source funcs for glob patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(1))
})
It("returns source funcs for embedded pattern", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "embedded")
Expect(ff).To(HaveLen(2)) // fromTag + fromFFmpegTag
})
It("handles multiple comma-separated patterns", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*, cd*.*, embedded")
Expect(ff).To(HaveLen(4)) // disc*.* + cd*.* + fromTag + fromFFmpegTag
})
It("ignores 'external' pattern silently", func() {
ff := reader.fromDiscArtPriority(context.Background(), nil, "external")
Expect(ff).To(HaveLen(0))
})
It("returns no source funcs when imgFiles is empty and pattern is not embedded", func() {
reader.imgFiles = nil
ff := reader.fromDiscArtPriority(context.Background(), nil, "disc*.*")
Expect(ff).To(HaveLen(0))
})
It("returns source func for discsubtitle pattern", func() {
reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(1))
})
It("returns no source func for discsubtitle when disc has no subtitle", func() {
reader.album = model.Album{Discs: model.Discs{2: ""}}
ff := reader.fromDiscArtPriority(context.Background(), nil, "discsubtitle")
Expect(ff).To(HaveLen(0))
cc := reader.discCandidates(context.Background(), nil, "discsubtitle")
Expect(cc).To(HaveLen(1))
Expect(cc[0].resolve).ToNot(BeNil())
})
})
})

View File

@ -6,6 +6,7 @@ import (
"errors"
"io"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -116,6 +117,25 @@ var _ = Describe("Acquisition → serve loop", func() {
albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
}
It("acquires and serves a cover whose format has no registered decoder (#5950)", func() {
libDir := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
conf.Server.CoverArtPriority = "cover.*"
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
bump("al", "al1")
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false)
Expect(err).ToNot(HaveOccurred())
Expect(img.Placeholder).To(BeFalse())
Expect(readAll(img)).To(Equal(jxlFixture))
})
It("acquires album folder art and serves the exact bytes under its hash", func() {
seedFolderAlbum("al1")
bump("al", "al1")
@ -317,6 +337,9 @@ func mustGet(img *artwork.Image, err error) *artwork.Image {
}
// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking
// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
// the production import the spec above guards.
var gifFixture = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00,

View File

@ -1,6 +1,7 @@
package artwork
import (
"context"
"errors"
"io"
"sync"
@ -16,6 +17,11 @@ import (
const (
breakerThreshold = 5
breakerProbeAfter = time.Minute
// breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the
// provider again. One is not enough: a provider that is rate-limiting or blocking us still
// answers the occasional request, and closing on the first of those puts the agent straight
// back to full rate, which is what earns the next block.
breakerRecoveries = 3
)
var errBreakerOpen = errors.New("artwork: external circuit breaker open")
@ -43,7 +49,8 @@ type extGate struct {
// gate runs a named external step through that agent's rate limiter and circuit breaker.
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
g := w.gateFor(name)
if !g.breaker.allow() {
allowed, gen := g.breaker.allow()
if !allowed {
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
return nil, "", errBreakerOpen
}
@ -54,7 +61,7 @@ func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.
}
callStart := time.Now()
r, path, err := f()
g.breaker.record(name, err)
g.breaker.record(name, gen, err)
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
return r, path, err
@ -78,43 +85,70 @@ func (w *Worker) gateFor(name string) *extGate {
}
// breaker opens after breakerThreshold consecutive errors and admits a single probe once
// breakerProbeAfter has elapsed; a success re-closes it.
// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers.
type breaker struct {
mu sync.Mutex
failures int
openedAt time.Time
// recoveries counts consecutive good answers while open; a single failure discards them.
recoveries int
// generation identifies the current open episode, so an answer from a call admitted before
// the breaker opened cannot be mistaken for evidence that it has recovered.
generation int
}
func newBreaker() *breaker { return &breaker{} }
func (b *breaker) allow() bool {
// allow reports whether a call may proceed, and the open episode it was admitted under: zero
// when the breaker was closed, the current generation when admitted as a half-open probe.
func (b *breaker) allow() (bool, int) {
b.mu.Lock()
defer b.mu.Unlock()
if b.failures < breakerThreshold {
return true
return true, 0
}
if time.Since(b.openedAt) >= breakerProbeAfter {
b.openedAt = time.Now() // start a fresh probe window so only one caller passes
return true
return true, b.generation
}
return false
return false, 0
}
func (b *breaker) record(name string, err error) {
func (b *breaker) record(name string, gen int, err error) {
// A cancelled run says nothing about the provider, so it neither counts nor clears.
if errors.Is(err, context.Canceled) {
return
}
b.mu.Lock()
defer b.mu.Unlock()
// A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal.
if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) {
if b.failures >= breakerThreshold {
log.Info("Artwork: Circuit breaker closed for agent", "agent", name)
if isTransientExternal(err) {
b.recoveries = 0
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
b.generation++
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
}
return
}
if b.failures < breakerThreshold {
b.failures = 0
return
}
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
// Only a probe from this open episode is evidence of recovery. The worker drains concurrently,
// so answers keep arriving from calls admitted before the breaker opened; counting those would
// close it with no probe interval elapsed, which is the burst this exists to prevent.
if gen == 0 || gen != b.generation {
return
}
// A not-found counts because the provider did answer, but on its own it is thin evidence that
// a provider which just blocked us is well.
b.recoveries++
if b.recoveries < breakerRecoveries {
return
}
log.Info("Artwork: Circuit breaker closed for agent", "agent", name,
"consecutiveAnswers", b.recoveries)
b.failures, b.recoveries = 0, 0
}

43
core/artwork/gate_test.go Normal file
View File

@ -0,0 +1,43 @@
package artwork
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// allowed drops the generation token when a caller only cares about admission.
func allowed(b *breaker) bool { ok, _ := b.allow(); return ok }
var _ = Describe("breaker", func() {
// The worker drains concurrently, so when the breaker opens there are already calls past
// allow(), queued in the rate limiter or waiting on a response. Their answers arrive
// afterwards. Counting those as recovery closes the breaker with no probe interval elapsed,
// which is the burst the ramp exists to prevent. No clock is involved: the race is an
// ordering, so it is reproduced by making the calls in the order concurrency produces.
It("ignores answers from calls admitted before it opened", func() {
b := newBreaker()
// A batch clears allow() while the breaker is still closed.
for range breakerThreshold + breakerRecoveries {
ok, gen := b.allow()
Expect(ok).To(BeTrue())
Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe")
}
// The fast failures in that batch open it.
for range breakerThreshold {
b.record("agentA", 0, errors.New("blocked"))
}
Expect(allowed(b)).To(BeFalse(), "breaker is open")
// The slower answers from the same batch land now.
for range breakerRecoveries {
b.record("agentA", 0, nil)
}
Expect(allowed(b)).To(BeFalse(),
"answers from calls admitted before the breaker opened must not close it")
})
})

View File

@ -6,6 +6,8 @@ import (
"encoding/hex"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
@ -16,27 +18,54 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
const staleAbsentAge = 24 * time.Hour
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
const StaleAbsentAge = 24 * time.Hour
// recheckKinds omits media files: they resolve embedded-only, at scan or on view.
var recheckKinds = []model.Kind{
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
var RecheckKinds = []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork,
}
// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc
// 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 }
// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds
// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker
// resolves them, it just never revisits them on its own.
var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork)
// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable.
func hasRecheckPath(prefix string) bool {
kind, ok := model.ParseKind(prefix)
return ok && slices.Contains(recheckKinds, kind)
return ok && slices.Contains(RecheckKinds, kind)
}
// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change.
const artworkEpoch = 1
// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
func fingerprint() string {
raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d",
conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder,
conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch)
// FingerprintInput is one config value the fingerprint covers, named after the setting it came from.
type FingerprintInput struct {
Name string
Value string
}
// FingerprintInputs is the single listing of what ConfigFingerprint hashes.
func FingerprintInputs() []FingerprintInput {
return []FingerprintInput{
{"CoverArtPriority", conf.Server.CoverArtPriority},
{"ArtistArtPriority", conf.Server.ArtistArtPriority},
{"ArtistImageFolder", conf.Server.ArtistImageFolder},
{"Agents", conf.Server.Agents},
{"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)},
{"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)},
}
}
// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state.
func ConfigFingerprint() string {
values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value })
raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch)
sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive
return hex.EncodeToString(sum[:])
}
@ -45,7 +74,7 @@ func fingerprint() string {
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
start := time.Now()
ctx = auth.WithAdminUser(ctx, ds)
current := fingerprint()
current := ConfigFingerprint()
props := ds.Property(ctx)
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
if err != nil {
@ -95,9 +124,9 @@ func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kin
}
func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
cutoff := time.Now().Add(-staleAbsentAge)
cutoff := time.Now().Add(-StaleAbsentAge)
queue := ds.ArtworkQueue(ctx)
for _, kind := range recheckKinds {
for _, kind := range RecheckKinds {
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
return err
}
@ -108,7 +137,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
queue := ds.ArtworkQueue(ctx)
for _, kind := range recheckKinds {
for _, kind := range RecheckKinds {
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
return err
}

View File

@ -52,6 +52,19 @@ func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error
return o.MockArtworkQueueRepo.Enqueue(items...)
}
var _ = Describe("RefreshableKinds", func() {
// The two are meant to describe the same fact. Nothing but this test stops them from drifting,
// and a drift would have `artwork explain` report state for a kind that keeps none.
It("holds exactly the kinds that keep state", func() {
for _, k := range []model.Kind{
model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork,
model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork,
} {
Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String())
}
})
})
var _ = Describe("Housekeeping", func() {
var (
ctx context.Context
@ -93,32 +106,54 @@ var _ = Describe("Housekeeping", func() {
Describe("Fingerprint", func() {
It("changes when a fingerprint-affecting config value changes", func() {
f1 := fingerprint()
f1 := ConfigFingerprint()
conf.Server.CoverArtPriority = "folder, embedded"
f2 := fingerprint()
f2 := ConfigFingerprint()
Expect(f1).NotTo(Equal(f2))
})
It("changes when ArtistImageFolder changes", func() {
conf.Server.ArtistImageFolder = "/before"
f1 := fingerprint()
f1 := ConfigFingerprint()
conf.Server.ArtistImageFolder = "/after"
Expect(fingerprint()).NotTo(Equal(f1))
Expect(ConfigFingerprint()).NotTo(Equal(f1))
})
It("changes when EnableM3UExternalAlbumArt is toggled", func() {
conf.Server.EnableM3UExternalAlbumArt = false
f1 := fingerprint()
f1 := ConfigFingerprint()
conf.Server.EnableM3UExternalAlbumArt = true
Expect(fingerprint()).NotTo(Equal(f1))
Expect(ConfigFingerprint()).NotTo(Equal(f1))
})
// Pinned: a changed formula re-resolves every library on upgrade, flooding external providers.
It("hashes a given config to a stable value", func() {
conf.Server.CoverArtPriority = "cover.*, embedded"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.ArtistImageFolder = ""
conf.Server.Agents = "lastfm,spotify"
conf.Server.EnableExternalServices = true
conf.Server.EnableM3UExternalAlbumArt = false
Expect(ConfigFingerprint()).To(Equal("7e537a22febc07d3d5ca40546e88da54"))
})
It("reports the config inputs it hashes, so a change can be traced to a setting", func() {
conf.Server.Agents = "lastfm,spotify"
conf.Server.CoverArtPriority = "cover.*, embedded"
Expect(FingerprintInputs()).To(ContainElements(
FingerprintInput{Name: "Agents", Value: "lastfm,spotify"},
FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"},
))
})
It("does not change when the server version changes", func() {
original := consts.Version
DeferCleanup(func() { consts.Version = original })
f1 := fingerprint()
f1 := ConfigFingerprint()
consts.Version = original + "-next"
Expect(fingerprint()).To(Equal(f1),
Expect(ConfigFingerprint()).To(Equal(f1),
"the version must not invalidate artwork state: it would re-resolve every entity on every build")
})
})
@ -126,7 +161,7 @@ var _ = Describe("Housekeeping", func() {
Describe("Backfill", func() {
It("enqueues nothing and returns false when the stored fingerprint matches", func() {
seedEntities()
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed())
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
did, err := backfill(ctx, ds)
Expect(err).ToNot(HaveOccurred())
@ -150,7 +185,7 @@ var _ = Describe("Housekeeping", func() {
stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey)
Expect(err).ToNot(HaveOccurred())
Expect(stored).To(Equal(fingerprint()))
Expect(stored).To(Equal(ConfigFingerprint()))
})
It("enqueues a private playlist by resolving it under an admin context", func() {

View File

@ -55,6 +55,10 @@ func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) {
if err != nil {
return nil, err
}
// An open() that reports "no image" as a nil reader would otherwise panic on the Close below.
if orig == nil {
return nil, ErrUnavailable
}
defer orig.Close()
data, err := readCapped(orig)
if err != nil {

View File

@ -0,0 +1,41 @@
package artwork
import (
"context"
"errors"
"io"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resizedItem", func() {
Describe("Reader", func() {
newItem := func(open func() (io.ReadCloser, error)) *resizedItem {
return &resizedItem{hash: "abc123", size: 300, open: open}
}
It("reports a nil reader as unavailable instead of panicking on it", func() {
// Every caller is expected to report "no image" as an error, but a nil reader reaches
// the deferred Close as a nil interface, which takes the whole request down.
_, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background())
Expect(err).To(MatchError(ErrUnavailable))
})
It("propagates the open error", func() {
boom := errors.New("boom")
_, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background())
Expect(err).To(MatchError(boom))
})
It("serves the original bytes when they cannot be resized", func() {
rc, err := newItem(func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("not an image")), nil
}).Reader(context.Background())
Expect(err).ToNot(HaveOccurred())
defer rc.Close()
Expect(io.ReadAll(rc)).To(Equal([]byte("not an image")))
})
})
})

View File

@ -13,6 +13,8 @@ import (
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/core/artwork/dominant"
"github.com/navidrome/navidrome/core/artwork/thumbhash"
@ -51,7 +53,9 @@ const thumbnailSize = 100
// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at
// an arbitrarily large endpoint.
const maxImageBytes = 20 << 20
func maxImageBytes() int64 {
return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize)
}
// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that
// image.Decode would expand into gigabytes.
@ -119,12 +123,20 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
art, err := repo.GetImage(hash)
switch {
case err == nil:
case err == nil && art.Width > 0:
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
"id", item.ItemID, "hash", hash)
case errors.Is(err, model.ErrNotFound):
// A row with no dimensions was stored when no decoder matched; retry in case one exists now.
case err == nil, errors.Is(err, model.ErrNotFound):
decodeStart := time.Now()
art, err = decodeArtwork(ctx, hash, data)
// Extension-matched local bytes we cannot decode are most likely a codec we lack; an
// external body carries no such guarantee, and empty bytes are no image at all.
if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) {
log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
art, err = undecodedArtwork(hash), nil
}
if err != nil {
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
@ -202,12 +214,13 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A
}
func readCapped(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1))
limit := maxImageBytes()
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if len(data) > maxImageBytes {
return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes)
if int64(len(data)) > limit {
return nil, fmt.Errorf("image exceeds size cap %d", limit)
}
return data, nil
}
@ -229,6 +242,12 @@ func decodeCapped(data []byte) (image.Image, string, error) {
return img, format, nil
}
// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions
// and none of the placeholders a decode would have produced.
func undecodedArtwork(hash string) *model.Artwork {
return &model.Artwork{Hash: hash, Mime: mimeForFormat("")}
}
// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two
// placeholder hashes, both encoded from one shared downscaled thumbnail.
func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) {
@ -283,6 +302,11 @@ func isFileBacked(source string) bool {
return source == "folder" || source == "upload"
}
// isLocalSource reports whether the bytes came off disk rather than off the network.
func isLocalSource(source string) bool {
return isFileBacked(source) || source == "embedded"
}
// placeBytes reports the item's backing-file provenance and writes the bytes into the store
// for the sources that have none.
func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) {

View File

@ -1,6 +1,7 @@
package artwork
import (
"bytes"
"context"
"encoding/binary"
"errors"
@ -24,6 +25,9 @@ import (
. "github.com/onsi/gomega"
)
// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder.
var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00}
// DecodeConfig reads only the header, so the pixel data can be omitted entirely.
func pngHeaderWithDims(w, h uint32) []byte {
ihdr := make([]byte, 13)
@ -259,6 +263,59 @@ var _ = Describe("processor.acquire", func() {
Expect(ia.Source).To(Equal("folder"))
})
It("undecodable local file: acquires it anyway, with no placeholder metadata", func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
Expect(out).To(Equal(outcomeFound))
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
art, err := artRepo.GetImage(ia.Hash)
Expect(err).ToNot(HaveOccurred())
Expect(art.Width).To(BeZero())
Expect(art.BlurHash).To(BeEmpty())
})
It("empty local file: fails without writing state", func() {
libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
// An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover.
It("undecodable external body: fails without writing state", func() {
conf.Server.CoverArtPriority = "external"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html>rate limited</html>"))
}))
DeferCleanup(srv.Close)
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}})
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
Expect(out).To(Equal(outcomeFailed))
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() {
conf.Server.CoverArtPriority = "external"
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
@ -374,7 +431,8 @@ var _ = Describe("processor.acquire", func() {
conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg")
Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed())
// Truncated PNG: a known format, so this is a real decode failure, not a missing decoder.
Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed())
radioRepo := tests.CreateMockedRadioRepo()
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}}
@ -394,7 +452,7 @@ var _ = Describe("processor.acquire", func() {
imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg")
f, err := os.Create(imgPath)
Expect(err).ToNot(HaveOccurred())
Expect(f.Truncate(maxImageBytes + 1)).To(Succeed())
Expect(f.Truncate(maxImageBytes() + 1)).To(Succeed())
Expect(f.Close()).To(Succeed())
radioRepo := tests.CreateMockedRadioRepo()
@ -428,6 +486,54 @@ var _ = Describe("processor.acquire", func() {
Expect(err.Error()).To(ContainSubstring("dimensions"))
})
It("unknown format: reports ErrFormat so the caller can decide", func() {
_, err := decodeArtwork(ctx, "jxl", jxlCodestream)
Expect(err).To(MatchError(image.ErrFormat))
})
It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() {
art := undecodedArtwork("jxl")
Expect(art.Hash).To(Equal("jxl"))
Expect(art.Mime).To(Equal("application/octet-stream"))
Expect(art.Width).To(BeZero())
Expect(art.Height).To(BeZero())
Expect(art.BlurHash).To(BeEmpty())
Expect(art.ThumbHash).To(BeEmpty())
Expect(art.DominantColor).To(BeEmpty())
})
It("corrupt image of a known format: still fails", func() {
data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing
_, err := decodeArtwork(ctx, "truncated", data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("decode image"))
})
// Without this a metadata-less row would be reused forever, so a decoder added later
// could never upgrade it.
It("metadata-less row: re-decodes on reuse instead of skipping", func() {
libRoot := GinkgoT().TempDir()
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred())
Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed())
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}})
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}}
hash, err := hashImage(bytes.NewReader(imgBytes))
Expect(err).ToNot(HaveOccurred())
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
Expect(out).To(Equal(outcomeFound))
upgraded, err := artRepo.GetImage(hash)
Expect(err).ToNot(HaveOccurred())
Expect(upgraded.Width).To(BeNumerically(">", 0))
Expect(upgraded.BlurHash).ToNot(BeEmpty())
})
It("store write failure: fails without writing state", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
@ -493,3 +599,14 @@ var _ = Describe("makeThumbnail", func() {
Expect(thumb.Pix[3]).To(BeNumerically("==", 128))
})
})
var _ = Describe("maxImageBytes", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("reads MaxImageSize from config", func() {
conf.Server.MaxImageSize = "30MB"
Expect(maxImageBytes()).To(Equal(int64(30_000_000)))
})
})

View File

@ -34,18 +34,31 @@ type resolution struct {
// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
type chainState struct{ extErr, localErr bool }
type chainState struct {
extErr, localErr bool
trace *ChainTrace // nil unless the CLI asked for a trace
}
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
func (c *chainState) try(res resolution, ok bool) (resolution, bool) {
func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) {
if ok {
res.extError = c.extErr
c.record(candidate, OutcomeHit, res.sourcePath)
return res, true
}
c.localErr = c.localErr || res.localError
if res.localError {
c.record(candidate, OutcomeUnreadable, "")
} else {
c.record(candidate, OutcomeMiss, "")
}
return resolution{}, false
}
func (c *chainState) record(candidate string, out Outcome, detail string) {
c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail})
}
// exhausted is the outcome when no source in the chain yielded an image.
func (c *chainState) exhausted() resolution {
return resolution{extError: c.extErr, localError: c.localErr}
@ -95,8 +108,69 @@ func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (re
}
}
// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network,
// so a local-only resolver is stopped here rather than at each point in the chain walk.
// Explainable reports whether TracingResolver can walk this kind's sources and report which one
// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain.
func Explainable(kind model.Kind) bool {
switch kind {
case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork:
return true
}
return false
}
// MayFetchExternal reports whether resolving this kind can issue an external request under the
// current config. Playlists inherit the album chain: the generated grid resolves album art.
func MayFetchExternal(kind model.Kind) bool {
switch kind {
case model.KindArtistArtwork:
return chainFetchesExternal(conf.Server.ArtistArtPriority)
case model.KindAlbumArtwork:
return chainFetchesExternal(conf.Server.CoverArtPriority)
case model.KindPlaylistArtwork:
return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority)
default:
return false
}
}
// ImageAgentCount is how many enabled agents provide artist and album images.
type ImageAgentCount struct{ Artist, Album int }
// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is
// tried, and a zero count still bills one, so agents the caller cannot see never read as free.
func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 {
if !MayFetchExternal(kind) {
return 0
}
switch kind {
case model.KindArtistArtwork:
return int64(max(agents.Artist, 1))
case model.KindAlbumArtwork:
return int64(max(agents.Album, 1))
case model.KindPlaylistArtwork:
var n int64
if conf.Server.EnableM3UExternalAlbumArt {
n++
}
if chainFetchesExternal(conf.Server.CoverArtPriority) {
n += PlaylistGridSamples * int64(max(agents.Album, 1))
}
return n
}
return 0
}
func chainFetchesExternal(priority string) bool {
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
if strings.TrimSpace(pattern) == externalCandidate {
return true
}
}
return false
}
// Album and artist fetches stop here when the resolver is local-only, rather than at each point in
// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself.
func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) {
if r.ext == nil {
return nil, "", false
@ -126,24 +200,31 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
return resolution{}, err
}
var chain chainState
chain := chainState{trace: traceFrom(ctx)}
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
switch {
case pattern == "embedded":
if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok {
res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
case pattern == "external":
case pattern == externalCandidate:
if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil {
return resolution{reader: rd, source: "external:" + name}, nil
return resolution{reader: rd, source: ExternalPrefix + name}, nil
} else if isErr {
chain.extErr = true
}
case len(imgFiles) > 0:
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok {
res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
default:
chain.record(pattern, OutcomeSkipped, "no images in album folder")
}
}
return chain.exhausted(), nil
@ -155,9 +236,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
if err != nil {
return resolution{}, err
}
upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload")
if ok {
return upload, nil
chain := chainState{trace: traceFrom(ctx)}
upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload")
if res, ok := chain.try("upload", upload, uploadOK); ok {
return res, nil
}
if upload.localError {
// The upload outranks every other source; falling through would persist a lower-priority
@ -191,32 +273,43 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
}
}
var chain chainState
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
switch {
case pattern == "external":
case pattern == externalCandidate:
if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil {
return resolution{reader: rd, source: "external:" + name}, nil
return resolution{reader: rd, source: ExternalPrefix + name}, nil
} else if isErr {
chain.extErr = true
}
case pattern == "image-folder":
if res, ok := chain.try(resolveArtistImageFolder(ar)); ok {
res, ok := resolveArtistImageFolder(ar)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
case strings.HasPrefix(pattern, "album/"):
if lib.FS == nil {
chain.record(pattern, OutcomeSkipped, "artist has no albums")
continue
}
if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok {
res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
default:
if lib.FS == nil || artistFolder == "" {
if lib.FS == nil {
chain.record(pattern, OutcomeSkipped, "artist has no albums")
continue
}
if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok {
if artistFolder == "" {
chain.record(pattern, OutcomeSkipped, "no artist folder")
continue
}
res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)
if res, ok = chain.try(pattern, res, ok); ok {
return res, nil
}
}
@ -224,6 +317,9 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
return chain.exhausted(), nil
}
// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid.
const PlaylistGridSamples = 4
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
pl, err := r.ds.Playlist(ctx).Get(playlistID)
@ -269,7 +365,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"})
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil {
return resolution{}, err
}
@ -295,7 +392,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
if decErr == nil {
tiles = append(tiles, tile)
}
if len(tiles) == 4 {
if len(tiles) == PlaylistGridSamples {
break
}
}
@ -337,15 +434,35 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
if err != nil {
return resolution{}, err
}
if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt {
chain := chainState{trace: traceFrom(ctx)}
switch {
case !conf.Server.EnableMediaFileCoverArt:
chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off")
return resolution{}, nil
case !mf.HasCoverArt:
chain.record("embedded", OutcomeMiss, "the track has no embedded cover art")
return resolution{}, nil
}
lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID)
if err != nil {
return resolution{}, err
}
res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
return res, nil
res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path)
if res, ok = chain.try("embedded", res, ok); ok {
return res, nil
}
return chain.exhausted(), nil
}
// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never
// queued: the serving path reads it through on every request, so this only ever explains.
func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) {
dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id})
if err != nil {
return resolution{}, err
}
chain := chainState{trace: traceFrom(ctx)}
return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain)
}
// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
@ -353,7 +470,7 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
r, path, err := gate(name, sf)
if r != nil {
return resolution{reader: r, source: "external", sourcePath: path}, true, false
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false
}
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
}
@ -394,14 +511,20 @@ func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, em
return resolution{localError: unreadable}, false
}
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)()
// resolveFolderSource turns a source that yields a library-relative image path into a folder
// resolution, keeping an existing-but-unopenable file distinct from an absent one.
func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) {
r, path, err := sf()
if r == nil {
return resolution{localError: errors.Is(err, errSourceUnreadable)}, false
}
return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true
}
func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) {
return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern))
}
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
folder := conf.Server.ArtistImageFolder
if folder == "" {

View File

@ -394,6 +394,28 @@ var _ = Describe("resolveItem", func() {
Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1),
)
// The grid samples album art through the full album chain, so a playlist reaches the
// network even with the m3u fetch off.
It("calls the album image agents for its grid tiles when m3u art is disabled", func() {
conf.Server.EnableM3UExternalAlbumArt = false
conf.Server.CoverArtPriority = "external"
folderRepo.result = nil
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}})
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}}
ds.MockedPlaylist = plRepo
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")})
var gatedNames []string
gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
gatedNames = append(gatedNames, name)
return f()
}
_, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"})
Expect(err).ToNot(HaveOccurred())
Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album")
})
It("resolves the uploaded image before the generated grid", func() {
tmpDir := GinkgoT().TempDir()
conf.Server.DataFolder = conf.NewDir(tmpDir)
@ -625,8 +647,115 @@ var _ = Describe("decodeTile", func() {
})
It("rejects a tile larger than the size cap", func() {
data := bytes.Repeat([]byte{0}, maxImageBytes+1)
data := bytes.Repeat([]byte{0}, int(maxImageBytes())+1)
_, err := decodeTile(io.NopCloser(bytes.NewReader(data)))
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("Explainable", func() {
It("is true for the kinds the resolver walks", func() {
Expect(Explainable(model.KindArtistArtwork)).To(BeTrue())
Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue())
Expect(Explainable(model.KindDiscArtwork)).To(BeTrue())
Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue())
})
It("is false for the kinds resolved from a fixed internal order", func() {
Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse())
Expect(Explainable(model.KindRadioArtwork)).To(BeFalse())
})
})
var _ = Describe("MayFetchExternal", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, embedded"
conf.Server.ArtistArtPriority = "artist.*"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("is true for the kinds whose chain includes the external candidate", func() {
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue())
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue())
})
It("is false for a chain with no external candidate", func() {
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse())
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse())
})
It("is true for playlists when the m3u image fetch is enabled", func() {
conf.Server.EnableM3UExternalAlbumArt = true
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
})
It("is true for playlists whose grid tiles resolve through an external album chain", func() {
conf.Server.CoverArtPriority = "cover.*, external"
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
})
It("is false for playlists with both paths off", func() {
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse())
})
It("is false for the kinds that only read local files", func() {
conf.Server.CoverArtPriority = "external"
conf.Server.ArtistArtPriority = "external"
conf.Server.EnableM3UExternalAlbumArt = true
Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse())
Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse())
})
})
var _ = Describe("ExternalLookupsPerItem", func() {
count := ImageAgentCount{Artist: 3, Album: 2}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.*, external"
conf.Server.ArtistArtPriority = "artist.*, external"
conf.Server.EnableM3UExternalAlbumArt = false
})
It("bills one call per agent, since the walk only stops early on a hit", func() {
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3)))
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2)))
})
It("bills a playlist for every album its grid samples", func() {
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
To(Equal(int64(PlaylistGridSamples) * 2))
})
It("adds the m3u image fetch on top of the grid", func() {
conf.Server.EnableM3UExternalAlbumArt = true
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
To(Equal(int64(PlaylistGridSamples)*2 + 1))
})
It("bills only the m3u fetch when the album chain stays local", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.EnableM3UExternalAlbumArt = true
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1)))
})
It("still bills a call when no agent is visible, which plugins never are offline", func() {
none := ImageAgentCount{}
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1)))
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1)))
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)).
To(Equal(int64(PlaylistGridSamples)))
})
It("is zero whenever the kind reaches no agent at all", func() {
conf.Server.CoverArtPriority = "cover.*"
conf.Server.ArtistArtPriority = "artist.*"
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero())
})
})

View File

@ -27,23 +27,6 @@ import (
// to open it is not evidence the entity has no artwork, so callers must not settle on absent.
var errSourceUnreadable = errors.New("artwork source unreadable")
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
for _, f := range extractFuncs {
if ctx.Err() != nil {
return nil, "", ctx.Err()
}
start := time.Now()
r, path, err := f()
if r != nil {
msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind)
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
return r, path, nil
}
log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
}
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
}
type sourceFunc func() (r io.ReadCloser, path string, err error)
func (f sourceFunc) String() string {

99
core/artwork/trace.go Normal file
View File

@ -0,0 +1,99 @@
package artwork
import (
"context"
"errors"
"io"
"slices"
"sync"
)
// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
type Outcome string
const (
OutcomeHit Outcome = "hit"
OutcomeMiss Outcome = "miss"
OutcomeUnreadable Outcome = "unreadable"
OutcomeSkipped Outcome = "skipped"
OutcomeWouldTry Outcome = "would-try"
OutcomeError Outcome = "error"
)
const (
// externalCandidate labels the external tier itself, for the cases that never reach an agent.
externalCandidate = "external"
// ExternalPrefix qualifies a candidate or a stored source with the agent that produced it.
ExternalPrefix = externalCandidate + ":"
)
// TraceStep is one candidate the priority chain considered.
type TraceStep struct {
Candidate string
Outcome Outcome
Detail string
}
// ChainTrace collects the walk of a single resolution. The artwork worker never attaches
// one; only the CLI does, so resolution stays allocation-free in the hot path.
type ChainTrace struct {
mu sync.Mutex
steps []TraceStep
}
func (t *ChainTrace) add(step TraceStep) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.steps = append(t.steps, step)
}
func (t *ChainTrace) Steps() []TraceStep {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
return slices.Clone(t.steps)
}
type traceCtxKey struct{}
func withTrace(ctx context.Context, t *ChainTrace) context.Context {
return context.WithValue(ctx, traceCtxKey{}, t)
}
func traceFrom(ctx context.Context) *ChainTrace {
t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace)
return t
}
var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)")
// tracingGate records each external agent's outcome without changing what the gate returns.
func tracingGate(t *ChainTrace, inner gateFunc) gateFunc {
return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
r, path, err := inner(name, f)
candidate := ExternalPrefix + name
switch {
case r != nil:
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
case isTransientExternal(err):
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
default:
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
}
return r, path, err
}
}
// offlineGate reports which agents would be asked without asking them, so a diagnostic
// command cannot add load to a provider that is already rate-limiting us.
func offlineGate(t *ChainTrace) gateFunc {
return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry})
return nil, "", errOfflineSkipped
}
}

628
core/artwork/trace_test.go Normal file
View File

@ -0,0 +1,628 @@
package artwork
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("trace vocabulary", func() {
// The CLI renders these verbatim and branches on them; a value change is a change to
// what `artwork explain` tells an operator, so it must be made deliberately.
It("pins the wire values the CLI reads", func() {
Expect([]Outcome{
OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError,
}).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"}))
Expect(externalCandidate).To(Equal("external"))
Expect(ExternalPrefix).To(Equal("external:"))
})
})
var _ = Describe("chainTrace", func() {
It("returns nil when no trace is attached", func() {
Expect(traceFrom(context.Background())).To(BeNil())
})
It("collects steps in order", func() {
t := &ChainTrace{}
ctx := withTrace(context.Background(), t)
traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss})
traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"})
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "cover.*", Outcome: OutcomeMiss},
{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"},
}))
s := t.Steps()
s[0].Candidate = "mutated"
Expect(t.Steps()[0].Candidate).To(Equal("cover.*"))
})
It("does not panic when the trace is nil", func() {
var t *ChainTrace
Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic())
Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it")
})
It("is safe to use concurrently", func() {
t := &ChainTrace{}
done := make(chan struct{})
for range 10 {
go func() {
defer GinkgoRecover()
t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss})
done <- struct{}{}
}()
}
for range 10 {
<-done
}
Expect(t.Steps()).To(HaveLen(10))
})
})
var _ = Describe("chainState tracing", func() {
It("records a miss when the candidate was absent", func() {
t := &ChainTrace{}
c := chainState{trace: t}
_, ok := c.try("cover.*", resolution{}, false)
Expect(ok).To(BeFalse())
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}}))
})
It("records unreadable when the candidate existed but could not be read", func() {
t := &ChainTrace{}
c := chainState{trace: t}
_, ok := c.try("cover.*", resolution{localError: true}, false)
Expect(ok).To(BeFalse())
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable),
"a candidate that existed and failed to decode must be distinguishable from one that was absent")
})
It("records a hit with the backing path", func() {
t := &ChainTrace{}
c := chainState{trace: t}
res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true)
Expect(ok).To(BeTrue())
Expect(res.source).To(Equal("embedded"))
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"},
}))
})
})
var _ = Describe("external gate tracing", func() {
hit := func() (io.ReadCloser, string, error) {
return io.NopCloser(strings.NewReader("x")), "http://img", nil
}
miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound }
boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") }
It("records a hit with the image path", func() {
t := &ChainTrace{}
g := tracingGate(t, passthroughGate)
r, _, err := g("deezer", hit)
Expect(err).ToNot(HaveOccurred())
Expect(r).ToNot(BeNil())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"},
}))
})
It("records a miss for a not-found", func() {
t := &ChainTrace{}
_, _, _ = tracingGate(t, passthroughGate)("deezer", miss)
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss))
})
It("records a miss for a model not-found", func() {
t := &ChainTrace{}
notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound }
_, _, _ = tracingGate(t, passthroughGate)("deezer", notFound)
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss),
"both not-found flavours are definitive answers, not faults")
})
It("records an error with its reason", func() {
t := &ChainTrace{}
_, _, _ = tracingGate(t, passthroughGate)("apple-music", boom)
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError))
Expect(t.Steps()[0].Detail).To(ContainSubstring("429"))
})
It("never calls the agent in offline mode", func() {
t := &ChainTrace{}
called := false
counting := func() (io.ReadCloser, string, error) {
called = true
return hit()
}
_, _, err := offlineGate(t)("deezer", counting)
Expect(called).To(BeFalse(), "offline mode must not perform external requests")
Expect(err).To(MatchError(errOfflineSkipped))
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "external:deezer", Outcome: OutcomeWouldTry},
}))
})
})
var _ = Describe("resolveAlbum tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
folderRepo *fakeFolderRepo
ffm *tests.MockFFmpeg
ag *agents.Agents
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.jpg, embedded"
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
folderRepo = &fakeFolderRepo{}
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("records a pattern skipped because the album folder holds no images", func() {
albumRepo.SetData(model.Albums{
{ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
})
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it")
Expect(t.Steps()[0]).To(Equal(TraceStep{
Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder",
}))
Expect(t.Steps()[1].Candidate).To(Equal("embedded"))
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
})
It("records an evaluated pattern that matched nothing as a miss, not a skip", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
albumRepo.SetData(model.Albums{
{ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}},
})
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}),
"the folder was searched and held no cover.jpg, which is not the same as never looking")
})
It("ignores an empty priority token", func() {
conf.Server.CoverArtPriority = "cover.jpg,"
albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"},
}))
})
})
var _ = Describe("resolveArtist tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
artistRepo *tests.MockArtistRepo
albumRepo *tests.MockAlbumRepo
folderRepo *fakeFolderRepo
ffm *tests.MockFFmpeg
ag *agents.Agents
t *ChainTrace
repoRoot string
)
uploadPath := func(file string) string {
path := model.UploadedImagePath(consts.EntityArtist, file)
Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed())
Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed())
return path
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.ArtistArtPriority = "album/artist.*"
var err error
repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
artistRepo = tests.CreateMockArtistRepo()
albumRepo = tests.CreateMockAlbumRepo()
folderRepo = &fakeFolderRepo{}
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedAlbum: albumRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
ag = agents.GetAgents(&tests.MockDataStore{}, nil)
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("records the upload short-circuit as a hit", func() {
path := uploadPath("ar1_test.jpg")
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}})
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}}))
})
It("records an upload miss before walking the chain", func() {
artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss}))
})
It("labels each step with the configured priority token", func() {
folderRepo.result = []model.Folder{{
LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"})
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(t.Steps()).To(HaveLen(2))
Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"),
"the step must be labelled with the priority token, not the pattern it was rewritten into")
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png"))
})
It("records a configured pattern that could not be evaluated", func() {
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "upload", Outcome: OutcomeMiss},
{Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"},
}), "a configured pattern that was never evaluated must still appear, and say why")
})
It("records why an artist folder pattern was skipped", func() {
conf.Server.ArtistArtPriority = "artist.*"
artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}})
albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "upload", Outcome: OutcomeMiss},
{Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"},
}))
})
It("records an upload that exists but cannot be read as unreadable", func() {
if runtime.GOOS == "windows" {
Skip("chmod does not restrict read access on Windows")
}
path := uploadPath("ar3_test.jpg")
Expect(os.Chmod(path, 0o000)).To(Succeed())
DeferCleanup(func() { _ = os.Chmod(path, 0o600) })
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"})
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable),
"an upload that exists and will not open must not look like an absent upload")
})
})
var _ = Describe("NewTracingResolver", func() {
var (
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
artistRepo *tests.MockArtistRepo
artworkRepo *tests.MockArtworkRepo
queueRepo *tests.MockArtworkQueueRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
conf.Server.CoverArtPriority = "external, embedded"
conf.Server.ArtistArtPriority = "external"
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
artistRepo = tests.CreateMockArtistRepo()
artworkRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo()
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedArtist: artistRepo,
MockedFolder: &fakeFolderRepo{},
MockedLibrary: libRepo,
MockedArtwork: artworkRepo,
MockedArtworkQueue: queueRepo,
}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
})
Context("offline", func() {
var fake *fakeImageAgent
BeforeEach(func() {
fake = &fakeImageAgent{name: "offline-probe"}
albumRepo.SetData(model.Albums{{
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
}})
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
})
It("reports the external tier without asking any agent", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(Equal("embedded"))
Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider")
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry}))
})
It("records the local chain steps too", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
last := t.Steps()[len(t.Steps())-1]
Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate")
Expect(last.Outcome).To(Equal(OutcomeHit))
})
It("never persists artwork state", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
Expect(artworkRepo.ItemData).To(BeEmpty(),
"an offline resolution carries extError, which must never be recorded as a real provider failure")
Expect(queueRepo.Data).To(BeEmpty())
})
It("resolves an artist without persisting anything", func() {
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(BeEmpty())
Expect(fake.artistCalls).To(BeZero())
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry}))
Expect(artworkRepo.ItemData).To(BeEmpty())
Expect(queueRepo.Data).To(BeEmpty())
})
It("closes the reader it does not hand back", func() {
conf.Server.CoverArtPriority = "embedded"
ffm = tests.NewMockFFmpeg("fake image bytes")
albumRepo.SetData(model.Albums{{
ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"},
}})
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2")
Expect(err).ToNot(HaveOccurred())
Expect(source).To(Equal("embedded"))
Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation")
})
It("propagates a lookup error", func() {
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
It("asks the agents when live is true", func() {
fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound}
albumRepo.SetData(model.Albums{{
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
}})
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
Expect(err).ToNot(HaveOccurred())
Expect(fake.albumCalls).To(Equal(1))
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss}))
})
})
var _ = Describe("resolveDisc tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
albumRepo *tests.MockAlbumRepo
folderRepo *fakeFolderRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
albumRepo = tests.CreateMockAlbumRepo()
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}})
folderRepo = &fakeFolderRepo{}
mfRepo := tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}})
ds = &tests.MockDataStore{
MockedAlbum: albumRepo,
MockedMediaFile: mfRepo,
MockedFolder: folderRepo,
MockedLibrary: libRepo,
}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("accounts for every configured entry, including the ones that map to no source", func() {
conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg"
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).To(BeNil())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"},
{Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"},
{Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"},
}))
})
It("records the entry that won and stops there", func() {
conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded"
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("folder"))
Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it")
Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss}))
Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg"))
Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit))
Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg")))
Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"),
"a library-relative path sends the operator looking in the wrong place")
})
It("reports an unparseable disc id rather than explaining another disc", func() {
conf.Server.DiscArtPriority = "cover.jpg"
_, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("resolveMediaFile tracing", func() {
var (
ctx context.Context
ds *tests.MockDataStore
mfRepo *tests.MockMediaFileRepo
ffm *tests.MockFFmpeg
t *ChainTrace
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{}
libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}})
mfRepo = tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{
ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3",
}})
ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo}
ffm = tests.NewMockFFmpeg("")
t = &ChainTrace{}
ctx = withTrace(context.Background(), t)
})
It("separates a disabled setting from a track with nothing embedded", func() {
conf.Server.EnableMediaFileCoverArt = false
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"},
}))
})
It("records a track with no embedded art as a miss", func() {
mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}})
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2")
Expect(err).ToNot(HaveOccurred())
Expect(t.Steps()).To(Equal([]TraceStep{
{Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"},
}))
})
It("records the embedded hit", func() {
res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")
Expect(err).ToNot(HaveOccurred())
Expect(res.reader).ToNot(BeNil())
defer res.reader.Close()
Expect(res.source).To(Equal("embedded"))
Expect(t.Steps()).To(HaveLen(1))
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit))
})
})

View File

@ -17,10 +17,14 @@ import (
// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default.
func MaxImageUploadSize() int64 {
if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 {
return parseSize(conf.Server.MaxImageUploadSize, consts.DefaultMaxImageUploadSize)
}
func parseSize(value, fallback string) int64 {
if size, err := humanize.ParseBytes(value); err == nil && size > 0 {
return int64(size)
}
size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize)
size, _ := humanize.ParseBytes(fallback)
return int64(size)
}

View File

@ -137,7 +137,7 @@ func (w *Worker) Backfill(ctx context.Context) (bool, error) {
return backfill(ctx, w.proc.ds)
}
// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge.
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge.
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
return enqueueStaleAbsentAll(ctx, w.proc.ds)
}

View File

@ -362,7 +362,7 @@ var _ = Describe("Worker", func() {
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
})
// Media files are excluded from recheckKinds, so an absent row here would never be
// Media files are excluded from RecheckKinds, so an absent row here would never be
// revisited: a transient read error would look permanent.
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
conf.Server.EnableMediaFileCoverArt = true
@ -539,6 +539,43 @@ var _ = Describe("Worker", func() {
Expect(calls).To(Equal(5), "the breaker should have re-closed after the success")
})
It("does not open the breaker when the run is cancelled", func() {
cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled }
for range breakerThreshold + 3 {
_, _, err := w.gate("A", cancelled)
Expect(err).To(MatchError(context.Canceled), "a cancellation passes through, never errBreakerOpen")
}
var calls int
counting := func() (io.ReadCloser, string, error) {
calls++
return nil, "", errors.New("boom")
}
_, _, _ = w.gate("A", counting)
Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs")
})
It("ignores a cancellation mid-run, neither counting nor clearing the failures", func() {
failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") }
cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled }
for range breakerThreshold - 1 {
_, _, _ = w.gate("A", failing)
}
_, _, _ = w.gate("A", cancelled)
var calls int
counting := func() (io.ReadCloser, string, error) {
calls++
return nil, "", errors.New("boom")
}
_, _, _ = w.gate("A", counting)
Expect(calls).To(Equal(1), "the cancellation must not have counted as the final failure")
_, _, err := w.gate("A", counting)
Expect(err).To(MatchError(errBreakerOpen), "the cancellation must not have cleared the earlier failures")
Expect(calls).To(Equal(1), "an open breaker must not call the external step")
})
It("does not open the breaker on a run of agent not-found misses", func() {
// agents.ErrNotFound is a definitive miss, not a fault: artless items must not
// trip the breaker, or they would loop in retry instead of settling absent.

View File

@ -20,24 +20,63 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
b := newBreaker()
for range breakerThreshold {
b.record("agentA", errors.New("boom"))
b.record("agentA", 0, errors.New("boom"))
}
g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors")
g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors")
time.Sleep(breakerProbeAfter - time.Nanosecond)
g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval")
g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval")
time.Sleep(time.Nanosecond)
g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted")
g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval")
ok, gen := b.allow()
g.Expect(ok).To(BeTrue(), "half-open: one probe is granted")
g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to")
g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval")
b.record("agentA", errors.New("boom")) // probe fails -> stay open
b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open
time.Sleep(breakerProbeAfter)
g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval")
ok, gen = b.allow()
g.Expect(ok).To(BeTrue(), "another probe after the next interval")
b.record("agentA", nil) // probe succeeds -> close
g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely")
g.Expect(b.allow()).To(BeTrue())
// One good answer must not reopen the floodgates: closing here is what let a burst out at
// full rate and got the provider to escalate from throttling to blocking.
b.record("agentA", gen, nil)
g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker")
for range breakerRecoveries - 1 {
time.Sleep(breakerProbeAfter)
ok, gen = b.allow()
g.Expect(ok).To(BeTrue())
b.record("agentA", gen, nil)
}
g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely")
g.Expect(allowed(b)).To(BeTrue())
})
}
// The failure seen in production: while an agent was blocked, the occasional answer it did serve
// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were
// seconds apart, over and over.
func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
g := NewWithT(t)
b := newBreaker()
open := func() {
for range breakerThreshold {
b.record("agentA", 0, errors.New("blocked"))
}
}
open()
// A not-found is an answer, so it counts toward recovery, but never on its own.
for range breakerRecoveries * 2 {
time.Sleep(breakerProbeAfter)
ok, gen := b.allow()
g.Expect(ok).To(BeTrue(), "one probe per interval")
b.record("agentA", gen, agents.ErrNotFound)
b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again
g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker")
}
})
}

View File

@ -6,6 +6,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/stretchr/testify/mock"
)
@ -110,6 +111,19 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
return args.Get(0).(model.MediaFiles), args.Error(1)
}
// GetRandom implements model.MediaFileRepository.
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}
args := m.Called(argsSlice...)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(model.MediaFiles), args.Error(1)
}
// SetError is a helper to set up a generic error for GetAll.
func (m *mockMediaFileRepo) SetError(hasError bool) {
if hasError {
@ -311,3 +325,7 @@ func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid
}
return nil, args.Error(1)
}
func ids(mfs model.MediaFiles) []string {
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
}

View File

@ -16,7 +16,6 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/slice"
"github.com/navidrome/navidrome/utils/str"
"golang.org/x/sync/errgroup"
@ -277,96 +276,6 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, nil
}
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return nil, err
}
var songs []agents.Song
// Try entity-specific similarity first
switch v := entity.(type) {
case *model.MediaFile:
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
case *model.Album:
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
case *model.Artist:
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
default:
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
return nil, model.ErrNotFound
}
if err == nil && len(songs) > 0 {
return e.matcher.MatchSongs(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
return e.similarSongsFallback(ctx, id, count)
}
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
// a weighted random selection of songs to return as similar songs.
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
}
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err()
}
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return ctx.Err()
}
topCount := max(count, 20)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
}
weight := topCount * (4 + artistWeight)
for _, mf := range topSongs {
weightedSongs.Add(mf, weight)
weight -= 4
}
return nil
}
err = addArtist(artist.Artist, weightedSongs, count, 10)
if err != nil {
return nil, err
}
for _, a := range artist.SimilarArtists {
err := addArtist(a, weightedSongs, count, 0)
if err != nil {
return nil, err
}
}
var similarSongs model.MediaFiles
for len(similarSongs) < count && weightedSongs.Size() > 0 {
s, err := weightedSongs.Pick()
if err != nil {
log.Warn(ctx, "Error getting weighted song", err)
continue
}
similarSongs = append(similarSongs, s)
}
return similarSongs, nil
}
func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) {
artist, err := e.findArtist(ctx, artistName, id)
if err != nil {

299
core/external/provider_similarsongs.go vendored Normal file
View File

@ -0,0 +1,299 @@
package external
import (
"context"
"errors"
"fmt"
"math/rand/v2"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/random"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/sync/errgroup"
)
const (
maxSeeds = 5
// Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is
// what the widest caller (similarAlbums, limit*5) legitimately asks for.
maxSimilarSongs = 500
)
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
// Subsonic passes the client's count straight through: a non-positive one has no valid
// interpretation, and an enormous one overflows the +1 in the local agent's query limit.
if count <= 0 {
return nil, nil
}
count = min(count, maxSimilarSongs)
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
// Genre ids don't resolve via GetEntityByID; look them up before giving up.
if !errors.Is(err, model.ErrNotFound) {
return nil, err
}
genre, err := e.ds.Genre(ctx).Get(id)
if err != nil {
return nil, err
}
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
return e.sampleGenreTracks(ctx, genre, maxSeeds)
})
}
// Try entity-specific similarity first, then fall back to seed-track sampling.
switch v := entity.(type) {
case *model.MediaFile:
return e.mixFromAgent(ctx, count,
func() ([]agents.Song, error) {
return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
},
func() (model.MediaFiles, error) {
return e.similarSongsFallback(ctx, id, count)
})
case *model.Album:
return e.mixFromAgent(ctx, count,
func() ([]agents.Song, error) {
return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
},
func() (model.MediaFiles, error) {
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
return e.sampleAlbumTracks(ctx, v.ID, maxSeeds)
})
})
case *model.Artist:
return e.mixFromAgent(ctx, count,
func() ([]agents.Song, error) {
return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
},
func() (model.MediaFiles, error) {
return e.similarSongsFallback(ctx, id, count)
},
func() (model.MediaFiles, error) {
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
return e.sampleArtistTracks(ctx, v.ID, maxSeeds)
})
})
case *model.Playlist:
return e.seedMix(ctx, count, func() (model.MediaFiles, error) {
return e.samplePlaylistTracks(ctx, v.ID, maxSeeds)
})
default:
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
return nil, model.ErrNotFound
}
}
// mixFromAgent returns the agent's recommendations matched to library tracks, topped up from the
// fallbacks in order when they don't fill the mix on their own.
func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallbacks ...func() (model.MediaFiles, error)) (model.MediaFiles, error) {
var matched model.MediaFiles
if songs, err := fetch(); err == nil {
// Match the whole response: capping at count can stop before a later unique pick.
matched, err = e.matcher.MatchSongs(ctx, songs, len(songs))
if err != nil {
return nil, err
}
}
return topUp(ctx, matched, count, fallbacks...)
}
// topUp draws on each source in turn until the mix holds count distinct tracks.
func topUp(ctx context.Context, res model.MediaFiles, count int, sources ...func() (model.MediaFiles, error)) (model.MediaFiles, error) {
// The matcher can re-emit a track, so a full-looking res may hold fewer than count unique ones.
res = dedupByID(res)
var lastErr error
for _, more := range sources {
if len(res) >= count {
break
}
extra, err := more()
if err != nil {
log.Debug(ctx, "Could not top up a short mix", "have", len(res), "want", count, err)
lastErr = err
continue
}
res = dedupByID(append(res, extra...))
}
if len(res) == 0 {
return nil, lastErr
}
return res[:min(len(res), count)], nil
}
// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges
// the results, falling back to the seeds themselves so the result is never empty.
func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) {
seeds, err := sample()
if err != nil {
return nil, err
}
if len(seeds) == 0 {
return nil, nil
}
seeds = seeds[:min(len(seeds), maxSeeds)]
// The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so
// run them concurrently. Best-effort: a seed that errors just contributes nothing.
perSeed := make([][]agents.Song, len(seeds))
var g errgroup.Group
for i, seed := range seeds {
g.Go(func() error {
if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil {
perSeed[i] = s
}
return nil
})
}
_ = g.Wait()
var songs []agents.Song
for _, s := range perSeed {
songs = append(songs, s...)
}
// Match the whole merged set, not just count of it: the matcher re-emits a track when two
// seeds recommend it identically, so the duplicates have to be dropped before trimming. Every
// seed reaches the shuffle, so no seed can crowd out the others.
matched, err := e.matcher.MatchSongs(ctx, songs, len(songs))
if err != nil {
return nil, err
}
matched = dedupByID(matched)
if len(matched) == 0 {
matched = seeds
}
rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] })
if len(matched) > count {
matched = matched[:count]
}
return matched, nil
}
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
repo := e.ds.Playlist(ctx).Tracks(playlistID, true)
if repo == nil {
return nil, model.ErrNotFound
}
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
// seed wastes an agent call and can reach the mix twice through the seed fallback.
tracks, err := repo.GetAll(model.QueryOptions{
Sort: "random",
Max: n * 4,
Filters: squirrel.Eq{"missing": false},
})
if err != nil {
return nil, err
}
mfs := dedupByID(tracks.MediaFiles())
return mfs[:min(len(mfs), n)], nil
}
func dedupByID(mfs model.MediaFiles) model.MediaFiles {
seen := make(map[string]struct{}, len(mfs))
return slice.Filter(mfs, func(mf model.MediaFile) bool {
if _, dup := seen[mf.ID]; dup {
return false
}
seen[mf.ID] = struct{}{}
return true
})
}
func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) {
return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n)
}
func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) {
// media_file.artist_id is the deprecated primary artist, so it misses an artist credited only
// on the album, as on compilations. Same filter the artist listings use.
filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist)
return e.sampleTracks(ctx, filter, n)
}
func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) {
return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n)
}
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
// missing files would surface as unplayable entries.
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
Max: n,
})
}
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
// a weighted random selection of songs to return as similar songs.
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
}
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return nil, ctx.Err()
}
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return ctx.Err()
}
topCount := max(count, 20)
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
}
weight := topCount * (4 + artistWeight)
for _, mf := range topSongs {
weightedSongs.Add(mf, weight)
weight -= 4
}
return nil
}
err = addArtist(artist.Artist, weightedSongs, count, 10)
if err != nil {
return nil, err
}
for _, a := range artist.SimilarArtists {
err := addArtist(a, weightedSongs, count, 0)
if err != nil {
return nil, err
}
}
// Count distinct tracks, not picks: a collaboration sits in the chooser once per artist that
// lists it, and letting those repeats consume the budget strands unique candidates.
var similarSongs model.MediaFiles
picked := map[string]struct{}{}
for len(similarSongs) < count && weightedSongs.Size() > 0 {
s, err := weightedSongs.Pick()
if err != nil {
log.Warn(ctx, "Error getting weighted song", err)
continue
}
if _, dup := picked[s.ID]; dup {
continue
}
picked[s.ID] = struct{}{}
similarSongs = append(similarSongs, s)
}
return similarSongs, nil
}

View File

@ -3,6 +3,8 @@ package external_test
import (
"context"
"errors"
"math"
"slices"
"strings"
"github.com/Masterminds/squirrel"
@ -26,6 +28,9 @@ var _ = Describe("Provider - SimilarSongs", func() {
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var playlistRepo *tests.MockPlaylistRepo
var playlistTrackRepo *tests.MockPlaylistTrackRepo
var genreRepo *tests.MockedGenreRepo
var ctx context.Context
BeforeEach(func() {
@ -34,11 +39,17 @@ var _ = Describe("Provider - SimilarSongs", func() {
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
playlistTrackRepo = &tests.MockPlaylistTrackRepo{}
playlistRepo = tests.CreateMockPlaylistRepo()
playlistRepo.TracksRepo = playlistTrackRepo
genreRepo = &tests.MockedGenreRepo{}
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
MockedPlaylist: playlistRepo,
MockedGenre: genreRepo,
}
mockAgent = &mockSimilarArtistAgent{}
@ -53,6 +64,18 @@ var _ = Describe("Provider - SimilarSongs", func() {
provider = NewProvider(ds, agentsCombined, matcher.New(ds))
})
// Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive:
// no spec here asserts how many times the entity is looked up.
stubTrackEntity := func() {
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe()
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
}
Describe("dispatch by entity type", func() {
Context("when ID is a MediaFile (track)", func() {
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
@ -71,7 +94,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1).
Return([]agents.Song{
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
}, nil).Once()
@ -114,7 +137,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
return false
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
songs, err := provider.SimilarSongs(ctx, "track-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@ -164,6 +187,76 @@ var _ = Describe("Provider - SimilarSongs", func() {
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("tops the mix up with the fallback when the agent's picks alone are too few", func() {
stubTrackEntity()
// The agent knows one track of the three asked for.
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3).
Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).
Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).
Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(ids(songs)).To(ConsistOf("agent-1", "song-1"))
})
It("reaches a unique pick that sits past the count-th repeat", func() {
stubTrackEntity()
mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Artist{}, nil).Maybe()
mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Song{}, nil).Maybe()
// "Song B" sits past the second repeat, so a matcher capped at count never reaches it.
repeated := agents.Song{Name: "Song A", MBID: "mbid-a"}
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2).
Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
{ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"},
{ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"},
}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
Expect(err).ToNot(HaveOccurred())
Expect(ids(songs)).To(ConsistOf("t-a", "t-b"))
})
It("keeps topping up when the agent's picks repeat a track", func() {
stubTrackEntity()
// The matcher re-emits a track when the same input song repeats, so these three
// picks resolve to only two distinct library tracks.
repeated := agents.Song{Name: "Song A", MBID: "mbid-a"}
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3).
Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
{ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"},
{ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"},
}, nil).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).
Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
})
})
Context("when ID is an Album", func() {
@ -175,7 +268,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1).
Return([]agents.Song{
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
}, nil).Once()
@ -196,53 +289,75 @@ var _ = Describe("Provider - SimilarSongs", func() {
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
songs, err := provider.SimilarSongs(ctx, "album-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() {
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
// and recursively calls getArtist(v.AlbumArtistID)
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
sql, args, err := opt.Filters.ToSql()
return err == nil && strings.Contains(sql, "album_id") &&
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1"))
})).Return(model.MediaFiles{seed}, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
// seedMix falls back to the seed itself when the agent finds nothing.
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything).
Return([]agents.Song{}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
Expect(songs[0].ID).To(Equal("seed-1"))
})
})
Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() {
It("samples the album's tracks and returns their track-similars", func() {
album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"}
artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "al-1").Return(&album, nil).Once()
// AudioMuse doesn't implement album similarity -> empty.
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5).
Return([]agents.Song{}, nil).Once()
// sampleAlbumTracks -> GetRandom(album_id) -> one seed track
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
sql, args, err := opt.Filters.ToSql()
return err == nil && strings.Contains(sql, "album_id") &&
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1"))
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5).
Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once()
// Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A".
aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"}
matchedTrack := model.MediaFile{
ID: "m1", Title: "AudioMuseResult", Artist: "A",
Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}},
}
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "al-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
Expect(songs[0].ID).To(Equal("m1"))
})
})
@ -252,7 +367,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1).
Return([]agents.Song{
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
}, nil).Once()
@ -273,13 +388,454 @@ var _ = Describe("Provider - SimilarSongs", func() {
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
})
Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() {
It("samples the artist's tracks and returns their track-similars", func() {
artist := model.Artist{ID: "ar-1", Name: "The Artist"}
// Get is called twice: once to resolve the entity, once inside similarSongsFallback.
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5).
Return([]agents.Song{}, nil).Once()
// similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups).
mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Artist{}, nil).Maybe()
mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Song{}, nil).Maybe()
// Seeds come from the participant join covering both roles, so an artist credited
// only on the album (compilations, classical) still yields seeds.
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
sql, args, err := opt.Filters.ToSql()
return err == nil && strings.Contains(sql, "media_file_artists") &&
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) &&
slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String()))
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5).
Return([]agents.Song{{Name: "Result"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "ar-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
})
})
Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() {
It("tops the mix up with the artist's own track-similars", func() {
artist := model.Artist{ID: "ar-1", Name: "Thin Artist"}
topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"}
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5).
Return([]agents.Song{}, nil).Once()
// No similar artist is in the library, so the fallback yields only the seed artist's
// own matching top song: one track for a mix of five.
mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once()
mediaFileRepo.On("GetRandom", mock.Anything).
Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).
Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "ar-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(ids(songs)).To(ConsistOf("top-1", "mix-1"))
})
})
Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() {
It("does not pay for seed-track sampling", func() {
artist := model.Artist{ID: "ar-1", Name: "The Artist"}
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3).
Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
{ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"},
{ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"},
}, nil).Once()
// The similar-artists fallback supplies the third track, so the mix is full.
mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything).
Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).
Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once()
mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Song{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "ar-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything)
})
})
Context("when ID is a Playlist", func() {
It("samples playlist tracks and returns their track-similars", func() {
pls := model.Playlist{ID: "pl-1", Name: "My List"}
seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"}
// GetEntityByID order: Artist, Album, Playlist(hit)
artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
// samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: seedTrack},
})
// seedMix -> GetSimilarSongsByTrack for the seed
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5).
Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once()
// Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups).
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "pl-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
})
It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() {
// A smart playlist materializes no playlist_tracks until it is evaluated, so sampling
// without the refresh would mix an empty seed set.
pls := model.Playlist{ID: "pl-smart", Name: "Smart"}
artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
})
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Song{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "pl-smart", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
Expect(playlistRepo.TracksRefreshed).To(BeTrue())
})
It("returns an error instead of panicking when the track repository is unavailable", func() {
// Tracks() logs and returns a nil repository when its own lookup fails.
pls := model.Playlist{ID: "pl-nil", Name: "Gone"}
artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistRepo.TracksRepo = nil
_, err := provider.SimilarSongs(ctx, "pl-nil", 5)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not seed a mix with missing tracks", func() {
pls := model.Playlist{ID: "pl-missing", Name: "Missing"}
artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
})
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return([]agents.Song{}, nil).Maybe()
_, err := provider.SimilarSongs(ctx, "pl-missing", 5)
Expect(err).ToNot(HaveOccurred())
sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql()
Expect(sqlErr).ToNot(HaveOccurred())
Expect(sql).To(ContainSubstring("missing"))
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
})
It("keeps a later seed's picks when an earlier seed overlaps it", func() {
// The matcher stops once it has count matches, and it re-emits the shared track, so
// matching only count of the merged set would spend slots on the duplicate.
pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"}
artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
})
shared := agents.Song{ID: "x", Name: "X"}
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3).
Return([]agents.Song{shared}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3).
Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
{ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"},
}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot")
})
It("returns a track once when two seeds recommend it", func() {
// The matcher re-emits a track when two inputs are identical, so overlapping
// recommendations would otherwise take two slots in the mix.
pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"}
artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
})
shared := agents.Song{ID: "m1", Name: "Shared"}
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5).
Return([]agents.Song{shared}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5).
Return([]agents.Song{shared}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1), "the shared recommendation must appear once")
})
It("does not seed a mix twice with a track the playlist repeats", func() {
pls := model.Playlist{ID: "pl-dup", Name: "Dupes"}
artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
// The same file at two positions, which playlists allow.
dup := model.MediaFile{ID: "s1", Title: "Seed One"}
playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}})
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5).
Return([]agents.Song{}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "pl-dup", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1), "the repeated track must appear once")
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1)
})
It("clamps an enormous count before it reaches the queries", func() {
// count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL
// limit unless Max is positive, so the query would hydrate the whole library.
pls := model.Playlist{ID: "pl-huge", Name: "Huge"}
artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
})
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500).
Return([]agents.Song{}, nil).Once()
_, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64)
Expect(err).ToNot(HaveOccurred())
agentsCombined.AssertExpectations(GinkgoT())
})
It("does not panic when the caller asks for a non-positive count", func() {
// Subsonic passes count straight through, so a negative one reaches the provider.
// The guard returns before any lookup, so no repository setup is needed.
songs, err := provider.SimilarSongs(ctx, "pl-neg", -1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("blends results from every seed, not just the first", func() {
pls := model.Playlist{ID: "pl-blend", Name: "Blend"}
artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
})
// Each seed returns a full count's worth, as a real similarity agent does. Asking for
// one more than seed one can supply makes this independent of the final shuffle:
// three of the four matches always include a seed-two track.
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3).
Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3).
Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
{ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"},
{ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"},
}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "pl-blend", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
ids := ids(songs)
Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix")
})
It("falls back to the seed tracks themselves when no similar songs are found", func() {
pls := model.Playlist{ID: "pl-2", Name: "Fallback List"}
seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"}
seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"}
artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(model.PlaylistTracks{
{MediaFile: seed1},
{MediaFile: seed2},
})
// Both seeds come back empty, so the mix must fall back to the seeds themselves.
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5).
Return([]agents.Song{}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5).
Return([]agents.Song{}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "pl-2", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2"))
})
It("samples the album when the agent's picks are not in this library", func() {
// Last.fm answers from its own catalogue, so a small library can match none of it.
// An unmatched non-empty answer must not shortcut the sampling fallback.
album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"}
artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "al-nm").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5).
Return([]agents.Song{{Name: "Not In Library"}}, nil).Once()
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe()
// The matcher resolves nothing; the sampled seed is what reaches the mix.
mediaFileRepo.On("GetRandom", mock.Anything).
Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5).
Return([]agents.Song{}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "al-nm", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("s1"))
})
It("caps agent calls at maxSeeds when the repository ignores the bound", func() {
// Isolates seedMix's own cap: the album sampler bounds the query with Max, so this
// exercises the guard by having the repo hand back more rows than were asked for.
album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"}
artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "al-cap").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5).
Return([]agents.Song{}, nil).Once()
var overflow model.MediaFiles
for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} {
overflow = append(overflow, model.MediaFile{ID: id, Title: id})
}
mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5).
Return([]agents.Song{}, nil)
_, err := provider.SimilarSongs(ctx, "al-cap", 5)
Expect(err).ToNot(HaveOccurred())
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5)
})
It("still finds maxSeeds distinct seeds when the leading positions repeat", func() {
// The sampler over-fetches for exactly this case: a page bounded at maxSeeds could
// be entirely one repeated file and collapse to a single seed.
pls := model.Playlist{ID: "pl-3", Name: "Big List"}
dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"}
tracks := model.PlaylistTracks{
{MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup},
}
for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} {
tracks = append(tracks, model.PlaylistTrack{
MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"},
})
}
artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once()
playlistRepo.SetData(model.Playlists{pls})
playlistTrackRepo.SetData(tracks)
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5).
Return([]agents.Song{}, nil)
songs, err := provider.SimilarSongs(ctx, "pl-3", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(5))
Expect(playlistTrackRepo.Options.Sort).To(Equal("random"))
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5)
})
})
Context("when ID is a Genre (not resolved by GetEntityByID)", func() {
It("samples genre songs and returns their track-similars", func() {
// GetEntityByID misses everywhere; the empty/auto-created mocks need no setup.
artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}}
// sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan)
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
if opt.Filters == nil {
return false
}
sql, args, err := opt.Filters.ToSql()
return err == nil && strings.Contains(sql, "media_file_tags") &&
!strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false))
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5).
Return([]agents.Song{{Name: "Similar"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "g-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
})
})
})
It("returns similar songs from main artist and similar artists", func() {
@ -341,6 +897,60 @@ var _ = Describe("Provider - SimilarSongs", func() {
}
})
It("keeps picking until the fallback holds count distinct tracks", func() {
// A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed
// count of entries lets those duplicates eat the budget and strand unique candidates.
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"}
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice()
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3).
Return([]agents.Song{}, nil).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return opt.Max == 0 && ok
})).Return(model.Artists{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Or)
return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"}
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).
Return(model.MediaFiles{shared}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
Return([]agents.Song{
{Name: "Shared", MBID: "mbid-a"},
{Name: "B", MBID: "mbid-b"},
{Name: "C", MBID: "mbid-c"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{
shared,
{ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"},
{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"},
}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
@ -383,7 +993,7 @@ var _ = Describe("Provider - SimilarSongs", func() {
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@ -412,6 +1022,13 @@ var _ = Describe("Provider - SimilarSongs", func() {
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
// Fallback yields nothing, so the sampling path is tried and also finds no tracks.
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
sql, args, err := opt.Filters.ToSql()
return err == nil && strings.Contains(sql, "artist_id") &&
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1"))
})).Return(model.MediaFiles{}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())

View File

@ -10,6 +10,7 @@ import (
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
@ -153,6 +154,17 @@ func getFSInfo(path string) *insights.FSInfo {
return &info
}
// installedPackage returns the official installer format used, as written by our own packagers.
func installedPackage() string {
data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package"))
return strings.TrimSpace(string(data))
}
// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image.
func hostingPlatform() string {
return strings.TrimSpace(os.Getenv("ND_PLATFORM"))
}
var staticData = sync.OnceValue(func() insights.Data {
// Basic info
data := insights.Data{
@ -165,11 +177,8 @@ var staticData = sync.OnceValue(func() insights.Data {
data.OS.Containerized = consts.InContainer
// Install info
packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package")
packageFileData, err := os.ReadFile(packageFilename)
if err == nil {
data.OS.Package = string(packageFileData)
}
data.OS.Package = installedPackage()
data.Platform = hostingPlatform()
// OS info
data.OS.Type = runtime.GOOS

View File

@ -4,7 +4,9 @@ type Data struct {
InsightsID string `json:"id"`
Version string `json:"version"`
Uptime int64 `json:"uptime"`
Build struct {
// Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM
Platform string `json:"platform,omitempty"`
Build struct {
// build settings used by the Go compiler
Settings map[string]string `json:"settings"`
GoVersion string `json:"goVersion"`

View File

@ -0,0 +1,70 @@
package metrics
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("installedPackage", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
})
It("returns empty when there's no .package file", func() {
Expect(installedPackage()).To(BeEmpty())
})
It("reads the .package file from the data folder", func() {
writePackageFile("deb")
Expect(installedPackage()).To(Equal("deb"))
})
It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() {
writePackageFile("msi\n")
Expect(installedPackage()).To(Equal("msi"))
})
It("ignores ND_PLATFORM", func() {
GinkgoT().Setenv("ND_PLATFORM", "zimaos")
Expect(installedPackage()).To(BeEmpty())
})
})
var _ = Describe("hostingPlatform", func() {
BeforeEach(func() {
// Setenv registers the restore, then unset so an inherited value can't leak in
GinkgoT().Setenv("ND_PLATFORM", "")
Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed())
})
It("returns empty when ND_PLATFORM is not set", func() {
Expect(hostingPlatform()).To(BeEmpty())
})
It("reads ND_PLATFORM", func() {
GinkgoT().Setenv("ND_PLATFORM", "zimaos")
Expect(hostingPlatform()).To(Equal("zimaos"))
})
It("trims surrounding whitespace", func() {
GinkgoT().Setenv("ND_PLATFORM", " pikapods\n")
Expect(hostingPlatform()).To(Equal("pikapods"))
})
})
func writePackageFile(content string) {
GinkgoHelper()
path := filepath.Join(conf.Server.DataFolder.String(), ".package")
Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed())
}

View File

@ -0,0 +1,17 @@
package metrics
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMetrics(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Metrics Suite")
}

View File

@ -0,0 +1,34 @@
-- +goose Up
create table if not exists media_file_tags(
media_file_id varchar not null
references media_file (id) on delete cascade,
tag_id varchar not null
references tag (id) on delete cascade,
constraint media_file_tags unique (media_file_id, tag_id)
);
create index if not exists media_file_tags_tag_id on media_file_tags (tag_id);
create table if not exists album_tags(
album_id varchar not null
references album (id) on delete cascade,
tag_id varchar not null
references tag (id) on delete cascade,
constraint album_tags unique (album_id, tag_id)
);
create index if not exists album_tags_tag_id on album_tags (tag_id);
-- Backfill genre rows from the per-row `tags` JSON. json_tree over the "$.genre" subtree yields one
-- row per node; the "id" key nodes carry the tag ids. Single scan per table, no correlated subquery.
insert or ignore into media_file_tags (media_file_id, tag_id)
select mf.id, jt.value
from media_file mf, json_tree(mf.tags, '$.genre') jt
where jt.key = 'id' and jt.atom is not null;
insert or ignore into album_tags (album_id, tag_id)
select al.id, jt.value
from album al, json_tree(al.tags, '$.genre') jt
where jt.key = 'id' and jt.atom is not null;
-- +goose Down
drop table if exists media_file_tags;
drop table if exists album_tags;

View File

@ -119,6 +119,8 @@ type ArtworkRepository interface {
}
type ArtworkQueueRepository interface {
// Get returns the pending row for an item, or ErrNotFound when it is not queued.
Get(kind Kind, id, imageType string) (*ArtworkQueueItem, error)
// Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset.
Enqueue(items ...ArtworkQueueItem) error
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
@ -130,6 +132,14 @@ type ArtworkQueueRepository interface {
EnqueueAllMissing(kind Kind, priority int) (int64, error)
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
EnqueueIfMissing(items ...ArtworkQueueItem) error
// CountBySource reports how many items of a kind currently resolve from the given sources.
// An empty sources slice means every source; "" matches absent state.
CountBySource(kind Kind, sources []string) (int64, error)
// SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included.
SourcesInUse(kind Kind) ([]string, error)
// EnqueueBySource inserts queue rows for items of a kind whose current source matches.
// It does not clear existing artwork state: the current image stays until it is replaced.
EnqueueBySource(kind Kind, sources []string, priority int) (int64, error)
// DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc.
// Restricted to the given kinds when any are passed, so one kind cannot block another's drain.
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
@ -139,6 +149,22 @@ type ArtworkQueueRepository interface {
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
Count() (int64, error)
// CountByKindAndPriority reports the pending queue rows grouped by kind and priority.
CountByKindAndPriority() ([]ArtworkQueueStat, error)
// CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent
// would pick up at the given cutoff.
CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error)
// PurgeDangling removes queue rows whose entity no longer exists.
PurgeDangling() (int64, error)
}
type ArtworkQueueStat struct {
ItemKind string
Priority int
Count int64
}
type ArtworkAbsentStat struct {
Total int64
Stale int64
}

View File

@ -11,4 +11,5 @@ type Genres []Genre
type GenreRepository interface {
GetAll(...QueryOptions) (Genres, error)
Get(id string) (*Genre, error)
}

View File

@ -12,7 +12,7 @@ import (
// NOTE: This struct is used as a map key, so it should only contain comparable types.
type ScanTarget struct {
LibraryID int
FolderPath string // Relative path within the library, or "" for entire library
FolderPath string // Path within the library (relative or absolute), or "" for entire library
}
func (st ScanTarget) String() string {

View File

@ -133,7 +133,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
"missing": booleanFilter,
"genre_id": tagIDFilter,
"genre_id": genreFilter(AlbumGenres),
"role_total_id": allRolesFilter,
"library_id": libraryIdFilter,
}
@ -212,7 +212,10 @@ func (r *albumRepository) Put(al *model.Album) error {
return err
}
al.ID = id
return r.updateParticipants(al.ID, al.Participants)
if err := r.updateParticipants(al.ID, al.Participants); err != nil {
return err
}
return r.updateTags(al.ID, al.Tags)
}
// TODO Move external metadata to a separated table

View File

@ -10,6 +10,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
)
@ -31,6 +32,16 @@ func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.Artwor
return r
}
func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) {
var res model.ArtworkQueueItem
err := r.queryOne(Select("*").From(r.tableName).
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res)
if err != nil {
return nil, err
}
return &res, nil
}
// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget.
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
@ -87,12 +98,49 @@ func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueIte
return nil
}
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is
// deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE.
func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) {
return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+
` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...))
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...))
}
// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state.
func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer {
f := And{Eq{"item_kind": kind.Prefix()}}
if len(sources) > 0 {
f = append(f, Eq{"source": sources})
}
return f
}
func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) {
var res struct{ Count int64 }
err := r.queryOne(Select("count(*) as count").From(itemArtworkTable).
Where(artworkSourceFilter(kind, sources)), &res)
return res.Count, err
}
func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) {
var res []struct{ Source string }
err := r.queryAll(Select("distinct source").From(itemArtworkTable).
Where(Eq{"item_kind": kind.Prefix()}), &res)
if err != nil {
return nil, err
}
return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil
}
// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the
// library's artwork until every item is resolved again.
func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) {
now := time.Now()
sel := Select("item_kind", "item_id", "image_type").
Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)).
From(itemArtworkTable).Where(artworkSourceFilter(kind, sources))
return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued))
}
func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error {
@ -146,4 +194,20 @@ func (r *artworkQueueRepository) Count() (int64, error) {
return res.Count, err
}
func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
var res []model.ArtworkQueueStat
err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName).
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res)
return res, err
}
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue.
func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
var res model.ArtworkAbsentStat
err := r.queryOne(Select("count(*) as total").
Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)).
From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res)
return res, err
}
var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil)

View File

@ -54,6 +54,22 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(got[0].ItemID).To(Equal("high"))
})
It("Get returns a queued row, including one still backing off", func() {
Expect(repo.Enqueue(item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed())
backOff("ar", "g1", time.Now().Add(time.Hour))
got, err := repo.Get(model.KindArtistArtwork, "g1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(got.Priority).To(Equal(model.ArtworkPriorityScan))
Expect(got.Attempts).To(Equal(1))
Expect(got.RetryAt).To(BeTemporally(">", time.Now()))
})
It("Get reports ErrNotFound when the item is not queued", func() {
_, err := repo.Get(model.KindArtistArtwork, "nope", model.ImageTypePrimary)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("keeps the higher priority on duplicate enqueue", func() {
Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed())
Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed())
@ -265,6 +281,89 @@ var _ = Describe("ArtworkQueueRepository", func() {
Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive")
})
Describe("EnqueueBySource", func() {
BeforeEach(func() {
artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
for _, ia := range []model.ItemArtwork{
{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"},
{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"},
{ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""},
{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"},
} {
Expect(artRepo.PutItemArtwork(&ia)).To(Succeed())
}
})
It("enqueues only the matching source within the kind", func() {
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched")
got, err := repo.DequeueBatch(10)
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1"))
})
It("treats the empty source as absent", func() {
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
got, _ := repo.DequeueBatch(10)
Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3"))
})
It("enqueues every source when none is given", func() {
n, err := repo.EnqueueBySource(model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(3)))
})
It("leaves the current artwork state in place", func() {
_, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
artRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced")
Expect(ia.Source).To(Equal("external:deezer"))
})
It("does not disturb an already-queued row", func() {
Expect(repo.Enqueue(item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed())
n, err := repo.EnqueueBySource(model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(BeZero())
got, _ := repo.DequeueBatch(10)
Expect(got).To(HaveLen(1))
Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump))
})
It("counts without enqueueing", func() {
n, err := repo.CountBySource(model.KindArtistArtwork, []string{"external:deezer"})
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(int64(1)))
queued, err := repo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(queued).To(BeZero(), "CountBySource must not enqueue")
})
It("counts the absent source and every source", func() {
Expect(repo.CountBySource(model.KindArtistArtwork, []string{""})).To(Equal(int64(1)))
Expect(repo.CountBySource(model.KindArtistArtwork, nil)).To(Equal(int64(3)))
})
It("lists the distinct sources in use by a kind", func() {
Expect(repo.SourcesInUse(model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm"))
Expect(repo.SourcesInUse(model.KindAlbumArtwork)).To(ConsistOf("external:deezer"))
Expect(repo.SourcesInUse(model.KindRadioArtwork)).To(BeEmpty())
})
})
It("does not disturb an already-queued entity when enqueueing missing rows", func() {
Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed())
@ -281,4 +380,43 @@ var _ = Describe("ArtworkQueueRepository", func() {
}
Expect(count).To(Equal(1), "the already-queued row must not be duplicated")
})
Describe("status counters", func() {
It("groups queue rows by kind and priority", func() {
Expect(repo.Enqueue(item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed())
Expect(repo.Enqueue(item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed())
Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed())
Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed())
Expect(repo.CountByKindAndPriority()).To(ConsistOf(
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1},
model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
))
})
It("reports an empty queue as no rows", func() {
Expect(repo.CountByKindAndPriority()).To(BeEmpty())
})
It("counts absent states and how many are due for recheck", func() {
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
old := time.Now().Add(-48 * time.Hour)
for _, ia := range []model.ItemArtwork{
{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old},
{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()},
{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old},
{ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old},
} {
Expect(awRepo.PutItemArtwork(&ia)).To(Succeed())
}
Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))).
To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1}))
})
It("reports a kind with no absent state as zero, not as an error", func() {
Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{}))
})
})
})

View File

@ -30,15 +30,19 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
return res, err
}
// Override ResourceRepository methods to return Genre objects instead of Tag objects
func (r *genreRepository) Read(id string) (any, error) {
func (r *genreRepository) Get(id string) (*model.Genre, error) {
sel := r.selectGenre().Where(Eq{"tag.id": id})
var res model.Genre
err := r.queryOne(sel, &res)
return &res, err
}
// Override ResourceRepository methods to return Genre objects instead of Tag objects
func (r *genreRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}

View File

@ -0,0 +1,108 @@
package persistence
import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("item genre tag indexes", func() {
var conn *dbx.DB
var mr model.MediaFileRepository
var ar model.AlbumRepository
var rock, jazz model.Tag
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid"})
conn = GetDBXBuilder()
mr = NewMediaFileRepository(ctx, conn)
ar = NewAlbumRepository(ctx, conn)
// Test-only genre values, so they can't collide with the golden fixtures.
rock = model.NewTag(model.TagGenre, "GenreIdxRock")
jazz = model.NewTag(model.TagGenre, "GenreIdxJazz")
// The join tables FK to tag(id); the scanner adds tags before saving items.
Expect(NewTagRepository(ctx, conn).Add(1, rock, jazz)).To(Succeed())
// The suite shares one golden DB with no per-test restore, so undo the rows we add
// (media_file/album deletes cascade to the *_tags join rows; tag deletes cascade too).
DeferCleanup(func() {
_, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'mf-%'").Execute()
_, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'al-%'").Execute()
_, _ = conn.NewQuery("DELETE FROM tag WHERE id={:r} OR id={:j}").
Bind(dbx.Params{"r": rock.ID, "j": jazz.ID}).Execute()
})
})
tagIDsFor := func(table, col, id string) []string {
var rows []struct {
TagID string `db:"tag_id"`
}
err := conn.NewQuery("SELECT tag_id FROM " + table + " WHERE " + col + "={:id}").
Bind(dbx.Params{"id": id}).All(&rows)
Expect(err).ToNot(HaveOccurred())
ids := make([]string, len(rows))
for i, r := range rows {
ids[i] = r.TagID
}
return ids
}
Describe("media files", func() {
It("writes a media_file_tags row for each genre when the track is saved", func() {
mf := model.MediaFile{ID: "mf-g1", LibraryID: 1, Path: "/m/g1.mp3", Title: "G1",
Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}}
Expect(mr.Put(&mf)).To(Succeed())
Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g1")).To(ConsistOf(rock.ID, jazz.ID))
})
It("replaces the rows when the genres change", func() {
mf := model.MediaFile{ID: "mf-g2", LibraryID: 1, Path: "/m/g2.mp3", Title: "G2",
Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}}
Expect(mr.Put(&mf)).To(Succeed())
mf.Tags = model.Tags{model.TagGenre: []string{jazz.TagValue}}
Expect(mr.Put(&mf)).To(Succeed())
Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g2")).To(ConsistOf(jazz.ID))
})
It("clears the rows when all genres are removed", func() {
mf := model.MediaFile{ID: "mf-g3", LibraryID: 1, Path: "/m/g3.mp3", Title: "G3",
Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}}
Expect(mr.Put(&mf)).To(Succeed())
mf.Tags = model.Tags{}
Expect(mr.Put(&mf)).To(Succeed())
Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g3")).To(BeEmpty())
})
})
Describe("albums", func() {
It("writes an album_tags row for each genre when the album is saved", func() {
al := model.Album{ID: "al-g1", LibraryID: 1, Name: "AG1",
Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}}
Expect(ar.Put(&al)).To(Succeed())
Expect(tagIDsFor("album_tags", "album_id", "al-g1")).To(ConsistOf(rock.ID, jazz.ID))
})
})
// The native (REST) API filters by genre_id; it must resolve through the join table too.
Describe("native genre_id filter", func() {
It("filters media files by genre_id", func() {
mf := model.MediaFile{ID: "mf-nat1", LibraryID: 1, Path: "/m/nat1.mp3", Title: "Nat1",
Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}}
Expect(mr.Put(&mf)).To(Succeed())
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}})
Expect(err).ToNot(HaveOccurred())
Expect(res.(model.MediaFiles)).To(ContainElement(HaveField("ID", "mf-nat1")))
})
It("filters albums by genre_id", func() {
al := model.Album{ID: "al-nat1", LibraryID: 1, Name: "ANat1",
Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}}
Expect(ar.Put(&al)).To(Succeed())
res, err := ar.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}})
Expect(err).ToNot(HaveOccurred())
Expect(res.(model.Albums)).To(ContainElement(HaveField("ID", "al-nat1")))
})
})
})

View File

@ -111,7 +111,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
"genre_id": tagIDFilter,
"genre_id": genreFilter(SongGenres),
"missing": booleanFilter,
"artists_id": mediaFileArtistFilter,
"library_id": libraryIdFilter,
@ -181,7 +181,10 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
return err
}
m.ID = id
return r.updateParticipants(m.ID, m.Participants)
if err := r.updateParticipants(m.ID, m.Participants); err != nil {
return err
}
return r.updateTags(m.ID, m.Tags)
}
func (r *mediaFileRepository) UpdateProbeData(id string, data string) error {

View File

@ -57,6 +57,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
"album_artist": "order_album_artist_name",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"title": "order_title",
"random": "random()",
// To make sure these fields will be whitelisted
"duration": "duration",
"year": "year",

View File

@ -37,6 +37,20 @@ var _ = Describe("PlaylistTrackRepository", func() {
})
})
Describe("GetAll", func() {
It("returns every row under a random sort, despite the integer id", func() {
// playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to
// TEXT, and it fails silently: no error, just no rows.
all, err := repo.GetAll(model.QueryOptions{Sort: "random"})
Expect(err).ToNot(HaveOccurred())
Expect(all).To(HaveLen(2), "a random sort must not silently drop rows")
got, err := repo.GetAll(model.QueryOptions{Sort: "random", Max: 1})
Expect(err).ToNot(HaveOccurred())
Expect(got).To(HaveLen(1))
})
})
Describe("CountAll", func() {
It("returns the number of tracks in the playlist", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))

View File

@ -82,13 +82,20 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
}
switch share.ResourceType {
case "artist":
albumRepo := NewAlbumRepository(r.ctx, r.db)
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"})
// Match by album-artist participation, not the deprecated album_artist_id
// column (first album artist only), so co-album-artists are included too.
// Load as the share owner so their library access is applied.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
mfRepo := NewMediaFileRepository(r.ctx, r.db)
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"})
albumRepo := NewAlbumRepository(ctx, r.db)
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"})
if err != nil {
return err
}
mfRepo := NewMediaFileRepository(ctx, r.db)
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"})
return err
case "album":
albumRepo := NewAlbumRepository(r.ctx, r.db)
@ -101,14 +108,10 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
return err
case "playlist":
// Load tracks as the share owner so their library access is applied.
owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID)
ctx, err := r.ownerContext(share)
if err != nil {
return fmt.Errorf("loading share owner %q: %w", share.UserID, err)
return err
}
if owner == nil {
return fmt.Errorf("share owner %q not found", share.UserID)
}
ctx := request.WithUser(r.ctx, *owner)
plsRepo := NewPlaylistRepository(ctx, r.db)
// Tracks returns nil when the playlist is no longer visible to the owner
// (e.g. it was made private after the share was created); leave the share
@ -133,6 +136,19 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
return nil
}
// ownerContext returns a context scoped to the share owner, so repository
// queries apply the owner's library access when a public share is rendered.
func (r *shareRepository) ownerContext(share *model.Share) (context.Context, error) {
owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID)
if err != nil {
return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err)
}
if owner == nil {
return nil, fmt.Errorf("share owner %q not found", share.UserID)
}
return request.WithUser(r.ctx, *owner), nil
}
func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
m := map[string]int{}
for i, mf := range mfs {

View File

@ -228,6 +228,89 @@ var _ = Describe("ShareRepository", func() {
})
})
Describe("Artist share library scoping", func() {
var otherLib model.Library
var owner model.User
const primaryID = "share-aa-primary"
const secondaryID = "share-aa-secondary"
BeforeEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
// A second library the owner has no access to
lr := NewLibraryRepository(adminCtx, b)
otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"}
Expect(lr.Put(&otherLib)).To(Succeed())
ar := NewArtistRepository(adminCtx, b)
Expect(createArtistWithLibrary(ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed())
Expect(createArtistWithLibrary(ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed())
// Secondary is a co-album-artist (not the first): album_artist_id points at
// primary, so the legacy-column filter would miss both tracks.
aaParticipants := model.Participants{model.RoleAlbumArtist: {
{Artist: model.Artist{ID: primaryID, Name: "AA Primary"}},
{Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}},
}}
alr := NewAlbumRepository(adminCtx, b)
Expect(alr.Put(&model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed())
Expect(alr.Put(&model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed())
mr := NewMediaFileRepository(adminCtx, b)
Expect(mr.Put(&model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed())
Expect(mr.Put(&model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed())
// Non-admin owner with access to library 1 only
owner = createUserWithLibraries("artist-share-owner", []int{1})
ur := NewUserRepository(adminCtx, b)
Expect(ur.Put(&owner)).To(Succeed())
Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed())
_, err := b.NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": "art-share", "user": owner.ID, "desc": "Artist scope share",
"type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
_, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute()
mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository)
_, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}}))
alr := NewAlbumRepository(adminCtx, b).(*albumRepository)
_, _ = alr.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}}))
ar := NewArtistRepository(adminCtx, b).(*artistRepository)
_, _ = ar.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}}))
lr := NewLibraryRepository(adminCtx, b).(*libraryRepository)
_ = lr.delete(squirrel.Eq{"id": otherLib.ID})
_ = NewUserRepository(adminCtx, b).Delete(owner.ID)
})
It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() {
// Read as admin (mimics the public-share render path); loadMedia must still
// scope to the owner's libraries.
adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder())
share, err := adminRepo.Get("art-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")),
"a co-album-artist track (not matched by album_artist_id) must be included")
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")),
"a track outside the owner's libraries must not appear in the share")
Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")),
"a co-album-artist album must be included")
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")),
"an album outside the owner's libraries must not appear in the share")
})
})
Describe("Ownership Checks", func() {
var ownerUser = model.User{ID: "2222", UserName: "regular-user"}
var otherUser = model.User{ID: "3333", UserName: "third-user"}

View File

@ -284,7 +284,9 @@ func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
if len(options) == 0 || options[0].Sort != "random" {
return
}
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName)
// CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to
// SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha).
options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(), r.tableName)
if options[0].Seed != "" {
hasher.SetSeed(r.seedKey(), options[0].Seed)
return

View File

@ -48,6 +48,77 @@ func marshalTags(tags model.Tags) string {
return string(res)
}
// indexedTagNames are the tag types materialized into the <table>_tags join tables, so filtering by
// them is an index-backed semi-join instead of a per-row json_tree(tags) scan. Genre only for now.
var indexedTagNames = []model.TagName{model.TagGenre}
// updateTags rewrites this item's <table>_tags rows from its in-memory tags, mirroring
// updateParticipants (delete-then-insert in the same Put; JOIN to tag skips not-yet-saved ids).
func (r sqlRepository) updateTags(itemID string, tags model.Tags) error {
del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID})
if _, err := r.executeSQL(del); err != nil {
return err
}
var tagIDs []string
for _, name := range indexedTagNames {
for _, value := range tags.Values(name) {
tagIDs = append(tagIDs, model.NewTag(name, value).ID)
}
}
if len(tagIDs) == 0 {
return nil
}
idsJSON, err := json.Marshal(tagIDs)
if err != nil {
return fmt.Errorf("marshaling tag ids: %w", err)
}
query := fmt.Sprintf(`
INSERT INTO %[1]s_tags (%[1]s_id, tag_id)
SELECT ?, value FROM json_each(?)
JOIN tag ON tag.id = value
ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName)
_, err = r.executeSQL(Expr(query, itemID, string(idsJSON)))
return err
}
// genreFilterDef builds indexed genre filters for one item type. Callers use the exported SongGenres / AlbumGenres instances.
type genreFilterDef struct{ idCol, table, joinCol string }
var (
SongGenres = genreFilterDef{"media_file.id", "media_file_tags", "media_file_id"}
AlbumGenres = genreFilterDef{"album.id", "album_tags", "album_id"}
)
// ByID matches items tagged with any of the given genre tag ids (scalar or slice).
func (g genreFilterDef) ByID(tagIDs any) Sqlizer {
sub, args, _ := Select(g.joinCol).From(g.table).Where(Eq{"tag_id": tagIDs}).ToSql()
return Expr(g.idCol+" IN ("+sub+")", args...)
}
// ByName matches by genre name (Subsonic passes a name, not an id), resolved through the tag
// dictionary, which is uniquely indexed on (tag_name, tag_value).
func (g genreFilterDef) ByName(genre string) Sqlizer {
sub, args, _ := Select("jt." + g.joinCol).From(g.table + " jt").
Join("tag on tag.id = jt.tag_id").
Where(And{Eq{"tag.tag_name": "genre"}, Like{"tag.tag_value": genre}}).ToSql()
return Expr(g.idCol+" IN ("+sub+")", args...)
}
// AlbumArtistsByGenreID matches album artists of albums tagged with any of the genre ids. It's a
// two-table join (album_artists ⨝ album_tags), so it doesn't fit the single-table genreFilterDef.
func AlbumArtistsByGenreID(tagIDs []string) Sqlizer {
sub, args, _ := Select("aa.artist_id").From("album_artists aa").
Join("album_tags at on at.album_id = aa.album_id").
Where(And{Eq{"aa.role": "albumartist"}, Eq{"at.tag_id": tagIDs}}).ToSql()
return Expr("artist.id IN ("+sub+")", args...)
}
func genreFilter(filter genreFilterDef) func(_ string, v any) Sqlizer {
return func(_ string, v any) Sqlizer {
return filter.ByID(v)
}
}
// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "<name>_id" key maps to "$.<name>".
func tagIDFilter(name string, idValue any) Sqlizer {
name = strings.TrimSuffix(name, "_id")

View File

@ -61,6 +61,9 @@ type Manager struct {
debounceTimers map[string]*time.Timer
debounceMu sync.Mutex
// transient is set by LoadPlugins, and nil for a server Start.
transient *transientLoad
// SubsonicAPI host function dependencies (set once before Start, not modified after)
subsonicRouter SubsonicRouter
ds model.DataStore
@ -110,27 +113,14 @@ func (m *Manager) Start(ctx context.Context) error {
}
if m.subsonicRouter == nil {
log.Fatal(ctx, "Plugin manager requires DataStore to be configured")
log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured")
}
// Set extism log level based on plugin-specific config or global log level
pluginLogLevel := conf.Server.Plugins.LogLevel
if pluginLogLevel == "" {
pluginLogLevel = conf.Server.LogLevel
}
extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel)))
m.ctx, m.cancel = context.WithCancel(ctx)
// Initialize wazero compilation cache for better performance
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize)
var err error
m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create wazero compilation cache", err)
return fmt.Errorf("creating wazero compilation cache: %w", err)
if err := m.initRuntime(ctx, cacheDir); err != nil {
return err
}
if conf.Server.Plugins.Folder.String() == "" {
@ -171,6 +161,49 @@ func (m *Manager) Start(ctx context.Context) error {
return nil
}
// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs.
func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error {
pluginLogLevel := conf.Server.Plugins.LogLevel
if pluginLogLevel == "" {
pluginLogLevel = conf.Server.LogLevel
}
extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel)))
m.ctx, m.cancel = context.WithCancel(ctx)
var err error
m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create wazero compilation cache", err)
return fmt.Errorf("creating wazero compilation cache: %w", err)
}
return nil
}
// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins.
type transientLoad struct {
only []string
runInit bool
}
// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is
// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done.
func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error {
if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 {
return nil
}
m.transient = &transientLoad{only: only, runInit: runInit}
cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins")
if err := m.initRuntime(ctx, cacheDir); err != nil {
return err
}
if err := m.loadEnabledPlugins(ctx); err != nil {
return fmt.Errorf("loading enabled plugins: %w", err)
}
return nil
}
// Stop shuts down the plugin manager and releases all resources.
func (m *Manager) Stop() error {
// Mark as stopped first to prevent new operations

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"slices"
"time"
extism "github.com/extism/go-sdk"
@ -232,6 +233,11 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
if !p.Enabled {
continue
}
// Instantiating a plugin creates its host services, so a transient load takes only the
// ones it may actually consult.
if m.transient != nil && !slices.Contains(m.transient.only, p.ID) {
continue
}
plugin := p // Capture for goroutine
g.Go(func() error {
@ -246,19 +252,21 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
}()
if err := m.loadPluginWithConfig(&plugin); err != nil {
// Store error in DB
plugin.LastError = err.Error()
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr)
// A transient load must not disable the user's plugin just for looking at it.
if m.transient == nil {
plugin.LastError = err.Error()
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr)
}
}
log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err)
return nil
}
// Clear any previous error
if plugin.LastError != "" {
if plugin.LastError != "" && m.transient == nil {
plugin.LastError = ""
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
@ -444,8 +452,11 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
m.mu.Unlock()
loaded = true
// Call plugin init function
callPluginInit(ctx, m.plugins[p.ID])
// Init is the plugin's first chance to run arbitrary code: open sockets, create task queues,
// schedule work. Only a caller that already intends to reach the network asks for it.
if m.transient == nil || m.transient.runInit {
callPluginInit(ctx, m.plugins[p.ID])
}
return nil
}

View File

@ -0,0 +1,125 @@
//go:build !windows
package plugins
import (
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manager.LoadPlugins", func() {
var (
mgr *Manager
repo *tests.MockPluginRepo
tmpDir string
)
// newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI
// has none, and Start would log.Fatal on that.
newManager := func(rows model.Plugins) *Manager {
DeferCleanup(configtest.SetupConfig())
var err error
tmpDir, err = os.MkdirTemp("", "plugins-readonly-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() { _ = os.RemoveAll(tmpDir) })
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = conf.NewDir(tmpDir)
if rows == nil {
rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension)
for i := range rows {
rows[i].AllUsers = true
}
}
repo = tests.CreateMockPluginRepo()
repo.Permitted = true
repo.SetData(rows)
m := &Manager{
plugins: make(map[string]*plugin),
ds: &tests.MockDataStore{MockedPlugin: repo},
metrics: noopMetricsRecorder{},
}
DeferCleanup(func() { _ = m.Stop() })
return m
}
It("detects capabilities without a Subsonic router configured", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent"))
})
Context("when a plugin cannot be loaded", func() {
brokenRows := func() model.Plugins {
return model.Plugins{{
ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"),
Enabled: true, AllUsers: true,
}}
}
It("leaves the stored row untouched", func() {
mgr = newManager(brokenRows())
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
stored, err := repo.Get("broken")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it")
Expect(stored.LastError).To(BeEmpty())
})
// Without this the test above would pass for the wrong reason. Start cannot be used: it
// syncs the folder first, dropping a row whose file is missing before any load.
It("still disables it when not read-only", func() {
mgr = newManager(brokenRows())
Expect(mgr.loadEnabledPlugins(GinkgoT().Context())).To(Succeed())
stored, err := repo.Get("broken")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Enabled).To(BeFalse())
Expect(stored.LastError).ToNot(BeEmpty())
})
})
// Loading a plugin creates its host services — a KVStore or task queue database on disk — so a
// plugin that could never supply an image must not be instantiated just to be ignored.
It("does not load a plugin that is not in the agent list", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"some-other-agent"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
})
It("does nothing when no agents are configured", func() {
mgr = newManager(nil)
Expect(mgr.LoadPlugins(GinkgoT().Context(), nil, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
// Not even the wazero cache: with nothing to load there is nothing to compile.
Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory())
})
It("does nothing when the plugin system is disabled", func() {
mgr = newManager(nil)
conf.Server.Plugins.Enabled = false
Expect(mgr.LoadPlugins(GinkgoT().Context(), []string{"test-metadata-agent", "broken"}, false)).To(Succeed())
Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty())
})
})

View File

@ -50,6 +50,15 @@ func newMetadataAgent(p *plugin) *MetadataAgent {
return &MetadataAgent{name: p.name, plugin: p}
}
// agentErr keeps a plugin fault distinguishable from a definitive miss: a method the plugin
// simply does not implement has answered, so it must not count against a caller's back-off.
func agentErr(err error) error {
if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) {
return errors.Join(agents.ErrNotFound, err)
}
return err
}
// MetadataAgent is an adapter that wraps an Extism plugin and implements
// the agents interfaces for metadata retrieval.
type MetadataAgent struct {
@ -69,7 +78,7 @@ func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name strin
input := capabilities.ArtistMBIDRequest{ID: id, Name: name}
result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input)
if err != nil {
return "", errors.Join(agents.ErrNotFound, err)
return "", agentErr(err)
}
if result == nil || result.MBID == "" {
@ -84,7 +93,7 @@ func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string)
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input)
if err != nil {
return "", errors.Join(agents.ErrNotFound, err)
return "", agentErr(err)
}
if result == nil || result.URL == "" {
return "", agents.ErrNotFound
@ -97,7 +106,7 @@ func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid s
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input)
if err != nil {
return "", errors.Join(agents.ErrNotFound, err)
return "", agentErr(err)
}
if result == nil || result.Biography == "" {
@ -112,7 +121,7 @@ func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid st
input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)}
result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
return nil, agentErr(err)
}
if result == nil || len(result.Artists) == 0 {
@ -132,7 +141,7 @@ func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid stri
input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid}
result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
return nil, agentErr(err)
}
if result == nil || len(result.Images) == 0 {
@ -152,7 +161,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m
input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)}
result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
return nil, agentErr(err)
}
if result == nil || len(result.Songs) == 0 {
@ -167,7 +176,7 @@ func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid str
input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid}
result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
return nil, agentErr(err)
}
if result == nil {
@ -187,7 +196,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s
input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid}
result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input)
if err != nil {
return nil, errors.Join(agents.ErrNotFound, err)
return nil, agentErr(err)
}
if result == nil || len(result.Images) == 0 {
@ -205,7 +214,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s
func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) {
result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input)
if err != nil {
return nil, err
return nil, agentErr(err)
}
if result == nil || len(result.Songs) == 0 {
return nil, agents.ErrNotFound

View File

@ -3,12 +3,36 @@
package plugins
import (
"errors"
"fmt"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/plugins/capabilities"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The partial-metadata-agent fixture registers through the Go PDK, which exports every method
// and answers -2, so only errNotImplemented reaches agentErr through a real plugin. A plugin
// that omits the export entirely yields errFunctionNotFound, covered here directly.
var _ = Describe("agentErr", func() {
DescribeTable("classifies a plugin error as a miss or a fault",
func(err error, wantMiss bool) {
got := agentErr(err)
Expect(errors.Is(got, agents.ErrNotFound)).To(Equal(wantMiss))
Expect(got).To(MatchError(err), "the underlying reason must survive for diagnostics")
},
Entry("an unimplemented method is a miss",
fmt.Errorf("%w: %s", errNotImplemented, FuncGetArtistImages), true),
Entry("a missing export is a miss",
fmt.Errorf("%w: %s", errFunctionNotFound, FuncGetArtistImages), true),
Entry("a call failure is a fault",
fmt.Errorf("plugin call failed: %w", errors.New("returned status 429")), false),
Entry("a non-zero exit is a fault",
errors.New("plugin call exited with code 1"), false),
)
})
var _ = Describe("MetadataAgent", Ordered, func() {
var agent agents.Interface
@ -166,82 +190,57 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() {
Expect(ok).To(BeTrue())
})
It("returns error from GetArtistMBID", func() {
retriever := errorAgent.(agents.ArtistMBIDRetriever)
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetArtistURL", func() {
retriever := errorAgent.(agents.ArtistURLRetriever)
_, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetArtistBiography", func() {
retriever := errorAgent.(agents.ArtistBiographyRetriever)
_, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetArtistImages", func() {
retriever := errorAgent.(agents.ArtistImageRetriever)
_, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarArtists", func() {
retriever := errorAgent.(agents.ArtistSimilarRetriever)
_, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetArtistTopSongs", func() {
retriever := errorAgent.(agents.ArtistTopSongsRetriever)
_, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetAlbumInfo", func() {
retriever := errorAgent.(agents.AlbumInfoRetriever)
_, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetAlbumImages", func() {
retriever := errorAgent.(agents.AlbumImageRetriever)
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarSongsByTrack", func() {
retriever := errorAgent.(agents.SimilarSongsByTrackRetriever)
_, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarSongsByAlbum", func() {
retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever)
_, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarSongsByArtist", func() {
retriever := errorAgent.(agents.SimilarSongsByArtistRetriever)
_, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
DescribeTable("surfaces the plugin failure, not a definitive not-found",
func(call func() error) {
err := call()
Expect(err).To(MatchError(ContainSubstring("simulated plugin error")))
Expect(err).ToNot(MatchError(agents.ErrNotFound))
},
Entry("GetArtistMBID", func() error {
_, err := errorAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test")
return err
}),
Entry("GetArtistURL", func() error {
_, err := errorAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid")
return err
}),
Entry("GetArtistBiography", func() error {
_, err := errorAgent.(agents.ArtistBiographyRetriever).GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid")
return err
}),
Entry("GetSimilarArtists", func() error {
_, err := errorAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5)
return err
}),
Entry("GetArtistImages", func() error {
_, err := errorAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid")
return err
}),
Entry("GetArtistTopSongs", func() error {
_, err := errorAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5)
return err
}),
Entry("GetAlbumInfo", func() error {
_, err := errorAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
return err
}),
Entry("GetAlbumImages", func() error {
_, err := errorAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
return err
}),
Entry("GetSimilarSongsByTrack", func() error {
_, err := errorAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
return err
}),
Entry("GetSimilarSongsByAlbum", func() error {
_, err := errorAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
return err
}),
Entry("GetSimilarSongsByArtist", func() error {
_, err := errorAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
return err
}),
)
})
var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
@ -268,68 +267,55 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
Expect(bio).To(Equal("Partial agent biography for Test Artist"))
})
It("returns ErrNotFound for unimplemented method (GetArtistMBID)", func() {
retriever := partialAgent.(agents.ArtistMBIDRetriever)
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetArtistURL)", func() {
retriever := partialAgent.(agents.ArtistURLRetriever)
_, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetArtistImages)", func() {
retriever := partialAgent.(agents.ArtistImageRetriever)
_, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarArtists)", func() {
retriever := partialAgent.(agents.ArtistSimilarRetriever)
_, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetArtistTopSongs)", func() {
retriever := partialAgent.(agents.ArtistTopSongsRetriever)
_, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetAlbumInfo)", func() {
retriever := partialAgent.(agents.AlbumInfoRetriever)
_, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetAlbumImages)", func() {
retriever := partialAgent.(agents.AlbumImageRetriever)
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() {
retriever := partialAgent.(agents.SimilarSongsByTrackRetriever)
_, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() {
retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever)
_, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() {
retriever := partialAgent.(agents.SimilarSongsByArtistRetriever)
_, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
// An unimplemented optional method is a definitive miss. Reported as a fault it would
// count against the artwork circuit breaker and keep the item in the retry queue.
DescribeTable("reports an unimplemented method as a definitive not-found",
func(call func() error) {
err := call()
Expect(err).To(MatchError(errNotImplemented))
Expect(err).To(MatchError(agents.ErrNotFound))
},
Entry("GetArtistMBID", func() error {
_, err := partialAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
return err
}),
Entry("GetArtistURL", func() error {
_, err := partialAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
return err
}),
Entry("GetArtistImages", func() error {
_, err := partialAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
return err
}),
Entry("GetSimilarArtists", func() error {
_, err := partialAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
return err
}),
Entry("GetArtistTopSongs", func() error {
_, err := partialAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5)
return err
}),
Entry("GetAlbumInfo", func() error {
_, err := partialAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid")
return err
}),
Entry("GetAlbumImages", func() error {
_, err := partialAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
return err
}),
Entry("GetSimilarSongsByTrack", func() error {
_, err := partialAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
return err
}),
Entry("GetSimilarSongsByAlbum", func() error {
_, err := partialAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
return err
}),
Entry("GetSimilarSongsByArtist", func() error {
_, err := partialAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
return err
}),
)
})
var _ = Describe("songRefToAgentSong multi-artist", func() {

View File

@ -37,6 +37,9 @@ types:
.webp: image/webp
.png: image/png
.bmp: image/bmp
.jxl: image/jxl
.heic: image/heic
.heif: image/heif
# List of audio formats that are considered lossless
lossless:

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"maps"
"path/filepath"
"slices"
"sync/atomic"
"time"
@ -51,6 +52,27 @@ func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: err.Error()})
}
// libraryRelativePath rebases an absolute scan target path onto the library root, since the
// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside
// the library root, are returned unchanged.
func libraryRelativePath(libPath, folderPath string) string {
if !filepath.IsAbs(folderPath) {
return folderPath
}
// The library root may be relative (e.g. the default "./music"); it must be made absolute
// to match against an absolute target, and it resolves against the same cwd as the scanner's fs.
absLib, err := filepath.Abs(libPath)
if err != nil {
return folderPath
}
rel, err := filepath.Rel(absLib, folderPath)
if err != nil || !filepath.IsLocal(rel) {
return folderPath
}
// The scanner's fs.FS is an io/fs, which always uses forward slashes.
return filepath.ToSlash(rel)
}
func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now()
@ -77,8 +99,12 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
// Selective scan: filter libraries and build targets map
state.targets = make(map[int][]string)
libPaths := slice.ToMap(allLibs, func(lib model.Library) (int, string) {
return lib.ID, lib.Path
})
for _, target := range targets {
folderPath := target.FolderPath
folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
if folderPath == "" {
folderPath = "."
}

View File

@ -4,6 +4,8 @@ package scanner
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline"
@ -11,6 +13,43 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("libraryRelativePath", func() {
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
// (a Unix-style "/foo" is not absolute on Windows).
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
It("returns a relative path unchanged", func() {
Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
})
It("rebases an absolute target when the library root is relative", func() {
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
})
It("rebases an absolute path that equals the library root to '.'", func() {
Expect(libraryRelativePath(libRoot, libRoot)).To(Equal("."))
})
It("rebases an absolute path under the library root", func() {
Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("handles a trailing slash on the library path", func() {
Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
})
It("leaves an absolute path outside the library root unchanged", func() {
Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside))
})
It("returns an empty path unchanged", func() {
Expect(libraryRelativePath(libRoot, "")).To(Equal(""))
})
})
type mockPhase struct {
num int
produceFunc func() ppl.Producer[int]

View File

@ -112,7 +112,7 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options {
options := Options{}
ff := And{}
if genre != "" {
ff = append(ff, filterByGenre(genre))
ff = append(ff, persistence.SongGenres.ByName(genre))
}
if fromYear != 0 {
ff = append(ff, GtOrEq{"year": fromYear})
@ -171,16 +171,17 @@ func ArtistsByRole(opts Options, role model.Role) Options {
return opts
}
func ByGenre(genre string) Options {
return addDefaultFilters(Options{
Sort: "name",
Filters: filterByGenre(genre),
})
// SongsByGenreID / AlbumsByGenreID (by tag id) and AlbumsByGenre / SongsByGenre (by name, wrapped
// as Options for Subsonic) delegate to the persistence genre filters, which own the join schema.
func SongsByGenreID(genreIds []string) Sqlizer { return persistence.SongGenres.ByID(genreIds) }
func AlbumsByGenreID(genreIds []string) Sqlizer { return persistence.AlbumGenres.ByID(genreIds) }
func AlbumsByGenre(genre string) Options {
return addDefaultFilters(Options{Sort: "name", Filters: persistence.AlbumGenres.ByName(genre)})
}
// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids.
func ByGenreID(genreIds []string) Sqlizer {
return genreTagFilter(Eq{"value": genreIds})
func SongsByGenre(genre string) Options {
return addDefaultFilters(Options{Sort: "name", Filters: persistence.SongGenres.ByName(genre)})
}
// ByAlbumID matches media files belonging to any of the given albums.
@ -198,14 +199,8 @@ func SongsByYears(years []int) Sqlizer {
return Eq{"year": years}
}
// ArtistsByGenreID matches artists credited as album artist on an album with any of the given
// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row.
func ArtistsByGenreID(genreIds []string) Sqlizer {
return Expr(
`artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt
WHERE jt.atom IS NOT NULL AND ?)`,
genreTagFilter(Eq{"value": genreIds}),
)
return persistence.AlbumArtistsByGenreID(genreIds)
}
// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each
@ -214,17 +209,11 @@ func tagIDFilter(tagName string, cond Sqlizer) Sqlizer {
return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond})
}
func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) }
// ByStudioID matches items (albums or songs) whose record-label tag id is in ids.
func ByStudioID(ids []string) Sqlizer {
return tagIDFilter("recordlabel", Eq{"value": ids})
}
func filterByGenre(genre string) Sqlizer {
return genreTagFilter(Like{"value": genre})
}
func ByRating() Options {
return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}})
}

View File

@ -85,15 +85,6 @@ player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the p
client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is
skipped, so it doesn't create a nameless player.
## ID encoding
Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id
is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients
parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`,
which chokes on Navidrome's base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can
itself be valid hex, correctness depends on every emit path encoding and every receive path
decoding — see `dto/ids.go`.
## Multi-library behavior
Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each
@ -121,7 +112,7 @@ returns direct children only (no tracks — no track is a library's direct child
| Area | Endpoints |
|---|---|
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` |
| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated), `GET QuickConnect/Enabled` |
| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` |
| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` |
| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) |
@ -180,26 +171,25 @@ warmer uses — so user-scoped items like private playlists still resolve their
falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to
their Navidrome `ArtworkID`.
## Finamp saved-queue id truncation
## Item ids are GUIDs
Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that
when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16
bytes. Navidrome ids are 22-character base62 strings, not 32-hex GUIDs, which means Finamp silently
stores only the first 16 characters of each id and asks for those **truncated ids** back when
restoring the queue — item lookups, then streaming, images, favorites and playback reports for the
restored tracks.
Jellyfin item ids are GUIDs, serialized as 32 lowercase hex chars with no dashes
(`Guid.ToString("N")`). Navidrome ids are canonical 22-char base62 encodings of a 128-bit value,
so `dto.EncodeID`/`dto.DecodeID` map between the two via `model/id` — losslessly except for the
~2⁻⁹⁶ chance an id's 128-bit value falls in the reserved space below (leading 12 bytes all zero).
This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome
id uses — is resolved to the full id by unique-prefix lookup (an indexed range scan;
ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as
requested**, because Finamp matches restored items back to its stored ids, and the other item
endpoints accept truncated ids transparently.
Three emitted ids aren't 128-bit values: integer library ids, the synthetic playlists folder, and
`PlaylistItemId` (a playlist *entry position* — `playlist_tracks.id` is an `integer` column).
They use a reserved GUID space — 12 zero bytes, a non-zero kind tag, a 24-bit payload — so
library `1` is `00000000000000000000000001000001`. The tag is never zero, because Jellyfin
serializes the all-zero GUID as `null`.
**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`)
should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the
queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids,
so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer
can be removed.
`DecodeID` accepts dashed and uppercase GUIDs (Jellyfin's `Guid.Parse` does) and returns
`ok=false` for anything malformed — including "" — which handlers surface as a 404.
The wire format must stay GUID-shaped for this reason: Finamp's saved-queue persistence bit-packs
each item id into exactly 16 bytes (`packIds()` in `lib/models/finamp_models.dart`), so a 32-hex
GUID round-trips exactly, whereas a longer id would be silently truncated.
## Streaming and transcoding
@ -242,7 +232,7 @@ plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension.
loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400
with `start_song_id and end_song_id are required.` when either id is missing.
`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients.
`item_id`/`start_song_id`/`end_song_id` are the GUID-form ids Navidrome hands Jellyfin clients.
`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features
(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library
setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries

View File

@ -5,7 +5,6 @@ import (
"math"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
@ -49,7 +48,10 @@ func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id s
// fetches this per item to render played/favourite indicators; resolveItemByID enforces the
// library-access gate.
func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
item, ok := api.resolveItemByID(r.Context(), id, nil)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
@ -64,7 +66,10 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
}
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return
@ -84,7 +89,10 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
}
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
repo, resource := api.resolveAnnotated(w, r, id)
if repo == nil {
return

View File

@ -21,7 +21,7 @@ var _ = Describe("Annotations", func() {
var broker *fakeEventBroker
// alice has access to library 1 only.
ctxUser := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
}
BeforeEach(func() {
@ -33,98 +33,98 @@ var _ = Describe("Annotations", func() {
Describe("markFavorite / unmarkFavorite", func() {
It("stars a song and returns IsFavorite=true", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeTrue())
Expect(mfRepo.Data["s1"].Starred).To(BeTrue())
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
})
It("stars an album and returns IsFavorite=true", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeTrue())
Expect(albumRepo.Data["a1"].Starred).To(BeTrue())
Expect(albumRepo.Data[testID("a1")].Starred).To(BeTrue())
})
It("stars an artist without checking library access (artists span multiple libraries)", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
// alice only has access to library 1, but artists aren't gated per-library.
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "ar1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("ar1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeTrue())
Expect(artistRepo.Data["ar1"].Starred).To(BeTrue())
Expect(artistRepo.Data[testID("ar1")].Starred).To(BeTrue())
})
It("stars a visible playlist", func() {
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}})
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(playlistRepo.Starred["p1"]).To(BeTrue())
Expect(playlistRepo.Starred[testID("p1")]).To(BeTrue())
})
It("unstars a song and returns IsFavorite=false", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.unmarkFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.IsFavorite).To(BeFalse())
Expect(mfRepo.Data["s1"].Starred).To(BeFalse())
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
})
It("returns 404 and does not star an album in a library the user can't access", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(albumRepo.Data["a1"].Starred).To(BeFalse())
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
})
It("returns 404 and does not star a song in a library the user can't access", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(mfRepo.Data["s1"].Starred).To(BeFalse())
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
})
It("returns 404 when the id doesn't match any entity", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "missing")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
@ -133,39 +133,39 @@ var _ = Describe("Annotations", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true)
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "x1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
It("emits a refreshResource event when starring a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
})
It("emits a refreshResource event when starring an album", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`))
})
It("does not emit an event when the item is not accessible", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.markFavorite, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(broker.Events).To(BeEmpty())
@ -175,13 +175,13 @@ var _ = Describe("Annotations", func() {
Describe("setRating / removeRating", func() {
It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(4))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.Rating).NotTo(BeNil())
@ -190,35 +190,35 @@ var _ = Describe("Annotations", func() {
It("rates an album", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(albumRepo.Data["a1"].Rating).To(Equal(5))
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(5))
})
It("rates a visible playlist", func() {
playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo)
playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}})
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID("p1"))
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(playlistRepo.Ratings["p1"]).To(Equal(4))
Expect(playlistRepo.Ratings[testID("p1")]).To(Equal(4))
})
It("removes a rating", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.removeRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
var d dto.UserItemDataDto
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
Expect(d.Rating).To(BeNil())
@ -226,79 +226,79 @@ var _ = Describe("Annotations", func() {
It("returns 404 and does not rate an album in a library the user can't access", func() {
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID("a1"))
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
Expect(albumRepo.Data["a1"].Rating).To(Equal(0))
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
})
It("rounds an odd rating to the nearest star instead of truncating", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
})
It("stores the minimum star for Rating=1 instead of clearing the rating", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(1))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
})
It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(4))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
})
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(5))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
})
It("clamps a negative Rating to Navidrome's min (0)", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(mfRepo.Data["s1"].Rating).To(Equal(0))
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
})
It("emits a refreshResource event when rating a song", func() {
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", "s1")
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.setRating, w, r)
Expect(broker.Events).To(HaveLen(1))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`))
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
})
})
})

View File

@ -104,6 +104,7 @@ func (api *Router) routes() http.Handler {
// player) even before the first playback report.
r.Use(api.withPlayer)
r.Get("/system/info", api.getSystemInfo)
r.Get("/system/endpoint", api.getEndpointInfo)
r.Get("/userviews", api.getUserViews)
r.Get("/users/{userId}/views", api.getUserViews)
r.Get("/users/me", api.getCurrentUser)
@ -143,6 +144,7 @@ func (api *Router) routes() http.Handler {
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
r.Get("/items/{itemId}/similar", api.getSimilarItems)
r.Get("/albums/{itemId}/similar", api.getSimilarAlbums)
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
r.Get("/genres", api.getGenres)
r.Get("/musicgenres", api.getGenres)

View File

@ -48,8 +48,8 @@ var _ = Describe("Router", func() {
ds := &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"})
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"})
Expect(err).ToNot(HaveOccurred())
fp := &fakePlayers{}

View File

@ -62,12 +62,13 @@ func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request
tracks := []audioMuseSimilarTrack{}
itemID := p.StringOr("item_id", "")
if itemID == "" {
id, ok := dto.DecodeID(itemID)
if !ok {
// Every other failure here (no provider, no match) degrades to an empty list rather than an
// error, so a malformed id does too instead of being the one path that 404s.
api.ok(w, r, tracks)
return
}
id := api.resolveItemID(ctx, dto.DecodeID(itemID))
n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit
eliminateDuplicates := p.BoolOr("eliminate_duplicates", true)
@ -129,11 +130,16 @@ func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) {
}
resp := audioMusePathResponse{Path: []audioMusePathTrack{}}
startDecoded, startOk := dto.DecodeID(startID)
endDecoded, endOk := dto.DecodeID(endID)
if !startOk || !endOk {
// This endpoint already 400s when an endpoint id is absent, so an unusable one reports the
// same way rather than looking like a successful search that found no path.
http.Error(w, "start_song_id and end_song_id must be valid item ids.", http.StatusBadRequest)
return
}
maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count
matches, err := api.sonic.FindSonicPath(ctx,
api.resolveItemID(ctx, dto.DecodeID(startID)),
api.resolveItemID(ctx, dto.DecodeID(endID)),
maxSteps)
matches, err := api.sonic.FindSonicPath(ctx, startDecoded, endDecoded, maxSteps)
if err != nil {
api.ok(w, r, resp)
return

View File

@ -118,28 +118,28 @@ var _ = Describe("AudioMuse similar_tracks", func() {
It("maps matches, decodes the seed id, encodes item ids, copies distance", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5},
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotID).To(Equal("seed"))
Expect(fake.gotID).To(Equal(testID("seed")))
Expect(fake.gotCount).To(Equal(5))
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(2))
Expect(body[0]).To(Equal(audioMuseSimilarTrack{
Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1",
Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("mf1")), Title: "T1",
}))
})
It("collapses to one track per artist when eliminate_duplicates defaults on", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5},
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(1))
@ -147,18 +147,18 @@ var _ = Describe("AudioMuse similar_tracks", func() {
It("keeps same-artist tracks when eliminate_duplicates=false", func() {
fake.similar = []sonic.SimilarMatch{
{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5},
{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3},
{MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5},
}
w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed"))+"&eliminate_duplicates=false", model.User{IsAdmin: true})
var body []audioMuseSimilarTrack
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body).To(HaveLen(2))
})
It("filters out tracks in libraries the user cannot access", func() {
fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}})
fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}})
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
@ -169,16 +169,25 @@ var _ = Describe("AudioMuse similar_tracks", func() {
Expect(fake.gotID).To(Equal(""))
})
// A malformed item_id degrades the same way as a missing one (200, empty array) rather than
// 404ing: every other failure on this endpoint already degrades gracefully.
It("returns an empty array without calling the engine when item_id is malformed", func() {
w := call("item_id=not-a-valid-id", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
Expect(fake.gotID).To(Equal(""))
})
It("returns 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true})
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
It("returns an empty array when the engine errors", func() {
fake.similarErr = errors.New("boom")
fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true})
fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}}
w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]"))
})
@ -198,53 +207,60 @@ var _ = Describe("AudioMuse find_path", func() {
})
It("returns 400 with the exact message when start_song_id is missing", func() {
w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true})
w := call("end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(400))
Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required."))
})
It("returns 400 when end_song_id is missing", func() {
w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true})
w := call("start_song_id="+dto.EncodeID(testID("s")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(400))
})
It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() {
bpm := 120
withBPM := mf("mf1", "A", "T1", 1)
withBPM := mf(testID("mf1"), "A", "T1", 1)
withBPM.BPM = &bpm
fake.path = []sonic.SimilarMatch{
{MediaFile: withBPM, Similarity: 1.5},
{MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0},
{MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 2.0},
}
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true})
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e"))+"&max_steps=10", model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
Expect(fake.gotStart).To(Equal("s"))
Expect(fake.gotEnd).To(Equal("e"))
Expect(fake.gotStart).To(Equal(testID("s")))
Expect(fake.gotEnd).To(Equal(testID("e")))
Expect(fake.gotCount).To(Equal(10))
var body audioMusePathResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Path).To(HaveLen(2))
Expect(body.TotalDistance).To(Equal(3.5))
Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1")))
Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID(testID("mf1"))))
Expect(*body.Path[0].Tempo).To(Equal(120.0))
Expect(body.Path[1].Tempo).To(BeNil())
})
It("returns 404 when no sonic provider is loaded", func() {
fake.provider = false
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true})
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(404))
})
It("returns an empty path object when the engine errors", func() {
fake.pathErr = errors.New("boom")
fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}}
w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true})
fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}}
w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(200))
var body audioMusePathResponse
Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed())
Expect(body.Path).To(BeEmpty())
Expect(body.TotalDistance).To(Equal(0.0))
})
It("400s without calling the engine when an id is malformed", func() {
w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true})
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(fake.gotStart).To(Equal(""))
Expect(fake.gotEnd).To(Equal(""))
})
})

View File

@ -22,7 +22,7 @@ var _ = Describe("AuthenticateByName", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed())
api = &Router{ds: ds}
})
@ -68,7 +68,7 @@ var _ = Describe("AuthenticateByName", func() {
It("reflects an administrator in the User.Policy", func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",
@ -92,7 +92,7 @@ var _ = Describe("AuthenticateByName", func() {
It("rejects an empty password even for a user with an empty stored password with 401", func() {
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed())
Expect(ur.Put(&model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed())
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",

View File

@ -28,13 +28,23 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol
opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)}
applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", ""))
scopeIDs, _ := parentIDScope(ctx, r)
scopeIDs, _, ok := parentIDScope(ctx, r)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
genreIds, ok := decodedQueryIDs(r, "genreids")
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false.
// Finamp's artist tab sends GenreIds when a genre filter is active.
q := itemsQuery{
scopeIDs: scopeIDs,
genreIds: decodedQueryIDs(r, "genreids"),
genreIds: genreIds,
search: searchTerm(p),
fields: dto.ParseFields(p.Strings("fields")...),
}
if q.search != "" {
opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit)
@ -66,7 +76,11 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) {
func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
scope, _ := parentIDScope(ctx, r)
scope, _, ok := parentIDScope(ctx, r)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)}
labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts)
if err != nil {
@ -82,7 +96,11 @@ func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) {
// library when accessible. Tags/OfficialRatings have no music source, so they are always empty.
func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
scope, _ := parentIDScope(ctx, r)
scope, _, ok := parentIDScope(ctx, r)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)}
genres, err := api.ds.Genre(ctx).GetAll(genreOpts)
if err != nil {

View File

@ -18,12 +18,12 @@ var _ = Describe("Browsing", func() {
var api *Router
var ds *tests.MockDataStore
ctxUser := func(libs model.Libraries) context.Context {
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs})
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs})
}
// admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership.
ctxAdmin := func() context.Context {
return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil})
return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil})
}
BeforeEach(func() {
@ -33,7 +33,7 @@ var _ = Describe("Browsing", func() {
Describe("getArtists", func() {
It("lists artists via /Artists", func() {
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}})
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -45,7 +45,7 @@ var _ = Describe("Browsing", func() {
})
It("handles /Artists/AlbumArtists the same way", func() {
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}})
ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -57,7 +57,7 @@ var _ = Describe("Browsing", func() {
It("scopes results to the user's accessible libraries", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs))
@ -71,10 +71,10 @@ var _ = Describe("Browsing", func() {
It("scopes to a single library when ParentId is an accessible library id", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}, {ID: 2}}
r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs))
r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(2), nil).WithContext(ctxUser(libs))
invoke(api.getArtists, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := artistRepo.Options.Filters.ToSql()
@ -86,10 +86,10 @@ var _ = Describe("Browsing", func() {
It("does not let ParentId=<inaccessible library id> narrow the scope", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1}} // no access to library 99
r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs))
r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(99), nil).WithContext(ctxUser(libs))
invoke(api.getArtists, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
sql, args, err := artistRepo.Options.Filters.ToSql()
@ -101,7 +101,7 @@ var _ = Describe("Browsing", func() {
It("forwards SearchTerm to the repo's Search method", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -113,7 +113,7 @@ var _ = Describe("Browsing", func() {
It("bounds a search the client left unbounded, and clamps an oversized one", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
@ -130,7 +130,7 @@ var _ = Describe("Browsing", func() {
It("forwards StartIndex/Limit as Offset/Max", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
@ -141,7 +141,7 @@ var _ = Describe("Browsing", func() {
It("does not restrict results for an admin user", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin())
invoke(api.getArtists, w, r)
@ -155,6 +155,15 @@ var _ = Describe("Browsing", func() {
Expect(err).NotTo(HaveOccurred())
Expect(sql).NotTo(ContainSubstring("library_artist.library_id"))
})
It("404s a malformed ParentId instead of listing every library's artists", func() {
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getArtists, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("getGenres", func() {
@ -199,6 +208,13 @@ var _ = Describe("Browsing", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(tagRepo.Options.Filters).To(BeNil())
})
It("404s a malformed ParentId instead of listing every library's studios", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Studios?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getStudios, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("getQueryFiltersLegacy", func() {
@ -222,5 +238,12 @@ var _ = Describe("Browsing", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(genreRepo.Options.Filters).To(BeNil())
})
It("404s a malformed ParentId instead of listing every library's filters", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/Filters?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}}))
invoke(api.getQueryFiltersLegacy, w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})

View File

@ -20,6 +20,12 @@ type SystemInfo struct {
CachePath string `json:"CachePath,omitempty"`
}
// EndPointInfo describes the caller's network location (GET /System/Endpoint).
type EndPointInfo struct {
IsLocal bool `json:"IsLocal"`
IsInNetwork bool `json:"IsInNetwork"`
}
type NameGuidPair struct {
Name string `json:"Name"`
Id string `json:"Id"`

View File

@ -4,6 +4,7 @@ import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -15,3 +16,7 @@ func TestDto(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Jellyfin DTO Suite")
}
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
// id shape production uses.
func testID(label string) string { return id.NewHash("jellyfin-test", label) }

View File

@ -1,23 +1,143 @@
package dto
import "encoding/hex"
import (
"bytes"
"encoding/hex"
"strconv"
"strings"
// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g.
// Finamp's queue packing) and crash on Navidrome's base62 ids if emitted as-is.
func EncodeID(id string) string {
if id == "" {
"github.com/navidrome/navidrome/model/id"
)
// guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes
// (what Guid.ToString("N") produces).
const guidLen = 32
// Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag,
// then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null.
const (
kindIdx = 12
maxPayload = 1<<24 - 1
)
const (
kindLibrary byte = iota + 1
kindPlaylistsFolder
kindPlaylistEntry
)
var zeroPrefix [kindIdx]byte
// PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be
// mistaken for a real id, which is always 22-char base62.
const PlaylistsFolderID = "playlists"
// PlaylistsFolderGUID is the wire form of PlaylistsFolderID.
var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0)
// EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes
// to "" rather than to a shape clients can't parse.
func EncodeID(ndID string) string {
b, err := id.Decode(ndID)
if err != nil {
return ""
}
return hex.EncodeToString([]byte(id))
return hex.EncodeToString(b)
}
// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id.
func DecodeID(id string) string {
if id == "" {
// EncodeLibraryID renders a library's integer id in the reserved GUID space.
func EncodeLibraryID(libID int) string {
return encodeReserved(kindLibrary, libID)
}
// EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer
// column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song.
func EncodePlaylistEntryID(entryID string) string {
n, err := strconv.Atoi(entryID)
if err != nil {
return ""
}
if b, err := hex.DecodeString(id); err == nil && len(b) > 0 {
return string(b)
}
return id
return encodeReserved(kindPlaylistEntry, n)
}
func encodeReserved(kind byte, payload int) string {
if payload < 0 || payload > maxPayload {
return ""
}
var b [16]byte
b[kindIdx] = kind
b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload)
return hex.EncodeToString(b[:])
}
// DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id,
// a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed
// GUID — including the empty string — so an undecodable id can't reach a caller as "no filter".
// Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries
// decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an
// entity id.
func DecodeID(guid string) (string, bool) {
b, ok := decodeGUID(guid)
if !ok {
return "", false
}
kind, payload, reserved := reservedFields(b)
if !reserved {
return id.Encode(b), true
}
switch kind {
case kindLibrary:
return strconv.Itoa(payload), true
case kindPlaylistsFolder:
return PlaylistsFolderID, true
}
return "", false
}
// DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a
// caller can't mistake "every entry failed" for "no filter" (see DecodeID).
func DecodeIDs(guids []string) ([]string, bool) {
out := make([]string, len(guids))
for i, guid := range guids {
decoded, ok := DecodeID(guid)
if !ok {
return nil, false
}
out[i] = decoded
}
return out, true
}
// DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind,
// so an entity id can't be taken for a playlist_tracks row.
func DecodePlaylistEntryID(guid string) (string, bool) {
b, ok := decodeGUID(guid)
if !ok {
return "", false
}
if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry {
return strconv.Itoa(payload), true
}
return "", false
}
func decodeGUID(guid string) ([16]byte, bool) {
guid = strings.ToLower(strings.ReplaceAll(guid, "-", ""))
if len(guid) != guidLen {
return [16]byte{}, false
}
bs, err := hex.DecodeString(guid)
if err != nil {
return [16]byte{}, false
}
return [16]byte(bs), true
}
// reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the
// entity GUIDs that make up almost all traffic.
func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) {
if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) {
return 0, 0, false
}
return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true
}

View File

@ -1,35 +1,161 @@
package dto
import (
"encoding/hex"
"strconv"
"strings"
"github.com/navidrome/navidrome/model/id"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("id codec", func() {
It("round-trips a base62 id", func() {
id := "5QFKvMsJrd57QE2Le2dKKo"
Expect(DecodeID(EncodeID(id))).To(Equal(id))
Describe("canonical ids", func() {
It("round-trips a random id", func() {
ndID := id.NewRandom()
decoded, ok := DecodeID(EncodeID(ndID))
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
It("round-trips a hash id", func() {
ndID := id.NewHash("artist", "Weird Al")
decoded, ok := DecodeID(EncodeID(ndID))
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
It("emits exactly 32 lowercase hex chars", func() {
for range 20 {
Expect(EncodeID(id.NewRandom())).To(MatchRegexp("^[0-9a-f]{32}$"))
}
})
It("accepts a dashed GUID, matching Jellyfin's Guid.Parse", func() {
ndID := id.NewRandom()
guid := EncodeID(ndID)
dashed := guid[0:8] + "-" + guid[8:12] + "-" + guid[12:16] + "-" + guid[16:20] + "-" + guid[20:32]
decoded, ok := DecodeID(dashed)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
It("accepts an uppercase GUID", func() {
ndID := id.NewRandom()
decoded, ok := DecodeID(strings.ToUpper(EncodeID(ndID)))
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(ndID))
})
})
It("passes a raw (non-hex) id through DecodeID unchanged", func() {
Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo"))
Describe("reserved space", func() {
DescribeTable("round-trips library ids",
func(libID int, guid string) {
Expect(EncodeLibraryID(libID)).To(Equal(guid))
decoded, ok := DecodeID(guid)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(strconv.Itoa(libID)))
},
Entry("first library", 1, "00000000000000000000000001000001"),
Entry("double digit", 42, "0000000000000000000000000100002a"),
Entry("max payload", 0xffffff, "00000000000000000000000001ffffff"),
)
It("round-trips the playlists folder", func() {
Expect(PlaylistsFolderGUID).To(Equal("00000000000000000000000002000000"))
decoded, ok := DecodeID(PlaylistsFolderGUID)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(PlaylistsFolderID))
})
DescribeTable("round-trips playlist entry positions",
func(entryID, guid string) {
Expect(EncodePlaylistEntryID(entryID)).To(Equal(guid))
decoded, ok := DecodePlaylistEntryID(guid)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(entryID))
},
Entry("first entry", "1", "00000000000000000000000003000001"),
Entry("later entry", "300", "0000000000000000000000000300012c"),
)
It("rejects a non-integer playlist entry id", func() {
Expect(EncodePlaylistEntryID("s1")).To(Equal(""))
Expect(EncodePlaylistEntryID("")).To(Equal(""))
Expect(EncodePlaylistEntryID("-1")).To(Equal(""))
})
It("rejects a payload wider than the reserved 24 bits", func() {
Expect(EncodeLibraryID(1 << 24)).To(Equal(""))
Expect(EncodeLibraryID(-1)).To(Equal(""))
Expect(EncodePlaylistEntryID("16777216")).To(Equal(""))
})
It("keeps library and playlist-entry GUIDs distinct for the same number", func() {
Expect(EncodeLibraryID(3)).ToNot(Equal(EncodePlaylistEntryID("3")))
})
It("does not let one reserved kind decode as another", func() {
_, ok := DecodeID(EncodePlaylistEntryID("3"))
Expect(ok).To(BeFalse())
_, ok = DecodePlaylistEntryID(EncodeLibraryID(3))
Expect(ok).To(BeFalse())
_, ok = DecodePlaylistEntryID(PlaylistsFolderGUID)
Expect(ok).To(BeFalse())
})
It("rejects an unknown kind tag", func() {
_, ok := DecodeID("000000000000000000000000ff000000")
Expect(ok).To(BeFalse())
})
It("never emits the all-zero GUID, which Jellyfin serializes as null", func() {
Expect(EncodeLibraryID(0)).ToNot(Equal("00000000000000000000000000000000"))
Expect(PlaylistsFolderGUID).ToNot(Equal("00000000000000000000000000000000"))
})
})
It("produces valid lowercase hex", func() {
encoded := EncodeID("song-1")
Expect(encoded).To(MatchRegexp("^[0-9a-f]+$"))
Expect(encoded).To(HaveLen(len("song-1") * 2))
Describe("malformed input", func() {
DescribeTable("DecodeID reports ok=false",
func(input string) {
decoded, ok := DecodeID(input)
Expect(ok).To(BeFalse())
Expect(decoded).To(BeEmpty())
},
Entry("empty", ""),
Entry("too short", "abc123"),
Entry("too long", "000000000000000000000000010000011"),
Entry("non-hex chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"),
Entry("a raw base62 id", id.NewRandom()),
Entry("the old 44-char hex format", hex.EncodeToString([]byte("5QFKvMsJrd57QE2Le2dKKo"))),
)
It("encodes a non-canonical id to the empty string", func() {
Expect(EncodeID("")).To(Equal(""))
Expect(EncodeID("playlists")).To(Equal(""))
Expect(EncodeID("42")).To(Equal(""))
})
})
It("round-trips the empty string", func() {
Expect(EncodeID("")).To(Equal(""))
Expect(DecodeID("")).To(Equal(""))
})
Describe("DecodeIDs", func() {
It("decodes every entry when all are well-formed", func() {
a, b := id.NewRandom(), id.NewRandom()
decoded, ok := DecodeIDs([]string{EncodeID(a), EncodeID(b)})
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal([]string{a, b}))
})
It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() {
// "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex
// string apart from one we encoded. Callers must always encode ids on emission and
// decode them on receipt so this ambiguity never surfaces in practice.
Expect(DecodeID(EncodeID("a1"))).To(Equal("a1"))
It("is all-or-nothing: one malformed entry fails the whole list", func() {
decoded, ok := DecodeIDs([]string{EncodeID(id.NewRandom()), "not-a-guid"})
Expect(ok).To(BeFalse())
Expect(decoded).To(BeNil())
})
It("succeeds on an empty list", func() {
decoded, ok := DecodeIDs(nil)
Expect(ok).To(BeTrue())
Expect(decoded).To(BeEmpty())
})
})
})

View File

@ -10,6 +10,15 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump
// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top.
func sortName(sortTag, orderName, displayName string) string {
if conf.Server.PreferSortTags {
return cmp.Or(sortTag, orderName, displayName)
}
return cmp.Or(orderName, displayName)
}
// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
const ticksPerMillis = 10_000
@ -129,6 +138,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a
// plain list response); a nil fields set means neither.
func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
albumID := EncodeID(mf.AlbumID)
item := BaseItemDto{
Name: mf.Title,
Id: EncodeID(mf.ID),
@ -137,9 +147,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
IsFolder: false,
LocationType: "FileSystem",
HasLyrics: mf.HasEmbeddedLyrics(),
ParentId: EncodeID(mf.AlbumID),
ParentId: albumID,
Album: mf.Album,
AlbumId: EncodeID(mf.AlbumID),
AlbumId: albumID,
AlbumArtist: mf.AlbumArtist,
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: jellyfinDate(&mf.CreatedAt),
@ -152,7 +162,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)}
}
if fields.Has("SortName") {
item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title)
item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title)
}
// Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit).
// Participants holds the per-artist list; fall back to the flattened display fields when absent.
@ -281,6 +291,9 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
// The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same
// convention as tracks; clients read it off the album item as NormalizationGain.
item.NormalizationGain = al.RGAlbumGain
if fields.Has("SortName") {
item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name)
}
return item
}
@ -302,6 +315,9 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
if fields.Has("SortName") {
item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name)
}
return item
}

View File

@ -14,10 +14,10 @@ import (
var _ = Describe("mappers", func() {
It("maps a song to an Audio BaseItemDto", func() {
mf := model.MediaFile{
ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1",
ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}},
Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
}
mf.PlayCount = 2
mf.Starred = true
@ -26,30 +26,29 @@ var _ = Describe("mappers", func() {
Expect(item.MediaType).To(Equal("Audio"))
Expect(item.IsFolder).To(BeFalse())
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Id).To(Equal(EncodeID("song-1")))
Expect(item.AlbumId).To(Equal(EncodeID("alb-1")))
Expect(item.ParentId).To(Equal(EncodeID("alb-1")))
Expect(item.Id).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.RunTimeTicks).To(Equal(int64(600_000_000)))
Expect(*item.IndexNumber).To(Equal(3))
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(item.UserData.Played).To(BeTrue())
Expect(item.UserData.Key).To(Equal(EncodeID("song-1")))
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1"))
Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1"))))
Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
Describe("Fields gating (matches real Jellyfin)", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources and SortName when Fields does not ask for them", func() {
It("omits MediaSources when Fields does not ask for them", func() {
item := SongToBaseItem(mf, nil)
Expect(item.MediaSources).To(BeNil())
Expect(item.SortName).To(BeEmpty())
})
It("includes MediaSources only when Fields=MediaSources", func() {
@ -58,77 +57,119 @@ var _ = Describe("mappers", func() {
Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000)))
})
It("includes SortName (from the sort title) only when Fields=SortName", func() {
Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song"))
// SortName must match the server sort order — see the sortName helper.
Describe("SortName", func() {
song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"}
ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"}
al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("is omitted unless Fields=SortName", func() {
Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty())
Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty())
Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty())
})
It("uses the order names by default, ignoring sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall"))
})
Context("with PreferSortTags", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = true
})
It("prefers the sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The"))
})
It("falls back to the order name when there is no sort tag", func() {
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"},
ParseFields("SortName")).SortName).To(Equal("x"))
})
})
It("falls back to the display name when order name and sort tag are empty", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T"))
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N"))
Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A"))
})
})
It("sets HasLyrics from the media file's lyrics", func() {
Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
// "[]" is the no-lyrics sentinel, not a truthy value.
Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
It("omits ImageBlurHashes when a song has no album", func() {
mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60}
mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60}
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("sets DateCreated from the media file's CreatedAt", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty())
})
It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() {
mf := model.MediaFile{
ID: "s1", Title: "Song",
Artist: "The Band", ArtistID: "ar-1",
AlbumArtist: "Various", AlbumArtistID: "ar-2",
ID: testID("s1"), Title: "Song",
Artist: "The Band", ArtistID: testID("ar-1"),
AlbumArtist: "Various", AlbumArtistID: testID("ar-2"),
}
item := SongToBaseItem(mf, nil)
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}}))
})
It("omits ArtistItems when the track has no artist id", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
})
It("omits Artists when the track has no artist name or participants", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: "s1", Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul",
AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul",
ID: testID("s1"), Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"),
AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"),
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}},
{Artist: model.Artist{ID: "ar-redman", Name: "Redman"}},
{Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}},
{Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}},
},
}
item := SongToBaseItem(mf, nil)
Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{
{Name: "De La Soul", Id: EncodeID("ar-delasoul")},
{Name: "Redman", Id: EncodeID("ar-redman")},
{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))},
{Name: "Redman", Id: EncodeID(testID("ar-redman"))},
}))
// AlbumArtists stays single, matching real Jellyfin.
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}}))
})
It("serializes normalization gains with Jellyfin's exact key casing", func() {
mf := model.MediaFile{ID: "s1", Title: "Song",
mf := model.MediaFile{ID: testID("s1"), Title: "Song",
RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)}
b, err := json.Marshal(SongToBaseItem(mf, nil))
Expect(err).ToNot(HaveOccurred())
@ -137,16 +178,16 @@ var _ = Describe("mappers", func() {
})
It("omits normalization gains when the file has no ReplayGain tags", func() {
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil))
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil))
Expect(err).ToNot(HaveOccurred())
// Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain).
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
It("builds a MediaSourceInfo from a media file", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
Expect(src.Id).To(Equal(EncodeID("s1")))
Expect(src.Id).To(Equal(EncodeID(testID("s1"))))
Expect(src.Size).To(Equal(int64(5242880)))
Expect(src.Container).To(Equal("mp3"))
Expect(src.Bitrate).To(Equal(320_000))
@ -157,7 +198,7 @@ var _ = Describe("mappers", func() {
It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() {
mf := model.MediaFile{
ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
Channels: 2, SampleRate: 44100, Codec: "mp3",
}
src := MediaSourceFromMediaFile(mf)
@ -172,7 +213,7 @@ var _ = Describe("mappers", func() {
})
It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() {
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
b, err := json.Marshal(src)
Expect(err).ToNot(HaveOccurred())
@ -200,7 +241,7 @@ var _ = Describe("mappers", func() {
Describe("Lyric media stream advertising", func() {
It("adds a Lyric media stream when the file has embedded lyrics", func() {
mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`}
mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(2))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
@ -210,20 +251,20 @@ var _ = Describe("mappers", func() {
})
It("emits only the Audio stream without lyrics", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"})
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")})
Expect(src.MediaStreams).To(HaveLen(1))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
})
It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"})
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"})
Expect(src.MediaStreams).To(HaveLen(1))
})
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1",
ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0,
Duration: 60,
}
@ -235,7 +276,7 @@ var _ = Describe("mappers", func() {
It("maps PlayDate to UserData.LastPlayedDate", func() {
playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC)
mf := model.MediaFile{
ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1",
ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", Duration: 60,
}
mf.PlayDate = &playDate
@ -245,25 +286,25 @@ var _ = Describe("mappers", func() {
})
It("maps an album to a MusicAlbum folder item", func() {
al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}}
al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}}
item := AlbumToBaseItem(al, nil)
Expect(item.Type).To(Equal("MusicAlbum"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("alb-1")))
Expect(item.ParentId).To(Equal(EncodeID("art-1")))
Expect(item.Id).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("art-1"))))
Expect(item.AlbumArtists).To(HaveLen(1))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1")))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1"))))
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1"))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
It("populates album Studios from record-label tags only when Fields=Studios", func() {
al := model.Album{ID: "alb-2", Name: "Alb2"}
al := model.Album{ID: testID("alb-2"), Name: "Alb2"}
al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}}
Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty())
@ -276,7 +317,7 @@ var _ = Describe("mappers", func() {
})
It("sets NormalizationGain on the album from its ReplayGain", func() {
al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)}
al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)}
b, err := json.Marshal(AlbumToBaseItem(al, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`))
@ -285,14 +326,14 @@ var _ = Describe("mappers", func() {
})
It("omits NormalizationGain when the album has no ReplayGain", func() {
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil))
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
Describe("PrimaryImageAspectRatio", func() {
nonSquare := func() model.Album {
al := model.Album{ID: "al1", Name: "Album"}
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800
return al
}
@ -310,14 +351,14 @@ var _ = Describe("mappers", func() {
})
It("is omitted when the dimensions are unknown, rather than guessing square", func() {
al := model.Album{ID: "al1", Name: "Album"}
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageHash = "abc"
item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio"))
Expect(item.PrimaryImageAspectRatio).To(BeNil())
})
It("is omitted when the item has no image at all", func() {
al := model.Album{ID: "al1", Name: "Album"}
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageAbsent = true
al.ImageWidth, al.ImageHeight = 1200, 800
item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio"))
@ -325,21 +366,21 @@ var _ = Describe("mappers", func() {
})
It("carries the ratio for an artist", func() {
ar := model.Artist{ID: "ar1", Name: "Artist"}
ar := model.Artist{ID: testID("ar1"), Name: "Artist"}
ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500
Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 2.0, 0.0001))
})
It("carries the ratio for a playlist", func() {
pl := model.Playlist{ID: "pl1", Name: "Playlist"}
pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"}
pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800
Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
})
It("carries the ratio for a song with its own art", func() {
mf := model.MediaFile{ID: "mf1", Title: "Song"}
mf := model.MediaFile{ID: testID("mf1"), Title: "Song"}
mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600
Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
@ -347,7 +388,7 @@ var _ = Describe("mappers", func() {
// A track without its own art shows the album's, so the ratio has to describe that image.
It("uses the album's dimensions for a track falling back to album art", func() {
mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"}
mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")}
mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800
item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio"))
Expect(item.AlbumPrimaryImageTag).To(Equal("abc"))
@ -356,73 +397,73 @@ var _ = Describe("mappers", func() {
})
It("maps an artist to a MusicArtist folder item", func() {
ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20}
ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20}
item := ArtistToBaseItem(ar, nil)
Expect(item.Type).To(Equal("MusicArtist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("art-1")))
Expect(item.Id).To(Equal(EncodeID(testID("art-1"))))
Expect(*item.AlbumCount).To(Equal(2))
})
It("maps a genre to a MusicGenre folder item", func() {
g := model.Genre{ID: "genre-1", Name: "Rock"}
g := model.Genre{ID: testID("genre-1"), Name: "Rock"}
item := GenreToBaseItem(g)
Expect(item.Type).To(Equal("MusicGenre"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("genre-1")))
Expect(item.Id).To(Equal(EncodeID(testID("genre-1"))))
Expect(item.Name).To(Equal("Rock"))
})
It("maps a tag to a Studio BaseItemDto", func() {
item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"})
item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"})
Expect(item.Type).To(Equal("Studio"))
Expect(item.Name).To(Equal("Blue Note"))
Expect(item.Id).To(Equal(EncodeID("t1")))
Expect(item.Id).To(Equal(EncodeID(testID("t1"))))
})
Describe("premiereDate", func() {
// Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily.
It("serializes a full date", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007}
item := SongToBaseItem(mf, nil)
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
})
It("pads a year-only date so clients can parse it", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z"))
})
It("pads a year-month date", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
})
It("falls back to the year when no date tag exists", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z"))
})
It("is omitted when the track has no date at all", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil())
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil())
})
It("is set on albums from their date, falling back to MaxYear", func() {
Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil())
Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil())
})
})
It("maps a playlist to a Playlist BaseItemDto", func() {
p := model.Playlist{
ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120,
ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120,
Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2},
}
item := PlaylistToBaseItem(p, nil)
Expect(item.Type).To(Equal("Playlist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("pl-1")))
Expect(item.Id).To(Equal(EncodeID(testID("pl-1"))))
Expect(item.Name).To(Equal("Chill"))
Expect(item.MediaType).To(Equal("Audio"))
Expect(*item.ChildCount).To(Equal(7))
@ -430,12 +471,12 @@ var _ = Describe("mappers", func() {
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(*item.UserData.Rating).To(Equal(8.0))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1"))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("changes the playlist image tag when the cover content changes", func() {
p := model.Playlist{ID: "pl-1", Name: "Chill"}
p := model.Playlist{ID: testID("pl-1"), Name: "Chill"}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.ImageHash = "2222222222222222"
@ -446,7 +487,7 @@ var _ = Describe("mappers", func() {
})
It("keeps the playlist image tag stable across a metadata-only edit", func() {
p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
@ -464,17 +505,17 @@ var _ = Describe("mappers", func() {
})
It("advertises the track id so the client triggers the read-through", func() {
mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true}
mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1"))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1")))
Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send")
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
})
It("falls back to the album when the track has no art of its own", func() {
mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false}
mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
@ -483,7 +524,7 @@ var _ = Describe("mappers", func() {
})
It("falls back to the album once the track's art is known absent", func() {
mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true}
mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.ItemImage.ImageAbsent = true
mf.AlbumImage.ImageHash = "0123456789abcdef"
@ -494,7 +535,7 @@ var _ = Describe("mappers", func() {
It("falls back to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true}
mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
@ -505,7 +546,7 @@ var _ = Describe("mappers", func() {
Describe("primary image tags", func() {
It("uses the content hash as the tag and emits the real blurhash", func() {
al := model.Album{ID: "alb-1", Name: "Album"}
al := model.Album{ID: testID("alb-1"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
al.BlurHash = "LEHV6nWB2yk8"
@ -515,7 +556,7 @@ var _ = Describe("mappers", func() {
})
It("omits the blurhash entirely when none was computed", func() {
al := model.Album{ID: "alb-2", Name: "Album"}
al := model.Album{ID: testID("alb-2"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
item := AlbumToBaseItem(al, nil)
@ -524,7 +565,7 @@ var _ = Describe("mappers", func() {
})
It("omits tags for known-absent artwork", func() {
al := model.Album{ID: "alb-3", Name: "Album"}
al := model.Album{ID: testID("alb-3"), Name: "Album"}
al.ImageAbsent = true
item := AlbumToBaseItem(al, nil)
@ -533,13 +574,13 @@ var _ = Describe("mappers", func() {
})
It("falls back to the entity id while artwork is still unresolved", func() {
item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4"))
item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions an artist's tag by content hash", func() {
ar := model.Artist{ID: "art-1", Name: "Artist"}
ar := model.Artist{ID: testID("art-1"), Name: "Artist"}
ar.ImageHash = "fedcba9876543210"
ar.BlurHash = "L6PZfSi_.AyE"
@ -551,7 +592,7 @@ var _ = Describe("mappers", func() {
Describe("song and playlist image tags", func() {
It("versions a song's album tag by the album's content hash", func() {
mf := model.MediaFile{ID: "song-1", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
mf.AlbumImage.BlurHash = "LEHV6nWB2yk8"
@ -561,7 +602,7 @@ var _ = Describe("mappers", func() {
})
It("never synthesizes a song blurhash when the album has none", func() {
mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"}
mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
@ -570,7 +611,7 @@ var _ = Describe("mappers", func() {
})
It("omits a song's album tag when the album art is known absent", func() {
mf := model.MediaFile{ID: "song-3", Title: "Song", AlbumID: "alb-3"}
mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")}
mf.AlbumImage.ImageAbsent = true
item := SongToBaseItem(mf, nil)
@ -579,7 +620,7 @@ var _ = Describe("mappers", func() {
})
It("versions a playlist tag by content hash instead of UpdatedAt", func() {
pl := model.Playlist{ID: "pl-1", Name: "Playlist"}
pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"}
pl.ImageHash = "abcdef0123456789"
item := PlaylistToBaseItem(pl, nil)
@ -589,7 +630,7 @@ var _ = Describe("mappers", func() {
Describe("per-song artwork", func() {
It("emits the track's own Primary tag when it has distinct art", func() {
mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "aaaaaaaaaaaaaaaa"
mf.BlurHash = "LTRACKblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
@ -603,7 +644,7 @@ var _ = Describe("mappers", func() {
})
It("falls back to the album tag when the track has no distinct art", func() {
mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "bbbbbbbbbbbbbbbb"
mf.BlurHash = "LALBUMblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
@ -617,7 +658,7 @@ var _ = Describe("mappers", func() {
})
It("omits the track tag when its own art is known absent", func() {
mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"}
mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageAbsent = true
mf.AlbumImage.ImageAbsent = true
@ -632,7 +673,7 @@ var _ = Describe("mappers", func() {
var _ = Describe("LyricDtoFromLyrics", func() {
ms := func(v int64) *int64 { return &v }
mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
It("maps synced lyrics with tick conversion", func() {
l := model.Lyrics{

View File

@ -79,7 +79,7 @@ var _ = Describe("Annotations", func() {
})
It("returns 404 when favoriting an unknown item", func() {
Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound))
Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound))
})
})
@ -102,7 +102,7 @@ var _ = Describe("Annotations", func() {
})
It("returns 404 for an unknown item", func() {
Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound))
Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound))
})
})

View File

@ -61,7 +61,9 @@ var _ = Describe("AudioMuse endpoints", func() {
parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body)
Expect(body).To(HaveLen(2))
Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What"))
Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title)))
decoded, ok := dto.DecodeID(body[0].ItemID)
Expect(ok).To(BeTrue())
Expect(decoded).To(Equal(songID(body[0].Title)))
})
It("collapses to one track per artist by default", func() {

View File

@ -27,9 +27,9 @@ var _ = Describe("Authentication", func() {
Expect(res.AccessToken).ToNot(BeEmpty())
Expect(res.User).ToNot(BeNil())
Expect(res.User.Name).To(Equal("admin"))
Expect(res.User.Id).To(Equal(enc("admin-1")))
Expect(res.User.Id).To(Equal(enc(testID("admin-1"))))
Expect(res.User.Policy.IsAdministrator).To(BeTrue())
Expect(res.ServerId).ToNot(BeEmpty())
Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$"))
// The returned token must actually authenticate a protected request.
r := httptest.NewRequest("GET", "/Users/Me", nil)
@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() {
users := publicUsers()
Expect(users).To(HaveLen(1))
Expect(users[0].Name).To(Equal("regular"))
Expect(users[0].Id).To(Equal(enc("regular-1")))
Expect(users[0].Id).To(Equal(enc(testID("regular-1"))))
Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login
})
})
@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() {
var u dto.UserDto
parseInto(getAs(regularUser, "/Users/Me"), &u)
Expect(u.Name).To(Equal("regular"))
Expect(u.Id).To(Equal(enc("regular-1")))
Expect(u.Id).To(Equal(enc(testID("regular-1"))))
})
It("returns the caller from GET /Users/{userId}", func() {

View File

@ -2,6 +2,7 @@ package e2e
import (
"net/http"
"slices"
"sort"
"time"
@ -45,7 +46,7 @@ var _ = Describe("Browsing", func() {
Expect(it.Type).To(Equal("Audio"))
Expect(it.MediaType).To(Equal("Audio"))
Expect(it.LocationType).To(Equal("FileSystem"))
Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it
Expect(it.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form
Expect(it.AlbumId).ToNot(BeEmpty())
}
})
@ -79,6 +80,24 @@ var _ = Describe("Browsing", func() {
Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist"))
})
// Finamp's A-Z jump scans SortName client-side, so it must follow the response order.
It("returns artists' SortName matching the server sort order when Fields=SortName", func() {
plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName"))
for _, it := range plain.Items {
Expect(it.SortName).To(BeEmpty())
}
q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName"))
Expect(q.Items).ToNot(BeEmpty())
sortNames := make([]string, 0, len(q.Items))
for _, it := range q.Items {
Expect(it.SortName).ToNot(BeEmpty())
sortNames = append(sortNames, it.SortName)
}
Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames)
// "The Beatles" must be filed under B, exposing the article-stripped key to clients.
Expect(sortNames).To(ContainElement("beatles"))
})
It("lists all genres", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true"))
Expect(q.TotalRecordCount).To(Equal(3))
@ -132,7 +151,7 @@ var _ = Describe("Browsing", func() {
// Finamp's download sync asks a library for the tracks outside any album this way; answering
// with every track would stream the whole library.
Describe("Recursive=false", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("returns no songs for a library parent", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false"))
@ -154,7 +173,7 @@ var _ = Describe("Browsing", func() {
// Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds
// for the actual artist filter, not ParentId=<artistId>.
Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("filters albums by AlbumArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles"))))
@ -190,6 +209,87 @@ var _ = Describe("Browsing", func() {
})
})
// A malformed id must 404, not silently drop the filter and widen the query to the whole
// library; a well-formed but unknown one must still fail closed to zero results.
Describe("stale and malformed id filtering", func() {
lib1 := dto.EncodeLibraryID(1)
It("404s a malformed ParentId instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed AlbumArtistIds instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed ArtistIds instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed Ids entry instead of batch-fetching nothing", func() {
w := get("/Items?Ids=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s when only one of several Ids entries is malformed", func() {
w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() {
q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed GenreIds entry instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s when only one of several GenreIds entries is malformed", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed AlbumIds entry instead of listing every song", func() {
w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("404s a malformed StudioIds entry instead of listing every album", func() {
w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio"))))
Expect(q.TotalRecordCount).To(BeZero())
Expect(q.Items).To(BeEmpty())
})
})
// Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true.
Describe("album filtering (AlbumIds)", func() {
It("filters songs by AlbumIds", func() {
@ -205,7 +305,7 @@ var _ = Describe("Browsing", func() {
})
It("returns nothing for an unknown album id", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album")))
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album"))))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
@ -242,16 +342,16 @@ var _ = Describe("Browsing", func() {
It("returns filter lists scoped to a ParentId library", func() {
var filters dto.QueryFiltersLegacy
parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters)
parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters)
Expect(filters.Years).To(ContainElements(1959, 1965))
studios := queryResult(get("/Studios?ParentId=" + enc("1")))
studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1)))
Expect(names(studios.Items)).To(ContainElement("Columbia"))
})
})
// Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>.
Describe("genre filtering (GenreIds)", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("filters albums by GenreIds", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz"))))
@ -276,7 +376,7 @@ var _ = Describe("Browsing", func() {
})
It("returns nothing for an unknown genre id", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre")))
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre"))))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
@ -298,7 +398,7 @@ var _ = Describe("Browsing", func() {
})
It("returns no artists for an unknown genre id", func() {
q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre")))
q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre"))))
Expect(q.Items).To(BeEmpty())
})
})
@ -308,7 +408,7 @@ var _ = Describe("Browsing", func() {
// binds them case-insensitively; these guard that our dispatcher does too, and that browsing an
// album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks.
Describe("camelCase query params (Jellify / JS SDK)", func() {
lib1 := enc("1")
lib1 := dto.EncodeLibraryID(1)
It("filters albums by camelCase albumArtistIds", func() {
q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles"))))
@ -345,35 +445,6 @@ var _ = Describe("Browsing", func() {
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV"))
})
// Finamp restores its saved queue with ids truncated to 16 bytes (see README).
Describe("Finamp-truncated ids (saved queue restore)", func() {
It("resolves a truncated id by unique prefix and echoes the requested id", func() {
full := songID("Come Together")
truncated := full[:16]
q := queryResult(get("/Items?ids=" + enc(truncated)))
Expect(names(q.Items)).To(ConsistOf("Come Together"))
// Finamp matches restored items by its stored ids, so the requested id must be echoed.
Expect(q.Items[0].Id).To(Equal(enc(truncated)))
})
It("batch-resolves a mixed list of truncated and full ids, keeping order", func() {
ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16])
q := queryResult(get("/Items?ids=" + ids))
Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"}))
})
It("streams a track by its truncated id", func() {
full := songID("So What")
w := get("/Audio/" + enc(full[:16]) + "/stream")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamerSpy.LastMediaFile.ID).To(Equal(full))
})
It("still 404s for a truncated id matching nothing", func() {
Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound))
})
})
It("applies Limit while reporting the full TotalRecordCount", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2"))
Expect(q.Items).To(HaveLen(2))
@ -477,7 +548,7 @@ var _ = Describe("Browsing", func() {
})
It("returns 404 for an unknown id", func() {
Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound))
Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound))
})
})

View File

@ -51,6 +51,7 @@ import (
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/server/events"
@ -70,6 +71,10 @@ func TestJellyfinE2E(t *testing.T) {
RunSpecs(t, "Jellyfin API E2E Suite")
}
// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same
// id shape production uses.
func testID(label string) string { return id.NewHash("jellyfin-test", label) }
// Easy aliases for the storagetest package
type _t = map[string]any
@ -91,14 +96,14 @@ var (
dataFolder string
adminUser = model.User{
ID: "admin-1",
ID: testID("admin-1"),
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
}
regularUser = model.User{
ID: "regular-1",
ID: testID("regular-1"),
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
@ -219,7 +224,9 @@ func createPlaylistAs(user model.User, name string, encodedIds ...string) string
var res map[string]string
parseInto(postAs(user, "/Playlists", string(body)), &res)
Expect(res["Id"]).ToNot(BeEmpty())
return dto.DecodeID(res["Id"])
id, ok := dto.DecodeID(res["Id"])
Expect(ok).To(BeTrue())
return id
}
// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) ---

View File

@ -53,7 +53,7 @@ var _ = Describe("Lyrics", func() {
})
It("returns 404 for a fabricated id", func() {
Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound))
Expect(get("/Audio/" + enc(testID("nope")) + "/Lyrics").Code).To(Equal(http.StatusNotFound))
})
})

View File

@ -18,7 +18,7 @@ var _ = Describe("Multi-user access control", func() {
})
It("hides all content from a user with no library access", func() {
noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"}
noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"}
Expect(ds.User(ctx).Put(&noAccess)).To(Succeed())
loaded, err := ds.User(ctx).FindByUsername("noaccess")
Expect(err).ToNot(HaveOccurred())

View File

@ -46,6 +46,20 @@ var _ = Describe("Playlists", func() {
plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))})
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1)
})
// dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get
// dropped while the well-formed entries are still used to create a playlist.
It("404s when one of the Ids is malformed, without creating a playlist", func() {
before, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}`
Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound))
after, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(after).To(Equal(before))
})
})
Describe("items", func() {
@ -81,6 +95,13 @@ var _ = Describe("Playlists", func() {
Expect(playlistItems(plID).TotalRecordCount).To(Equal(3))
})
It("404s when one of the ids to add is malformed, without adding any track", func() {
plID := createPlaylist("AddMalformed", nil)
url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + ",not-a-valid-id"
Expect(post(url, "").Code).To(Equal(http.StatusNotFound))
Expect(playlistItems(plID).TotalRecordCount).To(BeZero())
})
It("removes an entry by its PlaylistItemId", func() {
plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))})
entryID := playlistItems(plID).Items[0].PlaylistItemId
@ -103,7 +124,7 @@ var _ = Describe("Playlists", func() {
var perms []dto.PlaylistUserPermissions
parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms)
Expect(perms).To(HaveLen(1))
Expect(perms[0].UserId).To(Equal(enc("admin-1")))
Expect(perms[0].UserId).To(Equal(enc(testID("admin-1"))))
Expect(perms[0].CanEdit).To(BeTrue())
})
})
@ -135,12 +156,12 @@ var _ = Describe("Playlists", func() {
q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder"))
Expect(q.Items).To(HaveLen(1))
Expect(q.Items[0].CollectionType).To(Equal("playlists"))
Expect(q.Items[0].Id).To(Equal(enc("playlists")))
Expect(q.Items[0].Id).To(Equal(dto.PlaylistsFolderGUID))
})
It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() {
createPlaylist("My Mix", nil)
q := queryResult(get("/Items?parentId=" + enc("playlists")))
q := queryResult(get("/Items?parentId=" + dto.PlaylistsFolderGUID))
Expect(names(q.Items)).To(ContainElement("My Mix"))
Expect(q.Items[0].Type).To(Equal("Playlist"))
// Jellify keeps only playlists whose Path contains "data".
@ -149,10 +170,10 @@ var _ = Describe("Playlists", func() {
It("resolves the synthetic playlists folder by its own advertised id", func() {
var item dto.BaseItemDto
parseInto(get("/Items/"+enc("playlists")), &item)
parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item)
Expect(item.Type).To(Equal("ManualPlaylistsFolder"))
Expect(item.CollectionType).To(Equal("playlists"))
Expect(item.Id).To(Equal(enc("playlists")))
Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID))
})
})
@ -239,6 +260,15 @@ var _ = Describe("Playlists", func() {
})
Describe("update", func() {
It("404s when one of the replacement Ids is malformed, leaving the track list unchanged", func() {
plID := createPlaylist("UpdateMalformed", []string{enc(songID("Come Together"))})
body := `{"Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}`
Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNotFound))
q := playlistItems(plID)
Expect(q.TotalRecordCount).To(Equal(1))
Expect(names(q.Items)).To(ConsistOf("Come Together"))
})
It("makes a playlist public", func() {
plID := createPlaylist("Make Public", nil)
Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
@ -295,6 +325,20 @@ var _ = Describe("Playlists", func() {
post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user
Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden))
})
// An id that decodes to "" would tell Create to make a new playlist instead of updating one —
// itemIDParam must 404 before that decode ever runs, not silently create one.
It("404s for a malformed playlist id, without creating a playlist", func() {
before, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`)
Expect(w.Code).To(Equal(http.StatusNotFound))
after, err := ds.Playlist(ctx).CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(after).To(Equal(before))
})
})
Describe("delete", func() {

View File

@ -3,6 +3,7 @@ package e2e
import (
"fmt"
"github.com/navidrome/navidrome/server/jellyfin/dto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -12,7 +13,7 @@ import (
var _ = Describe("Search", func() {
BeforeEach(func() { setupTestDB() })
lib1 := func() string { return enc("1") } // Library id 1 encodes to "31"
lib1 := func() string { return dto.EncodeLibraryID(1) }
Describe("artists", func() {
It("searches all album artists", func() {

View File

@ -1,6 +1,8 @@
package e2e
import (
"net/http"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -12,7 +14,7 @@ var _ = Describe("Similar", func() {
Describe("GET /Artists/{id}/Similar", func() {
It("returns the provider's similar artists, excluding ones not in the library", func() {
providerFake.similarArtists = model.Artists{
{ID: "z", Name: "Led Zeppelin"},
{ID: testID("z"), Name: "Led Zeppelin"},
{ID: "", Name: "Not In Library"}, // no id -> not present -> excluded
}
q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar"))
@ -25,11 +27,18 @@ var _ = Describe("Similar", func() {
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(Equal(0))
})
// Unlike an unresolvable-but-well-formed id (empty result above), a malformed itemId never
// reaches provider lookup at all — itemIDParam rejects it first.
It("404s a malformed itemId", func() {
w := get("/Artists/not-a-valid-id/Similar")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("GET /Items/{id}/Similar", func() {
It("returns similar songs for a track", func() {
providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}}
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar"))
Expect(names(q.Items)).To(ConsistOf("Similar Song"))
Expect(q.Items[0].Type).To(Equal("Audio"))
@ -37,8 +46,8 @@ var _ = Describe("Similar", func() {
It("excludes similar songs from libraries the user can't access", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", Title: "In Library", LibraryID: 1},
{ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access
{ID: testID("x1"), Title: "In Library", LibraryID: 1},
{ID: testID("x2"), Title: "Other Library", LibraryID: 2}, // regularUser has no access
}
q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar"))
Expect(names(q.Items)).To(ConsistOf("In Library"))
@ -46,9 +55,9 @@ var _ = Describe("Similar", func() {
It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", AlbumID: albumID("IV")},
{ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once
{ID: "x3", AlbumID: albumID("Kind of Blue")},
{ID: testID("x1"), AlbumID: albumID("IV")},
{ID: testID("x2"), AlbumID: albumID("IV")}, // same album -> counted once
{ID: testID("x3"), AlbumID: albumID("Kind of Blue")},
}
q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar"))
Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"}))
@ -60,27 +69,32 @@ var _ = Describe("Similar", func() {
// provider similar-song at it.
otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"}
Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed())
otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2}
otherAlbum := model.Album{ID: testID("other-album"), Name: "Other Album", LibraryID: 2}
Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed())
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible
{ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser
{ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible
{ID: testID("x2"), AlbumID: testID("other-album")}, // library 2 -> filtered for regularUser
}
q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar"))
Expect(names(q.Items)).To(ConsistOf("IV"))
})
It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() {
q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar"))
q := queryResult(get("/Items/" + enc(testID("does-not-exist")) + "/Similar"))
Expect(q.Items).To(BeEmpty())
})
It("404s a malformed itemId", func() {
w := get("/Items/not-a-valid-id/Similar")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
// Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix.
Describe("GET /Items/{id}/InstantMix", func() {
It("returns the seed track first, followed by similar songs", func() {
providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}}
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19"))
Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"}))
Expect(q.Items[0].Type).To(Equal("Audio"))
@ -89,7 +103,7 @@ var _ = Describe("Similar", func() {
It("does not duplicate the seed when the provider returns it", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: songID("So What"), Title: "So What", LibraryID: 1},
{ID: "x1", Title: "Similar Song", LibraryID: 1},
{ID: testID("x1"), Title: "Similar Song", LibraryID: 1},
}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"}))
@ -97,9 +111,9 @@ var _ = Describe("Similar", func() {
It("caps the mix at the requested limit", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", Title: "S1", LibraryID: 1},
{ID: "x2", Title: "S2", LibraryID: 1},
{ID: "x3", Title: "S3", LibraryID: 1},
{ID: testID("x1"), Title: "S1", LibraryID: 1},
{ID: testID("x2"), Title: "S2", LibraryID: 1},
{ID: testID("x3"), Title: "S3", LibraryID: 1},
}
q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2"))
Expect(names(q.Items)).To(Equal([]string{"So What", "S1"}))
@ -107,21 +121,21 @@ var _ = Describe("Similar", func() {
It("excludes similar songs from libraries the user can't access", func() {
providerFake.similarSongs = model.MediaFiles{
{ID: "x1", Title: "In Library", LibraryID: 1},
{ID: "x2", Title: "Other Library", LibraryID: 2},
{ID: testID("x1"), Title: "In Library", LibraryID: 1},
{ID: testID("x2"), Title: "Other Library", LibraryID: 2},
}
q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"}))
})
It("returns a mix of the provider's similar songs for an artist seed", func() {
providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}}
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Artist Mix Song", LibraryID: 1}}
q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"}))
})
It("returns an empty result (not 404) for an unknown item", func() {
w := get("/Items/" + enc("does-not-exist") + "/InstantMix")
w := get("/Items/" + enc(testID("does-not-exist")) + "/InstantMix")
Expect(w.Code).To(Equal(200))
Expect(queryResult(w).Items).To(BeEmpty())
})
@ -130,5 +144,10 @@ var _ = Describe("Similar", func() {
q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"Help!"}))
})
It("404s a malformed itemId", func() {
w := get("/Items/not-a-valid-id/InstantMix")
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})

View File

@ -43,7 +43,7 @@ var _ = Describe("Streaming", func() {
})
It("returns 404 for an unknown track", func() {
Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound))
Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound))
})
})
@ -98,7 +98,7 @@ var _ = Describe("Streaming", func() {
parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info)
Expect(info.MediaSources).To(HaveLen(1))
Expect(info.MediaSources[0].Id).ToNot(BeEmpty())
Expect(info.PlaySessionId).ToNot(BeEmpty())
Expect(info.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$"))
})
It("returns a MediaSource via POST", func() {

View File

@ -51,6 +51,22 @@ var _ = Describe("System", func() {
})
})
Describe("GET /System/Endpoint", func() {
It("always reports IsInNetwork, which Finamp's connection test probes for", func() {
w := getAs(regularUser, "/System/Endpoint")
Expect(w.Code).To(Equal(http.StatusOK))
var info map[string]any
parseInto(w, &info)
Expect(info).To(HaveKey("IsInNetwork"))
Expect(info).To(HaveKey("IsLocal"))
})
It("rejects unauthenticated requests", func() {
w := rawReq("GET", "/System/Endpoint", "")
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
Describe("GET/POST /System/Ping", func() {
It("answers GET with a plain-text server name", func() {
w := rawReq("GET", "/System/Ping", "")

Some files were not shown because too many files have changed in this diff Show More