feat(jellyfin): advertise Jellyfin 12.1.0 and add the missing 12.x quick wins (#6163)

* 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.
This commit is contained in:
Deluan Quintão 2026-09-19 13:19:48 -04:00 • committed by GitHub
commit 549dfa7f30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 498 additions and 33 deletions

View file

@ -32,6 +32,7 @@ type Playlists interface {
// Track management
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error)
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
@ -268,6 +269,22 @@ func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []stri
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
}
// InsertTracks adds tracks before the 1-based position pos; a position past the end appends.
func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
}
var count int
// Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under
// concurrent writers instead of waiting for the lock.
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
var err error
count, err = tx.Playlist(ctx).Tracks(playlistID, false).Insert(ids, pos)
return err
})
return count, err
}
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return 0, err
@ -302,7 +319,7 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
return err
}
return s.ds.WithTx(func(tx model.DataStore) error {
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
})
}

View file

@ -324,6 +324,43 @@ var _ = Describe("Playlists", func() {
})
})
Describe("InsertTracks", func() {
var mockTracks *tests.MockPlaylistTrackRepo
BeforeEach(func() {
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
}
mockPlsRepo.TracksRepo = mockTracks
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
})
It("inserts the tracks at the given position for the owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(2))
Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"}))
Expect(mockTracks.InsertPos).To(Equal(3))
})
It("denies non-owner, non-admin", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrNotAuthorized))
Expect(mockTracks.AddedIds).To(BeEmpty())
})
It("denies editing smart playlists", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
_, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1)
Expect(err).To(MatchError(model.ErrPlaylistNotEditable))
})
})
Describe("ReorderTrack", func() {
var mockTracks *tests.MockPlaylistTrackRepo

View file

@ -184,6 +184,7 @@ type PlaylistTrackRepository interface {
GetAlbumIDs(options ...QueryOptions) ([]string, error)
GetMediaFileIDs(options ...QueryOptions) ([]string, error)
Add(mediaFileIds []string) (int, error)
Insert(mediaFileIds []string, pos int) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)
AddDiscs(discs []DiscID) (int, error)

View file

@ -195,6 +195,29 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) {
return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds)
}
// Insert adds tracks before the 1-based position pos, shifting the following entries down; a
// position past the end appends. Callers must run it in a transaction.
func (r *playlistTrackRepository) Insert(mediaFileIds []string, pos int) (int, error) {
if len(mediaFileIds) == 0 {
return 0, nil
}
pos = max(pos, 1)
n := len(mediaFileIds)
// Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index.
_, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos))
if err != nil {
return 0, err
}
res, err := r.executeSQL(Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId))
if err != nil {
return 0, err
}
if res == 0 {
return r.Add(mediaFileIds)
}
return n, r.playlistRepo.addTracks(r.playlistId, pos, mediaFileIds)
}
func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) {
sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number")
var ids []string
@ -247,8 +270,18 @@ func (r *playlistTrackRepository) DeleteAll() error {
return r.playlistRepo.renumber(r.playlistId)
}
// Reorder moves a track from pos to newPos, shifting other tracks accordingly.
// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped
// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap.
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
var res struct{ Max sql.NullInt32 }
if err := r.queryOne(r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil {
return err
}
last := int(res.Max.Int32)
if pos < 1 || pos > last {
return model.ErrNotFound
}
newPos = min(max(newPos, 1), last)
if pos == newPos {
return nil
}

View file

@ -78,6 +78,105 @@ var _ = Describe("PlaylistTrackRepository", func() {
})
})
Describe("Insert", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
})
order := func() []string {
ids, err := tracks.GetMediaFileIDs(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
return ids
}
DescribeTable("inserts before a 1-based position, keeping the new tracks' order",
func(pos int, want func() []string) {
Expect(tracks.Insert([]string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2))
Expect(order()).To(Equal(want()))
Expect(tracks.CountAll()).To(Equal(int64(4)))
},
Entry("in the middle", 2, func() []string {
return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID}
}),
Entry("at the start, for zero or less", 0, func() []string {
return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID}
}),
Entry("at the end, past the last position", 9, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID}
}),
)
It("renumbers positions contiguously", func() {
Expect(tracks.Insert([]string{songComeTogether.ID}, 1)).To(Equal(1))
all, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
})
})
Describe("Reorder", func() {
var tracks model.PlaylistTrackRepository
BeforeEach(func() {
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
plsRepo := NewPlaylistRepository(ctx, GetDBXBuilder())
pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"}
Expect(plsRepo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(plsRepo.Delete(pls.ID)).To(Succeed()) })
tracks = plsRepo.Tracks(pls.ID, false)
Expect(tracks.Add([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3))
})
rows := func() ([]string, []string) {
all, err := tracks.GetAll(model.QueryOptions{Sort: "id"})
Expect(err).ToNot(HaveOccurred())
var ids, songs []string
for _, t := range all {
ids = append(ids, t.ID)
songs = append(songs, t.MediaFileID)
}
return ids, songs
}
DescribeTable("clamps the destination to the playlist",
func(newPos int, want func() []string) {
Expect(tracks.Reorder(1, newPos)).To(Succeed())
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal(want()))
},
Entry("past the end moves to the end", 9, func() []string {
return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID}
}),
Entry("below 1 stays first", -4, func() []string {
return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}
}),
)
DescribeTable("rejects a source position outside the playlist, leaving rows untouched",
func(pos int) {
Expect(tracks.Reorder(pos, 1)).To(MatchError(model.ErrNotFound))
ids, songs := rows()
Expect(ids).To(Equal([]string{"1", "2", "3"}))
Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID}))
},
Entry("past the end", 4),
Entry("zero", 0),
)
})
Describe("Delete", func() {
var tracks model.PlaylistTrackRepository
const numTracks = deleteChunkSize*2 + 1

View file

@ -123,13 +123,13 @@ returns direct children only (no tracks — no track is a library's direct child
| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` |
| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) |
| Artists / genres | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres` |
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Items/{itemId}/InstantMix` |
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` |
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` |
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
| Lyrics | `GET Audio/{itemId}/Lyrics` |
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` |
| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` |
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` |
| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) |
| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` |
@ -350,7 +350,7 @@ make test PKG=./server/jellyfin/...
just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel
check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per
track so sidecar/plugin lyrics also light up. Feishin additionally requires server version
≥ 10.9 — the reason `jellyfinVersion` is 10.9.11.
≥ 10.9 (`jellyfinVersion` advertises 12.1.0).
Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader`
is singleflighted), and the load runs detached from the request context with a one-minute bound,
so a cancelled request or hung plugin can't fail or pin the load for other waiters.

View file

@ -93,6 +93,8 @@ func (api *Router) routes() http.Handler {
conf.Server.DevArtworkThrottleBacklogTimeout))
r.Get("/items/{itemId}/images/{type}", api.getItemImage)
r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage)
r.Head("/items/{itemId}/images/{type}", api.getItemImage)
r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage)
})
inner.Group(func(r chi.Router) {
@ -145,6 +147,12 @@ func (api *Router) routes() http.Handler {
r.Get("/items/{itemId}/similar", api.getSimilarItems)
r.Get("/albums/{itemId}/similar", api.getSimilarAlbums)
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
r.Get("/songs/{itemId}/instantmix", api.getInstantMix)
r.Get("/albums/{itemId}/instantmix", api.getInstantMix)
r.Get("/artists/{itemId}/instantmix", api.getInstantMix)
r.Get("/playlists/{itemId}/instantmix", api.getInstantMix)
r.Get("/artists/instantmix", api.getInstantMixByQuery)
r.Get("/musicgenres/instantmix", api.getInstantMixByQuery)
r.Get("/genres", api.getGenres)
r.Get("/musicgenres", api.getGenres)
r.Get("/studios", api.getStudios)
@ -155,6 +163,7 @@ func (api *Router) routes() http.Handler {
r.Post("/playlists/{playlistId}", api.updatePlaylist)
r.Post("/playlists/{playlistId}/items", api.addToPlaylist)
r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist)
r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem)
r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers)
r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser)
@ -166,6 +175,10 @@ func (api *Router) routes() http.Handler {
r.Get("/audio/{itemId}/stream", api.streamAudio)
r.Get("/audio/{itemId}/stream.{container}", api.streamAudio)
r.Get("/audio/{itemId}/universal", api.streamUniversal)
// Fintunes probes these with HEAD for the content type before playing or downloading.
r.Head("/audio/{itemId}/stream", api.streamAudio)
r.Head("/audio/{itemId}/stream.{container}", api.streamAudio)
r.Head("/audio/{itemId}/universal", api.streamUniversal)
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
@ -174,12 +187,15 @@ func (api *Router) routes() http.Handler {
// /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file.
r.Get("/items/{itemId}/file", api.streamFile)
r.Get("/items/{itemId}/download", api.streamFile)
r.Head("/items/{itemId}/file", api.streamFile)
r.Head("/items/{itemId}/download", api.streamFile)
r.Post("/sessions/playing", api.reportPlaybackStart)
r.Post("/sessions/playing/progress", api.reportPlaybackProgress)
r.Post("/sessions/playing/stopped", api.reportPlaybackStopped)
r.Post("/sessions/capabilities", api.postCapabilities)
r.Post("/sessions/capabilities/full", api.postCapabilities)
r.Post("/sessions/playing/ping", api.acknowledge)
r.Post("/sessions/capabilities", api.acknowledge)
r.Post("/sessions/capabilities/full", api.acknowledge)
// Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect.
r.Get("/socket", api.handleSocket)

View file

@ -41,6 +41,10 @@ var _ = Describe("Item images", func() {
Expect(u.IsAdmin).To(BeTrue())
})
It("answers HEAD without authentication", func() {
Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK))
})
It("serves images without authentication (public route)", func() {
id := albumID("IV")
w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "")

View file

@ -19,6 +19,7 @@ var _ = Describe("Playlists", func() {
playlistItems := func(plID string) dto.QueryResult {
return queryResult(get("/Playlists/" + enc(plID) + "/Items"))
}
order := func(plID string) []string { return names(playlistItems(plID).Items) }
Describe("create", func() {
It("creates an empty playlist", func() {
@ -102,6 +103,36 @@ var _ = Describe("Playlists", func() {
Expect(playlistItems(plID).TotalRecordCount).To(BeZero())
})
Describe("at a position", func() {
var plID string
addAt := func(position string, ids ...string) {
url := "/Playlists/" + enc(plID) + "/Items?position=" + position
for _, id := range ids {
url += "&ids=" + enc(id)
}
Expect(post(url, "").Code).To(Equal(http.StatusNoContent))
}
BeforeEach(func() {
plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))})
})
DescribeTable("inserts at Jellyfin's zero-based position",
func(position string, want []string) {
addAt(position, songID("So What"), songID("Help!"))
Expect(order(plID)).To(Equal(want))
},
Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}),
Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}),
Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}),
)
It("keeps an expanded album's track order", func() {
albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))}))
addAt("1", albumID("Abbey Road"))
Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something")))
})
})
It("removes an entry by its PlaylistItemId", func() {
plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))})
entryID := playlistItems(plID).Items[0].PlaylistItemId
@ -118,6 +149,58 @@ var _ = Describe("Playlists", func() {
})
})
Describe("move", func() {
move := func(u model.User, plID, entryID string, newIndex string) int {
return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code
}
var plID string
var entries []dto.BaseItemDto
BeforeEach(func() {
plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))})
entries = playlistItems(plID).Items
})
DescribeTable("moves an entry to Jellyfin's zero-based index",
func(entry int, newIndex string, want []string) {
Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent))
Expect(order(plID)).To(Equal(want))
var positions []string
for _, it := range playlistItems(plID).Items {
positions = append(positions, it.PlaylistItemId)
}
Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")}))
},
Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}),
Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}),
Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}),
Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}),
)
It("ignores an entry that is not in the playlist", func() {
Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent))
Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"}))
})
It("404s on a malformed entry id", func() {
Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound))
})
It("hides another user's private playlist", func() {
Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound))
Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"}))
})
It("forbids a non-owner on a public playlist", func() {
Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden))
Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"}))
})
It("rejects a negative index", func() {
Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest))
})
})
Describe("users", func() {
It("reports the current user as an editor", func() {
plID := createPlaylist("Perms", nil)

View file

@ -37,6 +37,10 @@ var _ = Describe("Sessions", func() {
Expect(mf.PlayCount).To(BeNumerically(">=", 1))
})
It("acknowledges a playback session ping", func() {
Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent))
})
It("does not count a brief play stopped before the threshold", func() {
// Regression: Finamp sends a Stopped report on every track switch, so an immediate skip
// (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50%

View file

@ -150,4 +150,33 @@ var _ = Describe("Similar", func() {
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("type-specific InstantMix routes", func() {
BeforeEach(func() {
providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}}
})
It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() {
q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix"))
Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"}))
})
DescribeTable("returns the provider's mix",
func(path func() string) {
q := queryResult(get(path()))
Expect(names(q.Items)).To(Equal([]string{"Mix Song"}))
},
Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }),
Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }),
Entry("Playlists/{id}", func() string {
return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix"
}),
Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }),
Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }),
)
It("404s a malformed id query param", func() {
Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound))
})
})
})

