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"
)
var similarWait = 10 * time.Second
const (
defaultSimilarLimit = 20
maxSimilarLimit = 100
maxInstantMixLimit = 500
)
const similarFetchTimeout = time.Minute
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)
}
}
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)
}))
}
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:
return api.similarSongs(ctx, id, limit)
}
}))
}
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)
}))
}
func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) {
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
api.instantMix(w, r, id)
}
func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) {
id, ok := dto.DecodeID(req.Params(r).StringOr("id", ""))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.instantMix(w, r, id)
}
func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) {
ctx := r.Context()
limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit)
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
}
}
tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult {
return api.similarSongs(ctx, id, limit)
})
if !isSong {
api.ok(w, r, tail)
return
}
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)
}
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)
}
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)
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().Get(ctx, 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)
}