diff --git a/cmd/artwork.go b/cmd/artwork.go new file mode 100644 index 000000000..e9c910f11 --- /dev/null +++ b/cmd/artwork.go @@ -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 ", + 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" + + " is one of: " + kindPrefixes(explainKinds) + ".\n" + + "A disc artwork id is the album id and the disc number, joined by a colon: :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 ...", + 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 +} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go new file mode 100644 index 000000000..b28d8dac5 --- /dev/null +++ b/cmd/artwork_test.go @@ -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 :", 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()) + }) +}) diff --git a/cmd/plugin.go b/cmd/plugin.go index 6cce8ea5f..ded28e969 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -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) diff --git a/cmd/utils.go b/cmd/utils.go index 81d646cf1..74da51828 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -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) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4bdd48422..49a99f8ca 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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) diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index ae24b61fa..8bc404fb1 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -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, diff --git a/conf/configuration.go b/conf/configuration.go index 5122b15eb..fbbaaf252 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -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) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 4eaa8e3d8..ac6477572 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -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) diff --git a/conf/export_test.go b/conf/export_test.go index cbb64b3d0..d1e1a6f99 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -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 diff --git a/consts/consts.go b/consts/consts.go index 46906b8b5..aed8ecf66 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -112,6 +112,7 @@ const ( const ( DefaultUICoverArtSize = 300 DefaultMaxImageUploadSize = "10MB" + DefaultMaxImageSize = "20MB" ) // Prometheus options diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e3087f995..e79b2b3c8 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -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"}})) }) }) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index d5f4a6580..7fc5de361 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -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") ) diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index ce8f9f07c..1cb9060a1 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -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() { diff --git a/core/agents/local_agent_test.go b/core/agents/local_agent_test.go new file mode 100644 index 000000000..50a0ce297 --- /dev/null +++ b/core/agents/local_agent_test.go @@ -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") + }) +}) diff --git a/core/archiver.go b/core/archiver.go index 8c42f8f49..c9436279e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -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 { diff --git a/core/archiver_test.go b/core/archiver_test.go index 2ba8f1fc0..461af1800 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -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) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index c7032b62b..a6f746959 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -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 { diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 716245c93..60a34352d 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -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) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 8ac8d9c9d..7edc80e99 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -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{} diff --git a/core/artwork/disc.go b/core/artwork/disc.go index 21f596b60..acd8a3740 100644 --- a/core/artwork/disc.go +++ b/core/artwork/disc.go @@ -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 diff --git a/core/artwork/disc_test.go b/core/artwork/disc_test.go index 8264ee27b..8852741cf 100644 --- a/core/artwork/disc_test.go +++ b/core/artwork/disc_test.go @@ -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()) }) }) }) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index d6c374e39..0a9363b5d 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -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, diff --git a/core/artwork/gate.go b/core/artwork/gate.go index a64c11a20..ebb65bcbe 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -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 } diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go new file mode 100644 index 000000000..abe723508 --- /dev/null +++ b/core/artwork/gate_test.go @@ -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") + }) +}) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ce72a6c8e..ae1fc0a0d 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -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 } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 3b75c5186..32a7688b4 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -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() { diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index 39938a755..b1970d21d 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -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 { diff --git a/core/artwork/image_cache_test.go b/core/artwork/image_cache_test.go new file mode 100644 index 000000000..a74c51088 --- /dev/null +++ b/core/artwork/image_cache_test.go @@ -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"))) + }) + }) +}) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index c3cf15b71..4d38ced95 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -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) { diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index d326dde09..1ada8415d 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -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("rate limited")) + })) + 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: 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))) + }) +}) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 11711469d..518b6e100 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -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 == "" { diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index fc1a6a3ff..25fb727de 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -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()) + }) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 885ca03cf..78b7dd68d 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -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 { diff --git a/core/artwork/trace.go b/core/artwork/trace.go new file mode 100644 index 000000000..5d02fa4ff --- /dev/null +++ b/core/artwork/trace.go @@ -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 + } +} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go new file mode 100644 index 000000000..5a54c9e91 --- /dev/null +++ b/core/artwork/trace_test.go @@ -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)) + }) +}) diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go index 9c38ae2e5..76526af3c 100644 --- a/core/artwork/uploader.go +++ b/core/artwork/uploader.go @@ -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) } diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3ded52629..be8495305 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -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) } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 5b6e42885..53b6a43b2 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -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. diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go index f7315b20a..63f12d03c 100644 --- a/core/artwork/worker_timing_test.go +++ b/core/artwork/worker_timing_test.go @@ -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") + } }) } diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index 8fabf4490..f7a155cd9 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -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 }) +} diff --git a/core/external/provider.go b/core/external/provider.go index efd061c0c..782c7c3aa 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -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 { diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go new file mode 100644 index 000000000..22b20565b --- /dev/null +++ b/core/external/provider_similarsongs.go @@ -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 +} diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 563003f83..ac54495f4 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -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()) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 78391779a..66d0b89bd 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -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 diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 126d759bc..8559d4204 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -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"` diff --git a/core/metrics/insights_internal_test.go b/core/metrics/insights_internal_test.go new file mode 100644 index 000000000..74c8ce236 --- /dev/null +++ b/core/metrics/insights_internal_test.go @@ -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()) +} diff --git a/core/metrics/metrics_suite_test.go b/core/metrics/metrics_suite_test.go new file mode 100644 index 000000000..bae622e90 --- /dev/null +++ b/core/metrics/metrics_suite_test.go @@ -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") +} diff --git a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql new file mode 100644 index 000000000..1a7d75190 --- /dev/null +++ b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql @@ -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; diff --git a/model/artwork.go b/model/artwork.go index 87b424f33..ea724265a 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -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 +} diff --git a/model/genre.go b/model/genre.go index bb05e747e..fa5b6ec62 100644 --- a/model/genre.go +++ b/model/genre.go @@ -11,4 +11,5 @@ type Genres []Genre type GenreRepository interface { GetAll(...QueryOptions) (Genres, error) + Get(id string) (*Genre, error) } diff --git a/model/scanner.go b/model/scanner.go index 54f81037c..36c9007fb 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -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 { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 91e90127c..486099fc5 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -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 diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index e5469fbea..1ff754dc3 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -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) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 6638a204e..d11d89a1f 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -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{})) + }) + }) }) diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 22443284f..0bb22c21b 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -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...)) } diff --git a/persistence/item_tags_test.go b/persistence/item_tags_test.go new file mode 100644 index 000000000..a2317a1a5 --- /dev/null +++ b/persistence/item_tags_test.go @@ -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"))) + }) + }) +}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index f372b30f5..f37e394cc 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -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 { diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a64efa20..c1f6fcf69 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -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", diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 36f9ae4a9..a5c67b92c 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -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))) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 0013e782b..4b6dc9240 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -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 { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 3ae456031..3af91b2af 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -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"} diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 33450fe9f..428ba7a7b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -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 diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 5177bc8e4..11f9fe00e 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,77 @@ func marshalTags(tags model.Tags) string { return string(res) } +// indexedTagNames are the tag types materialized into the _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
_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 "_id" key maps to "$.". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") diff --git a/plugins/manager.go b/plugins/manager.go index b3a71ce6c..bab67e987 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -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 diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 439c944f5..e5e3dbfc0 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -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 } diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go new file mode 100644 index 000000000..9d16b76e9 --- /dev/null +++ b/plugins/manager_readonly_test.go @@ -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()) + }) +}) diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index e5d6d43fc..607926438 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -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 diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index a14db5d34..2dc67d41c 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -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() { diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index f67b26af4..83abf2e5c 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -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: diff --git a/scanner/scanner.go b/scanner/scanner.go index 29a1f9b4e..d73007bdd 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -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 = "." } diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index e8abb7c7d..0778bd6ec 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -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] diff --git a/server/filter/filters.go b/server/filter/filters.go index 62d8f0523..9014bb799 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -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}}) } diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 13ec79aa8..5759e6cc0 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -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": }` (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 diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index f2c2f546e..ec84f65e0 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -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 diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 9e811a379..dd1487011 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -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") + `"]}`)) }) }) }) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 1f46c08b4..d64366a63 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -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) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index e8e9cbd4b..fe69c8321 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -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{} diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go index b01a4bf92..4cc1a2cc5 100644 --- a/server/jellyfin/audiomuse.go +++ b/server/jellyfin/audiomuse.go @@ -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 diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go index e9d6d4e85..6815753fd 100644 --- a/server/jellyfin/audiomuse_test.go +++ b/server/jellyfin/audiomuse_test.go @@ -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("")) + }) }) diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index b51420f1a..5a613f828 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -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", diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 29fe48c2b..9acd8d5a8 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -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 { diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index e346b56cb..2825c03e8 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -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= 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)) + }) }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 5a2a4c2b1..58a6b919e 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -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"` diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go index 1d8ec47e4..a4eebb0d3 100644 --- a/server/jellyfin/dto/dto_suite_test.go +++ b/server/jellyfin/dto/dto_suite_test.go @@ -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) } diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go index 2ac7e2a79..2c8b5317b 100644 --- a/server/jellyfin/dto/ids.go +++ b/server/jellyfin/dto/ids.go @@ -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 } diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go index d85786d99..c95dbd790 100644 --- a/server/jellyfin/dto/ids_test.go +++ b/server/jellyfin/dto/ids_test.go @@ -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()) + }) }) }) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 352fce11d..1ebf66ca1 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -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 } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index a7b381c5c..576482e61 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -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{ diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go index b1ad850e3..182fd5436 100644 --- a/server/jellyfin/e2e/annotations_test.go +++ b/server/jellyfin/e2e/annotations_test.go @@ -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)) }) }) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go index 383dbab83..074c8e1a4 100644 --- a/server/jellyfin/e2e/audiomuse_test.go +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -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() { diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 7128972ba..806b0e5e7 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -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() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index be6d6bb5d..004090de6 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -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= (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=. 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=&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= (scoping) plus GenreIds=. 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)) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ca1308e9e..1ee19ea01 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -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) --- diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go index 279f288b8..a49019bbf 100644 --- a/server/jellyfin/e2e/lyrics_test.go +++ b/server/jellyfin/e2e/lyrics_test.go @@ -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)) }) }) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go index 015d82d91..d3a521332 100644 --- a/server/jellyfin/e2e/multiuser_test.go +++ b/server/jellyfin/e2e/multiuser_test.go @@ -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()) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 08d6f4f6f..50c2e17f2 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -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() { diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go index 6c26569fc..fa14f6811 100644 --- a/server/jellyfin/e2e/search_test.go +++ b/server/jellyfin/e2e/search_test.go @@ -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() { diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 43ef857fa..5f1a88a21 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -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)) + }) }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index c096d904a..954981910 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -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() { diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index c6f9145d5..ac8c350c4 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -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", "") diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index b844e7c74..65f02d7a0 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -14,14 +14,12 @@ import ( "strconv" "github.com/dustin/go-humanize" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/imghttp" - "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -29,7 +27,10 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) - itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + itemId, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) @@ -84,11 +85,14 @@ func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { } // postItemImage handles cover upload. Only playlists are writable here; album/artist covers come -// from scanning. The body is always drained first (even on the not-implemented path) because -// Finamp writes it synchronously and sees a broken pipe if we respond before reading it. +// from scanning. Past the auth and id gates the body is drained before answering — including on the +// not-implemented path — because Finamp writes it synchronously and would see a broken pipe. func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } // Honor the same artwork-upload gate and size cap as the native endpoint. u, _ := request.UserFrom(ctx) @@ -143,7 +147,10 @@ func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { // deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } if _, err := api.playlists.Get(ctx, id); err != nil { http.Error(w, "Not Implemented", http.StatusNotImplemented) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index bdd47e956..0e0d6220d 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -63,16 +63,29 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) var _ = Describe("Images", func() { It("streams album artwork", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(Equal("IMG")) - Expect(fa.recvId).To(ContainSubstring("a1")) + Expect(fa.recvId).To(ContainSubstring(testID("a1"))) + }) + + // A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image. + It("404s a malformed itemId instead of serving a placeholder", func() { + ds := &tests.MockDataStore{} + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest("not-a-valid-id") + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id") }) // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. @@ -81,22 +94,23 @@ var _ = Describe("Images", func() { fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("deleted-item")) + w, r := newImageRequest(dto.EncodeID(testID("deleted-item"))) api.getItemImage(w, r) + // 200 here is what separates a well-formed unknown id from a malformed one, which 404s. + Expect(w.Code).To(Equal(http.StatusOK)) Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") - Expect(w.Body.String()).ToNot(ContainSubstring("deleted-item")) }) It("sniffs the Content-Type instead of hardcoding it", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) fa := &fakeArtwork{data: png} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -105,15 +119,15 @@ var _ = Describe("Images", func() { It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("pl1")) + w, r := newImageRequest(dto.EncodeID(testID("pl1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring(testID("pl1"))) }) // This endpoint is public (no user in the request), so artwork must be resolved under an @@ -121,11 +135,11 @@ var _ = Describe("Images", func() { // silently falls back to the placeholder. It("resolves artwork under an elevated admin context", func() { ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -137,11 +151,11 @@ var _ = Describe("Images", func() { It("serves immutable when the tag param asserts the current hash", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) q := r.URL.Query() q.Set("tag", hash) r.URL.RawQuery = q.Encode() @@ -155,11 +169,11 @@ var _ = Describe("Images", func() { It("revalidates via no-cache when no tag is provided", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -199,21 +213,21 @@ var _ = Describe("postItemImage", func() { var fp *fakePlaylists BeforeEach(func() { - fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} api = &Router{playlists: fp} }) It("uploads a raw JPEG body and returns 204", func() { body := jpegBytes() w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImagePlaylistID).To(Equal(testID("pl1"))) Expect(fp.setImageBytes).To(Equal(body)) Expect(fp.setImageExt).To(Equal(".jpeg")) }) @@ -222,9 +236,9 @@ var _ = Describe("postItemImage", func() { raw := pngBytes() encoded := base64.StdEncoding.EncodeToString(raw) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded))) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded))) r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -238,9 +252,9 @@ var _ = Describe("postItemImage", func() { fp.getByIDErr = model.ErrNotFound bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) api.postItemImage(w, r) @@ -251,9 +265,9 @@ var _ = Describe("postItemImage", func() { It("returns 500 when the service fails", func() { fp.setImageErr = errors.New("boom") w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -263,9 +277,9 @@ var _ = Describe("postItemImage", func() { It("accepts a raw WebP body", func() { body := webpBytes() w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/webp") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -277,9 +291,9 @@ var _ = Describe("postItemImage", func() { It("accepts a raw GIF body", func() { body := gifBytes() w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/gif") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -292,9 +306,9 @@ var _ = Describe("postItemImage", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.MaxImageUploadSize = "16" // 16 bytes w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -310,9 +324,9 @@ var _ = Describe("postItemImage", func() { body := []byte(base64.StdEncoding.EncodeToString(img)) Expect(len(body)).To(BeNumerically(">", len(img))) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -326,9 +340,9 @@ var _ = Describe("postItemImage", func() { conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) body := []byte(base64.StdEncoding.EncodeToString(img)) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -338,9 +352,9 @@ var _ = Describe("postItemImage", func() { It("rejects a body that is neither an image nor base64 with 400", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!")) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -351,9 +365,9 @@ var _ = Describe("postItemImage", func() { It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -365,10 +379,10 @@ var _ = Describe("postItemImage", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableArtworkUpload = false w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false})) api.postItemImage(w, r) @@ -380,10 +394,10 @@ var _ = Describe("postItemImage", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableArtworkUpload = false w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true})) api.postItemImage(w, r) @@ -393,24 +407,24 @@ var _ = Describe("postItemImage", func() { var _ = Describe("deleteItemImage", func() { It("removes the playlist image and returns 204", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} api := &Router{playlists: fp} w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + Expect(fp.removeImagePlaylistID).To(Equal(testID("pl1"))) }) It("returns 501 for a non-playlist item", func() { fp := &fakePlaylists{getByIDErr: model.ErrNotFound} api := &Router{playlists: fp} w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) api.deleteItemImage(w, r) @@ -418,11 +432,11 @@ var _ = Describe("deleteItemImage", func() { }) It("returns 500 when the service fails", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}, removeImageErr: errors.New("boom")} api := &Router{playlists: fp} w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 631530b6d..de0da88eb 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -2,6 +2,7 @@ package jellyfin import ( "context" + "errors" "io" "iter" "net/http" @@ -10,7 +11,6 @@ import ( "strings" "github.com/Masterminds/squirrel" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/sync/errgroup" ) // notMissing excludes items whose backing files are all gone ("missing" is a real column on @@ -33,6 +34,10 @@ func searchTerm(p *req.Values) string { func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Not Found", http.StatusNotFound) + return + } api.internalError(w, r, err) return } @@ -228,12 +233,37 @@ type itemsQuery struct { // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because -// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). -func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that +// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) { p := req.Params(r) + parentId, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Any malformed entry in one of these id lists must 404, not silently drop out of the filter + // (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything. + ids, ok := decodedQueryIDs(r, "ids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. + genreIds, ok := decodedQueryIDs(r, "genreids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Feishin fetches an album's tracks with AlbumIds instead of ParentId. + albumIds, ok := decodedQueryIDs(r, "albumids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + studioIds, ok := decodedQueryIDs(r, "studioids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } q := itemsQuery{ fields: dto.ParseFields(p.Strings("fields")...), - ids: decodedQueryIDs(r, "ids"), + ids: ids, rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), sortBy: p.StringOr("sortby", ""), @@ -242,20 +272,22 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu limit: p.IntOr("limit", 0), // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), - parentId: dto.DecodeID(p.StringOr("parentid", "")), - // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. - genreIds: decodedQueryIDs(r, "genreids"), - // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: parentId, + genreIds: genreIds, + albumIds: albumIds, years: parseYears(r), - studioIds: decodedQueryIDs(r, "studioids"), + studioIds: studioIds, } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) contributingScope := p.StringOr("contributingartistids", "") - q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + if !ok { + return itemsQuery{}, model.ErrNotFound + } + q.artistId = artistId q.contributingOnly = albumArtistScope == "" && contributingScope != "" q.types = parseTypes(q.rawTypes) @@ -271,7 +303,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { - if q.parentId == playlistsFolderID { + if q.parentId == dto.PlaylistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. q.types = []string{"Playlist"} } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { @@ -284,14 +316,17 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu if q.isLibraryParent || len(q.types) > 1 { q.entityParent = "" } - return q + return q, nil } // queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via // the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites // screen requests). func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { - q := api.parseItemsQuery(ctx, r) + q, err := api.parseItemsQuery(ctx, r) + if err != nil { + return itemsResult{}, err + } switch { // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. case len(q.ids) > 0: @@ -321,7 +356,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult // ok is false when ParentId isn't a visible playlist, so the caller falls through to the type // dispatch: ParentId is usually an album or artist. func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { - if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { + if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID { return nil, false } // Tracks enforces visibility. @@ -330,52 +365,97 @@ func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model. } func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { - // Each per-type query needs at most offset+limit rows (the worst case where one type fills the - // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. - window := 0 - if q.limit > 0 { - window = q.offset + q.limit + if q.limit == 0 { + return api.mergeTypesStreaming(ctx, q) } - // A search can't stream, so the window is what each type materializes and StartIndex would drive - // it without bound. Only below the window are the merged rows the true order, hence the clip - // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. - if q.search != "" { - window = min(window, maxSearchLimit) + // A random page doesn't stack on the previous one (the order reshuffles each request), so serving + // from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type. + offset := q.offset + if randomlySorted(q) { + offset = 0 } + return api.mergeTypesPaged(ctx, q, offset) +} + +// randomlySorted reports whether every merged type resolves to a random sort — the case where a page +// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than +// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen. +func randomlySorted(q itemsQuery) bool { + for _, itemType := range q.types { + var opts model.QueryOptions + applySort(&opts, itemType, q.sortBy, q.sortOrder) + if opts.Sort != "random" { + return false + } + } + return true +} + +// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows +// in order minus the first offset, without pulling every row into memory. +func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) { var results []itemsResult total := 0 for _, itemType := range q.types { - var opts model.QueryOptions - opts.Max = window - applySort(&opts, itemType, q.sortBy, q.sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, q) + res, err := api.queryTypeWindow(ctx, itemType, 0, q) if err != nil { return itemsResult{}, err } results = append(results, res) total += res.total } - if q.limit == 0 { - // No cap above, so merging in memory would pull every row of every type. The merged page is - // just their rows in order minus the first offset — what chaining the cursors yields. - return chained(results, total, q.offset), nil + return chained(results, total, q.offset), nil +} + +// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied. +func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) { + var opts model.QueryOptions + opts.Max = window + applySort(&opts, itemType, q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, itemType, opts, q) +} + +// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page +// is a mix rather than one type's rows followed by the next. +func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window). + window := offset + q.limit + if q.search != "" { + window = min(window, maxSearchLimit) } - var items []dto.BaseItemDto - for _, res := range results { - typeItems, err := res.collect() - if err != nil { - return itemsResult{}, err - } - items = append(items, typeItems...) + lists := make([][]dto.BaseItemDto, len(q.types)) + totals := make([]int, len(q.types)) + g, ctx := errgroup.WithContext(ctx) + for i, itemType := range q.types { + g.Go(func() error { + res, err := api.queryTypeWindow(ctx, itemType, window, q) + if err != nil { + return err + } + items, err := res.collect() + if err != nil { + return err + } + lists[i] = items + totals[i] = res.total + return nil + }) } + if err := g.Wait(); err != nil { + return itemsResult{}, err + } + total := 0 + for _, t := range totals { + total += t + } + items := interleave(lists) if q.search != "" { // Past the window the merged order isn't the true one, so drop it rather than serve another - // type's rows. The total is what's pageable overall, not this page, or a client paging on it - // would stop after the first page. + // type's rows. The total is what's pageable overall, so a client paging on it won't stop early. items = items[:min(window, len(items))] total = min(total, maxSearchLimit) } - return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil + return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil } func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { @@ -404,18 +484,20 @@ func firstNonEmpty(vals ...string) string { return "" } -// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. -func firstDecodedID(s string) string { +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting +// whether it decoded successfully (see decodeFilterParam). +func firstDecodedID(s string) (string, bool) { if s == "" { - return "" + return "", true } first, _, _ := strings.Cut(s, ",") - return dto.DecodeID(strings.TrimSpace(first)) + return decodeFilterParam(strings.TrimSpace(first)) } -// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. -func decodedQueryIDs(r *http.Request, key string) []string { - return slice.Map(queryIDs(r, key), dto.DecodeID) +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if +// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter. +func decodedQueryIDs(r *http.Request, key string) ([]string, bool) { + return dto.DecodeIDs(queryIDs(r, key)) } // parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. @@ -440,6 +522,8 @@ func parseTypes(types string) []string { recognized = append(recognized, t) } } + // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. + recognized = slice.Unique(recognized) if len(recognized) == 0 { return []string{"MusicAlbum"} } @@ -459,6 +543,25 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// interleave merges per-type item lists round-robin: one item from each list in turn, preserving +// each list's own order, so no single type dominates the head of a mixed-type result. +func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { + total, maxLen := 0, 0 + for _, l := range lists { + total += len(l) + maxLen = max(maxLen, len(l)) + } + out := make([]dto.BaseItemDto, 0, total) + for i := 0; i < maxLen; i++ { + for _, l := range lists { + if i < len(l) { + out = append(out, l[i]) + } + } + } + return out +} + // Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without // the ceiling, Limit=999999 still materializes every match. const ( @@ -510,7 +613,7 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it filters = append(filters, notMissing) } if len(q.genreIds) > 0 { - filters = append(filters, filter.ByGenreID(q.genreIds)) + filters = append(filters, filter.AlbumsByGenreID(q.genreIds)) } if len(q.years) > 0 { filters = append(filters, filter.AlbumsByYears(q.years)) @@ -557,7 +660,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite filters = append(filters, filter.ByAlbumID(q.albumIds)) } if len(q.genreIds) > 0 { - filters = append(filters, filter.ByGenreID(q.genreIds)) + filters = append(filters, filter.SongsByGenreID(q.genreIds)) } if len(q.years) > 0 { filters = append(filters, filter.SongsByYears(q.years)) @@ -669,11 +772,11 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q } // resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, -// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access +// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access // to their library, so an id can't probe content outside the user's libraries. func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { // The synthetic playlists folder must resolve by the id we advertised, not 404. - if id == playlistsFolderID { + if id == dto.PlaylistsFolderID { return playlistsFolder(), true } u, _ := request.UserFrom(ctx) @@ -711,6 +814,9 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if pl, err := api.playlists.Get(ctx, id); err == nil { return dto.PlaylistToBaseItem(*pl, fields), true } + if g, err := api.ds.Genre(ctx).Get(id); err == nil { + return dto.GenreToBaseItem(*g), true + } return dto.BaseItemDto{}, false } @@ -732,14 +838,11 @@ func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]mode } // itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. -// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored -// queue items against its stored (truncated) ids. func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { u, _ := request.UserFrom(ctx) - fullIDs := api.resolveItemIDs(ctx, ids) - songs := api.songsByIDs(ctx, fullIDs) + songs := api.songsByIDs(ctx, ids) var items []dto.BaseItemDto - for i, id := range fullIDs { + for _, id := range ids { var item dto.BaseItemDto if mf, ok := songs[id]; ok { if !u.HasLibraryAccess(mf.LibraryID) { @@ -749,16 +852,16 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { continue } - if id != ids[i] { - item.Id = dto.EncodeID(ids[i]) - } items = append(items, item) } return result(items, len(items), 0) } func (api *Router) getItem(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 + } fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) @@ -771,7 +874,10 @@ func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { // scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } if err := api.playlists.Delete(ctx, id); err != nil { api.playlistError(w, r, err) return @@ -818,9 +924,8 @@ func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { } } -// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type. Each repository maps -// logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no -// "random"). +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields +// to different real columns, e.g. media_file has "title" not "name"). var sortColumnsByType = map[string]map[string]string{ "Audio": { "sortname": "title", "name": "title", @@ -848,6 +953,7 @@ var sortColumnsByType = map[string]map[string]string{ "playcount": "play_count", "dateplayed": "play_date", "communityrating": "rating", + "random": "random", }, "MusicAlbum": { "sortname": "name", "name": "name", "album": "name", @@ -862,10 +968,12 @@ var sortColumnsByType = map[string]map[string]string{ }, "MusicGenre": { "sortname": "name", "name": "name", + "random": "random", }, "Playlist": { "sortname": "name", "name": "name", "datecreated": "created_at", + "random": "random", }, } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 4eddcc654..b227ab719 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" + "strings" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" @@ -30,14 +32,14 @@ var _ = Describe("Items", func() { var fp *fakePlaylists // alice has access to library 1 only; used by tests that don't care about scoping. 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"}}}) } ctxUserWithLibraries := 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() { ds = &tests.MockDataStore{} @@ -47,7 +49,7 @@ var _ = Describe("Items", func() { Describe("getItems", func() { It("lists albums when IncludeItemTypes=MusicAlbum", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -60,44 +62,44 @@ var _ = Describe("Items", func() { }) It("lists an album's songs when ParentId is an album and type is Audio", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { - fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, }} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) Expect(res.TotalRecordCount).To(Equal(2)) }) It("pages a playlist parent's tracks in the query, not in memory", func() { - fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, - {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, + {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, }} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -105,24 +107,24 @@ var _ = Describe("Items", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.TotalRecordCount).To(Equal(3)) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) Expect(fp.tracksRepo.Options.Max).To(Equal(1)) }) It("falls through to the type dispatch when ParentId is not a playlist", func() { fp.getErr = model.ErrNotFound - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { @@ -137,13 +139,13 @@ var _ = Describe("Items", func() { // looking for tracks outside any album; answering with every track streams the whole library. Describe("Recursive=false", func() { BeforeEach(func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) }) It("returns no songs for a library parent, as tracks are never its direct children", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -155,7 +157,7 @@ var _ = Describe("Items", func() { It("drops only Audio from a multi-type library query", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -166,7 +168,7 @@ var _ = Describe("Items", func() { It("still lists albums for a library parent, as they are its direct children", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -177,13 +179,13 @@ var _ = Describe("Items", func() { It("still lists an album's tracks, as they are its direct children", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("keeps returning every song when no parent scopes the query", func() { @@ -199,7 +201,7 @@ var _ = Describe("Items", func() { // `recursive ?? false`, so an omitted Recursive is a non-recursive request. It("treats an omitted Recursive as false, like Jellyfin", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -209,9 +211,9 @@ var _ = Describe("Items", func() { }) It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) @@ -223,7 +225,7 @@ var _ = Describe("Items", func() { }) It("lists artists when IncludeItemTypes=MusicArtist", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -244,7 +246,7 @@ var _ = Describe("Items", func() { }) It("lists playlists when IncludeItemTypes=Playlist", func() { - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -253,13 +255,13 @@ var _ = Describe("Items", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Type).To(Equal("Playlist")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1")))) Expect(res.TotalRecordCount).To(Equal(1)) }) It("merges results from every requested type in IncludeItemTypes", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -274,11 +276,11 @@ var _ = Describe("Items", func() { It("merges favorite songs, albums, and playlists", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -298,8 +300,8 @@ var _ = Describe("Items", func() { }) It("applies StartIndex/Limit to the merged multi-type result set", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -313,9 +315,9 @@ var _ = Describe("Items", func() { It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -327,7 +329,7 @@ var _ = Describe("Items", func() { It("applies a starred filter when Filters=IsFavorite", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -339,7 +341,7 @@ var _ = Describe("Items", func() { It("forwards SearchTerm to the repo's Search method", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -351,7 +353,7 @@ var _ = Describe("Items", func() { It("caps a search the client left unbounded", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -361,7 +363,7 @@ var _ = Describe("Items", func() { It("honors an explicit search Limit up to the ceiling", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). WithContext(ctxUser()) @@ -372,7 +374,7 @@ var _ = Describe("Items", func() { It("clamps a search Limit that would materialize the library", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). WithContext(ctxUser()) @@ -383,7 +385,7 @@ var _ = Describe("Items", func() { It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). WithContext(ctxUser()) @@ -398,10 +400,10 @@ var _ = Describe("Items", func() { It("reports a multi-type search total past the page, so clients keep paging", func() { songs := make(model.MediaFiles, defaultSearchLimit*2) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). WithContext(ctxUser()) @@ -415,8 +417,8 @@ var _ = Describe("Items", func() { It("bounds the multi-type search window however large StartIndex is", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). WithContext(ctxUser()) @@ -431,10 +433,10 @@ var _ = Describe("Items", func() { // past that window the merged order is no longer the true one. songs := make(model.MediaFiles, maxSearchLimit+1) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), @@ -450,10 +452,12 @@ var _ = Describe("Items", func() { It("serves the last page below the ceiling in full", func() { songs := make(model.MediaFiles, maxSearchLimit+1) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } + // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. + slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), @@ -462,18 +466,18 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - // Clipped to the window, and still the real row at that index — not the album behind it. + // Clipped to the window; the interleaved album takes one slot, shifting this song in by one. Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID))) }) It("bounds an unbounded multi-type search to the default in total, not per type", func() { songs := make(model.MediaFiles, defaultSearchLimit*2) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). WithContext(ctxUser()) @@ -487,10 +491,12 @@ var _ = Describe("Items", func() { It("pages an unbounded multi-type search past the default without dropping matches", func() { songs := make(model.MediaFiles, defaultSearchLimit*2) for i := range songs { - songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } + // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. + slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), @@ -500,12 +506,13 @@ var _ = Describe("Items", func() { var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).ToNot(BeEmpty()) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+50].ID))) + // The interleaved album takes one slot ahead of it, shifting this song in by one. + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID))) }) It("reports a search total beyond the fetched page instead of the page length", func() { ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ - {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, + {ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"}, }) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) @@ -519,7 +526,7 @@ var _ = Describe("Items", func() { It("forwards StartIndex/Limit as Offset/Max", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -532,31 +539,31 @@ var _ = Describe("Items", func() { // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=; without // this, queryItems ignored Ids and returned the default type-dispatched list instead. It("returns exactly the requested item when Ids has a single id", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.Items[0].Name).To(Equal("Song")) Expect(res.TotalRecordCount).To(Equal(1)) }) It("returns items of different types for a lowercase ids param with multiple ids", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(2)) ids := []string{res.Items[0].Id, res.Items[1].Id} - Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1"))) + Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1")))) types := []string{res.Items[0].Type, res.Items[1].Type} Expect(types).To(ConsistOf("MusicAlbum", "Audio")) Expect(res.TotalRecordCount).To(Equal(2)) @@ -564,9 +571,9 @@ var _ = Describe("Items", func() { It("resolves song ids with one batched IN query, not a Get per id", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult @@ -575,20 +582,20 @@ var _ = Describe("Items", func() { sql, args, err := mfRepo.Options.Filters.ToSql() Expect(err).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("media_file.id IN")) - Expect(args).To(ConsistOf("s1", "s2")) + Expect(args).To(ConsistOf(testID("s1"), testID("s2"))) }) It("omits an id in a library the user can't access, without erroring the whole batch", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1 w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) Expect(res.TotalRecordCount).To(Equal(1)) }) }) @@ -596,7 +603,7 @@ var _ = Describe("Items", func() { Describe("sorting", func() { It("maps SortBy=PlayCount to the play_count column", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -606,7 +613,7 @@ var _ = Describe("Items", func() { It("maps SortBy=DatePlayed to the play_date column", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -616,7 +623,7 @@ var _ = Describe("Items", func() { It("uses the first recognized key in a comma-separated SortBy list", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -626,7 +633,7 @@ var _ = Describe("Items", func() { It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -636,7 +643,7 @@ var _ = Describe("Items", func() { It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -646,7 +653,7 @@ var _ = Describe("Items", func() { It("leaves Sort at the repo default when no SortBy key is recognized", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -658,7 +665,7 @@ var _ = Describe("Items", func() { Describe("library scoping", func() { It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) @@ -672,7 +679,7 @@ var _ = Describe("Items", func() { It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) @@ -686,7 +693,7 @@ var _ = Describe("Items", func() { It("scopes a MusicArtist listing 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", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) @@ -700,10 +707,10 @@ var _ = Describe("Items", func() { It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := albumRepo.Options.Filters.ToSql() @@ -715,10 +722,10 @@ var _ = Describe("Items", func() { It("does not let ParentId= scope results to that library", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := albumRepo.Options.Filters.ToSql() @@ -733,7 +740,7 @@ var _ = Describe("Items", func() { It("does not restrict a default MusicAlbum listing for an admin user", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) invoke(api.getItems, w, r) @@ -748,58 +755,173 @@ var _ = Describe("Items", func() { Expect(sql).NotTo(ContainSubstring("library_id")) }) }) + + // A malformed id must 404, not silently drop the filter; a well-formed but unknown one must + // still reach the entity filter, not the unfiltered default. + Describe("stale and malformed id filtering", func() { + It("404s a malformed ParentId instead of listing every song", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed AlbumArtistIds instead of listing every album", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed ArtistIds instead of listing every song", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("album_artists")) + }) + + It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("album_id")) + Expect(args).To(ContainElement(testID("no-such-album"))) + }) + }) + + Describe("mixed IncludeItemTypes merge", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) + }) + + It("returns a mix of both types, not all of one", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(4)) + Expect(res.TotalRecordCount).To(Equal(4)) + types := map[string]int{} + for _, it := range res.Items { + types[it.Type]++ + } + Expect(types["Audio"]).To(Equal(2)) + Expect(types["MusicAlbum"]).To(Equal(2)) + }) + + It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type} + Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"})) + }) + + It("honors Limit across the merged set", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.TotalRecordCount).To(Equal(4)) + }) + + It("serves a full random page from offset 0 regardless of StartIndex", func() { + // A deep StartIndex on a random merge must not materialize offset+limit rows; since random + // reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty. + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(4)) + }) + + It("propagates a per-type query error", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) }) Describe("getItem", func() { It("returns an album by id", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) Expect(item.Type).To(Equal("MusicAlbum")) }) It("returns 404 when the id doesn't match any entity", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 for an album in a library the user can't access", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 for a song in a library the user can't access", func() { - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns an album to an admin even when it's outside their (empty) Libraries", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) }) // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the @@ -807,13 +929,13 @@ var _ = Describe("Items", func() { It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 1, Name: "Music Library"}} - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) Expect(item.Name).To(Equal("Music Library")) Expect(item.Type).To(Equal("CollectionFolder")) Expect(item.CollectionType).To(Equal("music")) @@ -823,8 +945,8 @@ var _ = Describe("Items", func() { It("does not resolve a library-view id the user has no access to", func() { w := httptest.NewRecorder() libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -832,15 +954,15 @@ var _ = Describe("Items", func() { // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it // 404s with "Could not fetch BaseItemDto from server." It("resolves a playlist id via the playlists service", func() { - fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("p1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("p1")))) Expect(item.Name).To(Equal("My Mix")) Expect(item.Type).To(Equal("Playlist")) }) @@ -848,22 +970,37 @@ var _ = Describe("Items", func() { It("returns 404 for a non-owned or absent playlist id", func() { fp.getByIDErr = model.ErrNotFound w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) + // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). + It("resolves a genre id as a MusicGenre item", func() { + Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID(testID("g1")))) + Expect(item.Name).To(Equal("Rock")) + Expect(item.Type).To(Equal("MusicGenre")) + }) + It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin()) - r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var item dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) Expect(item.Name).To(Equal("Music Library")) Expect(item.Type).To(Equal("CollectionFolder")) }) @@ -871,7 +1008,7 @@ var _ = Describe("Items", func() { Describe("getLatest", func() { It("returns a bare array of the newest albums", func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) invoke(api.getLatest, w, r) @@ -879,12 +1016,12 @@ var _ = Describe("Items", func() { var items []dto.BaseItemDto Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) Expect(items).To(HaveLen(1)) - Expect(items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) }) It("scopes to the user's accessible libraries", 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() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) @@ -896,4 +1033,87 @@ var _ = Describe("Items", func() { Expect(args).To(ContainElements(1, 2)) }) }) + + Describe("applySort random for all merge types", func() { + DescribeTable("maps Random -> random", + func(itemType string) { + var opts model.QueryOptions + applySort(&opts, itemType, "Random", "") + Expect(opts.Sort).To(Equal("random")) + }, + Entry("Audio", "Audio"), + Entry("MusicAlbum", "MusicAlbum"), + Entry("MusicArtist", "MusicArtist"), + Entry("MusicGenre", "MusicGenre"), + Entry("Playlist", "Playlist"), + ) + }) + + Describe("interleave", func() { + It("round-robins one item per list in turn", func() { + lists := [][]dto.BaseItemDto{ + {{Id: "a0"}, {Id: "a1"}, {Id: "a2"}}, + {{Id: "b0"}, {Id: "b1"}}, + } + got := interleave(lists) + ids := make([]string, len(got)) + for i, it := range got { + ids[i] = it.Id + } + Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"})) + }) + + It("returns empty for no lists", func() { + Expect(interleave(nil)).To(BeEmpty()) + }) + }) + + Describe("parseTypes", func() { + It("dedupes repeated types, preserving first-seen order", func() { + Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) + }) + }) + + Describe("decodeFilterParam", func() { + It("reports ok for an absent param, decoding to \"\"", func() { + id, ok := decodeFilterParam("") + Expect(id).To(BeEmpty()) + Expect(ok).To(BeTrue()) + }) + + It("reports ok for a well-formed id, whether or not it exists", func() { + id, ok := decodeFilterParam(dto.EncodeID(testID("a1"))) + Expect(id).To(Equal(testID("a1"))) + Expect(ok).To(BeTrue()) + }) + + It("reports not ok for a non-empty param that fails to decode", func() { + id, ok := decodeFilterParam("not-a-valid-id") + Expect(id).To(BeEmpty()) + Expect(ok).To(BeFalse()) + }) + }) + + Describe("decodedQueryIDs", func() { + It("decodes every entry when all are well-formed", func() { + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeTrue()) + Expect(ids).To(Equal([]string{testID("a1"), testID("a2")})) + }) + + It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() { + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeFalse()) + Expect(ids).To(BeEmpty()) + }) + + It("reports ok for an absent param, decoding to an empty list", func() { + r := httptest.NewRequest("GET", "/Items", nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeTrue()) + Expect(ids).To(BeEmpty()) + }) + }) }) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go index aab9628a0..2ae7da6e2 100644 --- a/server/jellyfin/jellyfin_suite_test.go +++ b/server/jellyfin/jellyfin_suite_test.go @@ -5,6 +5,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" @@ -17,6 +18,10 @@ func TestJellyfinApi(t *testing.T) { RunSpecs(t, "Jellyfin API 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) } + // invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call // handlers directly (with withChiURLParam for path params) instead of routing, so without this the // case-insensitive query folding real requests get would be skipped and PascalCase params dropped. diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2830f1105..b21603843 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/Masterminds/squirrel" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -33,9 +34,36 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// decodeFilterParam separates an absent param from an undecodable one: dropping the filter for a +// stale id would silently widen the query to the whole library. It is the only place that opts +// into treating "absent" as fine — every other caller of dto.DecodeID must 404 on ok=false. +func decodeFilterParam(raw string) (id string, ok bool) { + if raw == "" { + return "", true + } + return dto.DecodeID(raw) +} + +// itemIDParam decodes a chi URL id param, writing 404 and reporting false when it isn't a +// well-formed GUID. Handlers must return immediately when ok is false. +func itemIDParam(w http.ResponseWriter, r *http.Request, key string) (string, bool) { + id, ok := dto.DecodeID(chi.URLParam(r, key)) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return "", false + } + return id, true +} + // parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). -func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { - return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +// ok is false when a non-empty ParentId fails to decode (see decodeFilterParam). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool, ok bool) { + parentId, ok := decodeFilterParam(req.Params(r).StringOr("parentid", "")) + if !ok { + return nil, false, false + } + scopeIDs, isLibraryParent = resolveLibraryScope(ctx, parentId) + return scopeIDs, isLibraryParent, true } // libraryScopeFilter restricts a tag query to the given library scope. Empty scope means @@ -51,7 +79,7 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer { // Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. func libraryView(lib model.Library) dto.BaseItemDto { return dto.BaseItemDto{ - Id: dto.EncodeID(strconv.Itoa(lib.ID)), + Id: dto.EncodeLibraryID(lib.ID), Name: lib.Name, Type: "CollectionFolder", CollectionType: "music", diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index 402d14ed0..2cf53a280 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -53,8 +53,8 @@ var _ = Describe("getLyrics", func() { BeforeEach(func() { ds = &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", LibraryID: 1}, - {ID: "s2", Title: "Silent Song", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", LibraryID: 1}, + {ID: testID("s2"), Title: "Silent Song", LibraryID: 1}, }) fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} api = &Router{ @@ -66,7 +66,7 @@ var _ = Describe("getLyrics", func() { doRequest := func(id string) *httptest.ResponseRecorder { w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}) // Clients send hex-encoded ids (matching real traffic and the other handler tests). enc := dto.EncodeID(id) r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) @@ -76,10 +76,10 @@ var _ = Describe("getLyrics", func() { } It("returns 200 with a LyricDto for a track with synced lyrics", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - w := doRequest("s1") + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.LyricDto @@ -91,11 +91,11 @@ var _ = Describe("getLyrics", func() { }) It("serves the main-kind lyric when a translation is also present", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - w := doRequest("s1") + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.LyricDto @@ -105,13 +105,13 @@ var _ = Describe("getLyrics", func() { }) It("returns 404 when the service returns no lyrics", func() { - w := doRequest("s2") + w := doRequest(testID("s2")) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 when the main lyric has no lines", func() { - fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}} - w := doRequest("s1") + fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}} + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -121,35 +121,35 @@ var _ = Describe("getLyrics", func() { }) It("caches results so a second request doesn't re-invoke the service", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) Expect(fake.calls).To(Equal(1)) }) It("caches empty results too", func() { - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) Expect(fake.calls).To(Equal(1)) }) It("completes and caches the fetch even when the request context is cancelled", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } ctx, cancel := context.WithCancel(context.Background()) cancel() - list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"}) + list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")}) Expect(list).ToNot(BeEmpty()) - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) Expect(fake.calls).To(Equal(1)) }) It("bounds the detached fetch with a timeout", func() { - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) Expect(fake.hadDeadline).To(BeTrue()) }) }) diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index f3aa65d6f..b17b9a4ec 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -24,12 +24,12 @@ var _ = Describe("authenticate middleware", 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} }) tokenFor := func(name string) string { - t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name}) + t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name}) Expect(err).ToNot(HaveOccurred()) return t } @@ -129,7 +129,7 @@ var _ = Describe("withPlayer middleware", func() { }) It("injects the player's server-forced transcoding into the context", func() { - players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"} _, trc, hasTrc := callWith() Expect(hasTrc).To(BeTrue()) Expect(trc.TargetFormat).To(Equal("opus")) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 9afee5ac3..805b369c8 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -17,16 +17,11 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve -// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal -// can't collide with real ids (those are hashes). -const playlistsFolderID = "playlists" - // playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be // "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. func playlistsFolder() dto.BaseItemDto { return dto.BaseItemDto{ - Id: dto.EncodeID(playlistsFolderID), + Id: dto.PlaylistsFolderGUID, Name: "Playlists", Type: "ManualPlaylistsFolder", CollectionType: "playlists", @@ -61,7 +56,12 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad Request", http.StatusBadRequest) return } - ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID)) + decoded, ok := dto.DecodeIDs(body.Ids) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(r.Context(), decoded) id, err := api.playlists.Create(r.Context(), "", body.Name, ids) if err != nil { api.internalError(w, r, err) @@ -80,7 +80,10 @@ type updatePlaylistRequest struct { func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } var body updatePlaylistRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) @@ -96,7 +99,12 @@ func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { return } } else { - ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + decoded, ok := dto.DecodeIDs(*body.Ids) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { api.playlistError(w, r, err) return @@ -146,7 +154,7 @@ func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields // individually removable. func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { item := dto.SongToBaseItem(t.MediaFile, fields) - item.PlaylistItemId = dto.EncodeID(t.ID) + item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID) return item } @@ -155,7 +163,10 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { // be probed. func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } pls, err := api.playlists.Get(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -184,7 +195,10 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { // playlist id can't probe for private playlists. func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } repo, err := api.playlists.Tracks(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -232,7 +246,7 @@ func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []strin } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) } else { - out = append(out, id) // unknown id — pass through unchanged + out = append(out, id) // well-formed but unresolved — left for the caller's write to handle } } return out @@ -251,8 +265,16 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin // AddTracks enforces ownership; any error maps to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) - ids := api.expandContainerIDs(ctx, slice.Map(queryIDs(r, "ids"), dto.DecodeID)) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + decoded, ok := dto.DecodeIDs(queryIDs(r, "ids")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { http.Error(w, "Not Found", http.StatusNotFound) return @@ -265,8 +287,20 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { // ownership; any error maps to 404. func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) - ids := slice.Map(queryIDs(r, "entryids"), dto.DecodeID) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + raw := queryIDs(r, "entryids") + ids := make([]string, 0, len(raw)) + for _, entryGUID := range raw { + entry, ok := dto.DecodePlaylistEntryID(entryGUID) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids = append(ids, entry) + } if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { http.Error(w, "Not Found", http.StatusNotFound) return diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 7770cb6db..3599cc784 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -67,7 +67,7 @@ func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []s if f.createErr != nil { return "", f.createErr } - return "pl-new", nil + return testID("pl-new"), nil } // Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real @@ -145,15 +145,15 @@ var _ = Describe("Playlists", func() { Describe("createPlaylist", func() { It("creates a playlist and returns its id", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)). + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("s2"))+`"]}`)). WithContext(context.Background()) invoke(api.createPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res map[string]string Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new"))) + Expect(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new")))) Expect(fp.createdName).To(Equal("Mix")) - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("returns 400 on an invalid JSON body", func() { @@ -177,48 +177,48 @@ var _ = Describe("Playlists", func() { Describe("getPlaylistItems", func() { It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { fp.getPls = &model.Playlist{ - ID: "pl1", + ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}}, }, } w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) api.getPlaylistItems(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.TotalRecordCount).To(Equal(2)) Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) - Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2"))) - Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2")))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2"))) }) It("pages with StartIndex/Limit, pushing them down to the query", func() { fp.getPls = &model.Playlist{ - ID: "pl1", + ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, - {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, + {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, }, } w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil). WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.getPlaylistItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.TotalRecordCount).To(Equal(3)) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) Expect(fp.tracksRepo.Options.Max).To(Equal(1)) }) @@ -227,7 +227,7 @@ var _ = Describe("Playlists", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "missing") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) api.getPlaylistItems(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -245,73 +245,73 @@ var _ = Describe("Playlists", func() { createWith := func(id string) { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(id)+`"]}`)). WithContext(ctx) invoke(api.createPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusOK)) } It("passes a bare song id through unchanged", func() { - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}}) - createWith("s1") - Expect(fp.createdIds).To(Equal([]string{"s1"})) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) + createWith(testID("s1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1")})) }) It("expands an album id into its songs, filtered by album", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + {ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")}, }) - createWith("al1") - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters)) + createWith(testID("al1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters)) }) It("expands an artist id into its songs", func() { - ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}}) - createWith("ar1") - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters)) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) + createWith(testID("ar1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters)) }) It("expands a playlist id into its tracks' media file ids", func() { - fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"}, + fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")}, }} - createWith("pl9") - Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + createWith(testID("pl9")) + Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")})) }) }) Describe("getPlaylist", func() { It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { pls := &model.Playlist{ - ID: "pl1", + ID: testID("pl1"), Public: true, Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, - {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, }, } fp.getPls, fp.getByIDPls = pls, pls w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.getPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaylistInfo Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.OpenAccess).To(BeTrue()) Expect(res.Shares).To(BeEmpty()) - Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")})) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("s2"))})) }) It("returns 404 for a non-owned or absent playlist", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "missing") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) invoke(api.getPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -325,29 +325,29 @@ var _ = Describe("Playlists", func() { It("deletes the playlist and returns 204", func() { w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.deletePlaylistID).To(Equal("pl1")) + Expect(fp.deletePlaylistID).To(Equal(testID("pl1"))) }) It("returns 403 when the user doesn't own the playlist", func() { fp.deleteErr = model.ErrNotAuthorized w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusForbidden)) }) It("returns 404 for a missing playlist or non-playlist id", func() { fp.deleteErr = model.ErrNotFound w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("al1")) + api.deleteItem(w, deleteReq(testID("al1"))) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 500 on an unexpected error", func() { fp.deleteErr = errors.New("boom") w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) }) @@ -355,39 +355,39 @@ var _ = Describe("Playlists", func() { Describe("addToPlaylist", func() { It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal("pl1")) - Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) + Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("returns 404 when the service rejects the request (not found/not owned)", func() { fp.addErr = model.ErrNotAuthorized w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("passes no ids (not a spurious empty string) when the ids param is absent", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) Expect(fp.addIds).To(BeEmpty()) }) }) @@ -395,39 +395,48 @@ var _ = Describe("Playlists", func() { Describe("removeFromPlaylist", func() { It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) Expect(fp.removeIds).To(Equal([]string{"1", "2"})) }) It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.removeIds).To(Equal([]string{"1", "2"})) }) + It("404s an entryId that is a song id rather than a playlist-entry position", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(fp.removeIds).To(BeEmpty()) + }) + It("returns 404 when the service rejects the request (not found/not owned)", func() { fp.removeErr = model.ErrNotFound w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) Expect(fp.removeIds).To(BeEmpty()) }) }) @@ -435,30 +444,30 @@ var _ = Describe("Playlists", func() { Describe("getPlaylistUsers", func() { It("returns the current user with CanEdit true", func() { w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", "pl1") + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) api.getPlaylistUsers(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res []dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}})) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) }) }) Describe("getPlaylistUser", func() { It("returns CanEdit true for the requested user", func() { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background()) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", "pl1") - rctx.URLParams.Add("userId", "u1") + rctx.URLParams.Add("playlistId", testID("pl1")) + rctx.URLParams.Add("userId", testID("u1")) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) api.getPlaylistUser(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true})) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) }) }) }) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f83e5182d..a145e7359 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -21,14 +21,19 @@ type playbackReport struct { // decodeReport reads the playback report body. ItemId falls back to a query param (some clients send // it there) and is decoded here since it flows straight into scrobbler lookups by media file id. -// Finamp reports restored-queue playback with truncated ids, hence resolveItemID. +// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request +// failure; it just can't identify a track, same as an absent one. func (api *Router) decodeReport(r *http.Request) playbackReport { var body playbackReport _ = json.NewDecoder(r.Body).Decode(&body) if body.ItemId == "" { body.ItemId = r.URL.Query().Get("itemid") } - body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId)) + if id, ok := dto.DecodeID(body.ItemId); ok { + body.ItemId = id + } else { + body.ItemId = "" + } return body } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 24f945efd..05151c39a 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -57,8 +58,8 @@ var _ = Describe("Sessions", func() { var pt *fakePlayTracker authed := func(r *http.Request) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) - ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"}) return r.WithContext(ctx) } @@ -70,35 +71,46 @@ var _ = Describe("Sessions", func() { Describe("reportPlaybackStart", func() { It("reports playback start with the item id and position", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":10000000}`))) invoke(api.reportPlaybackStart, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) - Expect(pt.reported[0].ClientId).To(Equal("p1")) + Expect(pt.reported[0].ClientId).To(Equal(testID("p1"))) Expect(pt.reported[0].ClientName).To(Equal("Finamp")) }) It("falls back to the ItemId query param when the body has none", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil)) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("s2")), nil)) invoke(api.reportPlaybackStart, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal("s2")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s2"))) + }) + + It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","PositionTicks":10000000}`))) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(BeEmpty()) }) }) Describe("reportPlaybackProgress", func() { It("reports the playing state when not paused", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`))) invoke(api.reportPlaybackProgress, w, r) @@ -110,7 +122,7 @@ var _ = Describe("Sessions", func() { It("reports the paused state when IsPaused is true", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`))) invoke(api.reportPlaybackProgress, w, r) @@ -123,14 +135,14 @@ var _ = Describe("Sessions", func() { Describe("reportPlaybackStopped", func() { It("reports the stopped state and lets the scrobbler apply its play threshold", func() { w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":600000000}`))) invoke(api.reportPlaybackStopped, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 2698073bf..82d12f52b 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -2,11 +2,11 @@ package jellyfin import ( "context" + "errors" "fmt" "net/http" "time" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -56,7 +56,10 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch // external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are // returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. func (api *Router) getSimilarArtists(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 + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { return api.similarArtists(ctx, id, limit) @@ -68,7 +71,10 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { // result (not 404) so the client stops retrying. func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) @@ -88,16 +94,34 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { })) } +// getSimilarAlbums answers GET /Albums/{itemId}/Similar, powering Finamp's albumMix radio mode. +func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + api.ok(w, r, api.awaitSimilar(ctx, "albsim|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarAlbums(ctx, id, limit) + })) +} + // getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so // a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty // results, never a 404 the client would surface as an error. func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) + // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a + // song" and the provider knows what to do with it. A real lookup failure still stops here. entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil { + if err != nil && !errors.Is(err, model.ErrNotFound) { api.ok(w, r, result(nil, 0, 0)) return } @@ -169,7 +193,8 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. return result(nil, 0, 0) } u, _ := request.UserFrom(ctx) - seen := make(map[string]bool, limit) + // An album is not similar to itself, and the sampled-seed fallback returns its own tracks. + seen := map[string]bool{id: true} var items []dto.BaseItemDto for _, s := range songs { if s.AlbumID == "" || seen[s.AlbumID] { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index 302566195..cffcce0a8 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -34,7 +34,7 @@ var _ = Describe("awaitSimilar", func() { }) It("returns the fetch result when it completes within the wait", func() { - res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult { return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) }) Expect(res.Items).To(HaveLen(1)) @@ -45,7 +45,7 @@ var _ = Describe("awaitSimilar", func() { shortenWait() release := make(chan struct{}) DeferCleanup(func() { close(release) }) - res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + res := api.awaitSimilar(ctxFor(testID("u1")), "id2", 20, func(context.Context) dto.QueryResult { <-release // hung provider; would finish caching in the background return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) }) @@ -64,8 +64,8 @@ var _ = Describe("awaitSimilar", func() { } // Both calls time out, but the flight can't complete before release closes, so the // second call must join it rather than start a new fetch. - api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) - api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) close(release) Eventually(calls.Load).Should(Equal(int32(1))) Consistently(calls.Load, "50ms").Should(Equal(int32(1))) @@ -77,15 +77,15 @@ var _ = Describe("awaitSimilar", func() { calls.Add(1) return result(nil, 0, 0) } - api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) - api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch) + api.awaitSimilar(ctxFor(testID("u2")), "id4", 20, fetch) Expect(calls.Load()).To(Equal(int32(2))) }) It("hands the fetch a deadline-bounded background context", func() { var deadline time.Time var hasDeadline bool - api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult { + api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult { deadline, hasDeadline = ctx.Deadline() return result(nil, 0, 0) }) @@ -123,16 +123,16 @@ var _ = Describe("getInstantMix", func() { ds := &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Seed Song", LibraryID: 1}, + {ID: testID("s1"), Title: "Seed Song", LibraryID: 1}, }) release := make(chan struct{}) DeferCleanup(func() { close(release) }) api := &Router{ds: ds, provider: &blockingProvider{release: release}} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getInstantMix(w, r) var res dto.QueryResult @@ -145,18 +145,18 @@ var _ = Describe("getInstantMix", func() { // queue, so InstantMix gets its own, higher ceiling. It("honors a mix-sized limit above the Similar ceiling", func() { const want = 250 - songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + songs := model.MediaFiles{{ID: testID("s1"), Title: "Seed Song", LibraryID: 1}} for i := range want + 50 { // more than requested, so only the limit bounds the result - songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + songs = append(songs, model.MediaFile{ID: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) } ds := &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). - WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getInstantMix(w, r) var res dto.QueryResult @@ -164,4 +164,71 @@ var _ = Describe("getInstantMix", func() { Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") }) + + It("returns a mix for a genre id, which GetEntityByID can't resolve", func() { + ds := &tests.MockDataStore{} + songs := model.MediaFiles{ + {ID: testID("m1"), Title: "Track 1", LibraryID: 1}, + {ID: testID("m2"), Title: "Track 2", LibraryID: 1}, + } + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1"))+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) + api.getInstantMix(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + }) +}) + +var _ = Describe("getSimilarAlbums", func() { + It("does not return the seed album as its own similar album", func() { + // With no external agent the provider falls back to the album's own tracks, which map + // straight back to the requested album. + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: testID("al-1"), Name: "Seed Album", LibraryID: 1}, + }) + api := &Router{ds: ds, provider: &fakeSimilarProvider{ + songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-1"), LibraryID: 1}}, + }} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) + api.getSimilarAlbums(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + + It("returns albums derived from the provider's similar songs", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: testID("al-2"), Name: "Other", LibraryID: 1}, + }) + api := &Router{ds: ds, provider: &fakeSimilarProvider{ + songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-2"), LibraryID: 1}}, + }} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) + api.getSimilarAlbums(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("Other")) + }) }) diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 8c509fe91..401c791fd 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -88,9 +88,9 @@ var _ = Describe("handleSocket", 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()) - t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) token = t diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 022c00c96..86746f89a 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -23,7 +23,10 @@ import ( // Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { ctx := r.Context() - id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return nil, false + } mf, err := api.ds.MediaFile(ctx).Get(id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -62,7 +65,7 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { src.TranscodingSubProtocol = "http" src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) } - api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } // streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index f77c10ec1..d6220d33f 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -26,7 +26,7 @@ var _ = Describe("Stream", func() { // 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() { @@ -43,30 +43,30 @@ var _ = Describe("Stream", func() { Describe("getPlaybackInfo", func() { It("returns a media source for an accessible track", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.MediaSources[0].Container).To(Equal("mp3")) Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) - Expect(res.PlaySessionId).ToNot(BeEmpty()) + Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) }) It("returns 404 for a track in a library the user can't access", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -75,7 +75,7 @@ var _ = Describe("Stream", func() { It("returns 404 when the id doesn't match any media file", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -83,8 +83,8 @@ var _ = Describe("Stream", func() { playbackInfo := func() dto.PlaybackInfoResponse { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) @@ -103,10 +103,10 @@ var _ = Describe("Stream", func() { It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, }} Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) @@ -114,7 +114,7 @@ var _ = Describe("Stream", func() { It("advertises no Lyric stream when the pipeline finds nothing", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) Expect(lyricStreams(playbackInfo())).To(BeEmpty()) @@ -122,10 +122,10 @@ var _ = Describe("Stream", func() { It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Lang: "eng"}}, + testID("s1"): {{Kind: "main", Lang: "eng"}}, }} Expect(lyricStreams(playbackInfo())).To(BeEmpty()) @@ -133,10 +133,10 @@ var _ = Describe("Stream", func() { It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, }} Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) @@ -146,20 +146,20 @@ var _ = Describe("Stream", func() { // Own ID: an erroring loader isn't cached, but a shared ID could still pick up // another test's cached (non-error) result and mask this assertion. ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + {ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) api.lyrics = &fakeLyricsService{err: errors.New("boom")} w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s-err")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s-err"))) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s-err")))) Expect(lyricStreams(res)).To(BeEmpty()) }) }) @@ -167,12 +167,12 @@ var _ = Describe("Stream", func() { Describe("streamAudio", func() { It("invokes the transcode decider and streamer for an accessible track", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.content = "audio-bytes" w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -183,11 +183,11 @@ var _ = Describe("Stream", func() { It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -198,7 +198,7 @@ var _ = Describe("Stream", func() { It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -208,11 +208,11 @@ var _ = Describe("Stream", func() { It("converts the bps audioBitRate param to kbps", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(decider.req.BitRate).To(Equal(320)) @@ -220,11 +220,11 @@ var _ = Describe("Stream", func() { It("uses the audioCodec param as target format when no container is given", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(decider.req.Format).To(Equal("aac")) @@ -232,12 +232,12 @@ var _ = Describe("Stream", func() { It("returns 500 and logs when the streamer fails", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.err = errors.New("boom") w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) @@ -247,14 +247,14 @@ var _ = Describe("Stream", func() { Describe("streamHls", func() { BeforeEach(func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, }) }) hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamHls, w, r) return w } @@ -302,7 +302,7 @@ var _ = Describe("Stream", func() { It("returns 404 for a track in a library the user can't access", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2}, }) Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) }) @@ -316,12 +316,12 @@ var _ = Describe("Stream", func() { Describe("streamFile", func() { It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) streamer.content = "audio-bytes" w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -333,11 +333,11 @@ var _ = Describe("Stream", func() { It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, }) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", "s1") + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -348,7 +348,7 @@ var _ = Describe("Stream", func() { It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "missing") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 6f0c359d4..8b847987f 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -2,10 +2,14 @@ package jellyfin import ( "context" + "encoding/hex" "errors" "fmt" + "net" "net/http" + "net/netip" "path" + "strings" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" @@ -42,25 +46,32 @@ func (api *Router) serverID(ctx context.Context) string { return api.serverIDVal } if api.ds == nil { - api.serverIDVal = uuid.NewString() + api.serverIDVal = newServerID() return api.serverIDVal } id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): - id = uuid.NewString() + id = newServerID() if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { log.Error(ctx, "Jellyfin API: could not persist server id", err) return id } case err != nil: log.Error(ctx, "Jellyfin API: could not read server id", err) - return uuid.NewString() + return newServerID() } - api.serverIDVal = id + // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. + api.serverIDVal = strings.ReplaceAll(id, "-", "") return api.serverIDVal } +// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). +func newServerID() string { + u := uuid.New() + return hex.EncodeToString(u[:]) +} + func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { return dto.PublicSystemInfo{ LocalAddress: localAddress(r), @@ -99,6 +110,48 @@ func (api *Router) ping(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(api.serverName())) } +// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a +// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server". +func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) { + remote := remoteIP(r) + api.ok(w, r, dto.EndPointInfo{ + IsLocal: isSameMachine(r, remote), + IsInNetwork: isInLocalNetwork(remote), + }) +} + +// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no +// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. +func isInLocalNetwork(ip netip.Addr) bool { + return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast()) +} + +// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local +// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback. +func isSameMachine(r *http.Request, remote netip.Addr) bool { + local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) + if !ok { + return remote.IsLoopback() + } + return parseIP(local.String()) == remote +} + +// remoteIP parses RemoteAddr, which the RealIP middleware may have rewritten to a bare IP. +func remoteIP(r *http.Request) netip.Addr { + return parseIP(r.RemoteAddr) +} + +func parseIP(addr string) netip.Addr { + if h, _, err := net.SplitHostPort(addr); err == nil { + addr = h + } + ip, err := netip.ParseAddr(addr) + if err != nil { + return netip.Addr{} + } + return ip.Unmap() +} + func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { api.ok(w, r, false) } diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 7846e4a20..4b3ac240e 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "net/http/httptest" @@ -88,6 +89,61 @@ var _ = Describe("System", func() { Expect(w.Body.String()).To(HavePrefix("Navidrome")) }) + DescribeTable("reports the caller's network location on /System/Endpoint", + func(remoteAddr string, isLocal, isInNetwork bool) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = remoteAddr + api.getEndpointInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + // Finamp's connection test only probes for the key's presence, so it must always be emitted. + Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`)) + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(Equal(isLocal)) + Expect(info.IsInNetwork).To(Equal(isInNetwork)) + }, + Entry("loopback", "127.0.0.1:12345", true, true), + Entry("IPv6 loopback", "[::1]:12345", true, true), + Entry("LAN address", "192.168.1.20:54321", false, true), + Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true), + Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true), + Entry("IPv6 link-local", "[fe80::1]:80", false, true), + Entry("IPv6 unique-local", "[fd00::1]:80", false, true), + // Jellyfin's default LAN set omits 169.254.0.0/16, so we do too. + Entry("IPv4 link-local", "169.254.1.1:80", false, false), + Entry("public address", "8.8.8.8:443", false, false), + Entry("unparseable address", "not-an-ip", false, false), + ) + + It("reports IsLocal when the caller shares the connection's local address", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = "192.168.1.20:54321" + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, + &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) + api.getEndpointInfo(w, r.WithContext(ctx)) + + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(BeTrue()) + }) + + It("does not report IsLocal for a different host on the same LAN", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Endpoint", nil) + r.RemoteAddr = "192.168.1.99:54321" + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, + &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) + api.getEndpointInfo(w, r.WithContext(ctx)) + + var info dto.EndPointInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.IsLocal).To(BeFalse()) + Expect(info.IsInNetwork).To(BeTrue()) + }) + It("reports quick connect as disabled", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) @@ -125,21 +181,33 @@ var _ = Describe("System", func() { }) It("does not overwrite or pin over a stored id when the property read fails transiently", func() { - Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed()) r := &Router{ds: ds} props := ds.Property(ctx).(*tests.MockedPropertyRepo) props.Error = errors.New("database is locked") degraded := r.serverID(ctx) Expect(degraded).ToNot(BeEmpty()) - Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one + Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one props.Error = nil // Once the DB recovers, the stored id is intact and served again. - Expect(r.serverID(ctx)).To(Equal("stable-id")) + Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal("stable-id")) + Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) + }) + + It("returns Jellyfin's no-dash GUID form", func() { + r := &Router{ds: ds} + Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$")) + }) + + It("strips dashes from an already-persisted id", func() { + Expect(ds.Property(ctx).Put( + consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed()) + r := &Router{ds: ds} + Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427")) }) }) }) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go deleted file mode 100644 index bb3e4ce9b..000000000 --- a/server/jellyfin/truncated_ids.go +++ /dev/null @@ -1,113 +0,0 @@ -package jellyfin - -import ( - "context" - "slices" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming -// Jellyfin GUIDs). All Navidrome ids are 22 chars (share ids 10), so length alone flags a -// truncated id. See README. -// -// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers -// and ParentId scoping don't (a restored queue never edits playlists or browses by container id). -const truncatedIDLen = 16 - -// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is -// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous. -func (api *Router) resolveItemID(ctx context.Context, id string) string { - if len(id) != truncatedIDLen { - return id - } - probes := []func() []string{ - func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) }, - func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) }, - func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) }, - func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) }, - } - for _, probe := range probes { - switch ids := probe(); len(ids) { - case 0: - continue - case 1: - log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0]) - return ids[0] - default: - log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id) - return id - } - } - return id -} - -// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of -// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the -// leftovers (containers, unknowns) fall back to the per-id probes. -func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string { - var truncated []string - for _, id := range ids { - if len(id) == truncatedIDLen { - truncated = append(truncated, id) - } - } - if len(truncated) == 0 { - return ids - } - - byPrefix := make(map[string][]string, len(truncated)) - for chunk := range slice.CollectChunks(slices.Values(truncated), 100) { - ranges := make(squirrel.Or, len(chunk)) - for i, p := range chunk { - ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}} - } - mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges}) - if err != nil { - log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err) - break - } - for _, mf := range mfs { - p := mf.ID[:truncatedIDLen] - byPrefix[p] = append(byPrefix[p], mf.ID) - } - } - - out := make([]string, len(ids)) - for i, id := range ids { - switch full := byPrefix[id]; { - case len(full) == 1: - out[i] = full[0] - case len(id) == truncatedIDLen: - out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide - default: - out[i] = id - } - } - return out -} - -// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to -// detect ambiguity). '\x7f' is above every character the id alphabets use. -func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string { - rows, err := getAll(model.QueryOptions{ - Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}}, - Max: 2, - }) - if err != nil { - return nil - } - ids := make([]string, len(rows)) - for i, row := range rows { - ids[i] = id(row) - } - return ids -} - -func mediaFileID(mf model.MediaFile) string { return mf.ID } -func albumID(al model.Album) string { return al.ID } -func artistID(ar model.Artist) string { return ar.ID } -func playlistID(pl model.Playlist) string { return pl.ID } diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 6a1597b70..3e53d598f 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -19,7 +19,7 @@ import ( var _ = Describe("Users", func() { var api *Router authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) return r.WithContext(ctx) } BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) @@ -35,13 +35,13 @@ var _ = Describe("Users", func() { Expect(res.Items).To(HaveLen(2)) Expect(res.TotalRecordCount).To(Equal(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) Expect(res.Items[0].Name).To(Equal("Music")) Expect(res.Items[0].Type).To(Equal("CollectionFolder")) Expect(res.Items[0].CollectionType).To(Equal("music")) Expect(res.Items[0].IsFolder).To(BeTrue()) - Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeLibraryID(2))) Expect(res.Items[1].Name).To(Equal("Podcasts")) }) @@ -53,7 +53,7 @@ var _ = Describe("Users", func() { var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) }) It("returns no views for a user with no library access", func() { @@ -92,8 +92,8 @@ var _ = Describe("Users", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) - Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed()) - Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) }) It("returns an empty list when the config is unset", func() { @@ -106,7 +106,7 @@ var _ = Describe("Users", func() { users := publicUsers() Expect(users).To(HaveLen(2)) Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal(dto.EncodeID("u2"))) + Expect(users[0].Id).To(Equal(dto.EncodeID(testID("u2")))) Expect(users[1].Name).To(Equal("alice")) // The public list must not expose Policy/Configuration to unauthenticated callers. Expect(users[0].Policy).To(BeNil()) diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go index 3583c2db7..cfd943b1f 100644 --- a/server/nativeapi/artwork.go +++ b/server/nativeapi/artwork.go @@ -10,14 +10,6 @@ import ( "github.com/navidrome/navidrome/model" ) -var refreshableArtworkKinds = []model.Kind{ - model.KindAlbumArtwork, - model.KindArtistArtwork, - model.KindPlaylistArtwork, - model.KindRadioArtwork, - model.KindMediaFileArtwork, -} - func (api *Router) addArtworkRoute(r chi.Router) { r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) } @@ -28,7 +20,7 @@ func (api *Router) refreshArtwork() http.HandlerFunc { ctx := r.Context() kind, _ := model.ParseKind(chi.URLParam(r, "kind")) id := chi.URLParam(r, "id") - if !slices.Contains(refreshableArtworkKinds, kind) { + if !slices.Contains(artwork.RefreshableKinds, kind) { http.Error(w, "invalid artwork kind", http.StatusBadRequest) return } diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 041a3b8f2..ba5f88a68 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -46,7 +46,7 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { if err != nil { return nil, 0, err } - opts = filter.ByGenre(genre) + opts = filter.AlbumsByGenre(genre) case "byYear": fromYear, err := p.Int("fromYear") if err != nil { @@ -267,7 +267,7 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.ByGenre(genre) + opts := filter.SongsByGenre(genre) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) ctx := r.Context() diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 1b097ca32..c8e915daa 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -7,6 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) type MockArtworkQueueRepo struct { @@ -25,6 +26,19 @@ func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} } +func (m *MockArtworkQueueRepo) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)] + if !ok { + return nil, model.ErrNotFound + } + return &it, nil +} + func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -161,6 +175,49 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } +func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueStat + for _, it := range m.Data { + i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { + return s.ItemKind == it.ItemKind && s.Priority == it.Priority + }) + if i < 0 { + res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1}) + continue + } + res[i].Count++ + } + slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int { + return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority)) + }) + return res, nil +} + +// CountAbsent mirrors the SQL predicate: an absent state is one with no hash. +func (m *MockArtworkQueueRepo) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { + m.mu.Lock() + defer m.mu.Unlock() + var res model.ArtworkAbsentStat + if m.Err != nil || m.ItemArtworkSource == nil { + return res, m.Err + } + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind != kind.Prefix() || ia.Hash != "" { + continue + } + res.Total++ + if ia.AttemptedAt.Before(attemptedBefore) { + res.Stale++ + } + } + return res, nil +} + func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -216,6 +273,65 @@ func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefo return inserted, nil } +// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state. +func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { + if m.ItemArtworkSource == nil { + return nil + } + var res []model.ItemArtwork + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && (len(sources) == 0 || slices.Contains(sources, ia.Source)) { + res = append(res, ia) + } + } + return res +} + +func (m *MockArtworkQueueRepo) CountBySource(kind model.Kind, sources []string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.matchingSource(kind, sources))), nil +} + +func (m *MockArtworkQueueRepo) SourcesInUse(kind model.Kind) ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source }) + return slice.Unique(sources), nil +} + +func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + now := time.Now() + var inserted int64 + for _, ia := range m.matchingSource(kind, sources) { + k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: ia.ItemKind, + ItemID: ia.ItemID, + ImageType: ia.ImageType, + Priority: priority, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} + // EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { m.mu.Lock() diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 50796efc0..ad3ee1a6a 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -32,6 +32,17 @@ func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, e return all, nil } +func (r *MockedGenreRepo) Get(id string) (*model.Genre, error) { + if r.Error != nil { + return nil, r.Error + } + r.init() + if g, ok := r.Data[id]; ok { + return &g, nil + } + return nil, model.ErrNotFound +} + func (r *MockedGenreRepo) Put(g *model.Genre) error { if r.Error != nil { return r.Error diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index ee645e984..f04ed98c6 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -19,16 +19,17 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo { type MockPlaylistRepo struct { model.PlaylistRepository - Data map[string]*model.Playlist // keyed by ID - PathMap map[string]*model.Playlist // keyed by path - All model.Playlists - Options model.QueryOptions - Last *model.Playlist - Deleted []string - Starred map[string]bool // itemID -> starred - Ratings map[string]int // itemID -> rating - Err bool - TracksRepo model.PlaylistTrackRepository + Data map[string]*model.Playlist // keyed by ID + PathMap map[string]*model.Playlist // keyed by path + All model.Playlists + Options model.QueryOptions + Last *model.Playlist + Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating + Err bool + TracksRepo model.PlaylistTrackRepository + TracksRefreshed bool } func (m *MockPlaylistRepo) SetError(err bool) { @@ -163,7 +164,8 @@ func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { return nil } -func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { +func (m *MockPlaylistRepo) Tracks(_ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { + m.TracksRefreshed = refreshSmartPlaylist return m.TracksRepo } diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 0b48f232d..2f329a034 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -13,9 +13,19 @@ import { } from 'react-admin' import ShuffleIcon from '@material-ui/icons/Shuffle' import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import ShareIcon from '@material-ui/icons/Share' +import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' import { IoIosRadio } from 'react-icons/io' import { playShuffle, playTopSongs } from './actions.js' import { playSimilar } from '../common/playbackActions.js' +import { + openShareMenu, + openDownloadMenu, + DOWNLOAD_MENU_ARTIST, +} from '../actions' +import config from '../config' +import { formatBytes } from '../utils' +import { artistDownloadSize } from '../common/artist' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -62,6 +72,9 @@ const ArtistActions = ({ className, record, ...rest }) => { const [loadingAction, setLoadingAction] = React.useState(null) const isLoading = !!loadingAction + const albumArtistSize = artistDownloadSize(record) + const hasAlbumArtistContent = Boolean(albumArtistSize) + const handlePlay = React.useCallback(async () => { setLoadingAction('play') try { @@ -101,6 +114,14 @@ const ArtistActions = ({ className, record, ...rest }) => { } }, [dispatch, notify, record]) + const handleShare = React.useCallback(() => { + dispatch(openShareMenu([record.id], 'artist', record.name)) + }, [dispatch, record]) + + const handleDownload = React.useCallback(() => { + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ARTIST)) + }, [dispatch, record]) + return ( { loading={loadingAction === 'radio'} icon={} /> + {config.enableSharing && hasAlbumArtistContent && ( + } + /> + )} + {config.enableDownloads && hasAlbumArtistContent && ( + } + /> + )} ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index a11ee50e3..ad25177b3 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -4,6 +4,11 @@ import { TestContext } from 'ra-test' import { describe, it, expect, vi, beforeEach } from 'vitest' import ArtistActions from './ArtistActions' import subsonic from '../subsonic' +import { + openShareMenu, + openDownloadMenu, + DOWNLOAD_MENU_ARTIST, +} from '../actions' import { ThemeProvider, createTheme } from '@material-ui/core/styles' const mockDispatch = vi.fn() @@ -13,6 +18,11 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { enableSharing: true, enableDownloads: true }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) @@ -27,7 +37,11 @@ vi.mock('react-admin', async (importOriginal) => { }) describe('ArtistActions', () => { - const defaultRecord = { id: 'ar1', name: 'Artist' } + const defaultRecord = { + id: 'ar1', + name: 'Artist', + stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, + } const renderArtistActions = (record = defaultRecord) => { const theme = createTheme() @@ -48,6 +62,8 @@ describe('ArtistActions', () => { vi.clearAllMocks() // Mock console.error to suppress error logging in tests vi.spyOn(console, 'error').mockImplementation(() => {}) + mockConfig.enableSharing = true + mockConfig.enableDownloads = true const songWithReplayGain = { id: 'rec1', @@ -227,4 +243,51 @@ describe('ArtistActions', () => { expect(mockDispatch).not.toHaveBeenCalled() }) }) + + describe('Share action', () => { + it('shows the share button and dispatches openShareMenu when clicked', () => { + renderArtistActions() + fireEvent.click(screen.getByText('ra.action.share')) + expect(mockDispatch).toHaveBeenCalledWith( + openShareMenu(['ar1'], 'artist', 'Artist'), + ) + }) + + it('hides the share button when sharing is disabled', () => { + mockConfig.enableSharing = false + renderArtistActions() + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + }) + }) + + describe('Download action', () => { + it('shows the download button with album-artist size and dispatches openDownloadMenu when clicked', () => { + renderArtistActions() + expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() + fireEvent.click(screen.getByText(/ra\.action\.download/)) + expect(mockDispatch).toHaveBeenCalledWith( + openDownloadMenu(defaultRecord, DOWNLOAD_MENU_ARTIST), + ) + }) + + it('hides the download button when downloads are disabled', () => { + mockConfig.enableDownloads = false + renderArtistActions() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + }) + + describe('Album-artist gating', () => { + it('hides Share and Download for artists with no album-artist content', () => { + renderArtistActions({ id: 'ar1', name: 'Artist', stats: {} }) + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + + it('hides Share and Download for a missing artist', () => { + renderArtistActions({ ...defaultRecord, missing: true }) + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 47c9c6786..7ad8c735c 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -24,6 +24,7 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' +import { artistDownloadSize } from './artist' const useStyles = makeStyles({ noWrap: { @@ -70,6 +71,9 @@ const ContextMenu = ({ const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) + const isArtist = resource === 'artist' + const downloadSize = isArtist ? artistDownloadSize(record) : record?.size + const options = { play: { enabled: true, @@ -103,7 +107,7 @@ const ContextMenu = ({ }, ...(!hideShare && { share: { - enabled: config.enableSharing, + enabled: config.enableSharing && (!isArtist || downloadSize), needData: false, label: translate('ra.action.share'), action: (record) => @@ -111,9 +115,9 @@ const ContextMenu = ({ }, }), download: { - enabled: config.enableDownloads && record.size, + enabled: config.enableDownloads && downloadSize, needData: false, - label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, + label: `${translate('ra.action.download')} (${formatBytes(downloadSize)})`, action: () => { dispatch( openDownloadMenu( diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx new file mode 100644 index 000000000..72a98b64b --- /dev/null +++ b/ui/src/common/ContextMenus.test.jsx @@ -0,0 +1,78 @@ +import React from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus' + +const mockDispatch = vi.fn() +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: true, + enableDownloads: true, + enableFavourites: false, + }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ getList: vi.fn() }), + useTranslate: () => (x) => x, + } +}) + +describe('ContextMenus', () => { + const renderMenu = (Menu, record) => { + render( + + + + + , + ) + fireEvent.click(screen.getByLabelText('more')) + } + + beforeEach(() => { + vi.clearAllMocks() + mockConfig.enableSharing = true + mockConfig.enableDownloads = true + }) + + describe('ArtistContextMenu', () => { + const withAlbumArtist = { + id: 'ar1', + name: 'Artist', + stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, + } + + it('shows the album-artist size on the download item', () => { + renderMenu(ArtistContextMenu, withAlbumArtist) + expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() + }) + + it('hides download and share for artists with no album-artist content', () => { + renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + }) + }) + + describe('AlbumContextMenu', () => { + it('uses the total size on the album download item', () => { + renderMenu(AlbumContextMenu, { + id: 'al1', + name: 'Album', + duration: 100, + size: 1024 * 1024, + }) + expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() + }) + }) +}) diff --git a/ui/src/common/artist.js b/ui/src/common/artist.js new file mode 100644 index 000000000..370700160 --- /dev/null +++ b/ui/src/common/artist.js @@ -0,0 +1,6 @@ +// Size of an artist's downloadable album-artist content, or undefined when there +// is nothing to download (a missing artist, or no album-artist songs). Download +// and Share only cover album-artist songs, so callers gate on this, not the +// role-inclusive total. +export const artistDownloadSize = (record) => + record?.missing ? undefined : record?.stats?.albumartist?.size diff --git a/ui/src/common/artist.test.js b/ui/src/common/artist.test.js new file mode 100644 index 000000000..eff9935a6 --- /dev/null +++ b/ui/src/common/artist.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { artistDownloadSize } from './artist' + +describe('artistDownloadSize', () => { + it('returns the album-artist size', () => { + expect( + artistDownloadSize({ stats: { albumartist: { size: 1024 } } }), + ).toEqual(1024) + }) + + it('returns undefined for a missing artist', () => { + expect( + artistDownloadSize({ + missing: true, + stats: { albumartist: { size: 1024 } }, + }), + ).toBeUndefined() + }) + + it('returns undefined when there is no album-artist content', () => { + expect(artistDownloadSize({ stats: {} })).toBeUndefined() + expect(artistDownloadSize(undefined)).toBeUndefined() + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 7b5b376f3..047f5b3cf 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -1,4 +1,5 @@ export * from './AddToPlaylistButton' +export * from './artist' export * from './ArtistLinkField' export * from './BatchPlayButton' export * from './BitrateField' diff --git a/ui/src/dialogs/DownloadMenuDialog.jsx b/ui/src/dialogs/DownloadMenuDialog.jsx index 2104cbcad..61e84a08a 100644 --- a/ui/src/dialogs/DownloadMenuDialog.jsx +++ b/ui/src/dialogs/DownloadMenuDialog.jsx @@ -8,8 +8,9 @@ import { DialogTitle, } from '@material-ui/core' import subsonic from '../subsonic' -import { closeDownloadMenu } from '../actions' +import { closeDownloadMenu, DOWNLOAD_MENU_ARTIST } from '../actions' import { formatBytes } from '../utils' +import { artistDownloadSize } from '../common/artist' import { useTranscodingOptions } from './useTranscodingOptions' const DownloadMenuDialog = () => { @@ -22,6 +23,12 @@ const DownloadMenuDialog = () => { const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = useTranscodingOptions() + // Artist downloads only include album-artist songs, so show that size + const downloadSize = + recordType === DOWNLOAD_MENU_ARTIST + ? artistDownloadSize(record) + : record?.size + const handleClose = (e) => { dispatch(closeDownloadMenu()) e.stopPropagation() @@ -55,7 +62,7 @@ const DownloadMenuDialog = () => { smart_count: 1, }).toLocaleLowerCase(), name: record?.name || record?.title, - size: formatBytes(record?.size), + size: formatBytes(downloadSize), })} diff --git a/ui/src/dialogs/DownloadMenuDialog.test.jsx b/ui/src/dialogs/DownloadMenuDialog.test.jsx new file mode 100644 index 000000000..b9ce7f077 --- /dev/null +++ b/ui/src/dialogs/DownloadMenuDialog.test.jsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { render, screen, cleanup } from '@testing-library/react' +import { describe, afterEach, it, expect, vi } from 'vitest' +import DownloadMenuDialog from './DownloadMenuDialog' +import { DOWNLOAD_MENU_ALBUM, DOWNLOAD_MENU_ARTIST } from '../actions' + +vi.mock('./useTranscodingOptions', () => ({ + useTranscodingOptions: () => ({ + TranscodingOptionsInput: () => null, + format: '', + maxBitRate: 0, + originalFormat: true, + }), +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (key, opts) => + opts?.size ? `${key}:${opts.name}:${opts.size}` : key, + } +}) + +const renderDialog = (record, recordType) => + render( + + + , + ) + +describe('DownloadMenuDialog', () => { + afterEach(cleanup) + + it('shows the album-artist size (not the total) for an artist download', () => { + renderDialog( + { + id: 'ar1', + name: 'Artist', + size: 999999999, + stats: { albumartist: { size: 1024 } }, + }, + DOWNLOAD_MENU_ARTIST, + ) + expect(screen.getByText(/:Artist:1 KB$/)).toBeInTheDocument() + }) + + it('shows the total size for an album download', () => { + renderDialog( + { id: 'al1', name: 'Album', size: 1024 * 1024 }, + DOWNLOAD_MENU_ALBUM, + ) + expect(screen.getByText(/:Album:1 MB$/)).toBeInTheDocument() + }) +})