View file

@ -1,6 +1,7 @@
package e2e
import (
"fmt"
"net/http"
"strings"
@ -45,6 +46,19 @@ var _ = Describe("Streaming", func() {
It("returns 404 for an unknown track", func() {
Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound))
})
DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)",
func(path string) {
w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty())
},
Entry("stream", "/Audio/%s/stream"),
Entry("stream.{container}", "/Audio/%s/stream.mp3"),
Entry("universal", "/Audio/%s/universal"),
Entry("File", "/Items/%s/File"),
Entry("Download", "/Items/%s/Download"),
)
})
Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() {

View file

@ -23,14 +23,17 @@ import (
_ "golang.org/x/image/webp"
)
// imageSize picks the tighter of Jellyfin's two bounds, because Navidrome resizes on a single
// dimension: reading only MaxWidth serves the full-size original to a client that sent MaxHeight.
func imageSize(maxWidth, maxHeight int) int {
w, h := max(maxWidth, 0), max(maxHeight, 0)
if w == 0 || h == 0 {
return max(w, h)
// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are
// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound.
func imageSize(p *req.Values) int {
fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0))
size := 0
for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} {
if v > 0 && (size == 0 || v < size) {
size = v
}
}
return min(w, h)
return size
}
func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
@ -41,8 +44,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
p := req.Params(r)
size := imageSize(p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0))
size := imageSize(req.Params(r))
artID := api.resolveArtworkID(ctx, itemId)
img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false)

