mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-05 17:58:37 +01:00
* feat(jellyfin): advertise Jellyfin server version 12.1.0
Streamyfin, jellyfin-android and jellyfin-androidtv refuse servers older than
10.10, Swiftfin warns below 12.0, and @jellyfin/sdk flags anything below its
minimum as unsupported, so 10.9.11 locked those clients out. 12.1.0 is the
current Jellyfin release (after 10.11 Jellyfin renumbered to 12.0). No client
checked has an upper bound or assumes the major is 10, and the value keeps
three parts because the Kotlin SDK and Swiftfin reject two-part versions.
* feat(jellyfin): acknowledge POST /Sessions/Playing/Ping
Jellyfin clients ping this endpoint to keep a transcode job alive while
paused. Navidrome ties transcodes to the stream request, so there is nothing
to keep alive; answer 204 like Jellyfin instead of a 404. It shares one no-op
handler with Sessions/Capabilities, renamed to acknowledge.
* feat(jellyfin): reorder playlist entries via Items/{entryId}/Move
Adds POST /Playlists/{id}/Items/{entryId}/Move/{newIndex}, which clients use
to reorder playlists. It maps the entry's PlaylistItemId (its position) and
Jellyfin's zero-based newIndex onto the existing core ReorderTrack, which
enforces ownership. As in Jellyfin, an index past the end appends and an
unknown entry is a no-op; out-of-range positions never reach Reorder, which
would otherwise shift unrelated rows.
* feat(jellyfin): honor position when adding items to a playlist
POST /Playlists/{id}/Items takes an optional zero-based position (added to
the Jellyfin spec in 12.0). Match Jellyfin: zero or negative prepends, past
the end appends, otherwise the new items are inserted at that index in the
order they were added.
Adds PlaylistTrackRepository.Insert and core playlists.InsertTracks, which
shift the following entries and insert in one transaction, instead of
appending and moving each new track with its own ReorderTrack call.
* feat(jellyfin): add type-specific InstantMix routes
Jellyfin exposes InstantMix under Songs/, Albums/, Artists/ and Playlists/
as well as Items/, plus the legacy Artists/InstantMix and
MusicGenres/InstantMix forms that take the seed as ?id=. Clients generated
from the Jellyfin SDKs call the type-specific routes, which 404ed. All of them
now share the existing Items/{id}/InstantMix handler; the external provider
already builds mixes from song, album, artist, playlist and genre seeds.
* feat(jellyfin): honor Width, Height and Fill* image size params
The image endpoint only read MaxWidth/MaxHeight, so clients that size covers
with fillWidth/fillHeight (Manet, Finamp) or width/height got the full-size
original on a cold artwork cache: a 578 KB PNG instead of a 13 KB resize.
Jellyfin applies Width/Height, caps them with MaxWidth/MaxHeight, then
shrinks to the smallest size that still covers the Fill box. Navidrome
resizes on one dimension, so the tightest bound wins and a fill box counts
as its larger side.
* feat(jellyfin): answer HEAD on audio, file and image routes
Fintunes sends HEAD to /Audio/{id}/universal to read the content type and
detect direct play (a Content-Length means direct play), and to the audio and
image URLs before a download, aborting the download when it fails. Those routes
were GET-only, so HEAD got a 404. HEAD now reuses the GET handlers. Direct
play goes through Stream.Serve, which already answers HEAD; a transcode
answers with the target content type and no length without starting ffmpeg,
so a probe never costs a transcode.
* fix(jellyfin): clamp playlist move and insert positions before adding one
movePlaylistItem computed min(newIndex+1, SongCount): newIndex=MaxInt wrapped
to a negative position, and Reorder then left the playlist with a gap
(positions 2, 3, 999998), making the moved entry unmovable. Clamp against the
playlist length first. addToPlaylist's min(position, MaxInt32)+1 wrapped on
32-bit builds and req.Int truncated large values there, so a far-past-the-end
position prepended; parse as int64 and clamp before converting.
* fix(playlists): validate reorder positions inside the write transaction
Reorder never checked its positions, so a source outside the playlist or a
destination past its end shifted rows around a missing entry and left a gap
(e.g. ids 1 and 3), which made the moved entry unmovable afterwards. The
Jellyfin Move handler guarded this with a SongCount read before ReorderTrack,
but a concurrent removal between the two reopened it, and the native API
reorder endpoint passed client positions through unchecked.
Reorder now reads the last position in the same transaction, returns
ErrNotFound for a source outside the playlist and clamps the destination.
ReorderTrack uses an immediate transaction so that read and the updates are
atomic against other writers. The Jellyfin handler drops its pre-check and
maps ErrNotFound to Jellyfin's no-op 204; the native API now answers 404 for
an unknown track instead of corrupting the order.
395 lines
13 KiB
Go
395 lines
13 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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()})
|
|
}
|