mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(instant-mix): support album, playlist and genre sources (#5948)
* feat(agents): local agent genre-hint similar songs fallback
* feat(external): playlist instant mix via seed-track sampling
* test(external): cover playlist mix never-empty fallback and maxSeeds cap
Adds coverage for the empty-match seed fallback and the maxSeeds
call cap on GetSimilarSongsByTrack, per code review finding.
* feat(external): genre instant mix via seed-track sampling
* feat(external): album instant mix falls back to AudioMuse track similarity
* feat(external): artist instant mix falls back to seed-track sampling
* fix(jellyfin): route genre seeds through instant mix instead of empty
* feat(jellyfin): add /Albums/{id}/Similar route for albumMix radio
* perf(external): bound playlist seed sampling to a random N
samplePlaylistTracks loaded an entire playlist's joined rows just to keep
5 random seeds; push the bound and randomization into the query instead,
matching the other samplers (GetRandom/GetAllByTags with Max).
Fixing this surfaced a real bug: resetSeededRandom's SEEDEDRAND rewrite
assumed every table's id is TEXT, but playlist_tracks.id is an INTEGER
position, so the random sort silently dropped every row. Cast the id to
TEXT before hashing (no-op for the other, TEXT-id tables).
Also trims a changelog-flavored comment and a duplicated rationale in
server/jellyfin/similar_test.go.
* refactor(external): parallelize seed mix and dedup mix helpers
Run the up-to-5 per-seed GetSimilarSongsByTrack calls concurrently (errgroup),
route the four container cases through a shared seedMix helper, flatten the
genre lookup, and sample playlist seeds without forcing a smart-playlist
rebuild. Share the media-file->Song mapping in the local agent.
* perf(agents): use the indexed genre filter for local similarity
Replace GetAllByTags (a json_tree scan of every media_file row) with the
media_file_tags semi-join from #5940, deriving the seed's genre tag ids
locally since they hash from (name, value).
Also carry the library id and the recording MBID on the returned songs:
the matcher resolves by id first and looks up mbz_recording_id, so the
release-track id it got before matched nothing and the local fallback
silently returned no songs.
* refactor: drop redundant MBID and fold mixFromSeeds into seedMix
The local agent returns library tracks, so the id alone resolves them in the
matcher's first phase; the MBID was never consulted. mixFromSeeds had no
caller other than seedMix.
* docs: trim redundant comments
* fix(jellyfin): adopt the GUID id codec in the merged similar routes
getSimilarAlbums still used resolveItemID/DecodeID, which #5942 replaced with
itemIDParam; its tests passed raw ids that the strict codec now rejects.
* fix(external): guard non-positive counts and blend every seed
A negative Subsonic count reached matched[:count] and panicked. The matcher
also keeps input order and stops at count, so seed-grouped results let the
first seed fill the whole mix; interleaving gives every seed a share.
Drops the duplicate playlist-track mock in favour of tests.MockPlaylistTrackRepo,
which pages like the real repository and records the query options.
* fix(external): refresh smart playlists before sampling seeds
A smart playlist materializes no playlist_tracks until it is evaluated, so
sampling without the refresh mixed an empty seed set. The refresh is a no-op
for regular playlists, inside the refresh delay, and for non-owners.
* fix(external): skip missing tracks and a nil playlist-track repo when sampling
Tracks() logs and returns a nil repository when its own lookup fails, so the
chained GetAll panicked. Seeds can also reach the mix verbatim when the agents
find nothing, so a missing file would surface as an unplayable entry.
* fix(jellyfin): never report the seed album as its own similar album
The sampled-seed fallback returns the album's own tracks, which similarAlbums
mapped straight back to the requested album, often as the only result.
* test(agents): assert the genre predicate instead of relying on the mock
MockMediaFileRepo ignores QueryOptions.Filters, so the spec passed even with
no genre filter at all. It now checks the generated predicate carries the
seed's own tag id, the indexed join and the missing exclusion.
* fix(external): clamp the requested count before it becomes a query limit
Subsonic passes the client's count through unbounded. At MaxInt64 the local
agent's count+1 overflows negative, and GetRandom omits the SQL limit unless
Max is positive, so one request would hydrate every matching track. 500 is
what the widest caller (similarAlbums, limit*5) legitimately asks for.
* fix(external): deduplicate playlist seeds by media file
A playlist can hold the same file at several positions, so sampling its rows
could seed the mix twice: a wasted agent call, and a duplicate track whenever
the seed fallback kicks in.
* fix(external): drop tracks two seeds both recommend
The matcher re-emits a track when two inputs are identical, so overlapping
recommendations took several slots in the mix. Match the whole merged set and
dedup before trimming. Playlist sampling now over-fetches before its own
dedup, so repeated positions cannot collapse the seed count.
* test(external): make the seed-blend assertion independent of the shuffle
It matched four tracks and kept two at random, so both could come from the
first seed once in six runs. Keeping three of the four makes a seed-two track
unavoidable.
* fix(external): seed artist mixes from every credited role
media_file.artist_id is the deprecated primary artist, so an artist credited
only on the album, as on compilations, sampled no seeds at all. Use the same
participant filter the artist listings use.
* refactor(external): drop the now-vestigial seed interleaving
Matching the whole merged set removed the early truncation the interleave
guarded against, and the shuffle before the trim makes input order irrelevant.
Its comment described the old behaviour.
* test(agents): give the id-mapping fixture a matching genre
The related track carried no genre, so the real query would never return it;
the spec only passed because the mock ignores QueryOptions.Filters.
* test(agents): drop the MBID from the id-mapping fixture
Local agent candidates are non-missing library rows, so the matcher always
resolves them in its id phase and never reads the MBID. The field guarded a
regression that could not change behaviour.
* test(agents): remove unnecessary comment about MBID in GetArtistTopSongs test
* fix(jellyfin): only let a not-found entity fall through in getInstantMix
Discarding the error conflated a genre id, which never resolves, with a real
lookup failure, which then made a provider call that fails the same way.
* test: pin the invariants the specs only appeared to cover
The missing filter was asserted by substring, so flipping it to true passed
everywhere, including the spec named for it. Matching the whole merged set,
the local agent's over-fetch, and its no-genres early return had no coverage
at all; each is now pinned by a spec that fails when the code is broken.
* test: make the remaining specs say what they actually guard
The playlist-track spec named a sort whitelist it does not exercise; it guards
the integer-id CAST, so it now asserts no rows are dropped. The maxSeeds cap
passed with either bound removed, and the over-fetch was pinned by its literal
value rather than the duplicate positions it exists for. Also drops setup the
count guard returns before reaching.
* fix(external): fall back when the agent's picks are not in this library
A non-empty answer whose songs are all absent locally matched nothing and was
returned as-is, so the mix came back empty with sampleable source tracks
sitting right there.
* refactor(external): name the agent-then-fallback flow once
Each entity case repeated the same error and emptiness plumbing around the
matcher. mixFromAgent states it once and each case supplies only what differs:
how to ask, and what to do when the answer is unusable.
This commit is contained in:
parent
c66ef04dd3
commit
6c3e7e268b
@ -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"}}))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const LocalAgentName = "local"
|
||||
@ -37,14 +39,51 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []Song
|
||||
for _, s := range top {
|
||||
result = append(result, Song{
|
||||
Name: s.Title,
|
||||
MBID: s.MbzReleaseTrackID,
|
||||
})
|
||||
return songsFrom(top), nil
|
||||
}
|
||||
|
||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
|
||||
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
|
||||
if len(genreIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Max: count + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make(model.MediaFiles, 0, len(candidates))
|
||||
for _, s := range candidates {
|
||||
if s.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
if len(filtered) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return songsFrom(filtered), nil
|
||||
}
|
||||
|
||||
func songsFrom(mfs model.MediaFiles) []Song {
|
||||
if len(mfs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slice.Map(mfs, func(mf model.MediaFile) Song {
|
||||
return Song{ID: mf.ID, Name: mf.Title}
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
96
core/agents/local_agent_test.go
Normal file
96
core/agents/local_agent_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var agent *localAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
mfRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||
agent = &localAgent{ds: ds}
|
||||
})
|
||||
|
||||
It("excludes the seed track from its own similars", func() {
|
||||
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := slice.Map(songs, func(s Song) string { return s.Name })
|
||||
Expect(names).ToNot(ContainElement("Seed"))
|
||||
})
|
||||
|
||||
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
|
||||
// would pass just as well with no genre filter at all.
|
||||
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
|
||||
rock := model.NewTag(model.TagGenre, "Rock")
|
||||
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
|
||||
Expect(sqlErr).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
|
||||
Expect(sql).To(ContainSubstring("missing"))
|
||||
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
||||
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
|
||||
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
|
||||
})
|
||||
|
||||
It("returns the library id so the matcher can resolve the song", func() {
|
||||
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
|
||||
// so the local fallback silently returns an empty mix.
|
||||
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
|
||||
})
|
||||
|
||||
It("asks for one extra candidate so dropping the seed still fills the count", func() {
|
||||
// The mock returns rows sorted by id, so the seed comes first and would consume the only
|
||||
// slot if the query did not over-fetch.
|
||||
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].Name).To(Equal("Related"))
|
||||
})
|
||||
|
||||
It("returns nil when the seed track has no genres", func() {
|
||||
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
// Without the early return an empty tag filter would scan the whole library.
|
||||
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
|
||||
})
|
||||
})
|
||||
13
core/external/extdata_helper_test.go
vendored
13
core/external/extdata_helper_test.go
vendored
@ -110,6 +110,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 {
|
||||
|
||||
186
core/external/provider.go
vendored
186
core/external/provider.go
vendored
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@ -14,6 +15,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"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/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
@ -23,7 +25,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxSimilarArtists = 100
|
||||
maxSimilarArtists = 100
|
||||
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
|
||||
refreshDelay = 5 * time.Second
|
||||
refreshTimeout = 15 * time.Second
|
||||
refreshQueueLength = 2000
|
||||
@ -278,32 +284,188 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
var songs []agents.Song
|
||||
|
||||
// Try entity-specific similarity first
|
||||
// Try entity-specific similarity first, then fall back to seed-track sampling.
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
|
||||
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:
|
||||
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
|
||||
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:
|
||||
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
|
||||
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) {
|
||||
if res, ferr := e.similarSongsFallback(ctx, id, count); ferr == nil && len(res) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matcher.MatchSongs(ctx, songs, count)
|
||||
// mixFromAgent returns the agent's recommendations matched to library tracks, or the fallback
|
||||
// when the agent errors or none of its picks are in the library.
|
||||
func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallback func() (model.MediaFiles, error)) (model.MediaFiles, error) {
|
||||
songs, err := fetch()
|
||||
if err == nil {
|
||||
matched, merr := e.matcher.MatchSongs(ctx, songs, count)
|
||||
if merr != nil {
|
||||
return nil, merr
|
||||
}
|
||||
if len(matched) > 0 {
|
||||
return matched, nil
|
||||
}
|
||||
}
|
||||
return fallback()
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
return e.similarSongsFallback(ctx, id, count)
|
||||
// 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
|
||||
|
||||
468
core/external/provider_similarsongs_test.go
vendored
468
core/external/provider_similarsongs_test.go
vendored
@ -3,6 +3,8 @@ package external_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
@ -11,6 +13,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"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"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@ -26,6 +29,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 +40,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{}
|
||||
@ -203,46 +215,68 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
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"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -280,6 +314,381 @@ var _ = Describe("Provider - SimilarSongs", func() {
|
||||
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 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 := slice.Map(songs, func(mf model.MediaFile) string { return mf.ID })
|
||||
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() {
|
||||
@ -412,6 +821,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())
|
||||
|
||||
@ -57,6 +57,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
"album_artist": "order_album_artist_name",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"title": "order_title",
|
||||
"random": "random()",
|
||||
// To make sure these fields will be whitelisted
|
||||
"duration": "duration",
|
||||
"year": "year",
|
||||
|
||||
@ -37,6 +37,20 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns every row under a random sort, despite the integer id", func() {
|
||||
// playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to
|
||||
// TEXT, and it fails silently: no error, just no rows.
|
||||
all, err := repo.GetAll(model.QueryOptions{Sort: "random"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(all).To(HaveLen(2), "a random sort must not silently drop rows")
|
||||
|
||||
got, err := repo.GetAll(model.QueryOptions{Sort: "random", Max: 1})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CountAll", func() {
|
||||
It("returns the number of tracks in the playlist", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -143,6 +143,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)
|
||||
|
||||
@ -2,6 +2,7 @@ package jellyfin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@ -93,6 +94,19 @@ 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.
|
||||
@ -104,8 +118,10 @@ func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -177,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] {
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user