mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
Merge branch 'master' into msi-property-fix
This commit is contained in:
commit
013986e4b6
819
cmd/artwork.go
Normal file
819
cmd/artwork.go
Normal 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
946
cmd/artwork_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -112,6 +112,7 @@ const (
|
||||
const (
|
||||
DefaultUICoverArtSize = 300
|
||||
DefaultMaxImageUploadSize = "10MB"
|
||||
DefaultMaxImageSize = "20MB"
|
||||
)
|
||||
|
||||
// Prometheus options
|
||||
|
||||
@ -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"}}))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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")
|
||||
)
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
96
core/agents/local_agent_test.go
Normal file
96
core/agents/local_agent_test.go
Normal 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")
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
43
core/artwork/gate_test.go
Normal 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")
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
41
core/artwork/image_cache_test.go
Normal file
41
core/artwork/image_cache_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
99
core/artwork/trace.go
Normal 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
628
core/artwork/trace_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
18
core/external/extdata_helper_test.go
vendored
18
core/external/extdata_helper_test.go
vendored
@ -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 })
|
||||
}
|
||||
|
||||
91
core/external/provider.go
vendored
91
core/external/provider.go
vendored
@ -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
299
core/external/provider_similarsongs.go
vendored
Normal 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
|
||||
}
|
||||
683
core/external/provider_similarsongs_test.go
vendored
683
core/external/provider_similarsongs_test.go
vendored
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
70
core/metrics/insights_internal_test.go
Normal file
70
core/metrics/insights_internal_test.go
Normal 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())
|
||||
}
|
||||
17
core/metrics/metrics_suite_test.go
Normal file
17
core/metrics/metrics_suite_test.go
Normal 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")
|
||||
}
|
||||
34
db/migrations/20260811023338_add_item_genre_tag_indexes.sql
Normal file
34
db/migrations/20260811023338_add_item_genre_tag_indexes.sql
Normal 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;
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -11,4 +11,5 @@ type Genres []Genre
|
||||
|
||||
type GenreRepository interface {
|
||||
GetAll(...QueryOptions) (Genres, error)
|
||||
Get(id string) (*Genre, error)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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...))
|
||||
}
|
||||
|
||||
108
persistence/item_tags_test.go
Normal file
108
persistence/item_tags_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
125
plugins/manager_readonly_test.go
Normal file
125
plugins/manager_readonly_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = "."
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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}})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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") + `"]}`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(""))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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) ---
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user