navidrome/server/jellyfin/playlists.go
Deluan Quintão 39028f65c8
fix(jellyfin): honor IsPublic when creating a playlist (#6204)
POST /Playlists dropped the client's IsPublic flag, so every playlist was
created private. JellyBox Player's create-playlist form defaults its "public"
checkbox to true, so JellyBox users could never create a public playlist.

Upstream's PlaylistsController passes IsPublic into PlaylistCreationRequest.
core/playlists.Create has no visibility parameter and widening it would ripple
into the Subsonic and native APIs, so createPlaylist follows the same pattern
updatePlaylist already uses: after Create succeeds, a non-nil IsPublic is
applied with a follow-up Update. The field is a pointer so an absent one keeps
today's default instead of forcing private.

If that second write fails the handler surfaces the error through playlistError
rather than returning the id: answering 200 for a playlist that is not as
visible as the client asked is the same silent drop this fixes.
2026-09-23 09:54:35 -04:00

404 lines
14 KiB
Go

package jellyfin
import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/filter"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be
// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops.
func playlistsFolder() dto.BaseItemDto {
return dto.BaseItemDto{
Id: dto.PlaylistsFolderGUID,
Name: "Playlists",
Type: "ManualPlaylistsFolder",
CollectionType: "playlists",
IsFolder: true,
}
}
// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403,
// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500.
func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable):
http.Error(w, "Forbidden", http.StatusForbidden)
case errors.Is(err, model.ErrNotFound):
http.Error(w, "Not Found", http.StatusNotFound)
default:
api.internalError(w, r, err)
}
}
type createPlaylistRequest struct {
Name string `json:"Name"`
Ids []string `json:"Ids"`
MediaType string `json:"MediaType"`
IsPublic *bool `json:"IsPublic"`
}
// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to
// replace an existing one), owned by the authenticated user.
func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) {
var body createPlaylistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
decoded, ok := dto.DecodeIDs(body.Ids)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(r.Context(), decoded)
id, err := api.playlists.Create(r.Context(), "", body.Name, ids)
if err != nil {
api.internalError(w, r, err)
return
}
// Create takes no visibility, so a requested one costs a second write.
if body.IsPublic != nil {
if err := api.playlists.Update(r.Context(), id, nil, nil, body.IsPublic, nil, nil); err != nil {
api.playlistError(w, r, err)
return
}
}
api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)})
}
// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means
// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear).
type updatePlaylistRequest struct {
Name *string `json:"Name"`
Ids *[]string `json:"Ids"`
IsPublic *bool `json:"IsPublic"`
}
func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
var body updatePlaylistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// A present Ids replaces the track list. An empty list must clear it explicitly, since Create
// can't persist an empty track list (the repository skips track writes when the list is empty).
if body.Ids != nil {
if len(*body.Ids) == 0 {
if err := api.clearPlaylist(ctx, id); err != nil {
api.playlistError(w, r, err)
return
}
} else {
decoded, ok := dto.DecodeIDs(*body.Ids)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(ctx, decoded)
if _, err := api.playlists.Create(ctx, id, "", ids); err != nil {
api.playlistError(w, r, err)
return
}
}
}
if body.Ids == nil || body.Name != nil || body.IsPublic != nil {
if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil {
api.playlistError(w, r, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership.
func (api *Router) clearPlaylist(ctx context.Context, id string) error {
pls, err := api.playlists.GetWithTracks(ctx, id)
if err != nil {
return err
}
if len(pls.Tracks) == 0 {
return nil
}
entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID })
return api.playlists.RemoveTracks(ctx, id, entryIDs)
}
// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the
// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing
// tracks, and counts the same set, like GetWithTracks.
func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) {
total, err := repo.CountAll(model.QueryOptions{Filters: notMissing})
if err != nil {
return itemsResult{}, err
}
opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing}
open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) {
return repo.GetCursor(opts)
}, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) })
return streamed(open, int(total), offset), nil
}
// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the
// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via
// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain
// individually removable.
func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto {
item := dto.SongToBaseItem(t.MediaFile, fields)
item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID)
return item
}
// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the
// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't
// be probed.
func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
repo, err := api.playlists.Tracks(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
// PlaylistInfo carries every track id, so this can't be paged — but it needs no track data.
trackIDs, err := repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Filters: notMissing})
if err != nil {
api.internalError(w, r, err)
return
}
itemIds := slice.Map(trackIDs, dto.EncodeID)
api.ok(w, r, dto.PlaylistInfo{
OpenAccess: pls.Public,
Shares: []dto.PlaylistUserPermissions{},
ItemIds: itemIds,
})
}
// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a
// playlist id can't probe for private playlists.
func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
repo, err := api.playlists.Tracks(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
p := req.Params(r)
fields := dto.ParseFields(p.Strings("fields")...)
res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0))
if err != nil {
api.internalError(w, r, err)
return
}
api.ok(w, r, res)
}
// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single
// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns
// the flattened, non-empty ids across both forms.
func queryIDs(r *http.Request, key string) []string {
var ids []string
for _, v := range r.URL.Query()[key] {
for id := range strings.SplitSeq(v, ",") {
if id != "" {
ids = append(ids, id)
}
}
}
return ids
}
// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when
// building a playlist into their track ids, in order, since core/playlists only understands media
// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only
// the rest pays per-id container probes.
func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string {
songs := api.songsByIDs(ctx, ids)
out := make([]string, 0, len(ids))
for _, id := range ids {
if _, ok := songs[id]; ok {
out = append(out, id) // already a song
} else if _, err := api.ds.Album(ctx).Get(id); err == nil {
out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...)
} else if _, err := api.ds.Artist(ctx).Get(id); err == nil {
out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...)
} else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil {
out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...)
} else {
out = append(out, id) // well-formed but unresolved — left for the caller's write to handle
}
}
return out
}
func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string {
mfs, err := api.ds.MediaFile(ctx).GetAll(opts)
if err != nil {
log.Error(ctx, "Jellyfin: error expanding container to tracks", err)
return nil
}
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
}
// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position
// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404.
func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
decoded, ok := dto.DecodeIDs(queryIDs(r, "ids"))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids := api.expandContainerIDs(ctx, decoded)
var err error
if position, perr := req.Params(r).Int64("position"); perr == nil {
_, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position))
} else {
_, err = api.playlists.AddTracks(ctx, id, ids)
}
if err != nil {
if errors.Is(err, model.ErrPlaylistNotEditable) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so
// it can't wrap on 32-bit builds.
func insertPosition(position int64) int {
return int(min(max(position, 0), math.MaxInt32-1) + 1)
}
// removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media
// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces
// ownership; a locked playlist maps to 403, any other error to 404.
func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
raw := queryIDs(r, "entryids")
ids := make([]string, 0, len(raw))
for _, entryGUID := range raw {
entry, ok := dto.DecodePlaylistEntryID(entryGUID)
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ids = append(ids, entry)
}
if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil {
if errors.Is(err, model.ErrPlaylistNotEditable) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end
// indexes and rejects unknown entries, which Jellyfin treats as a no-op.
func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId"))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex"))
if err != nil || newIndex < 0 {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op.
if _, err := api.playlists.Get(ctx, id); err != nil {
api.playlistError(w, r, err)
return
}
pos, _ := strconv.Atoi(entry)
err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1)
if err != nil && !errors.Is(err, model.ErrNotFound) {
api.playlistError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries
// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing.
func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
u, _ := request.UserFrom(ctx)
api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}})
}
func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "playlistId")
if !ok {
return
}
pls, err := api.playlists.Get(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
userId := chi.URLParam(r, "userId")
api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()})
}