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" ) 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 } rating, err := p.Int("rating") if err != nil { return nil, err } 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(ctx) resource = "artist" case *model.Album: repo = api.ds.Album(ctx) resource = "album" case *model.Playlist: repo = api.ds.Playlist(ctx) resource = "playlist" default: repo = api.ds.MediaFile(ctx) resource = "song" } err = repo.SetRating(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(ctx) resource = "artist" case *model.Album: repo = tx.Album(ctx) resource = "album" case *model.Playlist: repo = tx.Playlist(ctx) resource = "playlist" default: repo = tx.MediaFile(ctx) resource = "song" } if err := repo.SetStar(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 { 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(ctx).Get(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 }