🏡 index : quine-global/quintodrome.git

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) {
	id, ok := itemIDParam(w, r, "itemId")
	if !ok {
		return
	}
	api.instantMix(w, r, id)
}

// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms,
// which take the seed as ?id= instead of a path segment.
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)

	// 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().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)
}