View file

@ -65,7 +65,7 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request)
var _ = Describe("Images", func() {
// Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must
// still get a resized image rather than the full-size original.
DescribeTable("derives the requested size from MaxWidth or MaxHeight",
DescribeTable("derives the requested size from the Jellyfin size params",
func(query string, wantSize int) {
ds := &tests.MockDataStore{}
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
@ -84,6 +84,15 @@ var _ = Describe("Images", func() {
Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200),
Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200),
Entry("neither", "", 0),
Entry("Width only", "width=300", 300),
Entry("Height only", "height=300", 300),
Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300),
Entry("FillWidth only", "fillwidth=300", 300),
Entry("FillHeight only", "fillheight=300", 300),
Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300),
Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300),
Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200),
Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250),
)
It("streams album artwork", func() {

View file

@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"math"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
@ -261,8 +263,8 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
}
// addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs).
// AddTracks enforces ownership; a locked playlist maps to 403, any other error to 404.
// 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")
@ -275,7 +277,13 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
return
}
ids := api.expandContainerIDs(ctx, decoded)
if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil {
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
@ -286,6 +294,12 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) {
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.
@ -316,6 +330,38 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) {
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) {

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
@ -133,6 +134,17 @@ func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error
return f.removeImageErr
}
var _ = DescribeTable("insertPosition",
func(position int64, want int) {
Expect(insertPosition(position)).To(Equal(want))
},
Entry("zero-based index becomes a 1-based position", int64(2), 3),
Entry("negative prepends", int64(-5), 1),
Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32),
Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32),
Entry("smallest int64 prepends", int64(math.MinInt64), 1),
)
var _ = Describe("Playlists", func() {
var api *Router
var fp *fakePlaylists

View file

@ -113,8 +113,8 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
// postCapabilities acknowledges Jellyfin session-capability negotiation.
// Navidrome doesn't track per-session client capabilities, so this is a no-op.
func (api *Router) postCapabilities(w http.ResponseWriter, _ *http.Request) {
// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per
// session) and playback pings (transcodes live only as long as their stream request).
func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}

View file

@ -152,12 +152,12 @@ var _ = Describe("Sessions", func() {
})
})
Describe("postCapabilities", func() {
Describe("acknowledge", func() {
It("returns 204 No Content and does not touch the scrobbler", func() {
w := httptest.NewRecorder()
r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`)))
api.postCapabilities(w, r)
api.acknowledge(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(pt.reported).To(BeEmpty())

View file

@ -111,11 +111,26 @@ func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) {
// a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty
// results, never a 404 the client would surface as an error.
func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, ok := itemIDParam(w, r, "itemId")
if !ok {
return
}
api.instantMix(w, r, id)
}
// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms,
// which take the seed as ?id= instead of a path segment.
func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) {
id, ok := dto.DecodeID(req.Params(r).StringOr("id", ""))
if !ok {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
api.instantMix(w, r, id)
}
func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) {
ctx := r.Context()
limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit)
// Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a

View file

@ -4,6 +4,7 @@ import (
"cmp"
"fmt"
"math"
"mime"
"net/http"
"net/url"
"slices"
@ -139,6 +140,14 @@ func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.
func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) {
ctx := r.Context()
// A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes)
// this is not direct play; direct play falls through to Serve, which answers HEAD itself.
if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" {
w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format))
w.Header().Set("Accept-Ranges", "none")
w.WriteHeader(http.StatusOK)
return
}
s, err := api.streamer.NewStream(ctx, mf, streamReq)
if err != nil {
api.internalError(w, r, err)

View file

@ -244,6 +244,36 @@ var _ = Describe("Stream", func() {
})
})
Describe("HEAD requests", func() {
head := func(query string) *httptest.ResponseRecorder {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1},
})
streamer.content = "audio-bytes"
w := httptest.NewRecorder()
r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser())
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
invoke(api.streamAudio, w, r)
return w
}
It("answers a transcode with the target type and no length, without starting it", func() {
w := head("audioCodec=mp3")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg"))
Expect(w.Header().Get("Content-Length")).To(BeEmpty())
Expect(w.Body.String()).To(BeEmpty())
Expect(streamer.invoked).To(BeFalse())
})
It("answers direct play through the streamer, without a body", func() {
w := head("static=true")
Expect(w.Code).To(Equal(http.StatusOK))
Expect(streamer.invoked).To(BeTrue())
Expect(w.Body.String()).To(BeEmpty())
})
})
Describe("streamUniversal", func() {
universal := func(query string) {
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{

View file

@ -20,10 +20,9 @@ import (
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients feature-gate
// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required
// for Feishin to use the server lyrics endpoint.
const jellyfinVersion = "10.9.11"
// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on
// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts.
const jellyfinVersion = "12.1.0"
func (api *Router) serverName() string {
if conf.Server.Jellyfin.ServerName != "" {

View file

@ -13,6 +13,7 @@ type MockPlaylistTrackRepo struct {
DeletedIds []string
Reordered bool
AddCount int
InsertPos int
Err error
AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options
}
@ -89,6 +90,11 @@ func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) {
return m.AddCount, nil
}
func (m *MockPlaylistTrackRepo) Insert(ids []string, pos int) (int, error) {
m.InsertPos = pos
return m.Add(ids)
}
func (m *MockPlaylistTrackRepo) AddAlbums(_ []string) (int, error) {
if m.Err != nil {
return 0, m.Err