🏡 index : quine-global/quintodrome.git

package subsonic

import (
	"context"
	"errors"
	"fmt"
	"math"
	"net/http"
	"time"

	"github.com/navidrome/navidrome/core/scrobbler"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	"github.com/navidrome/navidrome/server/events"
	"github.com/navidrome/navidrome/server/subsonic/responses"
	"github.com/navidrome/navidrome/utils/req"
)

// maxRating is the highest value accepted by the Subsonic spec for setRating. 0 clears the rating.
const maxRating = 5

func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
	p := req.Params(r)
	id, err := p.String("id")
	if err != nil {
		return nil, err
	}
	rating64, err := p.Int64("rating")
	if err != nil {
		return nil, err
	}
	// Out-of-range values would otherwise land in average_rating, which every user sees.
	// Check before narrowing to int, so a value like 4294967296 cannot wrap into range on 32-bit builds.
	if rating64 < 0 || rating64 > maxRating {
		return nil, newError(responses.ErrorGeneric, "Invalid rating: %d. Must be between 0 and %d", rating64, maxRating)
	}
	rating := int(rating64)

	log.Debug(r, "Setting rating", "rating", rating, "id", id)
	err = api.setRating(r.Context(), id, rating)
	if err != nil {
		log.Error(r, err)
		return nil, err
	}

	return newResponse(), nil
}

func (api *Router) setRating(ctx context.Context, id string, rating int) error {
	var repo model.AnnotatedRepository
	var resource string

	entity, err := model.GetEntityByID(ctx, api.ds, id)
	if err != nil {
		return err
	}
	switch entity.(type) {
	case *model.Artist:
		repo = api.ds.Artist()
		resource = "artist"
	case *model.Album:
		repo = api.ds.Album()
		resource = "album"
	case *model.Playlist:
		repo = api.ds.Playlist()
		resource = "playlist"
	default:
		repo = api.ds.MediaFile()
		resource = "song"
	}
	err = repo.SetRating(ctx, rating, id)
	if err != nil {
		return err
	}
	event := &events.RefreshResource{}
	api.broker.SendMessage(ctx, event.With(resource, id))
	return nil
}

func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) {
	p := req.Params(r)
	ids := p.Strings("id")
	albumIds := p.Strings("albumId")
	artistIds := p.Strings("artistId")
	if len(ids)+len(albumIds)+len(artistIds) == 0 {
		return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
	}
	ids = append(ids, albumIds...)
	ids = append(ids, artistIds...)

	err := api.setStar(r.Context(), true, ids...)
	if err != nil {
		return nil, err
	}

	return newResponse(), nil
}

func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) {
	p := req.Params(r)
	ids := p.Strings("id")
	albumIds := p.Strings("albumId")
	artistIds := p.Strings("artistId")
	if len(ids)+len(albumIds)+len(artistIds) == 0 {
		return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing")
	}
	ids = append(ids, albumIds...)
	ids = append(ids, artistIds...)

	err := api.setStar(r.Context(), false, ids...)
	if err != nil {
		return nil, err
	}

	return newResponse(), nil
}

func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error {
	if len(ids) == 0 {
		log.Warn(ctx, "Cannot star/unstar an empty list of ids")
		return nil
	}
	log.Debug(ctx, "Changing starred", "ids", ids, "starred", star)
	err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
		event := &events.RefreshResource{}
		changed := false
		for _, id := range ids {
			var repo model.AnnotatedRepository
			var resource string
			entity, err := model.GetEntityByID(ctx, tx, id)
			if err != nil {
				if !errors.Is(err, model.ErrNotFound) {
					return err
				}
				log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id)
				continue
			}
			switch entity.(type) {
			case *model.Artist:
				repo = tx.Artist()
				resource = "artist"
			case *model.Album:
				repo = tx.Album()
				resource = "album"
			case *model.Playlist:
				repo = tx.Playlist()
				resource = "playlist"
			default:
				repo = tx.MediaFile()
				resource = "song"
			}
			if err := repo.SetStar(ctx, star, id); err != nil {
				return err
			}
			event = event.With(resource, id)
			changed = true
		}
		// Skip the broadcast when nothing changed: an empty RefreshResource
		// serializes as a "{*:*}" wildcard, forcing every client to refresh.
		if changed {
			api.broker.SendMessage(ctx, event)
		}
		return nil
	})
	if err != nil {
		log.Error(ctx, err)
		return err
	}
	return nil
}

