navidrome/server/subsonic/media_annotation.go
Deluan Quintão 3158451b8d
refactor(server): drop redundant error return from req.Strings parsing (#5812)
* refactor(req): drop redundant error return from Strings

The error from Strings carried no information beyond emptiness — it fired
exactly when the param was absent — and nearly every caller discarded it with
a blank identifier. Strings now just returns the values (empty when absent),
making the common optional-list reads one clean expression.

The few required-param callers (scrobble, createShare) check for emptiness and
return the same Subsonic error code 10 as before; their e2e tests now pin that
code. Ints and Times keep their contracts by synthesizing ErrMissingParam
themselves, so selectedMusicFolderIds is untouched. The jellyfin parseFields
helper is inlined away, since ParseFields(p.Strings("fields")...) now
compiles directly.

* docs(req): clarify Strings returns nil when param is absent
2026-07-18 19:30:04 -04:00

297 lines
7.6 KiB
Go

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
}