mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-14 17:01:17 -05:00
* feat(subsonic): add OS readonly and validUntil properties
* remove duplicated test
* test: fix and enable disabled child smart playlist tests
Fixed the XContext("child smart playlists") tests that were disabled with
a TODO comment. The tests had several issues: nested playlists were missing
Public: true (required by InPlaylist criteria), the criteria matched no
test fixtures, the "not expired" test set EvaluatedAt on the parent too
(preventing it from refreshing at all), and the "expired" test dereferenced
a nil EvaluatedAt. Added proper cleanup with DeferCleanup and config
restoration via configtest.
* fix(subsonic): always include readonly field in JSON playlist responses
Removed omitempty from the JSON tag of the Readonly field in
OpenSubsonicPlaylist so that readonly: false is always serialized in
JSON responses, per the OpenSubsonic spec requirement that supported
fields must be returned with default values. Added a test case with an
empty OpenSubsonicPlaylist to verify the behavior.
---------
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
205 lines
5.3 KiB
Go
205 lines
5.3 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/subsonic/responses"
|
|
. "github.com/navidrome/navidrome/utils/gg"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
|
ctx := r.Context()
|
|
allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"})
|
|
if err != nil {
|
|
log.Error(r, err)
|
|
return nil, err
|
|
}
|
|
response := newResponse()
|
|
response.Playlists = &responses.Playlists{
|
|
Playlist: slice.MapWithArg(allPls, ctx, api.buildPlaylist),
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
id, err := p.String("id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return api.getPlaylist(ctx, id)
|
|
}
|
|
|
|
func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) {
|
|
pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Error(ctx, err.Error(), "id", id)
|
|
return nil, newError(responses.ErrorDataNotFound, "playlist not found")
|
|
}
|
|
if err != nil {
|
|
log.Error(ctx, err)
|
|
return nil, err
|
|
}
|
|
|
|
response := newResponse()
|
|
response.Playlist = &responses.PlaylistWithSongs{
|
|
Playlist: api.buildPlaylist(ctx, *pls),
|
|
}
|
|
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
|
|
return response, nil
|
|
}
|
|
|
|
func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) {
|
|
err := api.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
owner := getUser(ctx)
|
|
var pls *model.Playlist
|
|
var err error
|
|
|
|
if playlistId != "" {
|
|
pls, err = tx.Playlist(ctx).Get(playlistId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owner.ID != pls.OwnerID {
|
|
return model.ErrNotAuthorized
|
|
}
|
|
} else {
|
|
pls = &model.Playlist{Name: name}
|
|
pls.OwnerID = owner.ID
|
|
}
|
|
pls.Tracks = nil
|
|
pls.AddMediaFilesByID(ids)
|
|
|
|
err = tx.Playlist(ctx).Put(pls)
|
|
playlistId = pls.ID
|
|
return err
|
|
})
|
|
return playlistId, err
|
|
}
|
|
|
|
func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
songIds, _ := p.Strings("songId")
|
|
playlistId, _ := p.String("playlistId")
|
|
name, _ := p.String("name")
|
|
if playlistId == "" && name == "" {
|
|
return nil, errors.New("required parameter name is missing")
|
|
}
|
|
id, err := api.create(ctx, playlistId, name, songIds)
|
|
if err != nil {
|
|
log.Error(r, err)
|
|
return nil, err
|
|
}
|
|
return api.getPlaylist(ctx, id)
|
|
}
|
|
|
|
func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
id, err := p.String("id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = api.ds.Playlist(r.Context()).Delete(id)
|
|
if errors.Is(err, model.ErrNotAuthorized) {
|
|
return nil, newError(responses.ErrorAuthorizationFail)
|
|
}
|
|
if err != nil {
|
|
log.Error(r, err)
|
|
return nil, err
|
|
}
|
|
return newResponse(), nil
|
|
}
|
|
|
|
func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) {
|
|
p := req.Params(r)
|
|
playlistId, err := p.String("playlistId")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
songsToAdd, _ := p.Strings("songIdToAdd")
|
|
songIndexesToRemove, _ := p.Ints("songIndexToRemove")
|
|
var plsName *string
|
|
if s, err := p.String("name"); err == nil {
|
|
plsName = &s
|
|
}
|
|
comment := p.StringPtr("comment")
|
|
public := p.BoolPtr("public")
|
|
|
|
log.Debug(r, "Updating playlist", "id", playlistId)
|
|
if plsName != nil {
|
|
log.Trace(r, fmt.Sprintf("-- New Name: '%s'", *plsName))
|
|
}
|
|
log.Trace(r, fmt.Sprintf("-- Adding: '%v'", songsToAdd))
|
|
log.Trace(r, fmt.Sprintf("-- Removing: '%v'", songIndexesToRemove))
|
|
|
|
err = api.playlists.Update(r.Context(), playlistId, plsName, comment, public, songsToAdd, songIndexesToRemove)
|
|
if errors.Is(err, model.ErrNotAuthorized) {
|
|
return nil, newError(responses.ErrorAuthorizationFail)
|
|
}
|
|
if err != nil {
|
|
log.Error(r, "Error updating playlist", "id", playlistId, err)
|
|
return nil, err
|
|
}
|
|
return newResponse(), nil
|
|
}
|
|
|
|
func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) responses.Playlist {
|
|
pls := responses.Playlist{}
|
|
pls.Id = p.ID
|
|
pls.Name = p.Name
|
|
pls.SongCount = int32(p.SongCount)
|
|
pls.Duration = int32(p.Duration)
|
|
pls.Created = p.CreatedAt
|
|
if p.IsSmartPlaylist() {
|
|
if p.EvaluatedAt != nil {
|
|
pls.Changed = *p.EvaluatedAt
|
|
} else {
|
|
pls.Changed = time.Now()
|
|
}
|
|
} else {
|
|
pls.Changed = p.UpdatedAt
|
|
}
|
|
|
|
player, ok := request.PlayerFrom(ctx)
|
|
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
|
return pls
|
|
}
|
|
|
|
pls.Comment = p.Comment
|
|
pls.Owner = p.OwnerName
|
|
pls.Public = p.Public
|
|
pls.CoverArt = p.CoverArtID().String()
|
|
pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p)
|
|
|
|
return pls
|
|
}
|
|
|
|
func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist {
|
|
pls := responses.OpenSubsonicPlaylist{}
|
|
|
|
if p.IsSmartPlaylist() {
|
|
pls.Readonly = true
|
|
|
|
if p.EvaluatedAt != nil {
|
|
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
|
|
}
|
|
} else {
|
|
user, ok := request.UserFrom(ctx)
|
|
pls.Readonly = !ok || p.OwnerID != user.ID
|
|
}
|
|
|
|
return &pls
|
|
}
|