mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-30 23:17:51 +01:00
Jellyfin clients (Finamp) send container ids — an album, artist or playlist — in a playlist's Ids list and expect the server to expand each into its child tracks. core/playlists only understands media file ids, so creating or adding with an album id silently produced an empty playlist. Expand container ids to their tracks (in order) before create/add; bare song ids still pass through. Adds filter.SongsByArtistID.
178 lines
7.5 KiB
Go
178 lines
7.5 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"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/slice"
|
|
)
|
|
|
|
type createPlaylistRequest struct {
|
|
Name string `json:"Name"`
|
|
Ids []string `json:"Ids"`
|
|
MediaType string `json:"MediaType"`
|
|
}
|
|
|
|
// createPlaylist always creates a brand-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
|
|
}
|
|
ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID))
|
|
id, err := api.playlists.Create(r.Context(), "", body.Name, ids)
|
|
if err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)})
|
|
}
|
|
|
|
// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId — the
|
|
// entry's position within the playlist (model.PlaylistTrack.ID) — rather than only the underlying
|
|
// song id. Jellyfin clients read this list, then echo PlaylistItemId back via
|
|
// DELETE .../Items?EntryIds=... to remove a specific occurrence; that's also the id
|
|
// core/playlists.RemoveTracks expects (see removeFromPlaylist), so the round trip works even when
|
|
// the same song appears more than once in the playlist.
|
|
func trackToBaseItem(t model.PlaylistTrack) dto.BaseItemDto {
|
|
item := dto.SongToBaseItem(t.MediaFile)
|
|
item.PlaylistItemId = dto.EncodeID(t.ID)
|
|
return item
|
|
}
|
|
|
|
// getPlaylist returns a playlist's public-visibility flag and item ids. Finamp calls this before
|
|
// opening a playlist's edit screen to read OpenAccess. Visibility is enforced by GetWithTracks
|
|
// (public or owned by the current user); 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 := dto.DecodeID(chi.URLParam(r, "playlistId"))
|
|
pls, err := api.playlists.GetWithTracks(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
itemIds := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return dto.EncodeID(t.MediaFileID) })
|
|
api.ok(w, r, dto.PlaylistInfo{
|
|
OpenAccess: pls.Public,
|
|
Shares: []dto.PlaylistUserPermissions{},
|
|
ItemIds: itemIds,
|
|
})
|
|
}
|
|
|
|
// getPlaylistItems relies on core/playlists.GetWithTracks to enforce playlist visibility
|
|
// (public or owned by the current user); any error — not found or not visible — is reported as
|
|
// a generic 404 so a playlist id can't be used to probe for the existence of private playlists.
|
|
func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
|
|
pls, err := api.playlists.GetWithTracks(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
items := slice.Map(pls.Tracks, trackToBaseItem)
|
|
api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)})
|
|
}
|
|
|
|
// splitIds parses a comma-separated query parameter into ids, treating an absent or empty
|
|
// parameter as no ids rather than a single empty-string id.
|
|
func splitIds(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(s, ",")
|
|
}
|
|
|
|
// queryParam reads lower first, falling back to pascal. Real Jellyfin clients (e.g. Finamp) send
|
|
// lowercase query params (ids, entryIds); PascalCase is also accepted for robustness.
|
|
func queryParam(r *http.Request, lower, pascal string) string {
|
|
if v := r.URL.Query().Get(lower); v != "" {
|
|
return v
|
|
}
|
|
return r.URL.Query().Get(pascal)
|
|
}
|
|
|
|
// expandContainerIDs turns the ids a Jellyfin client sends when building a playlist into the
|
|
// underlying media file ids. Clients populate the id list with containers — albums, artists,
|
|
// playlists — not just songs, and expect the server to expand each into its tracks, in order.
|
|
// core/playlists only understands media file ids, so an unexpanded album id would silently add
|
|
// nothing. A bare song id (or any id matching no container) passes through unchanged.
|
|
func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string {
|
|
out := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
if _, err := api.ds.MediaFile(ctx).Get(id); err == nil {
|
|
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) // unknown id — pass through unchanged
|
|
}
|
|
}
|
|
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 appends items by id, expanding album/artist/playlist containers into their tracks
|
|
// (see expandContainerIDs). Ownership/editability is enforced by AddTracks itself; any error maps
|
|
// to 404.
|
|
func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
|
|
ids := api.expandContainerIDs(ctx, slice.Map(splitIds(queryParam(r, "ids", "Ids")), dto.DecodeID))
|
|
if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// removeFromPlaylist removes entries by entryIds. Unlike addToPlaylist's ids, these must be
|
|
// playlist-entry ids (model.PlaylistTrack.ID / trackToBaseItem's PlaylistItemId) — the position
|
|
// of the entry within the playlist — because core/playlists.RemoveTracks deletes playlist_tracks
|
|
// rows by that id, not by media file id. Ownership/editability is enforced by RemoveTracks itself;
|
|
// any error maps to 404.
|
|
func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
|
|
ids := slice.Map(splitIds(queryParam(r, "entryIds", "EntryIds")), dto.DecodeID)
|
|
if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// getPlaylistUsers and getPlaylistUser are best-effort probes some clients (e.g. Finamp) make
|
|
// before allowing playlist edits. Navidrome has no per-playlist ACL model, so every user is
|
|
// reported as able to edit; ownership is still enforced by AddTracks/RemoveTracks themselves.
|
|
func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) {
|
|
u, _ := request.UserFrom(r.Context())
|
|
api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: u.ID, CanEdit: true}})
|
|
}
|
|
|
|
func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) {
|
|
userId := chi.URLParam(r, "userId")
|
|
api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true})
|
|
}
|