mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* 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
297 lines
7.6 KiB
Go
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
|
|
}
|