func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) {
	p := req.Params(r)
	ids := p.Strings("id")
	if len(ids) == 0 {
		return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'")
	}
	times, _ := p.Times("time")
	if len(times) > 0 && len(times) != len(ids) {
		return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids))
	}
	submission := p.BoolOr("submission", true)
	position := p.IntOr("position", 0)
	ctx := r.Context()

	if submission {
		err := api.scrobblerSubmit(ctx, ids, times)
		if err != nil {
			log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err)
		}
	} else {
		if len(ids) > 1 {
			log.Warn(ctx, "Multiple ids sent to a nowPlaying notification, only the first one will be used", "ids", ids)
		}
		err := api.scrobblerNowPlaying(ctx, ids[0], position)
		if err != nil {
			log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err)
		}
	}

	return newResponse(), nil
}

func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []time.Time) error {
	var submissions []scrobbler.Submission
	log.Debug(ctx, "Scrobbling tracks", "ids", ids, "times", times)
	for i, id := range ids {
		var t time.Time
		if len(times) > 0 {
			t = times[i]
		} else {
			t = time.Now()
		}
		submissions = append(submissions, scrobbler.Submission{TrackID: id, Timestamp: t})
	}

	return api.scrobbler.Submit(ctx, submissions)
}

func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error {
	mf, err := api.ds.MediaFile().Get(ctx, trackId)
	if err != nil {
		return err
	}
	if mf == nil {
		return fmt.Errorf(`ID "%s" not found`, trackId)
	}

	player, _ := request.PlayerFrom(ctx)
	username, _ := request.UsernameFrom(ctx)
	client, _ := request.ClientFrom(ctx)
	clientId, ok := request.ClientUniqueIdFrom(ctx)
	if !ok {
		clientId = player.ID
	}

	log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position)
	return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
		MediaId:      trackId,
		PositionMs:   int64(position) * 1000,
		State:        scrobbler.StatePlaying,
		PlaybackRate: 1.0,
		ClientId:     clientId,
		ClientName:   client,
	})
}

func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) {
	p := req.Params(r)
	mediaId, err := p.String("mediaId")
	if err != nil {
		return nil, err
	}
	mediaType, err := p.String("mediaType")
	if err != nil {
		return nil, err
	}
	positionMs, err := p.Int64("positionMs")
	if err != nil {
		return nil, err
	}
	if positionMs < 0 {
		return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative")
	}
	state, err := p.String("state")
	if err != nil {
		return nil, err
	}

	if !scrobbler.ValidStates[state] {
		return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state)
	}

	playbackRate := p.Float64Or("playbackRate", 1.0)
	if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 {
		return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number")
	}
	ignoreScrobble := p.BoolOr("ignoreScrobble", false)

	ctx := r.Context()
	if mediaType != "song" {
		log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId)
	}

	player, _ := request.PlayerFrom(ctx)
	client, _ := request.ClientFrom(ctx)
	clientId, ok := request.ClientUniqueIdFrom(ctx)
	if !ok {
		clientId = player.ID
	}

	err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{
		MediaId:        mediaId,
		PositionMs:     positionMs,
		State:          state,
		PlaybackRate:   playbackRate,
		IgnoreScrobble: ignoreScrobble,
		ClientId:       clientId,
		ClientName:     client,
	})
	if err != nil {
		log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err)
		return nil, err
	}

	return newResponse(), nil
}