mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* 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.
213 lines
7.8 KiB
Go
213 lines
7.8 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real
|
|
// result beats an instant empty list, which clients cache as "no similar items exist". A var so
|
|
// tests can shorten it.
|
|
var similarWait = 10 * time.Second
|
|
|
|
const (
|
|
defaultSimilarLimit = 20
|
|
maxSimilarLimit = 100
|
|
// A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the
|
|
// Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query.
|
|
maxInstantMixLimit = 500
|
|
)
|
|
|
|
// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine
|
|
// indefinitely.
|
|
const similarFetchTimeout = time.Minute
|
|
|
|
// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the
|
|
// request times out or the client disconnects), waiting up to similarWait then answering empty.
|
|
// Identical concurrent requests share one fetch via singleflight; the key includes the user since
|
|
// mapped items embed that user's annotations.
|
|
func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult {
|
|
u, _ := request.UserFrom(ctx)
|
|
key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit)
|
|
ch := api.similarFlight.DoChan(key, func() (any, error) {
|
|
bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout)
|
|
defer cancel()
|
|
return fetch(bgCtx), nil
|
|
})
|
|
select {
|
|
case res := <-ch:
|
|
return res.Val.(dto.QueryResult)
|
|
case <-time.After(similarWait):
|
|
return result(nil, 0, 0)
|
|
}
|
|
}
|
|
|
|
// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same
|
|
// 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, 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)
|
|
}))
|
|
}
|
|
|
|
// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar
|
|
// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty
|
|
// result (not 404) so the client stops retrying.
|
|
func (api *Router) getSimilarItems(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)
|
|
|
|
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
|
if err != nil {
|
|
api.ok(w, r, result(nil, 0, 0))
|
|
return
|
|
}
|
|
api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult {
|
|
switch entity.(type) {
|
|
case *model.Artist:
|
|
return api.similarArtists(ctx, id, limit)
|
|
case *model.Album:
|
|
return api.similarAlbums(ctx, id, limit)
|
|
default: // *model.MediaFile
|
|
return api.similarSongs(ctx, id, limit)
|
|
}
|
|
}))
|
|
}
|
|
|
|
// 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, 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 && !errors.Is(err, model.ErrNotFound) {
|
|
api.ok(w, r, result(nil, 0, 0))
|
|
return
|
|
}
|
|
mf, isSong := entity.(*model.MediaFile)
|
|
if isSong {
|
|
if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) {
|
|
api.ok(w, r, result(nil, 0, 0))
|
|
return
|
|
}
|
|
}
|
|
// Prefixed key: a mix must not share the singleflight/cache slot with a Similar request.
|
|
tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult {
|
|
return api.similarSongs(ctx, id, limit)
|
|
})
|
|
if !isSong {
|
|
// Container seeds: the provider's similar songs already blend the seed's own tracks.
|
|
api.ok(w, r, tail)
|
|
return
|
|
}
|
|
// The seed leads the mix and must not depend on the provider: a slow or failing provider times
|
|
// the await out with an empty tail, but the tapped track still plays.
|
|
items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)}
|
|
for _, it := range tail.Items {
|
|
if len(items) >= limit {
|
|
break
|
|
}
|
|
if it.Id != items[0].Id {
|
|
items = append(items, it)
|
|
}
|
|
}
|
|
api.ok(w, r, result(items, len(items), 0))
|
|
}
|
|
|
|
func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult {
|
|
artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false)
|
|
if err != nil {
|
|
log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err)
|
|
return result(nil, 0, 0)
|
|
}
|
|
present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" })
|
|
items := slice.Map(present, func(a model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(a, nil) })
|
|
return result(items, len(items), 0)
|
|
}
|
|
|
|
func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult {
|
|
songs, err := api.provider.SimilarSongs(ctx, id, limit)
|
|
if err != nil {
|
|
log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err)
|
|
return result(nil, 0, 0)
|
|
}
|
|
// Filter to the caller's libraries; the provider can return songs from any library.
|
|
u, _ := request.UserFrom(ctx)
|
|
var items []dto.BaseItemDto
|
|
for _, mf := range songs {
|
|
if u.HasLibraryAccess(mf.LibraryID) {
|
|
items = append(items, dto.SongToBaseItem(mf, nil))
|
|
}
|
|
}
|
|
return result(items, len(items), 0)
|
|
}
|
|
|
|
// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct
|
|
// "similar albums" source), keeping each album once in first-seen order and resolving it to a full
|
|
// model.Album for cover art and metadata.
|
|
func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult {
|
|
songs, err := api.provider.SimilarSongs(ctx, id, limit*5)
|
|
if err != nil {
|
|
log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err)
|
|
return result(nil, 0, 0)
|
|
}
|
|
u, _ := request.UserFrom(ctx)
|
|
// 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] {
|
|
continue
|
|
}
|
|
seen[s.AlbumID] = true
|
|
if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) {
|
|
items = append(items, dto.AlbumToBaseItem(*al, nil))
|
|
if len(items) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return result(items, len(items), 0)
|
|
}
|