From 969e7e108ca4874d28bcf1e83401b454db65b81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 13 Jul 2026 09:04:24 -0400 Subject: [PATCH 01/14] fix(share): enforce track membership on public share streams (#5769) * fix(share): enforce track membership on public share streams The public share stream endpoint (GET /share/s/{jwt}) validated that the share existed, was unexpired, and that the share owner had library access to the requested track, but it never verified that the track was actually a member of the share. It also accepted stream tokens with no share id (sid) claim, skipping share checks entirely. Enforce that the requested media file belongs to share.Tracks, and make the sid claim mandatory on the stream path. The only producer of stream tokens (encodeMediafileShare) always sets sid, so no legitimate flow is affected; the image endpoint decodes independently and is unchanged. Also document why a JWT is used to represent a shared track: it is a signed, scoped capability for a single public share, not part of authentication. * docs(share): clarify JWT usage comment wording --- server/public/handle_shares.go | 16 ++++++++++ server/public/handle_streams.go | 47 +++++++++++++++++----------- server/public/handle_streams_test.go | 36 +++++++++++++++------ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 18bfcc01c..76f674483 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -97,6 +97,22 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { return &s } +// encodeMediafileShare builds the signed token embedded in a public share link +// for a single track. +// +// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication. +// The token is a signed, opaque capability that identifies one shared track +// (plus its transcode format/bitrate and the parent share id). We use a JWT here +// (reusing the library we already have) because it is a simple way to get three +// properties for a public link: the embedded ids can't be enumerated by guessing, +// the signature +// makes the claims tamper-evident, and the self-contained exp lets us reject +// stale links without a DB lookup. It carries no user identity (no subject, no +// admin flag) and grants access to nothing beyond the share it belongs to; the +// stream handler still verifies the share exists, is unexpired, and that the +// track is actually a member of it. An attacker who can forge these tokens +// necessarily already holds the signing secret, which also signs real user +// sessions, so that scenario is out of scope for the share boundary specifically. func encodeMediafileShare(s model.Share, id string) string { claims := auth.Claims{ ID: id, diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 8fc407e9e..15abab693 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -3,6 +3,7 @@ package public import ( "errors" "net/http" + "slices" "strconv" "time" @@ -25,23 +26,20 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - var shareOwner *model.User - if info.shareID != "" { - share, err := pub.ds.Share(ctx).Get(info.shareID) - if err != nil { - checkShareError(ctx, w, err, info.shareID) - return - } - if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { - checkShareError(ctx, w, model.ErrExpired, info.shareID) - return - } - shareOwner, err = pub.ds.User(ctx).Get(share.UserID) - if err != nil { - log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + shareOwner, err := pub.ds.User(ctx).Get(share.UserID) + if err != nil { + log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return } mf, err := pub.ds.MediaFile(ctx).Get(info.id) @@ -56,7 +54,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { } // 404 rather than 403 so the response doesn't reveal whether the id exists. - if shareOwner != nil && !shareOwner.HasLibraryAccess(mf.LibraryID) { + // The track must belong to the share AND be within the owner's libraries. + if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) { http.Error(w, "not found", http.StatusNotFound) return } @@ -98,6 +97,15 @@ type shareTrackInfo struct { shareID string } +func shareContainsTrack(share *model.Share, mediaFileID string) bool { + return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool { + return mf.ID == mediaFileID + }) +} + +// decodeStreamInfo decodes the signed share-link token. This is a scoped +// public-share capability, not an auth credential; see encodeMediafileShare for +// why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { c, err := auth.Validate(tokenString) if err != nil { @@ -106,6 +114,9 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } + if c.ShareID == "" { + return shareTrackInfo{}, errors.New("required claim \"sid\" not found") + } return shareTrackInfo{ id: c.ID, format: c.Format, diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 6fa083045..2f32ea6f2 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -71,14 +71,11 @@ var _ = Describe("decodeStreamInfo", func() { Expect(err).To(HaveOccurred()) }) - It("handles tokens without shareID (backward compat)", func() { + It("rejects a token without a shareID claim", func() { claims := auth.Claims{ID: "mf-123", Format: "opus"} token, _ := auth.CreatePublicToken(claims) - info, err := decodeStreamInfo(token) - Expect(err).NotTo(HaveOccurred()) - Expect(info.id).To(Equal("mf-123")) - Expect(info.format).To(Equal("opus")) - Expect(info.shareID).To(BeEmpty()) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) }) }) @@ -133,7 +130,7 @@ var _ = Describe("handleStream", func() { shareOwnedBy := func(owner model.User, mf model.MediaFile) { shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID} + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} userRepo := tests.CreateMockUserRepo() Expect(userRepo.Put(&owner)).To(Succeed()) ds.MockedUser = userRepo @@ -171,6 +168,25 @@ var _ = Describe("handleStream", func() { Expect(streamer.called).To(BeFalse()) }) + It("returns 404 when the track is not a member of the share", func() { + owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} + userRepo := tests.CreateMockUserRepo() + Expect(userRepo.Put(&owner)).To(Succeed()) + ds.MockedUser = userRepo + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) + ds.MockedMediaFile = mfRepo + shareRepo.ID = "share123" + shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}} + + claims := auth.Claims{ID: "mf-other", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(streamer.called).To(BeFalse()) + }) + It("streams a track inside the share owner's libraries", func() { shareOwnedBy( model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, @@ -217,12 +233,12 @@ var _ = Describe("handleStream", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) - It("skips share check for tokens without shareID (backward compat)", func() { + It("returns 400 for tokens without a shareID", func() { claims := auth.Claims{ID: "mf-123"} token, _ := auth.CreatePublicToken(claims) w := makeRequest(token) - // Should get past share check, then fail on media file lookup (no mock data) - Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(streamer.called).To(BeFalse()) }) It("returns 400 for an invalid token", func() { From 4998ac2c591d7c1b17bf36f5109f64f521d1169c Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:32:03 +0000 Subject: [PATCH 02/14] feat(server): add scrobble history Native API (#5761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial scrobble api * feat: add scrobble retrieval api * address feedback (1) * fix spelling * be explicit about get * add primary key field, update index, remove rowid references * use unix timestamp for input and output --------- Co-authored-by: Deluan Quintão --- core/scrobbler/play_tracker_test.go | 2 +- ...ary_key_and_update_index_for_scrobbles.sql | 39 ++++ model/scrobble.go | 12 +- persistence/persistence.go | 2 + persistence/persistence_suite_test.go | 20 ++ persistence/scrobble_repository.go | 65 +++++++ persistence/scrobble_repository_test.go | 181 +++++++++++++++--- server/nativeapi/native_api.go | 3 +- tests/mock_scrobble_repo.go | 23 ++- 9 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 74b4be893..831b0ce0d 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -290,7 +290,7 @@ var _ = Describe("PlayTracker", func() { Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) - Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) + Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix())) }) It("does not record scrobble when history is disabled", func() { diff --git a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql new file mode 100644 index 000000000..220d7cf75 --- /dev/null +++ b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql @@ -0,0 +1,39 @@ +-- +goose Up +CREATE TABLE scrobbles_tmp( + id INTEGER PRIMARY KEY, + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_date; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time); + + +-- +goose Down +CREATE TABLE scrobbles_tmp( + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_user_time; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_date ON scrobbles(submission_time); \ No newline at end of file diff --git a/model/scrobble.go b/model/scrobble.go index e1567abc3..a8022fc16 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,11 +3,17 @@ package model import "time" type Scrobble struct { - MediaFileID string - UserID string - SubmissionTime time.Time + ID int64 `structs:"id" json:"id"` + MediaFileID string `structs:"media_file_id" json:"mediaFileId"` + UserID string `json:"-"` + SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` } type ScrobbleRepository interface { + CountAll(options ...QueryOptions) (int64, error) + Get(id string) (*Scrobble, error) + GetAll(options ...QueryOptions) (Scrobbles, error) RecordScrobble(mediaFileID string, submissionTime time.Time) error } + +type Scrobbles []Scrobble diff --git a/persistence/persistence.go b/persistence/persistence.go index 83211bdd5..1164eb70f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -123,6 +123,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository return s.Tag(ctx).(model.ResourceRepository) case model.Plugin: return s.Plugin(ctx).(model.ResourceRepository) + case model.Scrobble: + return s.Scrobble(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index abc5c4b6a..4f2fd7fe2 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" @@ -157,6 +158,13 @@ var ( testUsers = model.Users{adminUser, regularUser, thirdUser} ) +var ( + firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()} + secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()} + thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()} + scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} +) + func p(path string) string { return filepath.FromSlash(path) } @@ -304,6 +312,18 @@ var _ = BeforeSuite(func() { songComeTogether.Starred = true songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether + + scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository) + for _, s := range scrobbles { + _, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{ + "media_file_id": s.MediaFileID, + "user_id": s.UserID, + "submission_time": s.SubmissionTime, + })) + if err != nil { + panic(err) + } + } }) func GetDBXBuilder() *dbx.DB { diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..7cc60ae23 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -5,6 +5,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -13,11 +14,34 @@ type scrobbleRepository struct { sqlRepository } +func fromTs(_ string, value any) Sqlizer { + return GtOrEq{"scrobbles.submission_time": value} +} + +func toTs(_ string, value any) Sqlizer { + return LtOrEq{"scrobbles.submission_time": value} +} + +func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder { + user := loggedUser(r.ctx) + + return r.newSelect(options...). + Columns("id", "media_file_id", "submission_time"). + Where(Eq{"scrobbles.user_id": user.ID}) +} + func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { r := &scrobbleRepository{} r.ctx = ctx r.db = db r.tableName = "scrobbles" + r.registerModel(&model.Scrobble{}, map[string]filterFunc{ + "from": fromTs, + "to": toTs, + }) + r.setSortMappings(map[string]string{ + "submission_time": "submission_time", + }) return r } @@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t _, err := r.executeSQL(insert) return err } + +func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.baseQuery(), options...) +} + +func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { + sel := r.baseQuery().Where(Eq{"id": id}) + var res model.Scrobble + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + sel := r.baseQuery(options...) + var scrobbles model.Scrobbles + err := r.queryAll(sel, &scrobbles) + return scrobbles, err +} + +func (r *scrobbleRepository) Read(id string) (any, error) { + return r.Get(id) +} + +func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) EntityName() string { + return "scrobble" +} + +func (r *scrobbleRepository) NewInstance() any { + return &model.Scrobble{} +} + +var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) +var _ model.ResourceRepository = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index d43848d03..e9103b127 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -15,32 +16,33 @@ import ( var _ = Describe("ScrobbleRepository", func() { var repo model.ScrobbleRepository - var rawRepo sqlRepository var ctx context.Context - var fileID string - var userID string - - BeforeEach(func() { - fileID = id.NewRandom() - userID = id.NewRandom() - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) - db := GetDBXBuilder() - repo = NewScrobbleRepository(ctx, db) - - rawRepo = sqlRepository{ - ctx: ctx, - tableName: "scrobbles", - db: db, - } - }) - - AfterEach(func() { - _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() - _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() - _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() - }) Describe("RecordScrobble", func() { + var fileID string + var userID string + var rawRepo sqlRepository + + BeforeEach(func() { + fileID = id.NewRandom() + userID = id.NewRandom() + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) + db := GetDBXBuilder() + repo = NewScrobbleRepository(ctx, db) + + rawRepo = sqlRepository{ + ctx: ctx, + tableName: "scrobbles", + db: db, + } + }) + + AfterEach(func() { + _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() + _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() + _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() + }) + It("records a scrobble event", func() { submissionTime := time.Now().UTC() @@ -81,4 +83,137 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix())) }) }) + + Context("admin user (id userid)", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("1") + Expect(err).To(BeNil()) + Expect(scrobble.ID).To(Equal(int64(1))) + Expect(scrobble.MediaFileID).To(Equal("1001")) + Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("2") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(2)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + + Expect(scrobbles[1].ID).To(Equal(int64(1))) + Expect(scrobbles[1].MediaFileID).To(Equal("1001")) + Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + }) + }) + }) + + Context("non-admin user", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), regularUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(1))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("2") + Expect(err).To(BeNil()) + Expect(scrobble.ID).To(Equal(int64(2))) + Expect(scrobble.MediaFileID).To(Equal("1003")) + Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + }) + }) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..5a7023eb6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -72,7 +72,8 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - api.R(r, "/tag", model.Tag{}, true) + api.R(r, "/tag", model.Tag{}, false) + api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) } diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 34561c257..d6d88d221 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -2,6 +2,7 @@ package tests import ( "context" + "strconv" "time" "github.com/navidrome/navidrome/model" @@ -13,12 +14,32 @@ type MockScrobbleRepo struct { ctx context.Context } +func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { + for idx := range m.RecordedScrobbles { + if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { + return &m.RecordedScrobbles[idx], nil + } + } + + return nil, model.ErrNotFound +} + +func (m *MockScrobbleRepo) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + return m.RecordedScrobbles, nil +} + +func (m *MockScrobbleRepo) CountAll(options ...model.QueryOptions) (int64, error) { + return int64(len(m.RecordedScrobbles)), nil +} + func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { user, _ := request.UserFrom(m.ctx) m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ MediaFileID: fileID, UserID: user.ID, - SubmissionTime: submissionTime, + SubmissionTime: submissionTime.Unix(), }) return nil } + +var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) From cc315dcc8c38fc40e721e90d9d22c6f0aeec97d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 13 Jul 2026 12:04:29 -0400 Subject: [PATCH 03/14] perf(db): keep query planner statistics trustworthy with full ANALYZE (#5740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(db): keep query planner statistics trustworthy with full ANALYZE PRAGMA optimize's internal ANALYZE runs with a limited analysis budget (~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality indexes: on a 96K-track library it claimed (missing, library_id) narrows to ~2000 rows when it matches the whole table. The planner then prefers that index over the sort index and falls back to a full-table temp B-tree sort per request, turning paginated song listings into multi-second queries (reproduced at 5.5s on real hardware; ~90x slower than with correct stats). Every index-creating migration re-triggered the poisoning via the post-migration optimize, and the daily optimizer could re-trigger it on large library changes. Setting analysis_limit on the connection does not help: optimize ignores it. Run a plain full ANALYZE instead: after migrations with schema changes, and in db.Optimize (daily schedule and scan-end). Stats are stored in the database file, so one connection suffices and the per-connection pool loop is gone. The Optimize call at shutdown is removed: stats are maintained at migration/scan/daily points, and an ANALYZE during shutdown only delays it and races container stop timeouts. * perf(db): drop startup PRAGMA optimize that re-poisons planner stats The startup PRAGMA optimize=0x10002 runs SQLite's budget-limited internal ANALYZE (bit 0x02), which writes truncated sqlite_stat1 rows for low-cardinality indexes -- the exact statistics-poisoning this PR set out to eliminate. Because DevOptimizeDB defaults to true, a restart with no pending migrations would re-poison the planner until the next scan or daily Optimize. Remove it: statistics are already refreshed with a full ANALYZE after schema-changing migrations (Init) and via Optimize at scan-end and on the daily schedule, so nothing on the startup path needs to touch them. Also clarify that Optimize is a no-op unless DevOptimizeDB is enabled. * chore(db): remove the DevOptimizeDB flag and skip Optimize on quick scans The flag only gated the optimize/ANALYZE maintenance calls and there is no reason to leave planner statistics unmaintained; the guards are gone along with the flag. The scan-end Optimize now runs only after full scans — quick scans barely move the statistics, and the daily schedule covers drift. * style(scanner): drop redundant comment in runOptimize * chore(persistence): drop the no-op PRAGMA optimize from ScanEnd Mask 0x10000 only selects candidate tables by size change; without the 0x02 action bit optimize does nothing (verified: sqlite_stat1 stays stale after a 100x table growth). The scan-end statistics refresh is db.Optimize's full ANALYZE, and the expression-collation-index concern the old comment guarded against no longer applies. * fix(scanner): run the post-scan ANALYZE in the server process With the external scanner (the default), the scan pipeline runs in a subprocess, so its ANALYZE was invisible to the server: SQLite loads sqlite_stat1 into the process's shared schema cache, and an ANALYZE from another process does not refresh it — verified with the production DSN that even brand-new pool connections keep planning with the old statistics until the server restarts. An in-process ANALYZE, by contrast, is immediately visible to every pooled connection through the same shared cache. Move the full-scan Optimize from the scanner pipeline to the scan controller, which always runs in the server process. * fix(scanner): honor promoted full scans in the optimize gate A quick scan resuming an interrupted full scan is promoted inside the scanner (possibly in a subprocess); mirror the promotion in the controller so the post-scan ANALYZE isn't skipped. * refactor: apply cleanup review findings - drop forceFullRescan's inline ANALYZE: Init already runs a full ANALYZE after any migration batch with schema changes, so upgrades including a full-rescan migration analyzed the whole DB twice - resumingFullScan uses a filtered CountAll instead of fetching and scanning all libraries - document why CallScan (CLI) deliberately skips the post-scan Optimize * perf(db): make planner analysis maintenance resilient Check analysis freshness every 30 minutes and refresh statistics when the last successful run is over 24 hours old or a scan marked them pending. Persist successful analysis state, retry skipped or failed maintenance, coordinate checks with scans, and cover standalone CLI full scans. * perf(db): avoid analyzing routine quick-scan changes Reserve pending analysis for full scans, unscanned libraries, and retry state. Incremental quick scans now rely on the 24-hour freshness window instead of triggering a full ANALYZE at the next maintenance check. * fix(scan): analyze resumed full scans in CLI * fix(db): back off failed analysis retries * feat(db): allow disabling scheduled analysis * test(db): remove redundant analysis coverage * refactor(db): split ANALYZE maintenance into optimize.go and dedupe call sites - move query-planner statistics code from db.go to its own optimize.go (and matching optimize_test.go) - log ANALYZE elapsed time inside Optimize/OptimizeIfNeeded instead of repeating the timing block at every call site - drop the LastDBAnalyzeAttemptAt write on success: it is only read while failures >= 1, and every failure rewrites it first - extract runPostScanAnalysis (cmd) and anyIncludedLibrary (scanner) helpers --- cmd/root.go | 22 ++- cmd/scan.go | 37 ++++- cmd/scan_test.go | 15 ++ conf/configuration.go | 4 +- conf/configuration_test.go | 13 ++ consts/consts.go | 7 +- core/metrics/insights.go | 1 + core/metrics/insights/data.go | 79 +++++------ db/db.go | 49 +------ db/export_test.go | 7 +- db/migrations/migration.go | 8 -- db/optimize.go | 224 ++++++++++++++++++++++++++++++ db/optimize_test.go | 162 +++++++++++++++++++++ persistence/library_repository.go | 9 -- scanner/controller.go | 79 ++++++++++- scanner/controller_test.go | 38 +++++ scanner/scanner.go | 13 -- scanner/scanner_selective_test.go | 36 ++++- 18 files changed, 673 insertions(+), 130 deletions(-) create mode 100644 db/optimize.go create mode 100644 db/optimize_test.go diff --git a/cmd/root.go b/cmd/root.go index 08773176a..b231aae0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -86,7 +86,7 @@ func runNavidrome(ctx context.Context) { g.Go(startPlaybackServer(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBOptimizer(ctx)) + g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { @@ -275,16 +275,24 @@ func schedulePeriodicBackup(ctx context.Context) func() error { } } -func scheduleDBOptimizer(ctx context.Context) func() error { +func scheduleDBAnalyzer(ctx context.Context) func() error { return func() error { - log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) + if !conf.Server.EnableScheduledDBAnalyze { + log.Info(ctx, "Scheduled DB analysis is DISABLED") + return nil + } + log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { - if scanner.IsScanning() { - log.Debug(ctx, "Skipping DB optimization because a scan is in progress") + _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { + release, ok := scanner.LockForMaintenance() + if !ok { + log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") return } - db.Optimize(ctx) + defer release() + if _, err := db.OptimizeIfNeeded(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } }) return err } diff --git a/cmd/scan.go b/cmd/scan.go index d8a563396..320b401d4 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/gob" + "errors" "fmt" "os" "strings" @@ -43,15 +44,20 @@ var scanCmd = &cobra.Command{ }, } -func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { +func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) { + var changesDetected bool + var scanErrors []error for status := range pl.ReadOrDone(ctx, progress) { if status.Warning != "" { log.Warn(ctx, "Scan warning", "error", status.Warning) } if status.Error != "" { log.Error(ctx, "Scan error", "error", status.Error) + scanErrors = append(scanErrors, errors.New(status.Error)) + } + if status.ChangesDetected { + changesDetected = true } - // Discard the progress status, we only care about errors } if fullScan { @@ -59,6 +65,7 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre } else { log.Info("Finished rescan") } + return changesDetected, errors.Join(scanErrors...) } func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { @@ -95,6 +102,16 @@ func runScanner(ctx context.Context) { log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) } + effectiveFullScan := fullScan + if !subprocess { + effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets) + if effectiveFullScan { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + } + progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) @@ -104,7 +121,21 @@ func runScanner(ctx context.Context) { if subprocess { trackScanAsSubprocess(ctx, progress) } else { - trackScanInteractively(ctx, progress) + changesDetected, scanErr := trackScanInteractively(ctx, progress) + runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr) + } +} + +func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) { + if changesDetected { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Error marking DB analysis pending", err) + } + } + if effectiveFullScan && scanErr == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Error analyzing DB", err) + } } } diff --git a/cmd/scan_test.go b/cmd/scan_test.go index beeecca19..309d09f98 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -1,14 +1,29 @@ package cmd import ( + "context" "os" "path/filepath" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("trackScanInteractively", func() { + It("reports changes and scan errors", func() { + progress := make(chan *scanner.ProgressInfo, 2) + progress <- &scanner.ProgressInfo{ChangesDetected: true} + progress <- &scanner.ProgressInfo{Error: "scan failed"} + close(progress) + + changesDetected, err := trackScanInteractively(context.Background(), progress) + Expect(changesDetected).To(BeTrue()) + Expect(err).To(MatchError("scan failed")) + }) +}) + var _ = Describe("readTargetsFromFile", func() { var tempDir string diff --git a/conf/configuration.go b/conf/configuration.go index 8646bf075..e939ebb7e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -51,6 +51,7 @@ type configOptions struct { EnableExternalServices bool EnableM3UExternalAlbumArt bool EnableInsightsCollector bool + EnableScheduledDBAnalyze bool EnableMediaFileCoverArt bool TranscodingCacheSize string ImageCacheSize string @@ -147,7 +148,6 @@ type configOptions struct { DevEnablePluginsInsights bool DevPluginCompilationTimeout time.Duration DevExternalArtistFetchMultiplier float64 - DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool DevEnableMediaFileProbe bool } @@ -800,6 +800,7 @@ func setViperDefaults() { viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) + viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) @@ -891,7 +892,6 @@ func setViperDefaults() { viper.SetDefault("devenablepluginsinsights", true) viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devexternalartistfetchmultiplier", 1.5) - viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) viper.SetDefault("devenablemediafileprobe", true) } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 9c25a0d19..e43c91a4b 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -58,6 +58,19 @@ var _ = Describe("Configuration", func() { }) }) + Describe("scheduled DB analysis", func() { + It("is enabled by default", func() { + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) + }) + + It("can be disabled", func() { + viper.Set("enablescheduleddbanalyze", false) + conf.Load(true) + Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) + }) + }) + Describe("ValidateURL", func() { It("accepts a valid http URL", func() { fn := conf.ValidateURL("TestOption", "http://example.com/path") diff --git a/consts/consts.go b/consts/consts.go index 3795b590a..73f89b450 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -20,6 +20,10 @@ const ( LastScanErrorKey = "LastScanError" LastScanTypeKey = "LastScanType" LastScanStartTimeKey = "LastScanStartTime" + LastDBAnalyzeAtKey = "LastDBAnalyzeAt" + LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" + DBAnalyzePendingKey = "DBAnalyzePending" + DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -28,7 +32,8 @@ const ( DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - OptimizeDBSchedule = "@every 24h" + DBAnalyzeCheckSchedule = "@every 30m" + DBAnalyzeMaxAge = 24 * time.Hour // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option diff --git a/core/metrics/insights.go b/core/metrics/insights.go index bcd0343c2..78391779a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -223,6 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.ScanSchedule = conf.Server.Scanner.Schedule data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID data.Config.HasCustomTags = len(conf.Server.Tags) > 0 diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 34648a49b..126d759bc 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -43,45 +43,46 @@ type Data struct { FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"` } `json:"library"` Config struct { - LogLevel string `json:"logLevel,omitempty"` - LogFileConfigured bool `json:"logFileConfigured,omitempty"` - TLSConfigured bool `json:"tlsConfigured,omitempty"` - ScannerEnabled bool `json:"scannerEnabled,omitempty"` - ScannerExtractor string `json:"scannerExtractor,omitempty"` - ScanSchedule string `json:"scanSchedule,omitempty"` - ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` - ScanOnStartup bool `json:"scanOnStartup,omitempty"` - TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` - ImageCacheSize string `json:"imageCacheSize,omitempty"` - EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` - EnableDownloads bool `json:"enableDownloads,omitempty"` - EnableSharing bool `json:"enableSharing,omitempty"` - EnableStarRating bool `json:"enableStarRating,omitempty"` - EnableLastFM bool `json:"enableLastFM,omitempty"` - EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` - EnableDeezer bool `json:"enableDeezer,omitempty"` - EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` - EnableJukebox bool `json:"enableJukebox,omitempty"` - EnablePrometheus bool `json:"enablePrometheus,omitempty"` - EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` - CoverArtQuality int `json:"coverArtQuality,omitempty"` - EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` - UICoverArtSize int `json:"uiCoverArtSize,omitempty"` - EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` - EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` - SessionTimeout uint64 `json:"sessionTimeout,omitempty"` - SearchFullString bool `json:"searchFullString,omitempty"` - SearchBackend string `json:"searchBackend,omitempty"` - RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` - PreferSortTags bool `json:"preferSortTags,omitempty"` - BackupSchedule string `json:"backupSchedule,omitempty"` - BackupCount int `json:"backupCount,omitempty"` - DevActivityPanel bool `json:"devActivityPanel,omitempty"` - DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` - HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` - ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` - HasCustomPID bool `json:"hasCustomPID,omitempty"` - HasCustomTags bool `json:"hasCustomTags,omitempty"` + LogLevel string `json:"logLevel,omitempty"` + LogFileConfigured bool `json:"logFileConfigured,omitempty"` + TLSConfigured bool `json:"tlsConfigured,omitempty"` + ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScannerExtractor string `json:"scannerExtractor,omitempty"` + ScanSchedule string `json:"scanSchedule,omitempty"` + ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` + ScanOnStartup bool `json:"scanOnStartup,omitempty"` + EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"` + TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` + ImageCacheSize string `json:"imageCacheSize,omitempty"` + EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` + EnableDownloads bool `json:"enableDownloads,omitempty"` + EnableSharing bool `json:"enableSharing,omitempty"` + EnableStarRating bool `json:"enableStarRating,omitempty"` + EnableLastFM bool `json:"enableLastFM,omitempty"` + EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` + EnableDeezer bool `json:"enableDeezer,omitempty"` + EnableMediaFileCoverArt bool `json:"enableMediaFileCoverArt,omitempty"` + EnableJukebox bool `json:"enableJukebox,omitempty"` + EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableArtworkUpload bool `json:"enableArtworkUpload,omitempty"` + CoverArtQuality int `json:"coverArtQuality,omitempty"` + EnableWebPEncoding bool `json:"enableWebPEncoding,omitempty"` + UICoverArtSize int `json:"uiCoverArtSize,omitempty"` + EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` + SessionTimeout uint64 `json:"sessionTimeout,omitempty"` + SearchFullString bool `json:"searchFullString,omitempty"` + SearchBackend string `json:"searchBackend,omitempty"` + RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` + PreferSortTags bool `json:"preferSortTags,omitempty"` + BackupSchedule string `json:"backupSchedule,omitempty"` + BackupCount int `json:"backupCount,omitempty"` + DevActivityPanel bool `json:"devActivityPanel,omitempty"` + DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` + HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` + ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` + HasCustomPID bool `json:"hasCustomPID,omitempty"` + HasCustomTags bool `json:"hasCustomTags,omitempty"` } `json:"config"` Plugins map[string]PluginInfo `json:"plugins,omitempty"` } diff --git a/db/db.go b/db/db.go index 6e5b2f569..3f3f61d71 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "fmt" "runtime" + "time" "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" @@ -47,12 +48,6 @@ func Db() *sql.DB { if err != nil { log.Fatal("Error opening database", err) } - if conf.Server.DevOptimizeDB { - _, err = db.Exec("PRAGMA optimize=0x10002") - if err != nil { - log.Error("Error applying PRAGMA optimize", err) - } - } return db }) } @@ -61,9 +56,6 @@ func Close(ctx context.Context) { // Ignore cancellations when closing the DB ctx = context.WithoutCancel(ctx) - // Run optimize before closing - Optimize(ctx) - log.Info(ctx, "Closing Database") err := Db().Close() if err != nil { @@ -102,11 +94,11 @@ func Init(ctx context.Context) func() { log.Fatal(ctx, "Failed to apply new migrations", err) } - if hasSchemaChanges && conf.Server.DevOptimizeDB { - log.Debug(ctx, "Applying PRAGMA optimize after schema changes") - _, err = db.ExecContext(ctx, "PRAGMA optimize") + if hasSchemaChanges { + log.Debug(ctx, "Running ANALYZE after schema changes") + err = optimizeAt(ctx, db, time.Now()) if err != nil { - log.Error(ctx, "Error applying PRAGMA optimize", err) + log.Error(ctx, "Error running ANALYZE", err) } } @@ -115,37 +107,6 @@ func Init(ctx context.Context) func() { } } -// Optimize runs PRAGMA optimize on each connection in the pool -func Optimize(ctx context.Context) { - if !conf.Server.DevOptimizeDB { - return - } - numConns := Db().Stats().OpenConnections - if numConns == 0 { - log.Debug(ctx, "No open connections to optimize") - return - } - log.Debug(ctx, "Optimizing open connections", "numConns", numConns) - var conns []*sql.Conn - for range numConns { - conn, err := Db().Conn(ctx) - conns = append(conns, conn) - if err != nil { - log.Error(ctx, "Error getting connection from pool", err) - continue - } - _, err = conn.ExecContext(ctx, "PRAGMA optimize;") - if err != nil { - log.Error(ctx, "Error running PRAGMA optimize", err) - } - } - - // Return all connections to the Connection Pool - for _, conn := range conns { - conn.Close() - } -} - type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/export_test.go b/db/export_test.go index 734a4462f..02b88cd66 100644 --- a/db/export_test.go +++ b/db/export_test.go @@ -2,6 +2,9 @@ package db // Definitions for testing private methods var ( - IsSchemaEmpty = isSchemaEmpty - BackupPath = backupPath + IsSchemaEmpty = isSchemaEmpty + BackupPath = backupPath + OptimizeDBAt = optimizeAt + OptimizeDBIfNeeded = optimizeIfNeeded + RecordAnalyzeFailure = recordAnalyzeFailure ) diff --git a/db/migrations/migration.go b/db/migrations/migration.go index 9b1098af1..df1c392a5 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -7,7 +7,6 @@ import ( "strings" "sync" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" ) @@ -21,13 +20,6 @@ func notice(ctx context.Context, tx *sql.Tx, msg string) { // Call this in migrations that requires a full rescan func forceFullRescan(ctx context.Context, tx *sql.Tx) error { - // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. - if conf.Server.DevOptimizeDB { - _, err := tx.ExecContext(ctx, `ANALYZE;`) - if err != nil { - return err - } - } _, err := tx.ExecContext(ctx, fmt.Sprintf(` INSERT OR REPLACE into property (id, value) values ('%s', '1'); `, consts.FullScanAfterMigrationFlagKey)) diff --git a/db/optimize.go b/db/optimize.go new file mode 100644 index 000000000..f46906c4e --- /dev/null +++ b/db/optimize.go @@ -0,0 +1,224 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" +) + +var analyzeMux sync.Mutex + +// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided +// because its limited analysis misestimates Navidrome's low-cardinality indexes. +func Optimize(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + if err := optimizeAt(ctx, Db(), start); err != nil { + return err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return nil +} + +// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation +// marked them for refresh. +func OptimizeIfNeeded(ctx context.Context) (bool, error) { + analyzeMux.Lock() + defer analyzeMux.Unlock() + start := time.Now() + ran, err := optimizeIfNeeded(ctx, Db(), start) + if err != nil || !ran { + return ran, err + } + log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) + return true, nil +} + +func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + due, err := optimizeDue(ctx, db, now) + if err != nil || !due { + return false, err + } + return true, optimizeAt(ctx, db, now) +} + +func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + backingOff, err := analyzeRetryBackoffActive(ctx, db, now) + if err != nil || backingOff { + return false, err + } + + pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey) + if err != nil { + return false, err + } + if found && pending == "1" { + return true, nil + } + + value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey) + if err != nil { + return false, err + } + if !found { + return true, nil + } + + lastAnalyze, valid := parseAnalyzeTime(value) + if !valid || lastAnalyze.After(now) { + return true, nil + } + return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil +} + +func parseAnalyzeTime(value string) (time.Time, bool) { + parsed, err := time.Parse(time.RFC3339Nano, value) + return parsed, err == nil +} + +func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { + value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey) + if err != nil || !found { + return false, err + } + failures, _ := strconv.Atoi(value) + if failures < 1 { + return false, nil + } + + value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey) + if err != nil || !found { + return false, err + } + lastAttempt, valid := parseAnalyzeTime(value) + if !valid || lastAttempt.After(now) { + return false, nil + } + return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil +} + +func analyzeRetryDelay(failures int) time.Duration { + switch failures { + case 1: + return 30 * time.Minute + case 2: + return time.Hour + case 3: + return 2 * time.Hour + default: + return 24 * time.Hour + } +} + +// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check. +func MarkOptimizePending(ctx context.Context) error { + analyzeMux.Lock() + defer analyzeMux.Unlock() + return markOptimizePending(ctx, Db()) +} + +func markOptimizePending(ctx context.Context, db *sql.DB) error { + return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1") +} + +func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error { + if err := markOptimizePending(ctx, db); err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err)) + } + log.Debug(ctx, "Refreshing query planner statistics") + _, err := db.ExecContext(ctx, "ANALYZE") + if err != nil { + return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err)) + } + if err = recordAnalyzeSuccess(ctx, db, now); err != nil { + return recordAnalyzeError(ctx, db, now, err) + } + return nil +} + +func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("recording ANALYZE time: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil { + return fmt.Errorf("clearing pending ANALYZE: %w", err) + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil { + return fmt.Errorf("clearing ANALYZE failure count: %w", err) + } + if err = tx.Commit(); err != nil { + return fmt.Errorf("recording ANALYZE state: %w", err) + } + return nil +} + +func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error { + if err := recordAnalyzeFailure(ctx, db, now); err != nil { + return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err)) + } + return analyzeErr +} + +func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey) + if err != nil { + return err + } + failures := 0 + if found { + failures, _ = strconv.Atoi(value) + failures = max(failures, 0) + } + if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil { + return err + } + if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +type sqlExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +type sqlQueryer interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func putProperty(ctx context.Context, db sqlExecer, key, value string) error { + _, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + return err +} + +func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) { + var value string + err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return value, err == nil, err +} diff --git a/db/optimize_test.go b/db/optimize_test.go new file mode 100644 index 000000000..da9b3b9c9 --- /dev/null +++ b/db/optimize_test.go @@ -0,0 +1,162 @@ +package db_test + +import ( + "context" + "database/sql" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/db" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Optimize", func() { + var ( + ctx context.Context + database *sql.DB + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC) + var err error + database, err = sql.Open(db.Dialect, "file::memory:") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(database.Close) + + _, err = database.Exec(`create table property( + id varchar(255) primary key, + value varchar(255) not null default '' + )`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create table analyze_probe(id integer primary key, flag int)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec(`insert into analyze_probe(flag) + with recursive s(x) as (select 1 union all select x+1 from s where x < 3000) + select 0 from s`) + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("create index probe_flag on analyze_probe(flag)") + Expect(err).ToNot(HaveOccurred()) + _, err = database.Exec("analyze") + Expect(err).ToNot(HaveOccurred()) + }) + + putProperty := func(key, value string) { + _, err := database.Exec(`insert into property(id, value) values(?, ?) + on conflict(id) do update set value=excluded.value`, key, value) + Expect(err).ToNot(HaveOccurred()) + } + + getProperty := func(key string) string { + var value string + Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed()) + return value + } + + poisonStats := func() { + _, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'") + Expect(err).ToNot(HaveOccurred()) + } + + It("replaces poisoned planner statistics with full-quality ones", func() { + poisonStats() + putProperty(consts.DBAnalyzePendingKey, "1") + + Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) + + var stat string + err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat) + Expect(err).ToNot(HaveOccurred()) + // A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count. + Expect(stat).To(Equal("3000 3000")) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("runs when no previous analysis was recorded", func() { + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("skips a recent analysis when no refresh is pending", func() { + lastAnalyze := now.Add(-23 * time.Hour) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + poisonStats() + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano))) + + var stat string + Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed()) + Expect(stat).To(Equal("3000 50")) + }) + + It("runs when the previous analysis is stale", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "0") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + }) + + It("runs when a refresh is pending even if the previous analysis is recent", func() { + putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano)) + putProperty(consts.DBAnalyzePendingKey, "1") + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + DescribeTable("backs off after consecutive analysis failures", + func(failures string, retryDelay time.Duration) { + putProperty(consts.DBAnalyzePendingKey, "1") + putProperty(consts.DBAnalyzeFailureCountKey, failures) + putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano)) + + ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeFalse()) + + ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay)) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0")) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) + }, + Entry("for 30 minutes after the first failure", "1", 30*time.Minute), + Entry("for one hour after the second failure", "2", time.Hour), + Entry("for two hours after the third failure", "3", 2*time.Hour), + Entry("for 24 hours after the fourth failure", "4", 24*time.Hour), + ) + + It("records consecutive analysis failures", func() { + putProperty(consts.DBAnalyzeFailureCountKey, "2") + + Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed()) + + Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3")) + Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano))) + Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + + It("does not record success when analysis fails", func() { + lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano) + putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze) + canceledCtx, cancel := context.WithCancel(ctx) + cancel() + + Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled"))) + Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 3789a71c9..5a0142423 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -173,15 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error { Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) _, err := r.executeSQL(sq) - if err != nil { - return err - } - // https://www.sqlite.org/pragma.html#pragma_optimize - // Use mask 0x10000 to check table sizes without running ANALYZE - // Running ANALYZE can cause query planner issues with expression-based collation indexes - if conf.Server.DevOptimizeDB { - _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) - } return err } diff --git a/scanner/controller.go b/scanner/controller.go index 175b92e26..463718ba3 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "slices" + "sync" "sync/atomic" "time" @@ -13,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -211,6 +214,16 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ ctx := request.AddValues(s.rootCtx, requestCtx) ctx = auth.WithAdminUser(ctx, s.ds) + // A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens + // inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must + // be read before the scan: ScanEnd clears the flag. + effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets) + if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) { + if err := db.MarkOptimizePending(ctx); err != nil { + log.Error(ctx, "Scanner: Error marking DB analysis pending", err) + } + } + // Send the initial scan status event s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) progress := make(chan *ProgressInfo, 100) @@ -229,6 +242,15 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ if scanError != nil { _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error()) } + // Refresh the query-planner statistics after a successful full scan. This must run in the + // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the + // server's pooled connections; their shared schema cache keeps the old statistics until the + // process restarts. + if effectiveFullScan && scanError == nil { + if err := db.Optimize(ctx); err != nil { + log.Error(ctx, "Scanner: Error analyzing DB", err) + } + } // If changes were detected, send a refresh event to all clients if s.changesDetected { log.Debug(ctx, "Library changes imported. Sending refresh event") @@ -255,18 +277,73 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ // This is a global variable that is used to prevent multiple scans from running at the same time. // "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg -var running atomic.Bool +var ( + running atomic.Bool + scanMaintenanceMux sync.Mutex +) func lockScan(ctx context.Context) (func(), error) { if !running.CompareAndSwap(false, true) { log.Debug(ctx, "Scanner already running, ignoring request") return func() {}, ErrAlreadyScanning } + scanMaintenanceMux.Lock() return func() { + scanMaintenanceMux.Unlock() running.Store(false) }, nil } +// LockForMaintenance prevents a scan from starting while database maintenance is running. +func LockForMaintenance() (func(), bool) { + if !scanMaintenanceMux.TryLock() { + return func() {}, false + } + if running.Load() { + scanMaintenanceMux.Unlock() + return func() {}, false + } + return scanMaintenanceMux.Unlock, true +} + +// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted +// full scan in one of the included libraries. +func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { + if fullScan { + return true + } + return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { + return library.FullScanInProgress + }) +} + +func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool { + return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool { + return library.LastScanAt.IsZero() + }) +} + +// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is +// empty) matches pred. +func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { + libraries, err := ds.Library(ctx).GetAll() + if err != nil { + return false + } + if len(targets) == 0 { + return slices.ContainsFunc(libraries, pred) + } + + targeted := make(map[int]struct{}, len(targets)) + for _, target := range targets { + targeted[target.LibraryID] = struct{}{} + } + return slices.ContainsFunc(libraries, func(library model.Library) bool { + _, ok := targeted[library.ID] + return ok && pred(library) + }) +} + func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) { s.count.Store(0) s.folderCount.Store(0) diff --git a/scanner/controller_test.go b/scanner/controller_test.go index d60d432b4..e4814da64 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -55,3 +55,41 @@ var _ = Describe("Controller", func() { }) }) }) + +var _ = Describe("LockForMaintenance", func() { + It("allows only one database maintenance operation at a time", func() { + release, ok := scanner.LockForMaintenance() + Expect(ok).To(BeTrue()) + DeferCleanup(release) + + _, ok = scanner.LockForMaintenance() + Expect(ok).To(BeFalse()) + }) +}) + +var _ = Describe("EffectiveFullScan", func() { + var ds *tests.MockDataStore + + BeforeEach(func() { + libraries := &tests.MockLibraryRepo{} + libraries.SetData(model.Libraries{ + {ID: 1, FullScanInProgress: true}, + {ID: 2}, + }) + ds = &tests.MockDataStore{MockedLibrary: libraries} + }) + + It("detects an interrupted full scan in a targeted library", func() { + targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) + }) + + It("detects an interrupted full scan when scanning all libraries", func() { + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue()) + }) + + It("ignores interrupted full scans in untargeted libraries", func() { + targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}} + Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) + }) +}) diff --git a/scanner/scanner.go b/scanner/scanner.go index 871b0c696..27e2b19d2 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -161,9 +160,6 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Update last_scan_completed_at for all libraries s.runUpdateLibraries(ctx, &state), - - // Optimize DB - s.runOptimize(ctx), ) if err != nil { log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) @@ -280,15 +276,6 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun } } -func (s *scannerImpl) runOptimize(ctx context.Context) func() error { - return func() error { - start := time.Now() - db.Optimize(ctx) - log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) - return nil - } -} - func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { return func() error { start := time.Now() diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6c70eb268..17772bf9d 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -4,10 +4,12 @@ import ( "context" "path/filepath" "testing/fstest" + "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" @@ -80,7 +82,7 @@ var _ = Describe("ScanFolders", Ordered, func() { rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) - createFS(fstest.MapFS{ + fsys = createFS(fstest.MapFS{ "rock/track1.mp3": rock(track(1, "Rock Track 1")), "rock/track2.mp3": rock(track(2, "Rock Track 2")), "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), @@ -122,6 +124,38 @@ var _ = Describe("ScanFolders", Ordered, func() { // Verify files in the pop folder were NOT scanned Expect(paths).ToNot(ContainElement("pop/track6.mp3")) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("1")) + }) + }) + + Describe("Planner statistics maintenance", func() { + It("does not mark routine quick-scan changes for immediate analysis", func() { + rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) + fsys = createFS(fstest.MapFS{ + "rock/track1.mp3": rock(track(1, "Rock Track 1")), + }) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + + fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) + _, err = s.ScanAll(ctx, false) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.DBAnalyzePendingKey)).To(Equal("0")) + }) + + It("does not treat an interrupted scan in an untargeted library as a full scan", func() { + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + Expect(ds.Library(ctx).ScanBegin(lib.ID, true)).To(Succeed()) + + lastAnalyze := "2026-07-09T12:00:00Z" + Expect(ds.Property(ctx).Put(consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) + Expect(ds.Property(ctx).Put(consts.DBAnalyzePendingKey, "0")).To(Succeed()) + + _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Property(ctx).Get(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) }) }) From edddc1acb5a566919cf3cfb026cf4c2a702c00fd Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 13 Jul 2026 14:06:50 -0400 Subject: [PATCH 04/14] chore(deps): update go-sqlite3, reflex, and golang.org/x dependencies to latest versions Signed-off-by: Deluan --- go.mod | 26 +++++++++++++------------- go.sum | 58 ++++++++++++++++++++++++++-------------------------------- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 71aabdcd7..014a43a56 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.1 - github.com/mattn/go-sqlite3 v1.14.47 + github.com/mattn/go-sqlite3 v1.14.48 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.0 @@ -59,12 +59,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.43.0 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 - golang.org/x/term v0.44.0 - golang.org/x/text v0.39.0 + golang.org/x/image v0.44.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 + golang.org/x/text v0.40.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -75,7 +75,7 @@ require ( github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/reflex v0.3.1 // indirect + github.com/cespare/reflex v0.3.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -89,7 +89,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect + github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.3 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index ec532b0a0..064974edb 100644 --- a/go.sum +++ b/go.sum @@ -16,13 +16,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= -github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= +github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= +github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,7 +61,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= @@ -105,8 +103,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= -github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= +github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -143,11 +141,8 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -174,8 +169,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= -github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -309,39 +304,38 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= -golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= -golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A= +golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= From feda8de7e9e172aeeba9ebbf3ccb5ba454bb85af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 13 Jul 2026 16:08:07 -0400 Subject: [PATCH 05/14] ci: bump github-script, cache and setup-qemu actions to latest majors (#5778) Update the GitHub Actions that had newer major versions available; all other actions in the workflows were already pinned to their latest major tag. - actions/github-script: v7 -> v9 - actions/cache: v5 -> v6 - docker/setup-qemu-action: v3 -> v4 --- .github/workflows/download-link-on-pr.yml | 2 +- .github/workflows/pipeline.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 076f963d4..5b421331b 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 86a1055f8..8e6e8126a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -166,7 +166,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 @@ -323,7 +323,7 @@ jobs: - name: Set up QEMU for smoke test if: env.IS_LINUX == 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 # The binary is static, so binfmt+qemu runs it directly on the runner. # Catches startup crashes in cross-compiled binaries before they ship, From 9ae252c418fe3884524a33eea3d262e569c3ac6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 14 Jul 2026 07:20:17 -0400 Subject: [PATCH 06/14] feat(ui): add Artists, Songs, and Playlists to Default View options (#5754) * Add resource lists to default view options * refactor(ui): reuse getStoredDefaultView in AlbumList default-view redirect Avoid duplicating the localStorage fallback logic and skip the unused albumLists lookup in the resource-redirect branch, per PR review feedback. --- ui/src/album/AlbumList.jsx | 12 +++++-- ui/src/personal/SelectDefaultView.jsx | 9 ++--- ui/src/personal/defaultViews.js | 20 +++++++++++ ui/src/personal/defaultViews.test.js | 48 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 ui/src/personal/defaultViews.js create mode 100644 ui/src/personal/defaultViews.test.js diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 0b8c256df..a860c85bb 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -28,7 +28,11 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' -import albumLists, { defaultAlbumList } from './albumLists' +import albumLists from './albumLists' +import { + getStoredDefaultView, + isResourceDefaultView, +} from '../personal/defaultViews' import config from '../config' import AlbumInfo from './AlbumInfo' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' @@ -220,8 +224,10 @@ const AlbumList = (props) => { // If it does not have filter/sort params (usually coming from Menu), // reload with correct filter/sort params if (!location.search) { - const type = - albumListType || localStorage.getItem('defaultView') || defaultAlbumList + const type = albumListType || getStoredDefaultView() + if (isResourceDefaultView(type)) { + return + } const listParams = albumLists[type] if (type === 'random') { refresh() diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx index 71c87305c..e90fd65bc 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.jsx @@ -1,13 +1,10 @@ import { SelectInput, useTranslate } from 'react-admin' -import albumLists, { defaultAlbumList } from '../album/albumLists' +import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = localStorage.getItem('defaultView') || defaultAlbumList - const choices = Object.keys(albumLists).map((type) => ({ - id: type, - name: translate(`resources.album.lists.${type}`), - })) + const current = getStoredDefaultView() + const choices = getDefaultViewChoices(translate) return ( + resourceDefaultViews.includes(defaultView) + +export const getDefaultViewChoices = (translate) => [ + ...Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })), + ...resourceDefaultViews.map((resource) => ({ + id: resource, + name: translate(`resources.${resource}.name`, { smart_count: 2 }), + })), +] + +export const getStoredDefaultView = () => + localStorage.getItem('defaultView') || defaultAlbumList diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js new file mode 100644 index 000000000..44057a736 --- /dev/null +++ b/ui/src/personal/defaultViews.test.js @@ -0,0 +1,48 @@ +import { + getDefaultViewChoices, + getStoredDefaultView, + isResourceDefaultView, + resourceDefaultViews, +} from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' + +describe('defaultViews', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('includes album lists and top-level resource lists as choices', () => { + const choices = getDefaultViewChoices((key, options) => + options?.smart_count ? `${key}:${options.smart_count}` : key, + ) + + expect(choices.map((choice) => choice.id)).toEqual([ + ...Object.keys(albumLists), + ...resourceDefaultViews, + ]) + expect(choices).toEqual( + expect.arrayContaining([ + { id: 'artist', name: 'resources.artist.name:2' }, + { id: 'song', name: 'resources.song.name:2' }, + { id: 'playlist', name: 'resources.playlist.name:2' }, + ]), + ) + }) + + it('identifies resource-backed default views', () => { + expect(isResourceDefaultView('artist')).toBe(true) + expect(isResourceDefaultView('song')).toBe(true) + expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('recentlyAdded')).toBe(false) + }) + + it('falls back to the default album list when no default view is stored', () => { + expect(getStoredDefaultView()).toBe(defaultAlbumList) + }) + + it('returns the stored default view', () => { + localStorage.setItem('defaultView', 'playlist') + + expect(getStoredDefaultView()).toBe('playlist') + }) +}) From ca27335d0671a08210fb7e4663ab21d7291ff651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 14 Jul 2026 07:38:25 -0400 Subject: [PATCH 07/14] feat(playlists): per-user starred/rating annotations (backend) (#5749) * feat(playlists): add average_rating column to playlist table * feat(playlists): store and read per-user starred/rating annotations * feat(playlists): clean up annotations when a playlist is deleted * feat(subsonic): route star/unstar of a playlist to the playlist repository * feat(subsonic): route setRating of a playlist to the playlist repository * test(subsonic): guard that playlist responses never expose annotations * fix(playlists): clean stale mis-typed annotations on upgrade; cover GetAll read-back * fix(playlists): scope annotation join by item_type and harden delete Address code-review findings on the playlist-annotations branch: - withAnnotation: add an item_type predicate to the LEFT JOIN so a mis-typed annotation row sharing an id can no longer leak into (or duplicate) another entity's read. Correct for every caller since each repo writes annotations with item_type = tableName. Regression test added. - migration: reclassify legacy media_file-typed rows for playlist ids to item_type='playlist' (instead of deleting them), preserving users' prior playlist star/rating; run before the average_rating backfill so those ratings are included. - playlist Delete: replace the per-request full-table cleanAnnotations() anti-join with a targeted, permission-safe (rows-affected gated), best-effort delete so a cleanup failure no longer misreports an already-committed delete as an error. - MockPlaylistRepo: implement GetAll/IncPlayCount/ReassignAnnotation to remove the dead All field and the nil-interface panic traps. - test: use slices.IndexFunc instead of a hand-rolled find loop. * feat(playlists): streamline playlist deletion by relying on annotation sweep * docs(playlists): trim comments in annotation migration and test Condense the verbose comments added in this branch per the project's comment-minimalism guideline, keeping only the non-obvious rationale. The migration's reclassify block is shortened while preserving the safety invariant (playlist and media_file ids never collide, so the item_type rewrite touches only mis-typed rows and cannot violate the unique key) and the ordering note. The redundant 'Populate average_rating' comment is dropped since the UPDATE is self-evident. The repository test's leakage comment is condensed to two lines. No code behavior changes. * refactor(subsonic): resolve setStar targets via GetEntityByID Replace setStar's Album/Artist/Playlist Exists probe chain with a single model.GetEntityByID lookup and a type switch, mirroring setRating. This removes three per-id existence queries and keeps the two annotation paths consistent. An id that resolves to no known entity is logged and skipped rather than filed as a spurious media_file annotation, and a lookup failure on one id no longer aborts the whole batch. Also drop a duplicate empty-ids guard. * refactor(playlists): drop no-op reclassify/backfill from migration The average_rating migration carried two data-fix UPDATEs that are no-ops on any real database: - The media_file->playlist reclassification only matches rows no released build ever created: playlists were never annotatable, so star/setRating of a playlist id was never written as item_type='playlist'. Any stray media_file-typed row for a playlist id is already removed by the media_file annotation GC sweep (item_id not in media_file). - The average_rating backfill runs before any item_type='playlist' row can exist, so it can only ever write the default 0. Going forward SetRating keeps average_rating current via updateAvgRating. Reduce the migration to the column add/drop. * refactor(persistence): bind annotation join params, derive idField from tableName Address PR review: use Squirrel parameter binding for item_type/user_id in the shared withAnnotation join instead of string concatenation, and pass r.tableName+".id" from selectPlaylist so the join field stays consistent with the surrounding r.tableName usage. * fix(subsonic): surface datastore errors in setStar instead of skipping Address PR review: setStar swallowed every GetEntityByID error and continued, so a real datastore failure would still commit the transaction and emit a refresh event as if the star succeeded. Skip only on model.ErrNotFound (an unknown id); return any other error so the request fails and rolls back. * test(subsonic): assert absent JSON keys instead of substring matches Address PR review: substring checks are brittle ("starred" matches "starredAt", "rating" matches "userRating"). Unmarshal the response and assert the annotation keys are absent. * fix(subsonic): skip refresh broadcast when a star request changes nothing Address PR review (Codex): once setStar began skipping unknown ids, a request containing only unresolvable ids left the RefreshResource empty, which SendMessage serializes as a {*:*} wildcard that forces every client to refresh. Only broadcast when at least one id was actually starred. * fix(db): rebase playlist average_rating migration timestamp past master The 20260708011823 migration predated the newest migration merged to master (20260712211040_add_primary_key...), which Goose would silently skip on already-upgraded databases. Rename it to a current timestamp so it applies in order. --- ...0714120000_add_playlist_average_rating.sql | 5 + model/playlist.go | 3 + persistence/persistence.go | 1 + persistence/playlist_repository.go | 3 +- persistence/playlist_repository_test.go | 95 +++++++++++++++++++ persistence/sql_annotations.go | 3 +- server/subsonic/media_annotation.go | 62 ++++++------ server/subsonic/media_annotation_test.go | 58 +++++++++++ server/subsonic/playlists_test.go | 22 +++++ tests/mock_playlist_repo.go | 57 +++++++++++ 10 files changed, 279 insertions(+), 30 deletions(-) create mode 100644 db/migrations/20260714120000_add_playlist_average_rating.sql diff --git a/db/migrations/20260714120000_add_playlist_average_rating.sql b/db/migrations/20260714120000_add_playlist_average_rating.sql new file mode 100644 index 000000000..5db642986 --- /dev/null +++ b/db/migrations/20260714120000_add_playlist_average_rating.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE playlist DROP COLUMN average_rating; diff --git a/model/playlist.go b/model/playlist.go index dc549f039..262774aa7 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -10,6 +10,8 @@ import ( ) type Playlist struct { + Annotations `structs:"-"` + ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` Comment string `structs:"comment" json:"comment"` @@ -121,6 +123,7 @@ type Playlists []Playlist type PlaylistRepository interface { ResourceRepository + AnnotatedRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) Put(pls *Playlist, cols ...string) error diff --git a/persistence/persistence.go b/persistence/persistence.go index 1164eb70f..93f0e3e71 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -193,6 +193,7 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), + trace(ctx, "clean playlist annotations", func() error { return s.Playlist(ctx).(*playlistRepository).cleanAnnotations() }), trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }), trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }), trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 4152505d2..fe1f50689 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -203,8 +203,9 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, } func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user on user.id = owner_id"). + sel := r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") + return r.withAnnotation(sel, r.tableName+".id") } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index cfabd0983..c5b16b88f 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,11 +1,14 @@ package persistence import ( + "slices" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -71,6 +74,98 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("Annotations", func() { + var plsID string + + BeforeEach(func() { + pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + plsID = pls.ID + }) + + countAnnotations := func() int { + var count int + Expect(GetDBXBuilder().NewQuery( + "SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}"). + Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed()) + return count + } + + It("stores and reads back starred", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeTrue()) + Expect(p.StarredAt).ToNot(BeNil()) + }) + + It("stores and reads back rating and average_rating", func() { + Expect(repo.SetRating(4, plsID)).To(Succeed()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Rating).To(Equal(4)) + Expect(p.RatedAt).ToNot(BeNil()) + Expect(p.AverageRating).To(Equal(4.0)) + }) + + It("keeps annotations isolated per user", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), + model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) + otherRepo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + + p, err := otherRepo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + }) + + It("reads starred back through GetAll", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) + Expect(idx).To(BeNumerically(">=", 0)) + Expect(all[idx].Starred).To(BeTrue()) + }) + + It("does not leak an annotation row of another item_type sharing the playlist id", func() { + // Older builds (and the star fallthrough) can leave a media_file-typed row + // under a playlist id; the item_type-scoped join must not surface or dupe it. + _, err := GetDBXBuilder().NewQuery( + "INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)"). + Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + p, err := repo.Get(plsID) + Expect(err).ToNot(HaveOccurred()) + Expect(p.Starred).To(BeFalse()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + matches := 0 + for _, pl := range all { + if pl.ID == plsID { + matches++ + } + } + Expect(matches).To(Equal(1)) + }) + + It("relies on the annotation sweep, not Delete, to clean up annotations", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + Expect(repo.Delete(plsID)).To(Succeed()) + Expect(countAnnotations()).To(Equal(1)) + + Expect(repo.(*playlistRepository).cleanAnnotations()).To(Succeed()) + Expect(countAnnotations()).To(Equal(0)) + }) + }) + It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 78b7938a1..46ad6a0de 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -67,7 +67,8 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec query = query. LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - " AND annotation.user_id = '"+userID+"')"). + " AND annotation.item_type = ?"+ + " AND annotation.user_id = ?)", r.tableName, userID). Columns( "coalesce(starred, 0) as starred", "coalesce(rating, 0) as rating", diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index e8b0278c1..27170c11b 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "errors" "fmt" "math" "net/http" @@ -52,6 +53,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { case *model.Album: repo = api.ds.Album(ctx) resource = "album" + case *model.Playlist: + repo = api.ds.Playlist(ctx) + resource = "playlist" default: repo = api.ds.MediaFile(ctx) resource = "song" @@ -104,48 +108,50 @@ func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { - if len(ids) == 0 { - return nil - } - log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) if len(ids) == 0 { log.Warn(ctx, "Cannot star/unstar an empty list of ids") return nil } - event := &events.RefreshResource{} + log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + event := &events.RefreshResource{} + changed := false for _, id := range ids { - exist, err := tx.Album(ctx).Exists(id) + var repo model.AnnotatedRepository + var resource string + entity, err := model.GetEntityByID(ctx, tx, id) if err != nil { - return err - } - if exist { - err = tx.Album(ctx).SetStar(star, id) - if err != nil { + if !errors.Is(err, model.ErrNotFound) { return err } - event = event.With("album", id) + log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) continue } - exist, err = tx.Artist(ctx).Exists(id) - if err != nil { + switch entity.(type) { + case *model.Artist: + repo = tx.Artist(ctx) + resource = "artist" + case *model.Album: + repo = tx.Album(ctx) + resource = "album" + case *model.Playlist: + repo = tx.Playlist(ctx) + resource = "playlist" + default: + repo = tx.MediaFile(ctx) + resource = "song" + } + if err := repo.SetStar(star, id); err != nil { return err } - if exist { - err = tx.Artist(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("artist", id) - continue - } - err = tx.MediaFile(ctx).SetStar(star, id) - if err != nil { - return err - } - event = event.With("song", id) + event = event.With(resource, id) + changed = true + } + // Skip the broadcast when nothing changed: an empty RefreshResource + // serializes as a "{*:*}" wildcard, forcing every client to refresh. + if changed { + api.broker.SendMessage(ctx, event) } - api.broker.SendMessage(ctx, event) return nil }) if err != nil { diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 487335d1a..1b16dfc68 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -185,6 +185,64 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) }) }) + + Describe("Star/Unstar playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("stars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", true)) + }) + + It("unstars a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1") + + _, err := router.Unstar(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", false)) + }) + }) + + Describe("SetRating playlists", func() { + var plRepo *tests.MockPlaylistRepo + + BeforeEach(func() { + plRepo = tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) + ds.(*tests.MockDataStore).MockedPlaylist = plRepo + }) + + It("rates a playlist by dispatching to the Playlist repo", func() { + r := newGetRequest("id=pl-1", "rating=4") + + _, err := router.SetRating(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4)) + }) + }) + + Describe("Star with an unresolvable id", func() { + It("skips the id without broadcasting an empty (wildcard) refresh", func() { + r := newGetRequest("id=does-not-exist") + + _, err := router.Star(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.Events).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 1d5f6a70a..697dd5852 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -2,6 +2,7 @@ package subsonic import ( "context" + "encoding/json" "time" "github.com/navidrome/navidrome/conf" @@ -248,6 +249,27 @@ var _ = Describe("buildPlaylist", func() { }) }) }) + + Describe("annotation leakage", func() { + It("does not serialize starred/rating even when the model carries them", func() { + p := model.Playlist{ID: "pl-1", Name: "My Playlist"} + p.Starred = true + p.Rating = 5 + + resp := router.buildPlaylist(ctx, p) + + data, err := json.Marshal(resp) + Expect(err).ToNot(HaveOccurred()) + var fields map[string]any + Expect(json.Unmarshal(data, &fields)).To(Succeed()) + Expect(fields).ToNot(HaveKey("starred")) + Expect(fields).ToNot(HaveKey("starredAt")) + Expect(fields).ToNot(HaveKey("rating")) + Expect(fields).ToNot(HaveKey("userRating")) + Expect(fields).ToNot(HaveKey("averageRating")) + Expect(fields).ToNot(HaveKey("playCount")) + }) + }) }) var _ = Describe("UpdatePlaylist", func() { diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9b38ea5b5..b7df5361f 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -2,6 +2,7 @@ package tests import ( "errors" + "time" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" @@ -19,8 +20,11 @@ type MockPlaylistRepo struct { model.PlaylistRepository Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path + All model.Playlists Last *model.Playlist Deleted []string + Starred map[string]bool // itemID -> starred + Ratings map[string]int // itemID -> rating Err bool TracksRepo model.PlaylistTrackRepository } @@ -29,6 +33,14 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } +func (m *MockPlaylistRepo) SetData(pls model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(pls)) + m.All = pls + for i, p := range m.All { + m.Data[p.ID] = &m.All[i] + } +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -45,6 +57,13 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } +func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") @@ -79,6 +98,44 @@ func (m *MockPlaylistRepo) Delete(id string) error { return nil } +func (m *MockPlaylistRepo) SetStar(starred bool, ids ...string) error { + if m.Err { + return errors.New("error") + } + if m.Starred == nil { + m.Starred = map[string]bool{} + } + for _, id := range ids { + m.Starred[id] = starred + } + return nil +} + +func (m *MockPlaylistRepo) SetRating(rating int, id string) error { + if m.Err { + return errors.New("error") + } + if m.Ratings == nil { + m.Ratings = map[string]int{} + } + m.Ratings[id] = rating + return nil +} + +func (m *MockPlaylistRepo) IncPlayCount(string, time.Time) error { + if m.Err { + return errors.New("error") + } + return nil +} + +func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { + if m.Err { + return errors.New("error") + } + return nil +} + func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { return m.TracksRepo } From 3cd4f1eb24a0fb743bad0f1ad011168d6d50ffe3 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:40:55 +0800 Subject: [PATCH 08/14] fix(ui): update Chinese Simplified translation (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update Chinese (Simplified) translation * Update Chinese (Simplified) translation --------- Co-authored-by: Deluan Quintão --- resources/i18n/zh-Hans.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 63ea5cf60..21778506a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -6,7 +6,7 @@ "fields": { "albumArtist": "专辑艺人", "duration": "时长", - "trackNumber": "音轨号", + "trackNumber": "曲目序号", "playCount": "播放次数", "title": "标题", "artist": "艺人", @@ -22,6 +22,8 @@ "bitRate": "比特率", "bitDepth": "位深度", "sampleRate": "采样率", + "albumGain": "专辑增益", + "trackGain": "曲目增益", "channels": "声道", "disc": "碟片 %{discNumber}", "discSubtitle": "碟片副标题", @@ -142,7 +144,7 @@ "name": "用户", "fields": { "userName": "用户名", - "isAdmin": "是否管理员", + "isAdmin": "是否为管理员", "lastLoginAt": "上次登录", "lastAccessAt": "上次访问", "updatedAt": "更新于", @@ -623,11 +625,11 @@ "lastfmScrobbling": "启用 Last.fm 的个性化记录", "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", "replaygain": "回放增益", - "preAmp": "前置放大器 (dB)", + "preAmp": "回放增益 - 前置放大 (dB)", "gain": { - "none": "禁用增益", - "album": "使用专辑增益信息", - "track": "使用歌曲增益信息" + "none": "禁用", + "album": "使用专辑增益", + "track": "使用曲目增益" } } }, From fe6ac2e577f14090a2fb33a9e4c91c1bee6196a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 14 Jul 2026 11:46:37 -0400 Subject: [PATCH 09/14] feat(jellyfin): experimental Jellyfin Music API support (#5730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sharing): enable sharing by default Flip the EnableSharing default from false to true so new installations have the sharing feature available out of the box. Users can still disable it via the EnableSharing config option. The native API only registers the /share route when sharing is enabled, so the nativeapi tests that build the router without wiring a share service now explicitly disable sharing in their setup to avoid registering a route backed by a nil service. * feat(jellyfin): add config flag and URL path constant Adds the disabled-by-default Server.Jellyfin config option (Enabled, ServerName) and consts.URLPathJellyfinAPI, following the existing LastFM/ListenBrainz patterns. Later tasks will use these to mount the Jellyfin-compatible API router. * fix(jellyfin): default Jellyfin ServerName to "Navidrome" * feat(jellyfin): package skeleton with System handshake endpoints Adds server/jellyfin: the Router (mirrors server/subsonic and server/public), its Wire-friendly New(...) constructor, a chi routes() table, and the ok() JSON response helper. Implements the unauthenticated handshake surface Jellyfin clients probe first: GET /System/Info/Public, GET+POST /System/Ping, and GET /QuickConnect/Enabled (quick connect is unsupported, so it always reports disabled). The public info payload's Id must be stable across restarts (Jellyfin clients cache ServerId), so it reuses the get-or-create Property pattern already used for InsightsID in core/metrics/insights.go: fetch consts.JellyfinServerIDKey from the Property repository, generating and persisting a new UUID on first read. A dedicated key (rather than the existing InsightsID) keeps the anonymous telemetry identifier from being exposed on this unauthenticated endpoint. * test(jellyfin): cover ping and quickConnectEnabled handlers Both were added alongside the System/Info/Public handshake endpoint but had no direct test coverage. * fix(jellyfin): memoize stable server Id and cover get-or-create path * feat(jellyfin): wire and mount the router behind Jellyfin.Enabled Add jellyfin.New to the shared Wire provider set and a CreateJellyfinAPIRouter injector, then mount the router at /jellyfin in startServer(), gated by conf.Server.Jellyfin.Enabled, mirroring the existing LastFM/ListenBrainz router mounts. * feat(jellyfin): add Jellyfin DTOs and model mappers Adds BaseItemDto, QueryResult, UserItemDataDto, UserDto, AuthenticationResult, MediaSourceInfo, PlaybackInfoResponse, NameGuidPair and SessionInfo DTOs to server/jellyfin/dto, plus model-to-DTO mappers for songs, albums, artists and genres that later browse/stream/write endpoints will consume. * test(jellyfin): cover GenreToBaseItem mapper * feat(jellyfin): advertise Jellyfin-compatible version, brand ServerName with Navidrome version * fix(jellyfin): map LastPlayedDate and omit zero track/disc numbers * feat(jellyfin): authentication middleware and AuthenticateByName login * fix(jellyfin): reject empty login password, wire ServerName, add negative auth tests * refactor(jellyfin): use new(x) builtin instead of intPtr helper * feat(jellyfin): user views and current-user endpoints * feat(jellyfin): Items query engine, item detail, and latest Adds the /Items universal query endpoint, dispatching by IncludeItemTypes over albums/artists/songs/genres with ParentId, SearchTerm, Filters=IsFavorite, SortBy/SortOrder and StartIndex/Limit support, plus GET /Items/{itemId} and /Users/{userId}/Items/Latest. Reuses server/subsonic/filter builders (by artist/album/starred) instead of hand-rolled squirrel filters, and resolves sort keys per item type since each repository maps sort names to different real/aliased columns. Also adds the missing CountAll to tests.MockArtistRepo, exposed by this task (the mock embedded a nil ArtistRepository for it and would panic on use). * refactor(server): extract shared query filter builders to server/filter Moves server/subsonic/filter to server/filter so server/jellyfin can use the shared query-option builders without importing server/subsonic, enforcing the rule that no API package imports another API's package. * feat(jellyfin): full multi-library support with per-user access scoping Replace the single hardcoded "music" UserView with one CollectionFolder view per library the user can access, and scope every /Items browse query (albums, songs, artists, /Latest) to those libraries via server/filter's ApplyLibraryFilter/ApplyArtistLibraryFilter. ParentId is now disambiguated: a numeric value the user has access to is treated as a library scope (browsing a UserView), otherwise it falls through as an entity id (artist/album), which safely matches nothing rather than leaking another library's content. getItem now 404s when fetching an album or song outside the user's accessible libraries; artists are skipped (they can span multiple libraries) with a TODO. Genres remain unscoped since they're global tags, not per-library entities. * test(jellyfin): cover admin library-access path and drop nil test contexts * feat(jellyfin): Artists and Genres endpoints Add /Artists, /Artists/AlbumArtists, /Genres and /MusicGenres. Artists listing is library-scoped (defaulting to the user's accessible libraries, narrowed by an accessible ParentId), delegating access control to listArtists/ApplyArtistLibraryFilter. Genres are global and unscoped, matching the ML decision already made for listGenres. * refactor(jellyfin): share resolveLibraryScope between items and artists * feat(jellyfin): item image endpoint via artwork service * fix(jellyfin): let net/http sniff image Content-Type instead of forcing jpeg * feat(jellyfin): audio streaming and PlaybackInfo with library access control * feat(jellyfin): favorites and rating write-back with library access control Adds POST/DELETE handlers for /Users/{userId}/FavoriteItems/{itemId} and /Users/{userId}/Items/{itemId}/Rating. A shared resolveAnnotated helper probes album/artist/media file (mirroring getItem's order) and 404s before writing if the user lacks access to the album/song's library; artists are exempt since they span multiple libraries. Ratings are halved coming in (Jellyfin 0-10 -> Navidrome 0-5) to match the doubling in dto.UserData. Also teaches MockMediaFileRepo and MockArtistRepo's SetStar/SetRating (and MockAlbumRepo's, previously a no-op) to actually mutate the backing data so write-back can be asserted in tests. * feat(jellyfin): playback reporting and scrobbling Add /Sessions/Playing[/Progress|/Stopped] and /Sessions/Capabilities[/Full] handlers, backed by core/scrobbler.PlayTracker. A new withPlayer middleware resolves/registers a model.Player from the Emby DeviceId header (used directly as the stable player id, unlike Subsonic's cookie fallback) and injects it into the request context for scrobbling. The Stopped report calls ReportPlayback with IgnoreScrobble to end the now-playing session without double-counting, then calls Submit as the single source of the play-count increment and external scrobble. * feat(jellyfin): playlist read and write-back Adds POST /Playlists, GET/POST/DELETE /Playlists/{id}/Items. Tags each playlist item with PlaylistItemId (the entry's position within the playlist) so DELETE .../Items?EntryIds=... can remove a specific occurrence by the id core/playlists.RemoveTracks actually expects, rather than the song id used everywhere else. * test(jellyfin): cover empty Ids/EntryIds in playlist add/remove * feat(jellyfin): unknown-route logging, generic 500s, rating clamp, docs Hardening pass ahead of real client testing: unmatched routes and unsupported methods now return a logged, JSON 404 instead of chi's default plain-text response, so a missing endpoint a client needs is easy to spot in the logs. Internal errors (ffmpeg output, file paths, etc.) no longer leak into 500 response bodies -- a shared internalError helper logs the real error server-side and always returns a generic message. Inbound Jellyfin ratings are clamped to 0-10 before being halved into Navidrome's 0-5 scale, and /System/Ping now replies with a bare plain-text body as real Jellyfin servers do. Adds a README with an enable/curl walkthrough and known limitations. * docs(jellyfin): clarify public image endpoint and accessibleLibraryIDs comments * fix(jellyfin): case-insensitive path routing for Jellyfin client compatibility * refactor(server): extract case-insensitive path routing to a shared helper * fix(jellyfin): return User Policy and Configuration so Finamp completes login * fix(jellyfin): support Playlist type, multi-type Items queries, and PlayCount/DatePlayed sort Real Finamp requests break against three /Items query engine bugs: Playlist requests fell through to albums, multi-type IncludeItemTypes (e.g. Finamp's favorites screen) only returned the first requested type, and comma-separated SortBy lists (e.g. "DateCreated,SortName") were matched as one opaque string so they always fell through to the repo default. Also extends tests/mock_playlist_repo.go with SetData/GetAll so playlist listing is testable like the other mock repos. * feat(jellyfin): implement /socket WebSocket for real-time client sessions * fix(jellyfin): resolve library-view ids in getItem so clients can load the library * fix(jellyfin): serve direct file at /Items/{id}/File and accept ApiKey query param * fix(jellyfin): resolve playlist ids in getItem * fix(jellyfin): read lowercase ids/entryIds playlist params and add playlist-users endpoints * fix(jellyfin): include MediaSources with Size/Bitrate on tracks so clients show download size * fix(jellyfin): populate all required MediaSourceInfo bool/array fields to match Jellyfin * fix(jellyfin): hex-encode item ids at the API boundary for Jellyfin client compatibility Finamp (and presumably other clients) parses ids as radix-16, but Navidrome's base62 nanoids aren't valid hex and crash its queue packing. Hex-encode every id emitted by the Jellyfin API and decode every id received, keeping the transform reversible and stateless at the boundary rather than touching any model id. * fix(jellyfin): populate MediaStreams with the audio stream so clients can size/transcode * fix(jellyfin): support Ids batch-fetch in /Items so clients can fetch items by id * fix(jellyfin): do not disable Jellyfin server when disabling external services * fix(jellyfin): emit per-image ImageBlurHashes so clients de-dupe images and stop warning * fix(jellyfin): support playlist cover upload/delete and display via item image endpoints * feat(jellyfin): implement GET /Playlists/{id} for playlist visibility Finamp's playlist edit screen calls GET /Playlists/{id} to read the OpenAccess (public visibility) flag; we only had the sub-routes, so it 404'd and the edit screen failed to load. Return the Jellyfin PlaylistDto shape (OpenAccess from Public, empty Shares, media item ids). * fix(jellyfin): display playlist cover art Uploaded playlist covers never showed in clients for two reasons: the playlist BaseItemDto advertised no Primary ImageTag (so clients didn't know to fetch a cover), and the public image endpoint resolved artwork under the request's anonymous context, so a private playlist failed its visibility filter and fell back to the placeholder. Advertise the Primary image tag/blurhash on playlists, and resolve artwork under an elevated context (as core/artwork's cache warmer does). * fix(jellyfin): expand album/artist/playlist ids when building playlists 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. * fix(jellyfin): support playlist deletion via DELETE /Items/{id} Finamp deletes a playlist with DELETE /Items/{id}, which we didn't route, so deletion silently failed. Implement it via core/playlists.Delete (which enforces ownership and removes the cover file). Only playlists are deletable through this API; non-playlist ids return 404, non-owners 403. * test(jellyfin): add e2e suite harness + smoke tests * test(jellyfin): e2e for system, auth, routing, browsing, annotations * test(jellyfin): e2e for playlists (CRUD, expansion, cover) and item images * test(jellyfin): e2e for streaming, sessions, and multi-user access control * fix(jellyfin): artist search 500 (library filter leaked into FTS query) getArtists/listArtists reused the browse-path filters (notMissing + ApplyArtistLibraryFilter) for the search path, but artist Search expects a sole Eq{library_id} filter it can consume as a scope — artists have no library_id column, so the compound/join filter leaked into the FTS query and 500'd. Every Finamp artist search failed (the filter is applied even unscoped, since admins resolve to all library ids). Build search filters separately. Adds e2e search coverage. * feat(jellyfin): implement POST /Playlists/{id} to update name, visibility, tracks Finamp edits a playlist (make public, rename, reorder) via POST /Playlists/{id}, which we didn't route, so every edit 404'd. Implement it: Ids present -> replace track list (Create with existing id, preserving name); otherwise update Name/IsPublic via core/playlists.Update. Adds a shared playlistError helper (403/404/500) reused by deleteItem, plus e2e coverage. * docs(jellyfin): update README for playlists, images, id encoding, and e2e * fix(jellyfin): default album track listing to track order Browsing an album's tracks (Items?ParentId=&IncludeItemTypes=Audio) took only SongsByAlbum's filters and dropped its Sort, so tracks came back in arbitrary order. Default opts.Sort to the album (disc+track) order when browsing an album without an explicit SortBy — matching Subsonic's GetAlbum and real Jellyfin. An explicit SortBy still wins. * fix(jellyfin): filter items by AlbumArtistIds/ArtistIds An artist's page in Finamp sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist itself, but queryItems only honored ParentId, so an artist's albums and tracks came back unfiltered (every artist's content). Parse the artist-id params and apply AlbumsByArtistID (albums) / SongsByArtistID (tracks). Adds e2e coverage. * fix(jellyfin): only count a play past the scrobble threshold reportPlaybackStopped set IgnoreScrobble and force-submitted a play on every Stopped report, so a briefly-played track (e.g. an immediate skip) was marked played. Finamp sends Stopped on every track switch, so the threshold must be applied server-side. Let ReportPlayback's StateStopped logic decide (play + scrobble only past 50% of the track / 4-minute cap) instead. Subsonic differs because there the client gates submission. * fix(jellyfin): sort album tracks by track number when client sends IndexNumber SortBy Finamp's album view requests SortBy=ParentIndexNumber,IndexNumber,SortName (disc, track, name), but applySort didn't recognize ParentIndexNumber or IndexNumber and fell through to SortName, sorting tracks alphabetically by title. Map both keys to the album (disc+track) sort. The reversed-title fixture lets the e2e tell track order from title order. * fix(jellyfin): honor the isFavorite query param for favorites filtering Finamp's artist 'Favourite tracks' widget requests favorites via the standalone isFavorite=true query param, not Filters=IsFavorite, so the filter was ignored and non-favorited tracks were returned. Detect both forms. (The widget's reshuffling is Finamp's explicit SortBy=Random.) * feat(jellyfin): emit DateCreated (Date Added) on items BaseItemDto had no DateCreated, so clients showed 'No Date Added' and had nothing to sort 'Recently Added' by. Emit it as ISO 8601 from each entity's CreatedAt (the same field the recently_added sort uses) for songs, albums and artists. * fix(jellyfin): set ArtistItems/AlbumArtists on songs (Now Playing artist) SongToBaseItem only set Artists (names) and AlbumArtist, not the structured ArtistItems/AlbumArtists. Finamp's Now Playing screen reads ArtistItems and shows 'Unknown Artist' when it's absent. Populate both (track artist and album artist) as name+id pairs, mirroring AlbumToBaseItem. * docs(jellyfin): note synthetic blurhash as a follow-up Document that ImageBlurHashes are derived from the item id (a solid-color placeholder), not computed from the cover art like real Jellyfin, and outline what a proper implementation would take. * docs(jellyfin): note WebSocket events and favourited playlists as follow-ups * fix(jellyfin): filter the Artists page by role (album artist vs performer) /Artists and /Artists/AlbumArtists both called the same role-agnostic handler, so Navidrome's per-role artist entries (composers, arrangers, performers) all showed as Album Artists, and the two tabs were identical. Filter /Artists/AlbumArtists to RoleAlbumArtist and /Artists to RoleArtist (and the MusicArtist browse to album artists), via filter.ArtistsByRole. Verified live: Beatles Singles' album-artists dropped 138->5, composers excluded, performers (Billy Preston) show only under /Artists. * fix(jellyfin): sort playlists by name (missing Playlist sort mapping) applySort had no sortColumnsByType entry for the Playlist type, so SortBy=SortName was ignored and the Playlists screen showed them in the repo's default order. Map SortName/Name -> name (as Subsonic does) and DateCreated -> created_at. Verified live: case-insensitive alphabetical. * fix(jellyfin): read query params case-insensitively (Jellify support) Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase (parentId, albumArtistIds, artistIds, includeItemTypes), where Finamp sends PascalCase. Our handlers read fixed-case keys, so every Jellify filter/sort/paging param was silently dropped: - an artist's page listed albums and tracks from all artists - opening an album listed every album instead of its tracks Real Jellyfin binds query params case-insensitively (ASP.NET model binding), so add a normalizeQueryKeys middleware that folds every query key to lowercase once, and read params by their lowercase name. This also removes the scattered dual-case hacks (Ids/ids, IsFavorite, ContributingArtistIds, api_key/ApiKey, queryParam) that let this bug class through. Also infer the child type from an album parent: Jellify browses an album with only parentId (no IncludeItemTypes), and Jellyfin infers Audio from the parent; without it we fell back to listing all albums. Verified live against Finamp+Jellify and with 4 new e2e specs replaying Jellify's exact request shapes. * fix(jellyfin): advertise LocalAddress in the public system info handshake Jellify (and other @jellyfin/sdk clients) that connect by raw address fall back to HTTP when TLS isn't available, then adopt the handshake's LocalAddress as their server base URL. We never populated it, so Jellify's SDK `api` object was undefined and sign-in crashed with "Cannot read property 'configuration' of undefined" (getUserApi(api!) with an undefined api). Over an HTTPS connection Jellify uses connectionType=hostname and never reads LocalAddress, which is why it worked while an HTTPS proxy was in front. Populate LocalAddress from the request — scheme + host (honoring X-Forwarded-*), plus the /jellyfin mount path — matching real Jellyfin, which always sends it. Export server.ServerAddress for the resolution. * fix(jellyfin): separate "Featured On" from an artist's own discography Jellify's artist page fetches the discography via albumArtistIds and the "Featured On" section via contributingArtistIds, relying on the server to return disjoint sets. We collapsed albumArtistIds/artistIds/ contributingArtistIds into one AlbumsByArtistID filter, so an artist's own albums appeared in both sections. Add AlbumsByContributingArtistID — albums where the artist is a track artist but NOT the album artist — matching Jellyfin's ContributingArtistIds (in Artists, not in AlbumArtists), and route contributingArtistIds to it. * fix(jellyfin): accept a bare Authorization token for audio streaming Jellify's native player (react-native-nitro-player) authenticates the audio stream by setting a bare Authorization header carrying the raw access token ({ AUTHORIZATION: api.accessToken }), not the "MediaBrowser ... Token=" scheme parseEmbyAuth understands. tokenFromRequest didn't recognize it, so every /Audio/{id}/stream request from the native player 401'd and no audio played (the JS client still optimistically posted playback progress, masking it). Accept a bare (or "Bearer ") Authorization header, as real Jellyfin does. Verified live: the native player's exact request now streams 200. * fix(jellyfin): embed a self-authenticating stream URL in PlaybackInfo Jellify's native audio player (react-native-nitro-player / ExoPlayer) fetches the stream without forwarding any auth — captured request headers were only Connection/Icy-Metadata/Accept-Encoding/User-Agent, no Authorization and no api_key — so every /Audio/{id}/stream request 401'd and nothing played (the JS client still optimistically posted progress, masking it). Real Jellyfin returns stream URLs with the token embedded; we returned none, so Jellify fell back to a token-less DirectPlay URL. Populate MediaSources[0].TranscodingUrl with /Audio/{id}/universal?api_key=, which Jellify's player uses verbatim. Direct-play clients (Finamp builds its own /Items/{id}/File?ApiKey URL) ignore the field, so they're unaffected. * feat(jellyfin): serve per-item UserData (GET /UserItems/{id}/UserData) Jellify fetches this per item to render played/favourite indicators; we 404'd it. Resolve the item via the existing getItem resolver (which loads the caller's annotations and enforces the same library-access gate) and return its UserData, falling back to an empty-but-valid object for items without annotations (e.g. playlists). Also registers the legacy /Users/{userId}/Items/{itemId}/UserData spelling. * refactor(jellyfin): drop unused bare-Authorization token support Added mid-troubleshooting on the theory that Jellify's native player sends a bare Authorization header, but the debug capture showed it sends no auth header at all — the real fix was embedding api_key in PlaybackInfo's TranscodingUrl. No client reaches this path (Jellify JS uses the MediaBrowser scheme, Finamp uses X-Emby-Token, the native player uses the api_key URL), so remove it and its tests per YAGNI. Effectively reverts 4f8ac1e0. * feat(jellyfin): implement Similar endpoints via the external provider Add GET /Artists/{id}/Similar (related artists) and GET /Items/{id}/Similar (similar songs for a track, similar albums for an album, related artists for an artist), sourced from the same external.Provider (Last.fm etc.) that powers Subsonic's getArtistInfo2/getSimilarSongs. Only library-present artists are returned so each is navigable; provider errors and unknown ids degrade to an empty 200 result, so clients (Jellify) stop hammering these with 404 retries. Injects external.Provider into the Router (regenerated via make wire). * perf(jellyfin): make Similar endpoints non-blocking (bounded quick wait) Each /Similar request fetched from Last.fm synchronously (500ms-1.4s), and Jellify requests Similar for many items at once on the home/artist screens, so the whole screen stalled — a home pull-to-refresh dragged for seconds. Run the external lookup on a background context and return within a 500ms quick wait: a cached artist resolves instantly, a cold one returns empty now while the lookup finishes caching in the background, so a later load is fast and populated. Verified live: cold calls bounded at ~500ms (was up to 1.4s), warm calls 3-8ms. * fix(jellyfin): answer ManualPlaylistsFolder so the home stops stalling Jellify resolves its "playlists library" via IncludeItemTypes= ManualPlaylistsFolder, then lists playlists with ParentId set to that folder's id. We didn't recognize the type, so parseTypes fell back to MusicAlbum and returned the album list. Jellify's query then found no item with CollectionType=playlists and resolved undefined — which React Query rejects, retrying it in a backoff loop that stalled the home pull-to-refresh for ~5s (every response was fast server-side; the delay was the client's retries). Return a synthetic "playlists" folder (CollectionType=playlists) for the ManualPlaylistsFolder query, resolve ParentId= to the user's playlists, and give playlists a Path under "data" (Jellify drops playlists whose Path lacks it). Adds a Path field to BaseItemDto. * docs(jellyfin): note lyrics and InstantMix/sonic-similarity as follow-ups * fix(jellyfin): genre paging params and same-key casing collisions getGenres read StartIndex/Limit in PascalCase, which normalizeQueryKeys had already folded to lowercase, so genre paging was silently ignored; totals now come from the full (small) genre list instead of the page length. normalizeQueryKeys now merges values when two casings of a key collide, instead of nondeterministically keeping one. * fix(jellyfin): round ratings to the nearest star instead of truncating Rating is a nullable double 0-10 in Jellyfin's contract. Truncating integer division stored 9 as 4 stars, and both Rating=1 and fractional values (which failed integer parsing) became 0 — silently deleting the rating. Parse as float, round, and floor nonzero input at one star. * fix(jellyfin): apply Name/IsPublic sent together with a track replacement Jellyfin's UpdatePlaylist applies every provided field, but the Ids branch returned early, silently discarding a rename or visibility change sent in the same body (core/playlists.Create with an existing id ignores the name). * fix(jellyfin): don't rotate the stored server id on transient DB errors serverID treated any Property.Get error as "no id yet" and persisted a fresh UUID over JellyfinServerID, with sync.Once pinning it for the process lifetime — a busy DB or canceled request context on the first request would break every client's cached ServerId. Only ErrNotFound mints a new id now, and failures yield an uncached temporary value so the next request retries. * fix(jellyfin): report real search totals instead of page length or unfiltered counts Artist search returned TotalRecordCount = len(page), so clients stopped after the first page; album/song search counted via CountAll, which can't see the search term, so clients paged through phantom results. The repos' Search API has no match count, so fetch one row beyond the page: offset+len is exact on the last page and a strictly growing lower bound before it — paging clients terminate exactly at the last match. * fix(jellyfin): browse playlists via the generic /Items path and resolve the playlists folder by id A typeless /Items?ParentId= (legal in real Jellyfin, used by generic clients) fell through to the MusicAlbum default and returned an empty list; it now returns the playlist's tracks, paginated, with visibility enforced by GetWithTracks. resolveItemByID also answers the synthetic playlists-folder id the server itself advertises instead of 404ing it. * perf(jellyfin): cap per-type queries in multi-type /Items requests The multi-type merge path queried each type with a zero-value QueryOptions — no LIMIT in SQL — materializing every matching row (each with embedded MediaSources) just to slice out one page in memory. Each type now fetches at most StartIndex+Limit rows, the worst case one type can contribute to the merged window; totals still come from CountAll. * fix(jellyfin): dedupe and bound the background Similar fetches awaitSimilar spawned a detached, deadline-free goroutine per request; clients re-polling after the empty quick-wait response piled up duplicate provider chains (no singleflight anywhere below) racing writes on the same artist row. Identical in-flight requests now share one fetch — keyed per user, since the mapped items embed the user's annotations — and the background context gets a one-minute deadline so a hung provider can't hold goroutines forever. * fix(jellyfin): gate private playlist covers on the public image route The unauthenticated image endpoint elevated every request to an admin context, so anyone who knew or guessed a playlist id could fetch another user's private uploaded cover. Library artwork still resolves elevated (Jellyfin clients fetch images without auth headers), but playlist covers are now served only when the playlist is public or the request's optional token identifies its owner or an admin; everyone else gets the placeholder. * fix(log): redact full api_key values, including JWTs The api_key pattern matched only word characters, stopping at a JWT's first '.' — the Jellyfin API embeds the session JWT as api_key in TranscodingUrl, so request logs kept its payload and signature, enough to reconstruct a replayable token by prepending the constant header. Match to the next query separator instead, like the sibling s=/p=/jwt= patterns. * fix(jellyfin): record LastLoginAt on Jellyfin logins authenticateByName re-implements credential validation and skipped the UpdateLastLoginAt call the web UI's validateLogin makes, so users who only log in via Jellyfin clients showed a never/stale Last Login in the admin UI. * perf(jellyfin): batch song resolution in /Items?ids= and playlist expansion Both paths probed up to four repositories per client-supplied id. Songs — the common case — now resolve via chunked media_file.id IN queries (same pattern as playqueue's loadTracks); only the residue pays the container probes. * fix(jellyfin): wait for the real Similar result instead of answering a cacheable empty list The 500ms quick wait returned an empty 200 for any cold lookup — indistinguishable from "no similar items exist", so clients cached the wrong answer until an app restart. With fetches deduplicated, wait up to the agents' HTTP timeout for the actual result; only a hung provider now yields the empty fallback, and its fetch still warms the cache in the background. Also makes the dedup test deterministic (the old one raced its release channel). * refactor(tests): extract the shared e2e harness into tests/harness The Subsonic and Jellyfin e2e suites each carried their own copy of the golden-DB lifecycle (boot, seed users/library, scan, WAL snapshot), the ATTACH-DATABASE restore, fixture-FS registration, and the SpyStreamer / NoopFFmpeg doubles. Those now live in one importable package (same pattern as core/storage/storagetest); fixture libraries and request helpers stay per-suite since they encode each API's test expectations. * docs(jellyfin): make comments concise Compress the narrative comments accumulated during live client testing into short why-only notes; keep the client quirks, security rationales and gotchas, drop the exposition. Comments-only change (net -141 lines). * fix(tests): silence gosec taint false-positive in harness snapshot write The write moved from a _test.go file (which gosec skips) into the importable harness package; the path derives from GinkgoT().TempDir(). * fix(jellyfin): route the current /UserFavoriteItems favorite endpoint @jellyfin/sdk 0.13.0 (used by Jellify) posts favorites to POST/DELETE /UserFavoriteItems/{itemId}, while we only routed the legacy /Users/{userId}/FavoriteItems/{itemId} (Finamp). Jellify favorites 404'd ("Failed to add favourite"). Route both spellings to the same handlers. * feat(jellyfin): honor Fields on /Items and add missing conformance fields Match real Jellyfin's response shape: gate MediaSources (and MediaStreams) behind Fields=MediaSources instead of always embedding them — clients that need Size request it, as Finamp does — which cuts a 46-track artist response from ~87KB to a fraction. Also emit the always-present fields Jellyfin sets: ServerId (stamped centrally in ok), LocationType, HasLyrics, and SortName (when Fields=SortName). PlaybackInfo still carries MediaSources (its purpose). * fix(jellyfin): address code review security and correctness findings Applies the reviewed findings from PR #5730: - Gate similar songs/albums on the caller's library access, so the external provider can't surface metadata from libraries the user cannot see. Also clamp the client-supplied limit before it sizes any allocation or provider fetch (CodeQL user-controlled allocation). - Prepend the /jellyfin mount prefix to the PlaybackInfo TranscodingUrl, so a client resolving it as an absolute host path still reaches the mounted router. - Recognize WebP and GIF magic numbers on raw cover uploads, matching the formats Navidrome already supports. - Bound the playlist cover upload: honor EnableArtworkUpload for non-admins and cap the body with MaxBytesReader/MaxImageUploadSize, mirroring the native image endpoint. - Propagate non-not-found repository errors from resolveAnnotated as 500 instead of silently returning 404. - Normalize the literal prefix of mixed literal.param path segments (e.g. STREAM.mp3) so case-insensitive routing reaches stream.{container}. - Rate-limit POST /Users/AuthenticateByName with the same per-IP limiter as /auth/login when AuthRequestLimit is set. - Guard against a nil user in authenticateByName. * fix(jellyfin): correct playlist track browsing and multi-id edits Fixes three playlist issues, two found testing against Jellify and one from the PR review: - Resolve a playlist ParentId to its tracks even when the client sends IncludeItemTypes=Audio. Jellify opens a playlist with ParentId=&IncludeItemTypes=Audio; the id was routed through listSongs as an album id, returning an empty list. - Read repeated id query params (ids=X&ids=Y), not just the first value. Jellify's @jellyfin/sdk serializes id arrays as repeated params, so adding an album (which it expands client-side into many ids) only added the first track. Applies to both add and remove; the comma-separated form other clients use still works. - Let an explicit empty Ids array clear a playlist. Ids is now a pointer so an omitted field still means 'leave unchanged', while an empty list clears the tracks (via RemoveTracks, since the repository skips track writes for an empty list). * fix(jellyfin): register clients as players on any authenticated request Jellyfin clients did not appear in the players list. Unlike Subsonic, whose getPlayer middleware runs on every authenticated endpoint, the Jellyfin router only registered a player on the /Sessions/Playing reports, so browsing or streaming never created one. Apply withPlayer to the whole authenticated group, mirroring Subsonic, so the calling device registers (and scrobbling has a player) as soon as it makes any authenticated request. Two follow-ups found while testing: - Skip registration when the request carries no client/device info (no X-Emby-Authorization, e.g. the /socket handshake that auths via ?api_key= only), which otherwise created a junk player named ' []'. - URL-decode the X-Emby-Authorization field values. Jellify's @jellyfin/sdk percent-encodes them (Device='Pixel%208%20Pro') while Finamp sends them raw, so the player name showed as 'Jellify [Pixel%208%20Pro]'. * refactor(jellyfin): move case-insensitive routing into the jellyfin package The case-insensitive path normalization lived in the server package but was only ever used by the Jellyfin router (its whole purpose is that Jellyfin clients route case-insensitively while chi does not). Move it into server/jellyfin and unexport it, so it sits with its only caller and no longer needs to be exported across a package boundary. * docs(jellyfin): document player registration, playlist and image behavior Updates the package README for the behavior added/fixed this round: - New 'Players and sessions' section: any authenticated request now registers the device as a player (like Subsonic), with the Client [Device] naming, URL-decoding of the Emby auth fields, and the /socket skip that avoids a nameless player. - Authentication: note AuthenticateByName is rate-limited per IP. - Playlists: repeated vs comma-separated id params, and that an explicit empty Ids clears the playlist while an omitted Ids leaves it untouched. - Cover art: WebP/GIF magic-number detection plus the MaxImageUploadSize and EnableArtworkUpload gates. - Endpoints table: add the /Similar and /UserFavoriteItems / /UserItems/.../UserData routes that were already served but unlisted. * feat(jellyfin): expose configured users on the login user-picker Adds Jellyfin.ExposedPublicUsers, a comma-separated allowlist of usernames that GET /Users/Public advertises so Jellyfin clients (Finamp, Jellify) can show a login user-picker instead of a blank username field. The endpoint is unauthenticated, so it defaults to exposing no users and never lists the full user table: only the admin-configured names are returned, resolved live per request (a name that doesn't exist is skipped and logged). Each entry is a minimal DTO (Name, Id) with no Policy/Configuration, so admin status isn't leaked pre-login, and no avatar since Navidrome has no per-user profile images. * style(jellyfin): trim redundant comments Remove comments that restated the code or duplicated an explanation already given nearby, keeping the ones that capture non-obvious rationale (client-specific quirks, gotchas). Comment-only change; no behavior difference. * perf(db): keep query planner statistics trustworthy with full ANALYZE PRAGMA optimize's internal ANALYZE runs with a limited analysis budget (~2000 rows) that writes wrong sqlite_stat1 entries for low-cardinality indexes: on a 96K-track library it claimed (missing, library_id) narrows to ~2000 rows when it matches the whole table. The planner then prefers that index over the sort index and falls back to a full-table temp B-tree sort per request, turning paginated song listings into multi-second queries (reproduced at 5.5s on real hardware; ~90x slower than with correct stats). Every index-creating migration re-triggered the poisoning via the post-migration optimize, and the daily optimizer could re-trigger it on large library changes. Setting analysis_limit on the connection does not help: optimize ignores it. Run a plain full ANALYZE instead: after migrations with schema changes, and in db.Optimize (daily schedule and scan-end). Stats are stored in the database file, so one connection suffices and the per-connection pool loop is gone. The Optimize call at shutdown is removed: stats are maintained at migration/scan/daily points, and an ANALYZE during shutdown only delays it and races container stop timeouts. * perf(db): add covering index for title-sorted song listings Deep pagination over songs sorted by title (WHERE missing/library_id, ORDER BY order_title LIMIT/OFFSET - the shape Jellyfin clients use to enumerate the library, and non-admin native/Subsonic song lists share) walked media_file_order_title and fetched the table row for every skipped entry just to evaluate the filter and the annotation/bookmark join keys: offset+limit random reads, seconds per page on cold spinning disks. The new (missing, library_id, order_title, id) index makes the offset skip fully index-resident: filter columns and the join key come from the index, and only the emitted page touches table rows. Measured on a 96K-track library: offset 50000 drops from ~6.5s (poisoned stats) / ~100ms (good stats, warm) to 24ms, and cold deep pages on NAS hardware from ~7s to ~0.5s. * feat(jellyfin): support transcoding via HLS playlist and server-forced player format - withPlayer now propagates the player's configured transcoding into the request context (like Subsonic's getPlayer), so a format forced in Settings > Players applies to the Jellyfin stream endpoints - new GET /Audio/{itemId}/main.m3u8, the endpoint Finamp plays through when its transcoding setting is enabled: a single-segment HLS VOD playlist pointing at the existing progressive transcode endpoint - streamAudio: treat audioBitRate as bits/sec (Jellyfin convention) and fall back to audioCodec as target format when no container is given * fix(jellyfin): honor GenreIds when browsing genre albums and tracks Finamp's genre screen sends ParentId= plus GenreIds=, but /Items ignored the param, so every genre returned the whole library. - filter.ByGenreID delegates to persistence.TagIDFilter (exported, was tagIDFilter), the same mechanism behind the native API's genre_id filter - id lists are read via queryIDs, covering both spellings clients use (comma-separated and repeated params); /Items?ids= gains the repeated form too * feat(jellyfin): filter album artists by GenreIds Finamp's artist tab sends GenreIds to /Artists/AlbumArtists when a genre filter is active; the param was ignored, returning all artists. filter.ArtistsByGenreID matches artists credited as album artist on an album with the genre, via a non-correlated semi-join over album participants (86ms on a 29K-artist library; the correlated EXISTS form takes 11 minutes). Applied on the browse path of /Artists* and /Items?IncludeItemTypes=MusicArtist. The performers variant (/Artists?GenreIds=) still ignores the filter: it needs the same semi-join against media_file.participants, unmeasured on large libraries. * fix(jellyfin): version playlist image tag so clients refresh uploaded covers Uploads were stored and served correctly, but Finamp kept showing the old cover: it caches covers keyed by blurHash (its imageId is the item id, which never changes), and both our image tag and synthetic blurhash were derived from the playlist id alone. The tag is now - (SetImage/RemoveImage go through a full Put, which bumps UpdatedAt), and the blurhash derives from the tag, so every cover change rotates both. UpdatedAt over-invalidates (any playlist edit busts the cover cache), which only costs a refetch; hashing the actual image remains the proper long-term tag. An e2e test guards the whole chain, since a partial Put(pls, cols...) would silently stop bumping UpdatedAt. * fix(jellyfin): align cover upload limit and validation with the native endpoint Cover uploads of large photos failed with a silent 400: the MaxImageUploadSize cap was applied to the wire body, which Jellyfin clients base64-encode (4/3 inflation), so the effective raw-image limit was only ~7.5MB — and Finamp uploads picked photos uncompressed. Align with the native endpoint: - the limit caps the decoded image; the read cap allows for base64 inflation - validate by decoding (image.DecodeConfig) and take the storage extension from the real format instead of the Content-Type header, which clients get wrong (Finamp falls back to image/jpeg); a HEIC or corrupt file is now rejected instead of stored as a broken cover - rejected uploads log the reason (size/limit/decode error); diagnosing this from production logs previously required guesswork * fix(jellyfin): optimize SongsByArtistID filter to improve performance at library scale Signed-off-by: Deluan * fix(jellyfin): sort tracks by release year for SortBy=PremiereDate Finamp's "Latest Releases" artist section sends SortBy=PremiereDate,Album,... descending; PremiereDate wasn't in the Audio sort map, so applySort fell through to Album and the view came back in reverse album-name order (a 2002 remix album first, the 2013 release last). Map premieredate/productionyear to the year sort key, matching the ProductionYear the DTO exposes for songs and the existing MusicAlbum mapping (premieredate -> max_year). * feat(jellyfin): expose PremiereDate on tracks and albums Finamp's "Latest Releases" artist/genre sections re-sort the merged server responses client-side by PremiereDate and keep the top 5. Without the field every comparison returns equal and Dart's unstable sort leaves the picks in arbitrary order — a 2007 remix could lead the list even with the server sorting by year correctly. Serialize PremiereDate as ISO 8601 from the date tag (padding partial "2007"/"2007-02" values so DateTime.tryParse accepts them), falling back to the year; omitted when neither exists. * feat(jellyfin): resolve Finamp-truncated item ids (saved queue restore) Finamp persists its play queue by packing every item id into exactly 16 bytes (packIds assumes Jellyfin's 32-hex GUID ids), so our longer ids come back truncated after an app restart and "Failed to restore queue" loops forever: the /Items?ids= batch resolves nothing. Navidrome ids can't be made GUID-shaped (nanoid ids can exceed 128 bits), so compensate server-side: a 16-char id — a length no Navidrome id family uses — is resolved by unique-prefix range scan, with ambiguity failing safe. The ids= batch echoes the id as requested (Finamp matches restored items by its stored ids), and stream, image, item, user-data, favorite, rating and playback-report endpoints accept truncated ids transparently. The proper fix belongs upstream in Finamp's packIds; documented in the README so this layer can be removed once that ships. * feat(jellyfin): implement Items/{id}/InstantMix Finamp requests an instant mix on every track tap when its "start instant mix for individual tracks" setting is on (plus the long-press menus); the 404 made those taps fail with an error and play nothing. A track seed returns itself first — Finamp plays exactly what comes back — followed by the external provider's similar songs, capped at the requested limit and filtered to the caller's libraries. Container seeds (artist/album) return the provider's similar-songs blend. Provider errors and unknown seeds degrade to seed-only/empty results instead of 404s, reusing the Similar endpoints' bounded-wait singleflight (with a distinct cache key, since mixes and similar lists answer different shapes). Sonic-similarity backing stays a follow-up (see README). * style(jellyfin): trim wordy comments * refactor(jellyfin): apply cleanup review findings - batch truncated-id resolution in /Items?ids=: one chunked range query for all media-file prefixes instead of a query per id (a restored queue sends hundreds of truncated ids) - resolve truncated ids on the /Similar endpoints too, matching the neighboring InstantMix; document which entry points don't resolve - hoist maxImageUploadSize to core, deleting the byte-identical copies in nativeapi and jellyfin (tests moved to core) - drop the unused type parameter on premiereDate * fix(jellyfin): never drop the instant mix seed on a slow provider The seed track was built inside the awaited provider fetch, so when the external agent was slow or unreachable the request hit the 10s wait and answered a fully empty mix — Finamp then played nothing on tap, even though the seed needs no provider at all (seen live: Last.fm unreachable from the server, responseSize=49). Build the seed outside the await: only the similar-songs tail is fetched and bounded, and a timeout now degrades to a seed-only mix. Also folds instantMixForSong into getInstantMix, since the tail is exactly similarSongs. * fix(jellyfin): prefer the recommended Authorization scheme when picking a token Jellyfin's authorization guidance deprecates X-Emby-Token, X-MediaBrowser-Token, X-Emby-Authorization and api_key; the Authorization MediaBrowser scheme is the recommended form. All spellings stay accepted, but when a client sends several, the recommended one now wins. Adds coverage for the canonical Authorization header, which no test exercised directly. * refactor(jellyfin): rename parseEmbyAuth to parseMediaBrowserAuth The scheme is named MediaBrowser; the old name evoked the deprecated X-Emby-* spellings even though the function also parses the recommended Authorization header. * fix(jellyfin): prefer the Authorization header over X-Emby-Authorization The recommended header now wins when both carry MediaBrowser data — but only when it actually parses as MediaBrowser: a reverse proxy may inject Basic/Digest credentials into Authorization while the client sends the deprecated header, and those must not swallow the client's auth. * fix(jellyfin): require the MediaBrowser scheme when parsing auth headers The parser extracted key="value" pairs from any Authorization value; a foreign scheme whose parameters happened to use our field names would have been misread as client auth. Validate the scheme word instead (case-insensitively, per HTTP), accepting the legacy "Emby" spelling like real Jellyfin. Replaces the any-recognized-field heuristic for detecting proxy-injected Basic/Digest credentials. * Revert "perf(db): keep query planner statistics trustworthy with full ANALYZE" This reverts commit 118563e1053196f0e2e42dd4bee54e39a04ab145. The planner-statistics work now lives in its own PR (#5740); this branch keeps only the covering-index migration. * fix(db): renumber jellyfin covering-index migration after master's latest * fix(db): renumber Jellyfin covering-index migration * docs(jellyfin): document playlist annotations * refactor(log): clarify comments on external services query params * refactor(e2e): move Subsonic e2e suite to server/subsonic/e2e All files in server/e2e were Subsonic tests, so relocate the package under server/subsonic/e2e to sit alongside the API it exercises. The package name stays 'e2e'; only doc/comment references are updated. * refactor(filter): consolidate genre filters and unexport tagIDFilter Route filterByGenre, ByGenreID, and ArtistsByGenreID through a single genreTagFilter helper so the EXISTS json_tree(tags,"$.genre") predicate lives in one place. With server/filter no longer using it, persistence's TagIDFilter is only referenced within the package, so unexport it. --------- Signed-off-by: Deluan --- cmd/root.go | 3 + cmd/wire_gen.go | 26 +- cmd/wire_injectors.go | 8 + conf/configuration.go | 11 + consts/consts.go | 5 + core/image_upload.go | 13 + core/image_upload_test.go | 26 + ...d_media_file_title_sort_covering_index.sql | 22 + log/log.go | 6 +- log/log_test.go | 5 + persistence/playlist_repository.go | 7 +- persistence/playlist_repository_test.go | 14 + persistence/sql_tags.go | 1 + server/{subsonic => }/filter/filters.go | 65 +- server/jellyfin/README.md | 329 ++++++++++ server/jellyfin/annotations.go | 131 ++++ server/jellyfin/annotations_test.go | 257 ++++++++ server/jellyfin/api.go | 200 ++++++ server/jellyfin/api_test.go | 87 +++ server/jellyfin/auth.go | 134 ++++ server/jellyfin/auth_test.go | 103 +++ server/jellyfin/browsing.go | 53 ++ server/jellyfin/browsing_test.go | 161 +++++ server/jellyfin/case_insensitive_routes.go | 68 ++ .../jellyfin/case_insensitive_routes_test.go | 90 +++ server/jellyfin/dto/blurhash.go | 36 ++ server/jellyfin/dto/blurhash_test.go | 27 + server/jellyfin/dto/dto.go | 258 ++++++++ server/jellyfin/dto/dto_suite_test.go | 17 + server/jellyfin/dto/fields.go | 24 + server/jellyfin/dto/ids.go | 23 + server/jellyfin/dto/ids_test.go | 35 + server/jellyfin/dto/mappers.go | 256 ++++++++ server/jellyfin/dto/mappers_test.go | 280 ++++++++ server/jellyfin/e2e/annotations_test.go | 142 ++++ server/jellyfin/e2e/auth_test.go | 120 ++++ server/jellyfin/e2e/browsing_test.go | 389 +++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 365 +++++++++++ server/jellyfin/e2e/images_test.go | 74 +++ server/jellyfin/e2e/multiuser_test.go | 64 ++ server/jellyfin/e2e/playlists_test.go | 311 +++++++++ server/jellyfin/e2e/routing_test.go | 31 + server/jellyfin/e2e/search_test.go | 76 +++ server/jellyfin/e2e/sessions_test.go | 62 ++ server/jellyfin/e2e/similar_test.go | 134 ++++ server/jellyfin/e2e/smoke_test.go | 49 ++ server/jellyfin/e2e/streaming_test.go | 128 ++++ server/jellyfin/e2e/system_test.go | 55 ++ server/jellyfin/images.go | 170 +++++ server/jellyfin/images_test.go | 394 ++++++++++++ server/jellyfin/items.go | 577 +++++++++++++++++ server/jellyfin/items_test.go | 608 ++++++++++++++++++ server/jellyfin/jellyfin_suite_test.go | 25 + server/jellyfin/library.go | 44 ++ server/jellyfin/middlewares.go | 187 ++++++ server/jellyfin/middlewares_test.go | 254 ++++++++ server/jellyfin/playlists.go | 257 ++++++++ server/jellyfin/playlists_test.go | 424 ++++++++++++ server/jellyfin/routing_test.go | 56 ++ server/jellyfin/sessions.go | 115 ++++ server/jellyfin/sessions_test.go | 216 +++++++ server/jellyfin/similar.go | 190 ++++++ server/jellyfin/similar_test.go | 131 ++++ server/jellyfin/socket.go | 55 ++ server/jellyfin/socket_test.go | 125 ++++ server/jellyfin/stream.go | 164 +++++ server/jellyfin/stream_test.go | 316 +++++++++ server/jellyfin/system.go | 96 +++ server/jellyfin/system_test.go | 124 ++++ server/jellyfin/truncated_ids.go | 113 ++++ server/jellyfin/users.go | 61 ++ server/jellyfin/users_test.go | 130 ++++ server/middlewares.go | 8 +- server/nativeapi/image_upload.go | 13 +- server/nativeapi/image_upload_test.go | 34 - server/subsonic/album_lists.go | 2 +- server/subsonic/browsing.go | 2 +- server/{ => subsonic}/e2e/doc.go | 2 +- server/{ => subsonic}/e2e/e2e_suite_test.go | 174 +---- .../e2e/subsonic_album_lists_test.go | 0 .../e2e/subsonic_bookmarks_test.go | 0 .../e2e/subsonic_browsing_test.go | 0 .../e2e/subsonic_lyrics_test.go | 0 .../e2e/subsonic_media_annotation_test.go | 0 .../e2e/subsonic_media_retrieval_test.go | 0 .../e2e/subsonic_multilibrary_test.go | 0 .../e2e/subsonic_multiuser_test.go | 0 .../e2e/subsonic_playlists_test.go | 0 .../{ => subsonic}/e2e/subsonic_radio_test.go | 0 .../{ => subsonic}/e2e/subsonic_scan_test.go | 0 .../e2e/subsonic_searching_test.go | 0 .../e2e/subsonic_sharing_test.go | 0 .../e2e/subsonic_sonic_similarity_test.go | 5 +- .../e2e/subsonic_stream_test.go | 0 .../e2e/subsonic_system_test.go | 0 .../e2e/subsonic_transcode_test.go | 0 .../{ => subsonic}/e2e/subsonic_users_test.go | 0 tests/harness/harness.go | 178 +++++ tests/mock_album_repo.go | 8 + tests/mock_artist_repo.go | 29 + tests/mock_mediafile_repo.go | 22 + tests/mock_playlist_repo.go | 24 +- 102 files changed, 9880 insertions(+), 234 deletions(-) create mode 100644 db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql rename server/{subsonic => }/filter/filters.go (59%) create mode 100644 server/jellyfin/README.md create mode 100644 server/jellyfin/annotations.go create mode 100644 server/jellyfin/annotations_test.go create mode 100644 server/jellyfin/api.go create mode 100644 server/jellyfin/api_test.go create mode 100644 server/jellyfin/auth.go create mode 100644 server/jellyfin/auth_test.go create mode 100644 server/jellyfin/browsing.go create mode 100644 server/jellyfin/browsing_test.go create mode 100644 server/jellyfin/case_insensitive_routes.go create mode 100644 server/jellyfin/case_insensitive_routes_test.go create mode 100644 server/jellyfin/dto/blurhash.go create mode 100644 server/jellyfin/dto/blurhash_test.go create mode 100644 server/jellyfin/dto/dto.go create mode 100644 server/jellyfin/dto/dto_suite_test.go create mode 100644 server/jellyfin/dto/fields.go create mode 100644 server/jellyfin/dto/ids.go create mode 100644 server/jellyfin/dto/ids_test.go create mode 100644 server/jellyfin/dto/mappers.go create mode 100644 server/jellyfin/dto/mappers_test.go create mode 100644 server/jellyfin/e2e/annotations_test.go create mode 100644 server/jellyfin/e2e/auth_test.go create mode 100644 server/jellyfin/e2e/browsing_test.go create mode 100644 server/jellyfin/e2e/e2e_suite_test.go create mode 100644 server/jellyfin/e2e/images_test.go create mode 100644 server/jellyfin/e2e/multiuser_test.go create mode 100644 server/jellyfin/e2e/playlists_test.go create mode 100644 server/jellyfin/e2e/routing_test.go create mode 100644 server/jellyfin/e2e/search_test.go create mode 100644 server/jellyfin/e2e/sessions_test.go create mode 100644 server/jellyfin/e2e/similar_test.go create mode 100644 server/jellyfin/e2e/smoke_test.go create mode 100644 server/jellyfin/e2e/streaming_test.go create mode 100644 server/jellyfin/e2e/system_test.go create mode 100644 server/jellyfin/images.go create mode 100644 server/jellyfin/images_test.go create mode 100644 server/jellyfin/items.go create mode 100644 server/jellyfin/items_test.go create mode 100644 server/jellyfin/jellyfin_suite_test.go create mode 100644 server/jellyfin/library.go create mode 100644 server/jellyfin/middlewares.go create mode 100644 server/jellyfin/middlewares_test.go create mode 100644 server/jellyfin/playlists.go create mode 100644 server/jellyfin/playlists_test.go create mode 100644 server/jellyfin/routing_test.go create mode 100644 server/jellyfin/sessions.go create mode 100644 server/jellyfin/sessions_test.go create mode 100644 server/jellyfin/similar.go create mode 100644 server/jellyfin/similar_test.go create mode 100644 server/jellyfin/socket.go create mode 100644 server/jellyfin/socket_test.go create mode 100644 server/jellyfin/stream.go create mode 100644 server/jellyfin/stream_test.go create mode 100644 server/jellyfin/system.go create mode 100644 server/jellyfin/system_test.go create mode 100644 server/jellyfin/truncated_ids.go create mode 100644 server/jellyfin/users.go create mode 100644 server/jellyfin/users_test.go delete mode 100644 server/nativeapi/image_upload_test.go rename server/{ => subsonic}/e2e/doc.go (98%) rename server/{ => subsonic}/e2e/e2e_suite_test.go (75%) rename server/{ => subsonic}/e2e/subsonic_album_lists_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_bookmarks_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_browsing_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_lyrics_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_media_annotation_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_media_retrieval_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_multilibrary_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_multiuser_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_playlists_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_radio_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_scan_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_searching_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_sharing_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_sonic_similarity_test.go (98%) rename server/{ => subsonic}/e2e/subsonic_stream_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_system_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_transcode_test.go (100%) rename server/{ => subsonic}/e2e/subsonic_users_test.go (100%) create mode 100644 tests/harness/harness.go diff --git a/cmd/root.go b/cmd/root.go index b231aae0d..9e2b38cd8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -124,6 +124,9 @@ func startServer(ctx context.Context) func() error { if conf.Server.ListenBrainz.Enabled { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } + if conf.Server.Jellyfin.Enabled { + a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) + } if conf.Server.Prometheus.Enabled { p := CreatePrometheus() // blocking call because takes <100ms but useful if fails diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d6ffc44d4..4a2b46289 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -31,6 +31,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -116,6 +117,29 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { return router } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + players := core.NewPlayers(dataStore) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider) + return router +} + func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -221,7 +245,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index bb5c5b5f3..0f6b73891 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -23,6 +23,7 @@ import ( "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -33,6 +34,7 @@ var allProviders = wire.NewSet( artwork.Set, server.New, subsonic.New, + jellyfin.New, nativeapi.New, public.New, persistence.New, @@ -79,6 +81,12 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { )) } +func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { + panic(wire.Build( + allProviders, + )) +} + func CreatePublicRouter() *public.Router { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index e939ebb7e..562f52465 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -117,6 +117,7 @@ type configOptions struct { LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` + Jellyfin jellyfinOptions `json:",omitzero"` EnableScrobbleHistory bool Tags map[string]TagConf `json:",omitempty"` Agents string @@ -218,6 +219,14 @@ type listenBrainzOptions struct { TrackAlgorithm string } +type jellyfinOptions struct { + Enabled bool + ServerName string + // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated + // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. + ExposedPublicUsers string +} + type httpHeaderOptions struct { FrameOptions string } @@ -849,6 +858,8 @@ func setViperDefaults() { viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) + viper.SetDefault("jellyfin.enabled", false) + viper.SetDefault("jellyfin.servername", "") viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/consts/consts.go b/consts/consts.go index 73f89b450..f453ac125 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -49,6 +49,11 @@ const ( URLPathSubsonicAPI = "/rest" URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" + URLPathJellyfinAPI = "/jellyfin" + + // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the + // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. + JellyfinServerIDKey = "JellyfinServerID" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome diff --git a/core/image_upload.go b/core/image_upload.go index c2432b647..eb61b225a 100644 --- a/core/image_upload.go +++ b/core/image_upload.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -17,6 +20,16 @@ type ImageUploadService interface { RemoveImage(ctx context.Context, path string) error } +// MaxImageUploadSize returns the configured MaxImageUploadSize in bytes, or the built-in default +// when it's unset/invalid. Shared by every API that accepts image uploads. +func MaxImageUploadSize() int64 { + if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return int64(size) + } + size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + return int64(size) +} + type imageUploadService struct{} func NewImageUploadService() ImageUploadService { diff --git a/core/image_upload_test.go b/core/image_upload_test.go index 265f60a95..e7648df34 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -97,3 +97,29 @@ var _ = Describe("ImageUploadService", func() { }) }) }) + +var _ = Describe("MaxImageUploadSize", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("returns the configured size when valid", func() { + conf.Server.MaxImageUploadSize = "20MB" + Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) + }) + + It("returns the default size when config is empty", func() { + conf.Server.MaxImageUploadSize = "" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("returns the default size when config is invalid", func() { + conf.Server.MaxImageUploadSize = "not-a-size" + Expect(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + }) + + It("parses raw byte values", func() { + conf.Server.MaxImageUploadSize = "52428800" + Expect(core.MaxImageUploadSize()).To(Equal(int64(52_428_800))) + }) +}) diff --git a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql new file mode 100644 index 000000000..18666eef9 --- /dev/null +++ b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin + +-- Covering index for the title-sorted, library-scoped song listing: +-- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m +-- (Jellyfin clients page through the whole library this way; non-admin native and +-- Subsonic song lists produce the same shape.) +-- +-- Without it, SQLite walks media_file_order_title and must fetch the table row for +-- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit +-- random row reads (seconds on cold spinning disks). With the filter columns in the +-- index the skip is index-only. `id` is included because the annotation/bookmark +-- LEFT JOINs run per candidate row and need the join key; without it each skipped +-- entry still triggers a row fetch. +create index if not exists media_file_missing_library_order_title + on media_file(missing, library_id, order_title, id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +drop index if exists media_file_missing_library_order_title; +-- +goose StatementEnd diff --git a/log/log.go b/log/log.go index eaea75fb9..1c4ee3b4b 100644 --- a/log/log.go +++ b/log/log.go @@ -45,8 +45,10 @@ var redacted = &Hook{ "([^\\w]p=)[^&]+", "([^\\w]jwt=)[^&]+", - // External services query params - "([^\\w]api_key=)[\\w]+", + // External services query params. Values can be JWTs (dots, dashes), so match everything up + // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop + // at a JWT's first '.' and leak its payload and signature. + "([^\\w]api_key=)[^&\\s]+", }, } diff --git a/log/log_test.go b/log/log_test.go index 7e1f3f3cc..7b6ecfc32 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -259,5 +259,10 @@ var _ = Describe("Logger", func() { msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title" Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title")) }) + + It("redacts a whole JWT in api_key, not just up to its first dot", func() { + msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) + }) }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index fe1f50689..573f43c10 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -85,8 +85,11 @@ func (r *playlistRepository) userFilter() Sqlizer { } func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sq := Select().Where(r.userFilter()) - return r.count(sq, options...) + query := Select().Where(r.userFilter()) + if filtersNeedAnnotation(r.applyFilters(query, options...)) { + query = r.withAnnotation(query, "playlist.id") + } + return r.count(query, options...) } func (r *playlistRepository) Exists(id string) (bool, error) { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c5b16b88f..831e24453 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "slices" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -132,6 +133,19 @@ var _ = Describe("PlaylistRepository", func() { Expect(all[idx].Starred).To(BeTrue()) }) + It("counts playlists using annotation filters", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} + starred, err := repo.GetAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + + count, err := repo.CountAll(options) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(len(starred)))) + }) + It("does not leak an annotation row of another item_type sharing the playlist id", func() { // Older builds (and the star fallthrough) can leave a media_file-typed row // under a playlist id; the item_type-scoped join must not surface or dupe it. diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 88acebb7f..5177bc8e4 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,7 @@ func marshalTags(tags model.Tags) string { return string(res) } +// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "_id" key maps to "$.". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") return Exists( diff --git a/server/subsonic/filter/filters.go b/server/filter/filters.go similarity index 59% rename from server/subsonic/filter/filters.go rename to server/filter/filters.go index d19e163dd..e149dbced 100644 --- a/server/subsonic/filter/filters.go +++ b/server/filter/filters.go @@ -61,6 +61,19 @@ func AlbumsByArtistID(artistId string) Options { }) } +// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the +// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an +// artist's own discography never leaks into it. +func AlbumsByContributingArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "max_year", + Filters: And{ + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + persistence.NotExists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + }, + }) +} + func AlbumsByYear(fromYear, toYear int) Options { orderOption := "" if fromYear > toYear { @@ -90,6 +103,17 @@ func SongsByAlbum(albumId string) Options { }) } +// SongsByArtistID matches media files where the artist participates as album or track artist, in +// album order. Semi-joins media_file_artists; scanning the participants JSON is ~10x slower at scale. +func SongsByArtistID(artistId string) Options { + return addDefaultFilters(Options{ + Sort: "album", + Filters: Expr( + "media_file.id IN (SELECT media_file_id FROM media_file_artists WHERE artist_id = ? AND role IN (?, ?))", + artistId, model.RoleArtist.String(), model.RoleAlbumArtist.String()), + }) +} + func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { options := Options{} ff := And{} @@ -138,6 +162,21 @@ func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { return opts } +// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist, +// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter). +func ArtistsByRole(opts Options, role model.Role) Options { + if _, ok := model.AllRoles[role.String()]; !ok { + return opts + } + roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL") + if opts.Filters == nil { + opts.Filters = roleFilter + } else { + opts.Filters = And{opts.Filters, roleFilter} + } + return opts +} + func ByGenre(genre string) Options { return addDefaultFilters(Options{ Sort: "name", @@ -145,11 +184,29 @@ func ByGenre(genre string) Options { }) } +// ByGenreID matches items (albums or songs) tagged with any of the given genre tag ids. +func ByGenreID(genreIds []string) Sqlizer { + return genreTagFilter(Eq{"value": genreIds}) +} + +// ArtistsByGenreID matches artists credited as album artist on an album with any of the given +// genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. +func ArtistsByGenreID(genreIds []string) Sqlizer { + return Expr( + `artist.id IN (SELECT jt.value FROM album, json_tree(album.participants, '$.albumartist') jt + WHERE jt.atom IS NOT NULL AND ?)`, + genreTagFilter(Eq{"value": genreIds}), + ) +} + +// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry +// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. +func genreTagFilter(cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +} + func filterByGenre(genre string) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{ - Like{"value": genre}, - NotEq{"atom": nil}, - }) + return genreTagFilter(Like{"value": genre}) } func ByRating() Options { diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md new file mode 100644 index 000000000..fb5c4a637 --- /dev/null +++ b/server/jellyfin/README.md @@ -0,0 +1,329 @@ +# Jellyfin API + +This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of +Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets +Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), +[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +requiring a real Jellyfin server. + +It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music +library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report +playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's +admin/dashboard APIs are out of scope. + +## Enabling + +The Jellyfin API is disabled by default. Enable it via `navidrome.toml`: + +```toml +[Jellyfin] +Enabled = true +# Optional: override the server name reported to clients (defaults to "Navidrome ") +ServerName = "My Music Server" +# Optional: usernames to show in the client login user-picker (default: none). See "Public user list". +ExposedPublicUsers = "alice, bob" +``` + +or via environment variables: + +```bash +ND_JELLYFIN_ENABLED=true +ND_JELLYFIN_SERVERNAME="My Music Server" +ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" +``` + +Once enabled, the API is mounted at: + +``` +http://:/jellyfin +``` + +All the paths below are relative to that base URL (e.g. `System/Info/Public` means +`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, +since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. + +## Authentication + +Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome +username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on +every subsequent request as the `X-Emby-Token` header (or embedded in the +`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey` +query param — all forms are accepted, matching what different clients do). + +`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native +`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force +surface. + +### Public user list (login picker) + +`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the +password) instead of a blank username field. It's **unauthenticated**, so by default it exposes +**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise: + +```toml +[Jellyfin] +ExposedPublicUsers = "alice, bob" +``` + +Only the named users are listed (never the full user table), resolved live per request; a configured +name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`) +with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no +avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images). + +## Players and sessions + +Every authenticated request registers (or refreshes) the calling device as a Navidrome player, +mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling +has a player) as soon as it makes any authenticated call, not only when it reports playback. The +player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is +`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them +(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no +client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is +skipped, so it doesn't create a nameless player. + +## ID encoding + +Navidrome item ids are **hex-encoded at the API boundary** (`dto.EncodeID`/`DecodeID`): every id +is hex-encoded on the way out and hex-decoded on the way in. This is required because some clients +parse ids as radix-16 — Finamp's queue `packIds`, for instance, does `int.parse(chunk, radix:16)`, +which chokes on Navidrome's base-62 nanoids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a raw MD5 id +from an old migrated library is itself valid hex, correctness depends on every emit path encoding +and every receive path decoding — see `dto/ids.go`. + +## Multi-library behavior + +Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each +Navidrome library the current user can access is exposed as its own top-level Jellyfin +"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. +Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the +authenticated user has access to; a library (or item within it) the user cannot access returns +`404`, never `403`, so ids can't be used as an existence oracle. + +### Browsing filters + +`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id +for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into +an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or +tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `GenreIds` (a +genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` +and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); +`SearchTerm`; +favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). + +## Implemented endpoints + +| Area | Endpoints | +|---|---| +| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | +| 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` | +| 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` | +| 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}]` | +| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | + +Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level +as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint +that isn't in the table above, check the server logs for these lines to see exactly what it's +requesting. + +## Playlist management + +Playlists are the main writable surface of this API: + +- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`) + or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album, + artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before + the write, matching how Jellyfin clients populate these lists. A bare song id passes through. +- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients + spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a + single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one + track of an expanded album. +- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp + uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an + omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to + Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`. +- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body, + JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it. + Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, + so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're + bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins. +- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the + playlist-track row id, distinct from the song id) so a client can echo it back via + `DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more + than once in the same playlist. + +Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if +it is visible to them (public) or `404` if it is not (private) — the API never reveals that +someone else's private playlist exists. + +## Images + +The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive, +matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is +therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache +warmer uses — so user-scoped items like private playlists still resolve their cover instead of +falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to +their Navidrome `ArtworkID`. + +## Finamp saved-queue id truncation + +Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that +when persisting its play queue across restarts: `packIds()` bit-packs every id into exactly 16 +bytes. Navidrome ids are longer (nanoid ids can exceed 128 bits, so they cannot be mapped into +GUIDs), which means Finamp silently stores only the first 16 characters of each id and asks for +those **truncated ids** back when restoring the queue — item lookups, then streaming, images, +favorites and playback reports for the restored tracks. + +This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome +id family uses — is resolved to the full id by unique-prefix lookup (an indexed range scan; +ambiguity is detected and fails safe). The `/Items?ids=` batch response echoes the id **as +requested**, because Finamp matches restored items back to its stored ids, and the other item +endpoints accept truncated ids transparently. + +**Proper fix (upstream):** Finamp's `packIds()`/`_unpackIds()` (`lib/models/finamp_models.dart`) +should handle ids that aren't 32-hex GUIDs — e.g. store variable-length ids when any id in the +queue doesn't match the GUID shape. Jellyfin-compatible servers aren't guaranteed to use GUID ids, +so this is worth a Finamp issue/PR; once a fixed release is widespread, this compatibility layer +can be removed. + +## Streaming and transcoding + +The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint: + +- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the + `.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`. + `audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true` + forces direct play (raw), never a transcode. +- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin. + Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g. + DSF) can't be rescued server-side on this path. +- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting + is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive + transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. + Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`, + `mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic + transcoded streams. +- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings → + Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as + Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are + advertised and served but packed-audio players won't decode them. + +## curl walkthrough + +This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the +library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist. + +```bash +BASE=http://localhost:4533/jellyfin + +# 1. Handshake (no auth required) +curl -s "$BASE/System/Info/Public" | jq . + +# 2. Login - capture the AccessToken +TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \ + -H 'Content-Type: application/json' \ + -d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken) + +AUTH=(-H "X-Emby-Token: $TOKEN") + +# 3. List the user's views (one per accessible library) +curl -s "${AUTH[@]}" "$BASE/UserViews" | jq . + +# 4. Browse artists +curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq . +ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id') + +# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum) +ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id') + +# 6. List the album's songs +USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id) +SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \ + | jq -r '.Items[0].Id') + +# 7. Ask for playback info, then stream the song +curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq . +curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio + +# 8. Favorite the song +curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq . + +# 9. Report playback start/stop (also drives scrobbling) +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing" +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped" + +# 10. Create a playlist from a whole album (the album id is expanded to its tracks) +PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id) + +# 11. Make it public, then remove one entry +curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ + -d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID" +ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId') +curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID" + +# 12. Delete the playlist +curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" +``` + +## Testing + +Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in +[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and +real repositories (only artwork/streaming/ffmpeg are stubbed), with per-`Describe` snapshot +isolation — mirroring the Subsonic `server/subsonic/e2e` suite. Run it with: + +```bash +make test PKG=./server/jellyfin/... +``` + +## Known limitations + +- **Genres are global.** `GET Genres`/`MusicGenres` is not scoped to the current user's + libraries (genre tags aren't per-library entities in Navidrome's model). +- **Artist item-access relies on list-time scoping.** Unlike albums and songs (which each + belong to exactly one library and are checked against `user.HasLibraryAccess` on every + fetch), an artist can have content across multiple libraries via `library_artist`, so there's + no single library id to gate a direct `GET Items/{artistId}` or favorite/rating call against. + Access control for artists is enforced by scoping the `Artists`/`Items?IncludeItemTypes=MusicArtist` + *list* to the user's libraries, plus the persistence layer's own defense-in-depth; a client + that already has an artist id from elsewhere is not re-checked against library membership. +- **MD5-hash ids from old migrated libraries.** The hex id codec assumes ids are opaque; a raw + 32-char MD5 id is itself valid hex and so must be encoded/decoded symmetrically like any other. + This is handled, but is the most fragile id case — see the note in `dto/ids.go`. +- **Blurhashes are synthetic, not computed from the artwork (follow-up).** `ImageBlurHashes` is + populated by `dto/blurhash.go`, which derives a well-formed **1-component (solid color)** + blurhash by hashing the item id — it never looks at the actual image. Real Jellyfin computes a + multi-component blurhash from the cover's pixels (downscaled to 128×128) once at scan time and + stores it per image, so its placeholder approximates the art. Ours satisfies the protocol + (Finamp gets a valid value to use as a de-dup key and a placeholder, no missing-blurhash + warning) but renders as a flat color while art loads. A proper implementation would compute the + real blurhash in the `core/artwork` pipeline (where the image is already decoded), cache it + keyed like the artwork, and have the mappers read it — keeping the synthetic value as a fallback + for art that hasn't been rendered yet. +- **The WebSocket only keep-alives; it pushes no events (follow-up).** `GET socket` sends a + `ForceKeepAlive` and answers `KeepAlive` pings so real-time clients (Finamp) settle into a + working session instead of 404-loop-reconnecting, but it never pushes anything. A follow-up + would broadcast real session/playstate and library-change events over it (via `server/events`), + mirroring Jellyfin's session messages. +- **No lyrics endpoint (follow-up).** `GET Audio/{id}/Lyrics` is unimplemented (404), but Finamp + and Jellify both request it. Navidrome already has line-synced lyrics, so a follow-up would serve + Jellyfin's `LyricsResponse` (`Lyrics: [{Text, Start}]`, `Start` in 100ns ticks) — enough for both + clients' synced view. (Finamp also renders word-level `Cues`, but Navidrome has only line-level + timing, so word-sync is out of scope.) +- **No sonic similarity (follow-up).** `Items/{id}/InstantMix` and the `/Similar` endpoints are + backed only by external metadata agents (Last.fm), not sonic analysis: an instant mix is the seed + track followed by the provider's similar songs (with agents disabled it degrades to a seed-only + mix). A follow-up would back them with Navidrome's `core/sonic` provider — the same one behind + the OpenSubsonic `sonicSimilarity` extension (`getSonicSimilarTracks`) that AudioMuse-AI feeds + via its Navidrome plugin, and the exact endpoint AudioMuse's own Jellyfin plugin overrides. + Needs the `core/sonic.Sonic` service injected into the `Router` (wire change). diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go new file mode 100644 index 000000000..8f900ee87 --- /dev/null +++ b/server/jellyfin/annotations.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "errors" + "math" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't +// access their library; artists span libraries (library_artist), so have no single LibraryID to +// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. +// When ok is false the response has already been written, so callers must return without writing +// the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.Album(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if _, err := api.ds.Artist(ctx).Get(id); err == nil { + return api.ds.Artist(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return api.ds.MediaFile(ctx), true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + playlistRepo := api.ds.Playlist(ctx) + if _, err := playlistRepo.Get(id); err == nil { + return playlistRepo, true + } else if !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, false + } + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false +} + +// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify +// fetches this per item to render played/favourite indicators; resolveItemByID enforces the +// library-access gate. +func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + item, ok := api.resolveItemByID(r.Context(), id, nil) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + data := item.UserData + if data == nil { + // Items without annotations still return a valid empty UserData. + data = dto.UserData(model.Annotations{}, id) + } + api.ok(w, r, data) +} + +func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetStar(starred, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) +} + +func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } +func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { + api.setFavorite(w, r, false) +} + +func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + repo, ok := api.resolveAnnotated(w, r, id) + if !ok { + return + } + if err := repo.SetRating(rating, id); err != nil { + api.internalError(w, r, err) + return + } + encodedID := dto.EncodeID(id) + d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} + if rating > 0 { + jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData + d.Rating = &jfRating + } + api.ok(w, r, d) +} + +// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to +// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since +// SetRating(0) is the delete path. +func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { + jfRating := req.Params(r).Float64Or("rating", 0) + jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating + rating := int(math.Round(jfRating / 2)) + if jfRating > 0 { + rating = max(rating, 1) + } + api.setItemRating(w, r, rating) +} + +func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { + api.setItemRating(w, r, 0) +} diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go new file mode 100644 index 000000000..4a7efabd7 --- /dev/null +++ b/server/jellyfin/annotations_test.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + var api *Router + var ds *tests.MockDataStore + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("markFavorite / unmarkFavorite", func() { + It("stars a song and returns IsFavorite=true", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(mfRepo.Data["s1"].Starred).To(BeTrue()) + }) + + It("stars an album and returns IsFavorite=true", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(albumRepo.Data["a1"].Starred).To(BeTrue()) + }) + + It("stars an artist without checking library access (artists span multiple libraries)", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + // alice only has access to library 1, but artists aren't gated per-library. + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "ar1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeTrue()) + Expect(artistRepo.Data["ar1"].Starred).To(BeTrue()) + }) + + It("stars a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Starred["p1"]).To(BeTrue()) + }) + + It("unstars a song and returns IsFavorite=false", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.unmarkFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.IsFavorite).To(BeFalse()) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Starred).To(BeFalse()) + }) + + It("returns 404 and does not star a song in a library the user can't access", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "x1") + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("setRating / removeRating", func() { + It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).NotTo(BeNil()) + Expect(*d.Rating).To(Equal(8.0)) + }) + + It("rates an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(5)) + }) + + It("rates a visible playlist", func() { + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "Mix", OwnerID: "u1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("p1")+"/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(playlistRepo.Ratings["p1"]).To(Equal(4)) + }) + + It("removes a rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.removeRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + var d dto.UserItemDataDto + Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) + Expect(d.Rating).To(BeNil()) + }) + + It("returns 404 and does not rate an album in a library the user can't access", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID("a1")+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(albumRepo.Data["a1"].Rating).To(Equal(0)) + }) + + It("rounds an odd rating to the nearest star instead of truncating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("stores the minimum star for Rating=1 instead of clearing the rating", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(1)) + }) + + It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + }) + + It("clamps a Rating above 10 to Navidrome's max (5)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + }) + + It("clamps a negative Rating to Navidrome's min (0)", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go new file mode 100644 index 000000000..fd523d154 --- /dev/null +++ b/server/jellyfin/api.go @@ -0,0 +1,200 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "golang.org/x/sync/singleflight" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type Router struct { + http.Handler + ds model.DataStore + artwork artwork.Artwork + streamer stream.MediaStreamer + transcodeDecider stream.TranscodeDecider + players core.Players + scrobbler scrobbler.PlayTracker + playlists playlists.Playlists + provider external.Provider + similarFlight singleflight.Group + serverIDMu sync.Mutex + serverIDVal string +} + +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, + transcodeDecider stream.TranscodeDecider, players core.Players, + scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider) *Router { + r := &Router{ + ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, + players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + } + r.Handler = r.routes() + return r +} + +func (api *Router) routes() http.Handler { + inner := chi.NewRouter() + + // Read query params case-insensitively, like real Jellyfin. Must precede all routes so every + // handler and the api_key check see folded keys. + inner.Use(normalizeQueryKeys) + + // Public (no auth): handshake + login. + inner.Get("/System/Info/Public", api.getPublicSystemInfo) + inner.Get("/System/Ping", api.ping) + inner.Post("/System/Ping", api.ping) + inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated + // brute-force surface, so it must share the same per-IP throttle when one is configured. + if conf.Server.AuthRequestLimit > 0 { + limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName) + } else { + inner.Post("/Users/AuthenticateByName", api.authenticateByName) + } + inner.Get("/Users/Public", api.getPublicUsers) + + // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. + inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + + inner.Group(func(r chi.Router) { + r.Use(api.authenticate) + // Register/refresh the calling device as a player on every authenticated request, like + // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a + // player) even before the first playback report. + r.Use(api.withPlayer) + r.Get("/UserViews", api.getUserViews) + r.Get("/Users/{userId}/Views", api.getUserViews) + r.Get("/Users/Me", api.getCurrentUser) + r.Get("/Users/{userId}", api.getCurrentUser) + + r.Get("/Items", api.getItems) + r.Get("/Users/{userId}/Items", api.getItems) + r.Get("/Items/{itemId}", api.getItem) + r.Get("/Users/{userId}/Items/{itemId}", api.getItem) + r.Delete("/Items/{itemId}", api.deleteItem) + r.Get("/Users/{userId}/Items/Latest", api.getLatest) + + // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the + // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. + r.Post("/UserFavoriteItems/{itemId}", api.markFavorite) + r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite) + r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite) + r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite) + r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating) + r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating) + + // Per-item play/favorite/rating state. Jellify uses the /UserItems form; + // /Users/{userId}/Items is the legacy spelling. + r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) + r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) + + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) + r.Get("/Items/{itemId}/Similar", api.getSimilarItems) + r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) + r.Get("/Genres", api.getGenres) + r.Get("/MusicGenres", api.getGenres) + + r.Post("/Playlists", api.createPlaylist) + r.Get("/Playlists/{playlistId}", api.getPlaylist) + r.Post("/Playlists/{playlistId}", api.updatePlaylist) + r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) + r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) + r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) + r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) + r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser) + + // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes + // above stay public. + r.Post("/Items/{itemId}/Images/{type}", api.postItemImage) + r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage) + + r.Get("/Audio/{itemId}/stream", api.streamAudio) + r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/Audio/{itemId}/universal", api.streamAudio) + r.Get("/Audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of + // /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.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) + + // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. + r.Get("/socket", api.handleSocket) + }) + + // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected + // traffic, and this just surfaces what's missing. + inner.NotFound(api.notFound) + inner.MethodNotAllowed(api.notFound) + + // Real Jellyfin clients route case-insensitively; chi does not. + return caseInsensitivePaths(inner) +} + +// ok writes payload as JSON, stamping ServerId on any item(s) in it — real Jellyfin always sets it, +// and it's the same value for every item, so it's applied here rather than threaded through mappers. +func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { + switch p := payload.(type) { + case dto.QueryResult: + api.stampServerID(r.Context(), p.Items) + case []dto.BaseItemDto: + api.stampServerID(r.Context(), p) + case dto.BaseItemDto: + p.ServerId = api.serverID(r.Context()) + payload = p + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := json.NewEncoder(w).Encode(payload); err != nil { + log.Error(r.Context(), "Jellyfin API: error encoding response", err) + } +} + +func (api *Router) stampServerID(ctx context.Context, items []dto.BaseItemDto) { + sid := api.serverID(ctx) + for i := range items { + items[i].ServerId = sid + } +} + +// notFound handles unmatched routes and unsupported methods, logging them so unimplemented +// endpoints surface instead of returning chi's default plain-text 404/405. +func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { + log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{}`)) +} + +// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output, +// file paths) never reaches the client. +func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) { + log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) +} diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go new file mode 100644 index 000000000..23504c73e --- /dev/null +++ b/server/jellyfin/api_test.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Router", func() { + It("serves the public handshake through the mounted handler", func() { + ds := &tests.MockDataStore{} + api := New(ds, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("returns 404 JSON for unknown routes", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("returns 404 JSON for a known path with an unsupported method", func() { + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Body.String()).To(Equal("{}")) + }) + + It("registers a player on a general authenticated request, not just playback reports", func() { + ds := &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(GinkgoT().Context()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + token, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + + fp := &fakePlayers{} + api := New(ds, nil, nil, nil, fp, nil, nil, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`) + r.Header.Set("X-Emby-Token", token) + api.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fp.registerCalls).To(Equal(1)) + Expect(fp.lastClient).To(Equal("Jellify")) + }) + + It("rate-limits AuthenticateByName by IP when a login limit is configured", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AuthRequestLimit = 2 + conf.Server.AuthWindowLength = time.Minute + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + + login := func() int { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) + r.RemoteAddr = "10.0.0.1:1234" + api.ServeHTTP(w, r) + return w.Code + } + // The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429. + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusUnauthorized)) + Expect(login()).To(Equal(http.StatusTooManyRequests)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go new file mode 100644 index 000000000..ecd129222 --- /dev/null +++ b/server/jellyfin/auth.go @@ -0,0 +1,134 @@ +package jellyfin + +import ( + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +type authenticateByNameRequest struct { + Username string `json:"Username"` + Pw string `json:"Pw"` +} + +func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body authenticateByNameRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. + usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username) + if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { + log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a + // never/stale "Last Login" in the admin UI. + if err := api.ds.User(ctx).UpdateLastLoginAt(usr.ID); err != nil { + log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) + } + + token, err := auth.CreateToken(usr) + if err != nil { + api.internalError(w, r, err) + return + } + + // SessionInfo is omitted, not partially filled: a stub {Id, UserId} could fail a strict client's + // parse, and Finamp's login doesn't need it (its AuthenticationResult.sessionInfo is nullable). + api.ok(w, r, dto.AuthenticationResult{ + User: userToDto(usr, api.serverName(), api.serverID(ctx)), + AccessToken: token, + ServerId: api.serverID(ctx), + }) +} + +// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after +// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults. +func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { + return &dto.UserDto{ + Name: u.UserName, + Id: u.ID, + ServerId: serverID, + ServerName: serverName, + HasPassword: true, + HasConfiguredPassword: true, + Policy: userPolicy(u), + Configuration: userConfiguration(), + } +} + +func userPolicy(u *model.User) *dto.UserPolicy { + return &dto.UserPolicy{ + IsAdministrator: u.IsAdmin, + IsHidden: false, + EnableCollectionManagement: false, + EnableSubtitleManagement: false, + EnableLyricManagement: false, + IsDisabled: false, + BlockedTags: []string{}, + AllowedTags: []string{}, + EnableUserPreferenceAccess: true, + AccessSchedules: []string{}, + BlockUnratedItems: []string{}, + EnableRemoteControlOfOtherUsers: false, + EnableSharedDeviceControl: false, + EnableRemoteAccess: true, + EnableLiveTvManagement: false, + EnableLiveTvAccess: false, + EnableMediaPlayback: true, + EnableAudioPlaybackTranscoding: true, + EnableVideoPlaybackTranscoding: true, + EnablePlaybackRemuxing: true, + ForceRemoteSourceTranscoding: false, + EnableContentDeletion: false, + EnableContentDeletionFromFolders: []string{}, + EnableContentDownloading: true, + EnableSyncTranscoding: true, + EnableMediaConversion: true, + EnabledDevices: []string{}, + EnableAllDevices: true, + EnabledChannels: []string{}, + EnableAllChannels: false, + EnabledFolders: []string{}, + EnableAllFolders: true, + InvalidLoginAttemptCount: 0, + LoginAttemptsBeforeLockout: -1, + MaxActiveSessions: 0, + EnablePublicSharing: true, + BlockedMediaFolders: []string{}, + BlockedChannels: []string{}, + RemoteClientBitrateLimit: 0, + AuthenticationProviderId: "", + PasswordResetProviderId: "", + SyncPlayAccess: "CreateAndJoinGroups", + } +} + +func userConfiguration() *dto.UserConfiguration { + return &dto.UserConfiguration{ + PlayDefaultAudioTrack: true, + SubtitleLanguagePreference: "", + DisplayMissingEpisodes: false, + GroupedFolders: []string{}, + SubtitleMode: "Default", + DisplayCollectionsView: false, + EnableLocalPassword: false, + OrderedViews: []string{}, + LatestItemsExcludes: []string{}, + MyMediaExcludes: []string{}, + HidePlayedInLatest: true, + RememberAudioSelections: true, + RememberSubtitleSelections: true, + EnableNextEpisodeAutoPlay: true, + CastReceiverId: "", + } +} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go new file mode 100644 index 000000000..b51420f1a --- /dev/null +++ b/server/jellyfin/auth_test.go @@ -0,0 +1,103 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AuthenticateByName", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + It("issues a token for valid credentials", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User.Name).To(Equal("alice")) + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Subject).To(Equal("alice")) + + // Finamp reads Policy/Configuration right after login and null-crashes if they're absent. + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) + Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) + Expect(res.User.Configuration).ToNot(BeNil()) + + // Ours is a partial SessionInfo; a strict client may fail to parse it, and Finamp's + // login doesn't require it, so it should be omitted entirely rather than sent partial. + Expect(res.SessionInfo).To(BeNil()) + }) + + It("records the login time, like the web UI login does", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr, err := ur.FindByUsername("alice") + Expect(err).ToNot(HaveOccurred()) + Expect(usr.LastLoginAt).ToNot(BeNil()) + }) + + It("reflects an administrator in the User.Policy", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "admin1", UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"root","Pw":"secret"}`)) + api.authenticateByName(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.AuthenticationResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.User.Policy).ToNot(BeNil()) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + }) + + It("rejects invalid credentials with 401", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password even for a user with an empty stored password with 401", func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "e", UserName: "empty", NewPassword: ""})).To(Succeed()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/AuthenticateByName", + strings.NewReader(`{"Username":"empty","Pw":""}`)) + api.authenticateByName(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go new file mode 100644 index 000000000..bf77c4594 --- /dev/null +++ b/server/jellyfin/browsing.go @@ -0,0 +1,53 @@ +package jellyfin + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists +// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers +// don't appear identically in both. +func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleArtist) +} + +func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { + api.listArtistsByRole(w, r, model.RoleAlbumArtist) +} + +// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library +// when accessible (like queryItems) or all accessible libraries otherwise. +func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) + + scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Finamp's artist tab sends GenreIds when a genre filter is active. + genreIds := decodedQueryIDs(r, "genreids") + + res, err := api.listArtists(ctx, opts, genreIds, scopeIDs, p.StringOr("searchterm", ""), false, role) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies. +func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} + res, err := api.listGenres(ctx, opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go new file mode 100644 index 000000000..7f50355e1 --- /dev/null +++ b/server/jellyfin/browsing_test.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Browsing", func() { + var api *Router + var ds *tests.MockDataStore + ctxUser := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + api = &Router{ds: ds} + }) + + Describe("getArtists", func() { + It("lists artists via /Artists", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("handles /Artists/AlbumArtists the same way", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("scopes results to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes to a single library when ParentId is an accessible library id", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(2)) + Expect(args).NotTo(ContainElement(1)) + }) + + It("does not let ParentId= narrow the scope", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Artists?ParentId=99", nil).WithContext(ctxUser(libs)) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Offset).To(Equal(5)) + Expect(artistRepo.Options.Max).To(Equal(10)) + }) + + It("does not restrict results for an admin user", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added. + if artistRepo.Options.Filters == nil { + return + } + sql, _, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) + }) + }) + + Describe("getGenres", func() { + It("lists genres via /Genres", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("handles /MusicGenres the same way", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getGenres, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) +}) diff --git a/server/jellyfin/case_insensitive_routes.go b/server/jellyfin/case_insensitive_routes.go new file mode 100644 index 000000000..cc95b526f --- /dev/null +++ b/server/jellyfin/case_insensitive_routes.go @@ -0,0 +1,68 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" +) + +// caseInsensitivePaths normalizes each request path's literal segments to the case they were +// registered with before delegating to r, since Jellyfin clients route case-insensitively but +// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id +// segments pass through untouched. +func caseInsensitivePaths(r chi.Router) http.Handler { + canon := canonicalRouteSegments(r) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + normalizeRequestPath(req, canon) + r.ServeHTTP(w, req) + }) +} + +// canonicalRouteSegments walks every registered route and records, for each literal (non-param) +// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. +// "audio" -> "Audio"). +func canonicalRouteSegments(router chi.Router) map[string]string { + canon := map[string]string{} + _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + for seg := range strings.SplitSeq(route, "/") { + if seg == "" || strings.Contains(seg, "{") { + continue + } + canon[strings.ToLower(seg)] = seg + } + return nil + }) + return canon +} + +// normalizeRequestPath rewrites literal path segments to the case routes were registered with. +// It must run before chi's matching. When the router is mounted under a parent, chi has already +// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so +// that's what must be normalized here. +func normalizeRequestPath(r *http.Request, canon map[string]string) { + if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) + return + } + r.URL.Path = normalizeCase(r.URL.Path, canon) +} + +// normalizeCase rewrites each "/"-separated literal segment of path to the case it was +// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. +// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"), +// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case +// the extension so chi's case-sensitive match still hits. +func normalizeCase(path string, canon map[string]string) string { + segs := strings.Split(path, "/") + for i, seg := range segs { + if canonical, ok := canon[strings.ToLower(seg)]; ok { + segs[i] = canonical + } else if prefix, suffix, found := strings.Cut(seg, "."); found { + if canonical, ok := canon[strings.ToLower(prefix)]; ok { + segs[i] = canonical + "." + strings.ToLower(suffix) + } + } + } + return strings.Join(segs, "/") +} diff --git a/server/jellyfin/case_insensitive_routes_test.go b/server/jellyfin/case_insensitive_routes_test.go new file mode 100644 index 000000000..4c9140f83 --- /dev/null +++ b/server/jellyfin/case_insensitive_routes_test.go @@ -0,0 +1,90 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID string + + var gotContainer string + + BeforeEach(func() { + gotID = "" + gotContainer = "" + r := chi.NewRouter() + r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + // A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream" + // literal prefix is registered separately via the bare /Foo/{id}/stream route below. + r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + It("normalizes the literal prefix of a mixed literal.param segment", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("matches a lower-cased request path against mixed-case registered literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("preserves the id segment's original casing", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/ID/bar", nil) + handler.ServeHTTP(w, r) + Expect(gotID).To(Equal("ID")) + }) + + It("leaves a real mixed-case id untouched while still matching literals", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) + handler.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) + }) +}) + +var _ = Describe("normalizeCase", func() { + It("rewrites known literal segments to their canonical case", func() { + canon := map[string]string{ + "audio": "Audio", + "stream": "stream", + } + got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) + }) + + It("normalizes the literal prefix of a mixed literal.extension segment", func() { + canon := map[string]string{"audio": "Audio", "stream": "stream"} + got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon) + Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3")) + }) + + It("leaves a dotted segment untouched when its prefix isn't a known literal", func() { + canon := map[string]string{"audio": "Audio"} + got := normalizeCase("/audio/some.file.id", canon) + Expect(got).To(Equal("/Audio/some.file.id")) + }) +}) diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go new file mode 100644 index 000000000..aaf6ff2af --- /dev/null +++ b/server/jellyfin/dto/blurhash.go @@ -0,0 +1,36 @@ +package dto + +import "hash/fnv" + +// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec. +const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// base83 encodes value as a fixed-width, big-endian base83 string of the given length. +func base83(value, length int) string { + b := make([]byte, length) + for i := 1; i <= length; i++ { + digit := (value / pow83(length-i)) % 83 + b[i-1] = base83Alphabet[digit] + } + return string(b) +} + +func pow83(n int) int { + result := 1 + for range n { + result *= 83 + } + return result +} + +// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a +// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so +// a solid color unique to the tag satisfies both without decoding cover art. +func blurHash(seed string) string { + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + sum := h.Sum(nil) + r, g, b := int(sum[0]), int(sum[1]), int(sum[2]) + dc := (r << 16) | (g << 8) | b + return "00" + base83(dc, 4) +} diff --git a/server/jellyfin/dto/blurhash_test.go b/server/jellyfin/dto/blurhash_test.go new file mode 100644 index 000000000..a6e36131d --- /dev/null +++ b/server/jellyfin/dto/blurhash_test.go @@ -0,0 +1,27 @@ +package dto + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("blurHash", func() { + It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() { + h := blurHash("x") + Expect(h).To(HaveLen(6)) + Expect(h).To(HavePrefix("00")) + for _, c := range h { + Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("is deterministic for the same seed", func() { + Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1"))) + }) + + It("differs for different seeds", func() { + Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2"))) + }) +}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go new file mode 100644 index 000000000..7720640c5 --- /dev/null +++ b/server/jellyfin/dto/dto.go @@ -0,0 +1,258 @@ +package dto + +// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public). +type PublicSystemInfo struct { + LocalAddress string `json:"LocalAddress,omitempty"` + ServerName string `json:"ServerName"` + Version string `json:"Version"` + ProductName string `json:"ProductName"` + OperatingSystem string `json:"OperatingSystem,omitempty"` + Id string `json:"Id"` + StartupWizardCompleted bool `json:"StartupWizardCompleted"` +} + +// SystemInfo is the authenticated variant (GET /System/Info). +type SystemInfo struct { + PublicSystemInfo + HasPendingRestart bool `json:"HasPendingRestart"` + IsShuttingDown bool `json:"IsShuttingDown"` + SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"` + CachePath string `json:"CachePath,omitempty"` +} + +type NameGuidPair struct { + Name string `json:"Name"` + Id string `json:"Id"` +} + +type UserItemDataDto struct { + Rating *float64 `json:"Rating,omitempty"` + PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` + PlayCount int `json:"PlayCount"` + IsFavorite bool `json:"IsFavorite"` + Played bool `json:"Played"` + Key string `json:"Key"` + ItemId string `json:"ItemId,omitempty"` + LastPlayedDate *string `json:"LastPlayedDate,omitempty"` +} + +type BaseItemDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + Id string `json:"Id"` + // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), + // distinct from Id so a song appearing more than once can be removed by occurrence + // (DELETE .../Items?EntryIds=...) rather than by song id. + PlaylistItemId string `json:"PlaylistItemId,omitempty"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + MediaType string `json:"MediaType,omitempty"` + CollectionType string `json:"CollectionType,omitempty"` + LocationType string `json:"LocationType,omitempty"` + HasLyrics bool `json:"HasLyrics,omitempty"` + SortName string `json:"SortName,omitempty"` + Path string `json:"Path,omitempty"` + ParentId string `json:"ParentId,omitempty"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + IndexNumber *int `json:"IndexNumber,omitempty"` + ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` + ProductionYear *int `json:"ProductionYear,omitempty"` + // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. + PremiereDate *string `json:"PremiereDate,omitempty"` + // DateCreated is the ISO 8601 date the item was added to the library; clients show it as + // "Date Added" and sort "Recently Added" by it. + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` + // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a + // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't + // calculating blurhashes. + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + BackdropImageTags []string `json:"BackdropImageTags"` + UserData *UserItemDataDto `json:"UserData,omitempty"` + MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` + Container string `json:"Container,omitempty"` + CanDownload bool `json:"CanDownload"` +} + +// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which +// Finamp probes before allowing playlist edits. +type PlaylistUserPermissions struct { + UserId string `json:"UserId"` + CanEdit bool `json:"CanEdit"` +} + +// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not +// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle. +type PlaylistInfo struct { + OpenAccess bool `json:"OpenAccess"` + Shares []PlaylistUserPermissions `json:"Shares"` + ItemIds []string `json:"ItemIds"` +} + +type QueryResult struct { + Items []BaseItemDto `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` + StartIndex int `json:"StartIndex"` +} + +type UserDto struct { + Name string `json:"Name"` + ServerId string `json:"ServerId,omitempty"` + ServerName string `json:"ServerName,omitempty"` + Id string `json:"Id"` + HasPassword bool `json:"HasPassword"` + HasConfiguredPassword bool `json:"HasConfiguredPassword"` + HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"` + PrimaryImageTag string `json:"PrimaryImageTag,omitempty"` + Policy *UserPolicy `json:"Policy,omitempty"` + Configuration *UserConfiguration `json:"Configuration,omitempty"` +} + +// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if +// it's absent, so every field must be present even though Navidrome lacks most of these concepts. +type UserPolicy struct { + IsAdministrator bool `json:"IsAdministrator"` + IsHidden bool `json:"IsHidden"` + EnableCollectionManagement bool `json:"EnableCollectionManagement"` + EnableSubtitleManagement bool `json:"EnableSubtitleManagement"` + EnableLyricManagement bool `json:"EnableLyricManagement"` + IsDisabled bool `json:"IsDisabled"` + BlockedTags []string `json:"BlockedTags"` + AllowedTags []string `json:"AllowedTags"` + EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"` + AccessSchedules []string `json:"AccessSchedules"` + BlockUnratedItems []string `json:"BlockUnratedItems"` + EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"` + EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"` + EnableRemoteAccess bool `json:"EnableRemoteAccess"` + EnableLiveTvManagement bool `json:"EnableLiveTvManagement"` + EnableLiveTvAccess bool `json:"EnableLiveTvAccess"` + EnableMediaPlayback bool `json:"EnableMediaPlayback"` + EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"` + EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"` + EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"` + ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"` + EnableContentDeletion bool `json:"EnableContentDeletion"` + EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"` + EnableContentDownloading bool `json:"EnableContentDownloading"` + EnableSyncTranscoding bool `json:"EnableSyncTranscoding"` + EnableMediaConversion bool `json:"EnableMediaConversion"` + EnabledDevices []string `json:"EnabledDevices"` + EnableAllDevices bool `json:"EnableAllDevices"` + EnabledChannels []string `json:"EnabledChannels"` + EnableAllChannels bool `json:"EnableAllChannels"` + EnabledFolders []string `json:"EnabledFolders"` + EnableAllFolders bool `json:"EnableAllFolders"` + InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"` + LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"` + MaxActiveSessions int `json:"MaxActiveSessions"` + EnablePublicSharing bool `json:"EnablePublicSharing"` + BlockedMediaFolders []string `json:"BlockedMediaFolders"` + BlockedChannels []string `json:"BlockedChannels"` + RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"` + AuthenticationProviderId string `json:"AuthenticationProviderId"` + PasswordResetProviderId string `json:"PasswordResetProviderId"` + SyncPlayAccess string `json:"SyncPlayAccess"` +} + +// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it +// always present, even though most settings don't apply to Navidrome's audio-only library. +type UserConfiguration struct { + PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"` + SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"` + DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"` + GroupedFolders []string `json:"GroupedFolders"` + SubtitleMode string `json:"SubtitleMode"` + DisplayCollectionsView bool `json:"DisplayCollectionsView"` + EnableLocalPassword bool `json:"EnableLocalPassword"` + OrderedViews []string `json:"OrderedViews"` + LatestItemsExcludes []string `json:"LatestItemsExcludes"` + MyMediaExcludes []string `json:"MyMediaExcludes"` + HidePlayedInLatest bool `json:"HidePlayedInLatest"` + RememberAudioSelections bool `json:"RememberAudioSelections"` + RememberSubtitleSelections bool `json:"RememberSubtitleSelections"` + EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` + CastReceiverId string `json:"CastReceiverId"` +} + +type SessionInfo struct { + Id string `json:"Id"` + UserId string `json:"UserId"` +} + +type AuthenticationResult struct { + User *UserDto `json:"User"` + SessionInfo *SessionInfo `json:"SessionInfo,omitempty"` + AccessToken string `json:"AccessToken"` + ServerId string `json:"ServerId"` +} + +// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so +// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type +// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws. +type MediaStream struct { + Codec string `json:"Codec,omitempty"` + Type string `json:"Type"` + Index int `json:"Index"` + BitRate int `json:"BitRate,omitempty"` + Channels int `json:"Channels,omitempty"` + SampleRate int `json:"SampleRate,omitempty"` + ChannelLayout string `json:"ChannelLayout,omitempty"` + IsInterlaced bool `json:"IsInterlaced"` + IsDefault bool `json:"IsDefault"` + IsForced bool `json:"IsForced"` + IsExternal bool `json:"IsExternal"` + IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` + SupportsExternalStream bool `json:"SupportsExternalStream"` +} + +// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as +// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing +// of the whole item list; emit them always (no omitempty on bools). +type MediaSourceInfo struct { + Id string `json:"Id"` + Path string `json:"Path,omitempty"` + Protocol string `json:"Protocol"` + Container string `json:"Container,omitempty"` + TranscodingUrl string `json:"TranscodingUrl,omitempty"` + TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"` + Size int64 `json:"Size,omitempty"` + Name string `json:"Name,omitempty"` + IsRemote bool `json:"IsRemote"` + RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` + Bitrate int `json:"Bitrate,omitempty"` + SupportsTranscoding bool `json:"SupportsTranscoding"` + SupportsDirectStream bool `json:"SupportsDirectStream"` + SupportsDirectPlay bool `json:"SupportsDirectPlay"` + Type string `json:"Type"` + ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"` + IgnoreDts bool `json:"IgnoreDts"` + IgnoreIndex bool `json:"IgnoreIndex"` + GenPtsInput bool `json:"GenPtsInput"` + IsInfiniteStream bool `json:"IsInfiniteStream"` + UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"` + RequiresOpening bool `json:"RequiresOpening"` + RequiresClosing bool `json:"RequiresClosing"` + RequiresLooping bool `json:"RequiresLooping"` + SupportsProbing bool `json:"SupportsProbing"` + HasSegments bool `json:"HasSegments"` + MediaStreams []MediaStream `json:"MediaStreams"` + MediaAttachments []any `json:"MediaAttachments"` + Formats []string `json:"Formats"` +} + +type PlaybackInfoResponse struct { + MediaSources []MediaSourceInfo `json:"MediaSources"` + PlaySessionId string `json:"PlaySessionId"` +} diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go new file mode 100644 index 000000000..1d8ec47e4 --- /dev/null +++ b/server/jellyfin/dto/dto_suite_test.go @@ -0,0 +1,17 @@ +package dto + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDto(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin DTO Suite") +} diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go new file mode 100644 index 000000000..faa31ec0d --- /dev/null +++ b/server/jellyfin/dto/fields.go @@ -0,0 +1,24 @@ +package dto + +import "strings" + +// Fields is the parsed set of a Jellyfin request's Fields param (lowercased). It controls which +// conditional fields a mapped item carries — chiefly MediaSources — matching real Jellyfin, which +// omits those unless the client asks for them. +type Fields map[string]struct{} + +// ParseFields splits the comma-separated Fields param into a lowercased set. +func ParseFields(csv string) Fields { + f := Fields{} + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } + } + return f +} + +func (f Fields) Has(name string) bool { + _, ok := f[strings.ToLower(name)] + return ok +} diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go new file mode 100644 index 000000000..3490ba260 --- /dev/null +++ b/server/jellyfin/dto/ids.go @@ -0,0 +1,23 @@ +package dto + +import "encoding/hex" + +// EncodeID renders a Navidrome id as lowercase hex; Jellyfin clients parse ids as radix-16 (e.g. +// Finamp's queue packing) and crash on Navidrome's base62 nanoids if emitted as-is. +func EncodeID(id string) string { + if id == "" { + return "" + } + return hex.EncodeToString([]byte(id)) +} + +// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id. +func DecodeID(id string) string { + if id == "" { + return "" + } + if b, err := hex.DecodeString(id); err == nil && len(b) > 0 { + return string(b) + } + return id +} diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go new file mode 100644 index 000000000..26a957604 --- /dev/null +++ b/server/jellyfin/dto/ids_test.go @@ -0,0 +1,35 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("id codec", func() { + It("round-trips a base62 nanoid through Encode/Decode", func() { + id := "5QFKvMsJrd57QE2Le2dKKo" + Expect(DecodeID(EncodeID(id))).To(Equal(id)) + }) + + It("passes a raw (non-hex) id through DecodeID unchanged", func() { + Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo")) + }) + + It("produces valid lowercase hex", func() { + encoded := EncodeID("song-1") + Expect(encoded).To(MatchRegexp("^[0-9a-f]+$")) + Expect(encoded).To(HaveLen(len("song-1") * 2)) + }) + + It("round-trips the empty string", func() { + Expect(EncodeID("")).To(Equal("")) + Expect(DecodeID("")).To(Equal("")) + }) + + It("decodes a hex-looking raw id incorrectly only when re-encoded consistently (encode/decode is always internally consistent)", func() { + // "a1" happens to be valid hex on its own; DecodeID can't tell a coincidental hex + // string apart from one we encoded. Callers must always encode ids on emission and + // decode them on receipt so this ambiguity never surfaces in practice. + Expect(DecodeID(EncodeID("a1"))).To(Equal("a1")) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go new file mode 100644 index 000000000..bd817e432 --- /dev/null +++ b/server/jellyfin/dto/mappers.go @@ -0,0 +1,256 @@ +package dto + +import ( + "cmp" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" +) + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } + +// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 +// PremiereDate clients parse, falling back to year; nil when neither exists. +func premiereDate(date string, year int) *string { + d := date + switch len(d) { + case 4: + d += "-01-01" + case 7: + d += "-01" + case 10: // already yyyy-mm-dd + default: + if year <= 0 { + return nil + } + d = fmt.Sprintf("%04d-01-01", year) + } + s := d + "T00:00:00Z" + return &s +} + +// jellyfinDate formats t as the ISO 8601 string clients expect, or "" for the zero time so the +// field is omitted rather than sent as a meaningless epoch. +func jellyfinDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. +func channelLayout(n int) string { + switch n { + case 1: + return "mono" + case 2: + return "stereo" + case 6: + return "5.1" + case 8: + return "7.1" + default: + return "" + } +} + +// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file. +// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo +// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). +func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { + return MediaSourceInfo{ + Id: EncodeID(mf.ID), + Protocol: "Http", + Container: mf.Suffix, + Size: mf.Size, + Name: mf.Title, + Type: "Default", + RunTimeTicks: TicksFromSeconds(mf.Duration), + Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. + SupportsDirectPlay: true, + SupportsDirectStream: true, + SupportsTranscoding: true, + IsRemote: false, + SupportsProbing: true, + MediaStreams: []MediaStream{{ + Type: "Audio", + Index: 0, + Codec: mf.Codec, + BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. + Channels: mf.Channels, + SampleRate: mf.SampleRate, + ChannelLayout: channelLayout(mf.Channels), + }}, + MediaAttachments: []any{}, + Formats: []string{}, + } +} + +func UserData(a model.Annotations, itemID string) *UserItemDataDto { + // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. + encodedID := EncodeID(itemID) + d := &UserItemDataDto{ + PlayCount: int(a.PlayCount), + IsFavorite: a.Starred, + Played: a.PlayCount > 0, + Key: encodedID, + ItemId: encodedID, + } + if a.Rating > 0 { + r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 + d.Rating = &r + } + if a.PlayDate != nil { + s := a.PlayDate.UTC().Format(time.RFC3339) + d.LastPlayedDate = &s + } + return d +} + +// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached +// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a +// plain list response); a nil fields set means neither. +func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { + item := BaseItemDto{ + Name: mf.Title, + Id: EncodeID(mf.ID), + Type: "Audio", + MediaType: "Audio", + IsFolder: false, + LocationType: "FileSystem", + HasLyrics: mf.Lyrics != "", + ParentId: EncodeID(mf.AlbumID), + Album: mf.Album, + AlbumId: EncodeID(mf.AlbumID), + AlbumArtist: mf.AlbumArtist, + Artists: []string{mf.Artist}, + RunTimeTicks: TicksFromSeconds(mf.Duration), + DateCreated: jellyfinDate(&mf.CreatedAt), + Container: mf.Suffix, + CanDownload: true, + BackdropImageTags: []string{}, + UserData: UserData(mf.Annotations, mf.ID), + } + if fields.Has("MediaSources") { + item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} + } + if fields.Has("SortName") { + item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) + } + // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown + // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; + // AlbumArtists the album artist. + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } + if mf.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} + } + if mf.Year > 0 { + item.ProductionYear = new(mf.Year) + } + item.PremiereDate = premiereDate(mf.Date, mf.Year) + if mf.TrackNumber > 0 { + item.IndexNumber = new(mf.TrackNumber) + } + if mf.DiscNumber > 0 { + item.ParentIndexNumber = new(mf.DiscNumber) + } + if len(mf.Genres) > 0 { + for _, g := range mf.Genres { + item.Genres = append(item.Genres, g.Name) + } + } else if mf.Genre != "" { + item.Genres = []string{mf.Genre} + } + // Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. + if mf.AlbumID != "" { + item.AlbumPrimaryImageTag = mf.AlbumID + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}} + } + return item +} + +func AlbumToBaseItem(al model.Album) BaseItemDto { + item := BaseItemDto{ + Name: al.Name, + Id: EncodeID(al.ID), + Type: "MusicAlbum", + IsFolder: true, + ParentId: EncodeID(al.AlbumArtistID), + AlbumArtist: al.AlbumArtist, + Album: al.Name, + ChildCount: new(al.SongCount), + SongCount: new(al.SongCount), + RunTimeTicks: TicksFromSeconds(al.Duration), + DateCreated: jellyfinDate(&al.CreatedAt), + ImageTags: map[string]string{"Primary": al.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), + } + if al.AlbumArtistID != "" { + item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} + item.ArtistItems = item.AlbumArtists + } + if al.MaxYear > 0 { + item.ProductionYear = new(al.MaxYear) + } + item.PremiereDate = premiereDate(al.Date, al.MaxYear) + if len(al.Genres) > 0 { + for _, g := range al.Genres { + item.Genres = append(item.Genres, g.Name) + } + } + return item +} + +func ArtistToBaseItem(ar model.Artist) BaseItemDto { + return BaseItemDto{ + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageTags: map[string]string{"Primary": ar.ID}, + ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}}, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), + } +} + +func GenreToBaseItem(g model.Genre) BaseItemDto { + return BaseItemDto{ + Name: g.Name, + Id: EncodeID(g.ID), + Type: "MusicGenre", + IsFolder: true, + BackdropImageTags: []string{}, + } +} + +// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. +func PlaylistToBaseItem(p model.Playlist) BaseItemDto { + // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. + // UpdatedAt versions it (Put bumps it on upload); over-invalidation only costs a refetch. + tag := fmt.Sprintf("%s-%x", p.ID, p.UpdatedAt.UnixMilli()) + return BaseItemDto{ + Name: p.Name, + Id: EncodeID(p.ID), + Type: "Playlist", + // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin + // stores them under its data folder), so without this its Playlists tab hides them all. + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageTags: map[string]string{"Primary": tag}, + ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}}, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), + } +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go new file mode 100644 index 000000000..bd351bee9 --- /dev/null +++ b/server/jellyfin/dto/mappers_test.go @@ -0,0 +1,280 @@ +package dto + +import ( + "encoding/json" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("mappers", func() { + It("maps a song to an Audio BaseItemDto", func() { + mf := model.MediaFile{ + ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, + Year: 1999, Duration: 60, Size: 2_500_000, + } + mf.PlayCount = 2 + mf.Starred = true + item := SongToBaseItem(mf, nil) + Expect(item.Type).To(Equal("Audio")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(item.IsFolder).To(BeFalse()) + Expect(item.LocationType).To(Equal("FileSystem")) + Expect(item.Id).To(Equal(EncodeID("song-1"))) + Expect(item.AlbumId).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("alb-1"))) + Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) + Expect(*item.IndexNumber).To(Equal(3)) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(item.UserData.Played).To(BeTrue()) + Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) + Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) + Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + }) + + Describe("Fields gating (matches real Jellyfin)", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, + SortTitle: "sort song", Lyrics: `[{"line":"la"}]`} + + It("omits MediaSources and SortName when Fields does not ask for them", func() { + item := SongToBaseItem(mf, nil) + Expect(item.MediaSources).To(BeNil()) + Expect(item.SortName).To(BeEmpty()) + }) + + It("includes MediaSources only when Fields=MediaSources", func() { + item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName")) + Expect(item.MediaSources).To(HaveLen(1)) + Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) + }) + + It("includes SortName (from the sort title) only when Fields=SortName", func() { + Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song")) + }) + + It("sets HasLyrics from the media file's lyrics", func() { + Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) + Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) + }) + }) + + It("omits ImageBlurHashes when a song has no album", func() { + mf := model.MediaFile{ID: "song-noalbum", Title: "Song", Duration: 60} + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("sets DateCreated from the media file's CreatedAt", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} + Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00Z")) + }) + + It("omits DateCreated when CreatedAt is the zero time", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).DateCreated).To(BeEmpty()) + }) + + It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Song", + Artist: "The Band", ArtistID: "ar-1", + AlbumArtist: "Various", AlbumArtistID: "ar-2", + } + item := SongToBaseItem(mf, nil) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}})) + }) + + It("omits ArtistItems when the track has no artist id", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) + }) + + It("builds a MediaSourceInfo from a media file", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + Expect(src.Id).To(Equal(EncodeID("s1"))) + Expect(src.Size).To(Equal(int64(5242880))) + Expect(src.Container).To(Equal("mp3")) + Expect(src.Bitrate).To(Equal(320_000)) + Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) + Expect(src.Protocol).To(Equal("Http")) + Expect(src.SupportsDirectPlay).To(BeTrue()) + }) + + It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { + mf := model.MediaFile{ + ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, + Channels: 2, SampleRate: 44100, Codec: "mp3", + } + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(1)) + stream := src.MediaStreams[0] + Expect(stream.Type).To(Equal("Audio")) + Expect(stream.Channels).To(Equal(2)) + Expect(stream.SampleRate).To(Equal(44100)) + Expect(stream.BitRate).To(Equal(320_000)) + Expect(stream.Codec).To(Equal("mp3")) + Expect(stream.ChannelLayout).To(Equal("stereo")) + }) + + It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { + mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + src := MediaSourceFromMediaFile(mf) + b, err := json.Marshal(src) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"SupportsProbing":true`)) + Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`)) + Expect(j).To(ContainSubstring(`"RequiresOpening":false`)) + Expect(j).To(ContainSubstring(`"MediaAttachments":[]`)) + Expect(j).To(ContainSubstring(`"Formats":[]`)) + }) + + It("serializes MediaStream's required non-nullable bools, never omitted", func() { + stream := MediaStream{Type: "Audio", Index: 0} + b, err := json.Marshal(stream) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Type":"Audio"`)) + Expect(j).To(ContainSubstring(`"IsDefault":false`)) + Expect(j).To(ContainSubstring(`"IsInterlaced":false`)) + Expect(j).To(ContainSubstring(`"IsForced":false`)) + Expect(j).To(ContainSubstring(`"IsExternal":false`)) + Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`)) + Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) + }) + + It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { + mf := model.MediaFile{ + ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, + Duration: 60, + } + item := SongToBaseItem(mf, nil) + Expect(item.IndexNumber).To(BeNil()) + Expect(item.ParentIndexNumber).To(BeNil()) + }) + + It("maps PlayDate to UserData.LastPlayedDate", func() { + playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) + mf := model.MediaFile{ + ID: "song-3", Title: "Song", Album: "Alb", AlbumID: "alb-1", + Artist: "Art", AlbumArtist: "AA", Duration: 60, + } + mf.PlayDate = &playDate + item := SongToBaseItem(mf, nil) + Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) + Expect(*item.UserData.LastPlayedDate).To(Equal(playDate.Format(time.RFC3339))) + }) + + It("maps an album to a MusicAlbum folder item", func() { + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + item := AlbumToBaseItem(al) + Expect(item.Type).To(Equal("MusicAlbum")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("alb-1"))) + Expect(item.ParentId).To(Equal(EncodeID("art-1"))) + Expect(item.AlbumArtists).To(HaveLen(1)) + Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1"))) + Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) + Expect(*item.ProductionYear).To(Equal(1999)) + Expect(*item.ChildCount).To(Equal(10)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) + Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + }) + + It("maps an artist to a MusicArtist folder item", func() { + ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} + item := ArtistToBaseItem(ar) + Expect(item.Type).To(Equal("MusicArtist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("art-1"))) + Expect(*item.AlbumCount).To(Equal(2)) + }) + + It("maps a genre to a MusicGenre folder item", func() { + g := model.Genre{ID: "genre-1", Name: "Rock"} + item := GenreToBaseItem(g) + Expect(item.Type).To(Equal("MusicGenre")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("genre-1"))) + Expect(item.Name).To(Equal("Rock")) + }) + + Describe("premiereDate", func() { + // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. + It("serializes a full date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02-01", Year: 2007} + item := SongToBaseItem(mf, nil) + Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("pads a year-only date so clients can parse it", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z")) + }) + + It("pads a year-month date", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z")) + }) + + It("falls back to the year when no date tag exists", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999} + Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z")) + }) + + It("is omitted when the track has no date at all", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).PremiereDate).To(BeNil()) + }) + + It("is set on albums from their date, falling back to MaxYear", func() { + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + }) + }) + + It("maps a playlist to a Playlist BaseItemDto", func() { + p := model.Playlist{ + ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, + Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, + } + item := PlaylistToBaseItem(p) + Expect(item.Type).To(Equal("Playlist")) + Expect(item.IsFolder).To(BeTrue()) + Expect(item.Id).To(Equal(EncodeID("pl-1"))) + Expect(item.Name).To(Equal("Chill")) + Expect(item.MediaType).To(Equal("Audio")) + Expect(*item.ChildCount).To(Equal(7)) + Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000))) + Expect(item.UserData.IsFavorite).To(BeTrue()) + Expect(item.UserData.PlayCount).To(Equal(2)) + Expect(*item.UserData.Rating).To(Equal(8.0)) + tag := item.ImageTags["Primary"] + Expect(tag).ToNot(BeEmpty()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag)) + Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6)) + }) + + It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() { + p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + before := PlaylistToBaseItem(p) + p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) + after := PlaylistToBaseItem(p) + + // Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover. + Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"])) + Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"])) + }) + + It("keeps the playlist image tag stable when nothing changed", func() { + p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) + }) +}) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go new file mode 100644 index 000000000..b1ad850e3 --- /dev/null +++ b/server/jellyfin/e2e/annotations_test.go @@ -0,0 +1,142 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Annotations", func() { + BeforeEach(func() { setupTestDB() }) + + itemUserData := func(id string) *dto.UserItemDataDto { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + return item.UserData + } + + Describe("favorites", func() { + It("marks and unmarks an album as favorite", func() { + id := albumID("Abbey Road") + + var marked dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("marks a song as favorite", func() { + id := songID("So What") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + }) + + It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() { + id := songID("Come Together") + + var marked dto.UserItemDataDto + parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked) + Expect(marked.IsFavorite).To(BeTrue()) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + var unmarked dto.UserItemDataDto + parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked) + Expect(unmarked.IsFavorite).To(BeFalse()) + Expect(itemUserData(id).IsFavorite).To(BeFalse()) + }) + + It("filters items to favorites only", func() { + post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Abbey Road")) + }) + + It("marks and lists a playlist as favorite", func() { + id := createPlaylist("Favorite Mix", nil) + Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK)) + Expect(itemUserData(id).IsFavorite).To(BeTrue()) + + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Favorite Mix")) + }) + + It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() { + // Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not + // Filters=IsFavorite), combined with ArtistIds. + post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "") + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true")) + Expect(names(q.Items)).To(ConsistOf("Help!")) + }) + + It("returns 404 when favoriting an unknown item", func() { + Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /UserItems/{id}/UserData", func() { + It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() { + id := songID("So What") + post("/Users/admin-1/FavoriteItems/"+enc(id), "") + + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data) + Expect(data.IsFavorite).To(BeTrue()) + Expect(data.ItemId).To(Equal(enc(id))) + }) + + It("returns a valid (unfavorited) UserData for an item with no annotations", func() { + var data dto.UserItemDataDto + parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data) + Expect(data.IsFavorite).To(BeFalse()) + Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue")))) + }) + + It("returns 404 for an unknown item", func() { + Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("ratings", func() { + It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { + id := albumID("IV") + + var set dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) + Expect(set.Rating).ToNot(BeNil()) + Expect(*set.Rating).To(Equal(float64(10))) + Expect(*itemUserData(id).Rating).To(Equal(float64(10))) + + // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` + // would leave the stale value. + var cleared dto.UserItemDataDto + parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) + Expect(cleared.Rating).To(BeNil()) + Expect(itemUserData(id).Rating).To(BeNil()) + }) + + It("sets and reads a playlist rating", func() { + id := createPlaylist("Rated Mix", nil) + Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) + Expect(*itemUserData(id).Rating).To(Equal(float64(8))) + }) + + It("clamps an out-of-range rating to the valid domain", func() { + id := albumID("Help!") + var data dto.UserItemDataDto + parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) + // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. + Expect(data.Rating).ToNot(BeNil()) + Expect(*data.Rating).To(Equal(float64(10))) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go new file mode 100644 index 000000000..f66833af2 --- /dev/null +++ b/server/jellyfin/e2e/auth_test.go @@ -0,0 +1,120 @@ +package e2e + +import ( + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Authentication", func() { + BeforeEach(func() { setupTestDB() }) + + authenticate := func(username, pw string) *httptest.ResponseRecorder { + body := `{"Username":"` + username + `","Pw":"` + pw + `"}` + return rawReq("POST", "/Users/AuthenticateByName", body) + } + + Describe("POST /Users/AuthenticateByName", func() { + It("authenticates a valid user and returns a usable token", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.AccessToken).ToNot(BeEmpty()) + Expect(res.User).ToNot(BeNil()) + Expect(res.User.Name).To(Equal("admin")) + Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Policy.IsAdministrator).To(BeTrue()) + Expect(res.ServerId).ToNot(BeEmpty()) + + // The returned token must actually authenticate a protected request. + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + }) + + It("marks a non-admin user's policy as non-administrator", func() { + w := authenticate("regular", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + Expect(res.User.Policy.IsAdministrator).To(BeFalse()) + }) + + It("rejects a wrong password", func() { + Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an empty password", func() { + Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects an unknown user", func() { + Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a malformed body", func() { + Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("GET /Users/Public", func() { + publicUsers := func() []dto.UserDto { + w := rawReq("GET", "/Users/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + parseInto(w, &users) + return users + } + + It("returns an empty list when no users are exposed", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users to an unauthenticated caller, without policy", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ExposedPublicUsers = "regular" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("regular")) + Expect(users[0].Id).To(Equal("regular-1")) + Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login + }) + }) + + Describe("current user", func() { + It("returns the caller from GET /Users/Me", func() { + var u dto.UserDto + parseInto(getAs(regularUser, "/Users/Me"), &u) + Expect(u.Name).To(Equal("regular")) + Expect(u.Id).To(Equal("regular-1")) + }) + + It("returns the caller from GET /Users/{userId}", func() { + var u dto.UserDto + parseInto(get("/Users/admin-1"), &u) + Expect(u.Name).To(Equal("admin")) + }) + }) + + Describe("auth enforcement", func() { + It("rejects a protected request with no token", func() { + Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a protected request with a bogus token", func() { + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", "not-a-valid-jwt") + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go new file mode 100644 index 000000000..af639ed29 --- /dev/null +++ b/server/jellyfin/e2e/browsing_test.go @@ -0,0 +1,389 @@ +package e2e + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func names(items []dto.BaseItemDto) []string { + out := make([]string, len(items)) + for i, it := range items { + out[i] = it.Name + } + return out +} + +var _ = Describe("Browsing", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /UserViews", func() { + It("returns the user's libraries as CollectionFolders", func() { + q := queryResult(get("/UserViews")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Music Library")) + Expect(q.Items[0].Type).To(Equal("CollectionFolder")) + Expect(q.Items[0].CollectionType).To(Equal("music")) + }) + }) + + Describe("GET /Items by type", func() { + It("lists all albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("lists all songs with Audio type and an AlbumId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(7)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Audio")) + Expect(it.MediaType).To(Equal("Audio")) + Expect(it.LocationType).To(Equal("FileSystem")) + Expect(it.ServerId).ToNot(BeEmpty()) // real Jellyfin always sets it + Expect(it.AlbumId).ToNot(BeEmpty()) + } + }) + + // Real Jellyfin omits MediaSources from a plain list response, returning it only when the + // client asks via Fields=MediaSources (Finamp's download dialog does). + It("omits MediaSources unless Fields=MediaSources is requested", func() { + plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) + for _, it := range plain.Items { + Expect(it.MediaSources).To(BeEmpty()) + } + withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources")) + for _, it := range withSources.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + + It("lists all album artists", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(4)) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + }) + + It("lists all genres", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(3)) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("returns no playlists when none exist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + Expect(q.Items).To(BeEmpty()) + }) + + It("defaults to albums when IncludeItemTypes is unrecognized", func() { + q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + }) + + Describe("ParentId browsing", func() { + It("browses an artist's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("browses an album's tracks in track order by default", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) + Expect(q.TotalRecordCount).To(Equal(2)) + // Track order (Something=1, Come Together=2) differs from alphabetical title order, + // proving the sort is by track number, not name. + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + Expect(*q.Items[0].IndexNumber).To(Equal(1)) + Expect(*q.Items[1].IndexNumber).To(Equal(2)) + }) + + // "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order. + It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) + + "&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending")) + got := names(q.Items) + Expect(got).To(HaveLen(3)) + Expect(got[:2]).To(ConsistOf("Come Together", "Something")) + Expect(got[2]).To(Equal("Help!")) + }) + + It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + }) + + // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds + // for the actual artist filter, not ParentId=. + Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { + lib1 := enc("1") + + It("filters albums by AlbumArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by ArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("filters albums by a single-album artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin")))) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("filters songs by a single-track artist", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) + Expect(names(q.Items)).To(ConsistOf("So What")) + }) + + // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears + // on, which must exclude their own discography (albums where they are the album artist). + It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) + Expect(names(q.Items)).To(ConsistOf("Singles")) + }) + + It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) + Expect(names(q.Items)).ToNot(ContainElement("Help!")) + }) + }) + + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. + Describe("genre filtering (GenreIds)", func() { + lib1 := enc("1") + + It("filters albums by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("filters songs by GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(4)) + }) + + It("matches any of multiple comma-separated GenreIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) + }) + + It("returns nothing for an unknown genre id", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + + It("filters album artists by GenreIds on /Artists/AlbumArtists", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis")) + Expect(q.TotalRecordCount).To(Equal(1)) + }) + + It("matches album artists of any of multiple GenreIds", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) + Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist")) + }) + + It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock")))) + Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin")) + }) + + It("returns no artists for an unknown genre id", func() { + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre"))) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase + // (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin + // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an + // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. + Describe("camelCase query params (Jellify / JS SDK)", func() { + lib1 := enc("1") + + It("filters albums by camelCase albumArtistIds", func() { + q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("filters songs by camelCase artistIds", func() { + q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) + }) + + It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) + }) + + It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() { + q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles")))) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) + }) + }) + + Describe("search, batch and pagination", func() { + It("searches albums by term", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("batch-fetches specific items by Ids", func() { + ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) + q := queryResult(get("/Items?ids=" + ids)) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) + }) + + // Finamp restores its saved queue with ids truncated to 16 bytes (see README). + Describe("Finamp-truncated ids (saved queue restore)", func() { + It("resolves a truncated id by unique prefix and echoes the requested id", func() { + full := songID("Come Together") + truncated := full[:16] + q := queryResult(get("/Items?ids=" + enc(truncated))) + Expect(names(q.Items)).To(ConsistOf("Come Together")) + // Finamp matches restored items by its stored ids, so the requested id must be echoed. + Expect(q.Items[0].Id).To(Equal(enc(truncated))) + }) + + It("batch-resolves a mixed list of truncated and full ids, keeping order", func() { + ids := enc(songID("Come Together")[:16]) + "," + enc(songID("So What")) + "," + enc(songID("Help!")[:16]) + q := queryResult(get("/Items?ids=" + ids)) + Expect(names(q.Items)).To(Equal([]string{"Come Together", "So What", "Help!"})) + }) + + It("streams a track by its truncated id", func() { + full := songID("So What") + w := get("/Audio/" + enc(full[:16]) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(full)) + }) + + It("still 404s for a truncated id matching nothing", func() { + Expect(get("/Audio/" + enc("zzzzzzzzzzzzzzzz") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + It("applies Limit while reporting the full TotalRecordCount", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) + Expect(q.Items).To(HaveLen(2)) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("pages distinct items via StartIndex", func() { + p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) + Expect(p1.Items).To(HaveLen(2)) + Expect(p2.Items).To(HaveLen(2)) + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + }) + + It("merges multiple types into one paginated result", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs + }) + }) + + Describe("GET /Items/{id}", func() { + It("resolves an album", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) + Expect(item.Name).To(Equal("Kind of Blue")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("resolves a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.Type).To(Equal("Audio")) + }) + + It("includes a parseable DateCreated (Date Added) on a song", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.DateCreated).ToNot(BeEmpty()) + _, err := time.Parse(time.RFC3339, item.DateCreated) + Expect(err).ToNot(HaveOccurred()) + }) + + It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.ArtistItems).ToNot(BeEmpty()) + Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis")) + Expect(item.ArtistItems[0].Id).ToNot(BeEmpty()) + Expect(item.AlbumArtists).ToNot(BeEmpty()) + Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) + }) + + It("resolves an artist", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) + Expect(item.Type).To(Equal("MusicArtist")) + }) + + It("returns 404 for an unknown id", func() { + Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Users/{userId}/Items/Latest", func() { + It("returns recent albums as a bare array, respecting Limit", func() { + var items []dto.BaseItemDto + parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) + Expect(items).To(HaveLen(3)) + for _, it := range items { + Expect(it.Type).To(Equal("MusicAlbum")) + } + }) + }) + + Describe("GET /Artists and /Genres", func() { + It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { + names := names(queryResult(get("/Artists/AlbumArtists")).Items) + Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) + Expect(names).ToNot(ContainElement("Featured Guest")) + }) + + It("lists performing artists on /Artists (includes a track's guest artist)", func() { + names := names(queryResult(get("/Artists")).Items) + Expect(names).To(ContainElement("Featured Guest")) + Expect(names).To(ContainElement("Solo Artist")) + }) + + It("returns different lists for album artists and performing artists", func() { + aa := names(queryResult(get("/Artists/AlbumArtists")).Items) + ar := names(queryResult(get("/Artists")).Items) + Expect(aa).ToNot(Equal(ar)) + }) + + It("lists genres", func() { + q := queryResult(get("/Genres")) + Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) + }) + + It("pages genres with StartIndex/Limit and still reports the full total", func() { + q := queryResult(get("/Genres?StartIndex=1&Limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go new file mode 100644 index 000000000..02c5183f8 --- /dev/null +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -0,0 +1,365 @@ +// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API. +// +// These tests exercise the full HTTP request/response cycle through the Jellyfin API router, +// using a real SQLite database and real repository implementations while stubbing out external +// services (artwork, streaming, transcoding) with spy/noop implementations. +// +// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite +// database, seeds two users (admin + regular) and one library backed by a fake in-memory +// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores +// that snapshot and builds a fresh jellyfin.Router. +// +// # Seeded library (see buildTestFS) +// +// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) +// Rock/The Beatles/Help!/ 01 Help! (1965) +// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) +// Jazz/Miles Davis/Kind of Blue/01 So What (1959) +// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest") +// +// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists), +// 3 genres (Rock=4, Jazz=1, Pop=2). +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/jellyfin" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API E2E Suite") +} + +// Easy aliases for the storagetest package +type _t = map[string]any + +var ( + template = storagetest.Template + track = storagetest.Track +) + +// Shared test state +var ( + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + goldenDB *harness.DB + dataFolder string + + adminUser = model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + } + + regularUser = model.User{ + ID: "regular-1", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + } +) + +// buildTestFS creates the seeded test filesystem (see package doc for totals). +func buildTestFS() storagetest.FakeFS { + abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) + help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) + ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) + + return harness.CreateFS(fstest.MapFS{ + // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, + // Come Together=2) so tests can tell track-order sorting apart from title sorting. + "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), + "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), + "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), + "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), + // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a + // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. + "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), + }) +} + +// --- Request helpers --- + +// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. +func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +// rawReq performs a request with no authentication (for public routes). +func rawReq(method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + var reader io.Reader + if body != "" { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, path, reader) + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + router.ServeHTTP(w, r) + return w +} + +func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") } +func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") } +func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) } +func postAs(u model.User, path, body string) *httptest.ResponseRecorder { + return jReq(u, "POST", path, body) +} +func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } +func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } + +// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). +func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", path, bytes.NewReader(body)) + token, err := auth.CreateToken(&user) + Expect(err).ToNot(HaveOccurred()) + r.Header.Set("X-Emby-Token", token) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) + r.Header.Set("Content-Type", contentType) + router.ServeHTTP(w, r) + return w +} + +// parseInto asserts a 200 and unmarshals the JSON body into target. +func parseInto(w *httptest.ResponseRecorder, target any) { + Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) + Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed()) +} + +// queryResult asserts a 200 and returns the parsed QueryResult. +func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { + var q dto.QueryResult + parseInto(w, &q) + return q +} + +// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a +// client would send) and returns its decoded Navidrome id. +func createPlaylist(name string, encodedIds []string) string { + return createPlaylistAs(adminUser, name, encodedIds...) +} + +// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. +func createPlaylistAs(user model.User, name string, encodedIds ...string) string { + if encodedIds == nil { + encodedIds = []string{} + } + body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) + Expect(err).ToNot(HaveOccurred()) + var res map[string]string + parseInto(postAs(user, "/Playlists", string(body)), &res) + Expect(res["Id"]).ToNot(BeEmpty()) + return dto.DecodeID(res["Id"]) +} + +// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- + +func enc(id string) string { return dto.EncodeID(id) } + +// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than +// guessing repository filter column names. + +func albumID(name string) string { + albums, err := ds.Album(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range albums { + if a.Name == name { + return a.ID + } + } + Fail("album not found: " + name) + return "" +} + +func songID(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return mf.ID + } + } + Fail("song not found: " + title) + return "" +} + +func artistID(name string) string { + artists, err := ds.Artist(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, a := range artists { + if a.Name == name { + return a.ID + } + } + Fail("artist not found: " + name) + return "" +} + +func genreID(name string) string { + genres, err := ds.Genre(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, g := range genres { + if g.Name == name { + return g.ID + } + } + Fail("genre not found: " + name) + return "" +} + +// --- Suite lifecycle --- + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + dataFolder = filepath.Join(GinkgoT().TempDir(), "data") + Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed()) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + + buildTestFS() + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from +// BeforeEach in each test container. +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DataFolder = conf.NewDir(dataFolder) + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + + goldenDB.Restore() + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + streamerSpy = &harness.SpyStreamer{} + artworkSpy = &spyArtwork{} + providerFake = &fakeExternalProvider{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + router = jellyfin.New( + ds, + artworkSpy, + streamerSpy, + decider, + core.NewPlayers(ds), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + providerFake, + ) +} + +// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return +// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses +// are overridden — the embedded interface panics for anything else, flagging unexpected calls. +type fakeExternalProvider struct { + external.Provider + similarArtists model.Artists + similarSongs model.MediaFiles +} + +func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) { + return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil +} + +func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return f.similarSongs, nil +} + +// --- Spy/noop dependencies (shared ones live in tests/harness) --- + +// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the +// resolved ArtworkID and that resolution runs under an elevated (admin) context. +type spyArtwork struct { + lastID string + lastCtx context.Context + data []byte +} + +func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { + return nil, time.Time{}, model.ErrNotFound +} + +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { + s.lastID = id + s.lastCtx = c + d := s.data + if d == nil { + d = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(d)), time.Time{}, nil +} + +var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go new file mode 100644 index 000000000..0c53d75f3 --- /dev/null +++ b/server/jellyfin/e2e/images_test.go @@ -0,0 +1,74 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite +// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert +// resolution and elevation without needing real image processing. +var _ = Describe("Item images", func() { + BeforeEach(func() { setupTestDB() }) + + It("resolves an album's Primary image", func() { + id := albumID("Abbey Road") + w := get("/Items/" + enc(id) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves an artist's Primary image", func() { + id := artistID("Miles Davis") + Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(id)) + }) + + It("resolves a private playlist's cover for its owner under an elevated context", func() { + // The route carries no user in ctx (public); the owner is identified by the request token, + // and resolution then runs elevated so the visibility filter doesn't eat the cover. + plID := createPlaylist("Private Mix", nil) + w := get("/Items/" + enc(plID) + "/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + + u, ok := request.UserFrom(artworkSpy.lastCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) + + It("serves images without authentication (public route)", func() { + id := albumID("IV") + w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + }) + + Describe("private playlist covers", func() { + It("does not resolve a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("does not resolve a private playlist's cover for another user", func() { + plID := createPlaylist("Secret Mix", nil) + w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) + }) + + It("resolves a public playlist's cover for anyone", func() { + plID := createPlaylist("Shared Mix", nil) + Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) + }) + }) +}) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go new file mode 100644 index 000000000..015d82d91 --- /dev/null +++ b/server/jellyfin/e2e/multiuser_test.go @@ -0,0 +1,64 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Multi-user access control", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("library scoping", func() { + It("lets a library member browse its content", func() { + q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + }) + + It("hides all content from a user with no library access", func() { + noAccess := model.User{ID: "noaccess-1", UserName: "noaccess", Name: "No Access", NewPassword: "password"} + Expect(ds.User(ctx).Put(&noAccess)).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername("noaccess") + Expect(err).ToNot(HaveOccurred()) + + q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("private playlists", func() { + It("does not expose another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + + // Owner sees it. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + // A different user does not. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + // And can't read its items. + Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) + }) + + It("does not let a non-owner delete another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — + // the API never reveals that someone else's private playlist exists. + Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) + // Still present for the owner. + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("does not let a non-owner annotate another user's private playlist", func() { + adminPl := createPlaylist("Admin Private", nil) + Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) + Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) + }) + + It("lets each user manage their own playlist", func() { + regularPl := createPlaylistAs(regularUser, "Regular's Mix") + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go new file mode 100644 index 000000000..73b76e3d8 --- /dev/null +++ b/server/jellyfin/e2e/playlists_test.go @@ -0,0 +1,311 @@ +package e2e + +import ( + "bytes" + "image" + jpeglib "image/jpeg" + "net/http" + "os" + "time" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + BeforeEach(func() { setupTestDB() }) + + playlistItems := func(plID string) dto.QueryResult { + return queryResult(get("/Playlists/" + enc(plID) + "/Items")) + } + + Describe("create", func() { + It("creates an empty playlist", func() { + plID := createPlaylist("Empty", nil) + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeFalse()) + Expect(info.Shares).To(BeEmpty()) + Expect(info.ItemIds).To(BeEmpty()) + }) + + It("creates a playlist from song ids", func() { + plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("expands an album id into its tracks", func() { + plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + + It("expands an artist id into its tracks", func() { + plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) + }) + }) + + Describe("items", func() { + It("tags each entry with a PlaylistItemId", func() { + plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) + q := playlistItems(plID) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].Type).To(Equal("Audio")) + Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) + }) + }) + + Describe("add and remove", func() { + It("adds a song by id", func() { + plID := createPlaylist("Add", nil) + Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("adds an album (expanding to its tracks)", func() { + plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) + post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) + }) + + // Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a + // comma-joined value; all ids must be added, not just the first. + It("adds multiple songs sent as repeated ids params", func() { + plID := createPlaylist("Multi", nil) + url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + + "&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!")) + Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) + }) + + 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 + Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + + It("removes multiple entries sent as repeated entryIds params", func() { + plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) + items := playlistItems(plID).Items + url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId + Expect(del(url).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("users", func() { + It("reports the current user as an editor", func() { + plID := createPlaylist("Perms", nil) + var perms []dto.PlaylistUserPermissions + parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) + Expect(perms).To(HaveLen(1)) + Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].CanEdit).To(BeTrue()) + }) + }) + + Describe("listing", func() { + It("lists a created playlist advertising a Primary image tag", func() { + createPlaylist("Listed", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("Listed")) + Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) + }) + + It("sorts playlists by name when SortBy=SortName", func() { + createPlaylist("Charlie", nil) + createPlaylist("Alpha", nil) + createPlaylist("Bravo", nil) + q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"})) + }) + }) + + // Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists + // playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item + // whose CollectionType is "playlists", its query resolves undefined and React Query retries in a + // backoff loop that stalls the home screen. + Describe("playlists library folder (ManualPlaylistsFolder)", func() { + It("returns a synthetic playlists folder with CollectionType=playlists", func() { + q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.Items[0].CollectionType).To(Equal("playlists")) + Expect(q.Items[0].Id).To(Equal(enc("playlists"))) + }) + + It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { + createPlaylist("My Mix", nil) + q := queryResult(get("/Items?parentId=" + enc("playlists"))) + Expect(names(q.Items)).To(ContainElement("My Mix")) + Expect(q.Items[0].Type).To(Equal("Playlist")) + // Jellify keeps only playlists whose Path contains "data". + Expect(q.Items[0].Path).To(ContainSubstring("data")) + }) + + It("resolves the synthetic playlists folder by its own advertised id", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc("playlists")), &item) + Expect(item.Type).To(Equal("ManualPlaylistsFolder")) + Expect(item.CollectionType).To(Equal("playlists")) + Expect(item.Id).To(Equal(enc("playlists"))) + }) + }) + + // Real Jellyfin returns a playlist's children for /Items?ParentId= with no + // IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way. + Describe("browsing a playlist via the generic /Items path", func() { + It("lists the playlist's tracks for a typeless ParentId query", func() { + plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID))) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("pages the playlist's tracks", func() { + plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + // Jellify opens a playlist with ParentId=&IncludeItemTypes=Audio&Recursive=false. + // The playlist id must resolve to its tracks, not be treated as an album id (which returns none). + It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() { + plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))}) + q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false")) + Expect(q.TotalRecordCount).To(Equal(2)) + Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) + }) + }) + + Describe("cover art", func() { + // A real (decodable) image: the upload endpoint validates by decoding, like the native one. + var jpeg []byte + BeforeEach(func() { + var buf bytes.Buffer + Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + jpeg = buf.Bytes() + }) + + It("uploads and removes a playlist cover", func() { + plID := createPlaylist("Cover", nil) + + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + pls, err := ds.Playlist(ctx).Get(plID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.UploadedImage).ToNot(BeEmpty()) + _, statErr := os.Stat(pls.UploadedImagePath()) + Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") + + Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) + pls, _ = ds.Playlist(ctx).Get(plID) + Expect(pls.UploadedImage).To(BeEmpty()) + }) + + It("rejects cover upload for a non-playlist item", func() { + Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNotImplemented)) + }) + + // Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the + // tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever. + It("rotates the playlist's image tag and blurhash after a cover upload", func() { + plID := createPlaylist("Cover Tag", nil) + imageTag := func() string { + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items).To(HaveLen(1)) + return q.Items[0].ImageTags["Primary"] + } + before := imageTag() + Expect(before).ToNot(BeEmpty()) + + time.Sleep(2 * time.Millisecond) // UpdatedAt has millisecond resolution in the tag + Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). + To(Equal(http.StatusNoContent)) + + after := imageTag() + Expect(after).ToNot(Equal(before)) + q := queryResult(get("/Items?ids=" + enc(plID))) + Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after)) + }) + }) + + Describe("update", func() { + It("makes a playlist public", func() { + plID := createPlaylist("Make Public", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) + + var info dto.PlaylistInfo + parseInto(get("/Playlists/"+enc(plID)), &info) + Expect(info.OpenAccess).To(BeTrue()) + // Now visible to other users. + Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) + }) + + It("renames a playlist", func() { + plID := createPlaylist("Old Name", nil) + Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("New Name")) + }) + + It("replaces the track list when Ids are provided", func() { + plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))}) + // Replace with a single different track. + Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + }) + + It("clears the track list when an explicit empty Ids array is sent", func() { + plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(0)) + }) + + It("leaves the track list intact when Ids is omitted (metadata-only update)", func() { + plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))}) + Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent)) + Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) + }) + + It("applies Name and IsPublic sent together with a track replacement", func() { + plID := createPlaylist("Combo", []string{enc(songID("Come Together"))}) + body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}` + Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(q.Items[0].Name).To(Equal("So What")) + pls, _ := ds.Playlist(ctx).Get(plID) + Expect(pls.Name).To(Equal("Combo Renamed")) + Expect(pls.Public).To(BeTrue()) + }) + + It("forbids a non-owner from updating a public playlist", func() { + plID := createPlaylist("Owned", nil) + post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user + Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) + }) + }) + + Describe("delete", func() { + It("deletes a playlist", func() { + plID := createPlaylist("ToDelete", nil) + Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) + Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) + }) + + It("returns 404 when deleting a non-playlist item", func() { + Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) + }) + }) +}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go new file mode 100644 index 000000000..49faad342 --- /dev/null +++ b/server/jellyfin/e2e/routing_test.go @@ -0,0 +1,31 @@ +package e2e + +import ( + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Routing", func() { + BeforeEach(func() { setupTestDB() }) + + It("routes authenticated endpoints case-insensitively", func() { + // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. + lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(lower.TotalRecordCount).To(Equal(5)) + }) + + It("returns a JSON 404 for an unknown route", func() { + w := get("/Nonexistent/Route") + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) + Expect(w.Body.String()).To(ContainSubstring("{}")) + }) + + It("returns 404 for an unsupported method on a known path", func() { + // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. + w := jReq(adminUser, "PUT", "/Items", "") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go new file mode 100644 index 000000000..6c26569fc --- /dev/null +++ b/server/jellyfin/e2e/search_test.go @@ -0,0 +1,76 @@ +package e2e + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the +// tricky case: they have no library_id column, so the repo's Search does its own scope handling. +var _ = Describe("Search", func() { + BeforeEach(func() { setupTestDB() }) + + lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + + Describe("artists", func() { + It("searches all album artists", func() { + q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("searches album artists scoped to a library (ParentId)", func() { + q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) + Expect(names(q.Items)).To(ConsistOf("The Beatles")) + }) + + It("returns an empty result for a non-matching term", func() { + q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + Describe("albums and songs", func() { + It("searches albums scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) + Expect(names(q.Items)).To(ContainElement("Abbey Road")) + }) + + It("searches songs scoped to a library", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) + Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) + }) + }) + + Describe("pagination totals", func() { + It("reports the search match count, not the unfiltered library count", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey&Limit=50")) + Expect(q.Items).To(HaveLen(1)) + Expect(q.TotalRecordCount).To(Equal(1)) // not the 5-album library total + }) + + It("reaches the true total when paging song search results", func() { + // "So" prefix-matches several songs (titles and Solo Artist's tracks); learn the true + // count from an unpaged query, then walk one-item pages: the reported total must keep + // the client paging until the last match and stop it exactly there. + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So")) + total := all.TotalRecordCount + Expect(total).To(Equal(len(all.Items))) + Expect(total).To(BeNumerically(">=", 2)) + + var collected []string + for start := range total { + page := queryResult(get(fmt.Sprintf("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So&Limit=1&StartIndex=%d", start))) + Expect(page.Items).To(HaveLen(1)) + if start+1 < total { + Expect(page.TotalRecordCount).To(BeNumerically(">", start+1)) // more remain: keep paging + } else { + Expect(page.TotalRecordCount).To(Equal(total)) // last page: exact, so the client stops + } + collected = append(collected, page.Items[0].Name) + } + Expect(collected).To(ConsistOf(names(all.Items))) + }) + }) +}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go new file mode 100644 index 000000000..2057b43c6 --- /dev/null +++ b/server/jellyfin/e2e/sessions_test.go @@ -0,0 +1,62 @@ +package e2e + +import ( + "net/http" + "strconv" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Sessions", func() { + BeforeEach(func() { setupTestDB() }) + + ticks := func(ms int64) int64 { return ms * 10_000 } + reportBody := func(itemID string, positionTicks int64) string { + return `{"ItemId":"` + enc(itemID) + `","PositionTicks":` + strconv.FormatInt(positionTicks, 10) + `}` + } + + Describe("playback reporting", func() { + It("accepts a playback start report", func() { + Expect(post("/Sessions/Playing", reportBody(songID("Come Together"), 0)).Code).To(Equal(http.StatusNoContent)) + }) + + It("accepts a playback progress report", func() { + Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"), ticks(5000))).Code).To(Equal(http.StatusNoContent)) + }) + + It("counts a play stopped past the threshold", func() { + id := songID("So What") + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) + + mf, err = ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(BeNumerically(">=", 1)) + }) + + 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% + // threshold is always well above 1s. + id := songID("Help!") + Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) + + mf, err := ds.MediaFile(ctx).Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("capabilities", func() { + It("acknowledges POST /Sessions/Capabilities", func() { + Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) + }) + + It("acknowledges POST /Sessions/Capabilities/Full", func() { + Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) + }) + }) +}) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go new file mode 100644 index 000000000..43ef857fa --- /dev/null +++ b/server/jellyfin/e2e/similar_test.go @@ -0,0 +1,134 @@ +package e2e + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Similar", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Artists/{id}/Similar", func() { + It("returns the provider's similar artists, excluding ones not in the library", func() { + providerFake.similarArtists = model.Artists{ + {ID: "z", Name: "Led Zeppelin"}, + {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded + } + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Led Zeppelin")) + Expect(q.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("returns an empty result (not 404) when the provider has nothing", func() { + q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + + Describe("GET /Items/{id}/Similar", func() { + It("returns similar songs for a track", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) + Expect(names(q.Items)).To(ConsistOf("Similar Song")) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, // regularUser has no access + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("In Library")) + }) + + It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, + {ID: "x2", AlbumID: albumID("IV")}, // same album -> counted once + {ID: "x3", AlbumID: albumID("Kind of Blue")}, + } + q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) + Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) + Expect(q.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("excludes similar albums from libraries the user can't access", func() { + // Seed an album in a second library the regular user has no access to, and point a + // provider similar-song at it. + otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} + Expect(ds.Library(ctx).Put(&otherLib)).To(Succeed()) + otherAlbum := model.Album{ID: "other-album", Name: "Other Album", LibraryID: 2} + Expect(ds.Album(ctx).Put(&otherAlbum)).To(Succeed()) + + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", AlbumID: albumID("IV")}, // library 1 -> visible + {ID: "x2", AlbumID: "other-album"}, // library 2 -> filtered for regularUser + } + q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) + Expect(names(q.Items)).To(ConsistOf("IV")) + }) + + It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { + q := queryResult(get("/Items/" + enc("does-not-exist") + "/Similar")) + Expect(q.Items).To(BeEmpty()) + }) + }) + + // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. + Describe("GET /Items/{id}/InstantMix", func() { + It("returns the seed track first, followed by similar songs", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Similar Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + Expect(q.Items[0].Type).To(Equal("Audio")) + }) + + It("does not duplicate the seed when the provider returns it", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: songID("So What"), Title: "So What", LibraryID: 1}, + {ID: "x1", Title: "Similar Song", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) + }) + + It("caps the mix at the requested limit", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "S1", LibraryID: 1}, + {ID: "x2", Title: "S2", LibraryID: 1}, + {ID: "x3", Title: "S3", LibraryID: 1}, + } + q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) + Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) + }) + + It("excludes similar songs from libraries the user can't access", func() { + providerFake.similarSongs = model.MediaFiles{ + {ID: "x1", Title: "In Library", LibraryID: 1}, + {ID: "x2", Title: "Other Library", LibraryID: 2}, + } + q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) + }) + + It("returns a mix of the provider's similar songs for an artist seed", func() { + providerFake.similarSongs = model.MediaFiles{{ID: "x1", Title: "Artist Mix Song", LibraryID: 1}} + q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) + }) + + It("returns an empty result (not 404) for an unknown item", func() { + w := get("/Items/" + enc("does-not-exist") + "/InstantMix") + Expect(w.Code).To(Equal(200)) + Expect(queryResult(w).Items).To(BeEmpty()) + }) + + It("returns only the seed when the provider has nothing", func() { + q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) + Expect(names(q.Items)).To(Equal([]string{"Help!"})) + }) + }) +}) diff --git a/server/jellyfin/e2e/smoke_test.go b/server/jellyfin/e2e/smoke_test.go new file mode 100644 index 000000000..b26955632 --- /dev/null +++ b/server/jellyfin/e2e/smoke_test.go @@ -0,0 +1,49 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded +// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files. +var _ = Describe("Smoke", func() { + BeforeEach(func() { setupTestDB() }) + + It("serves public system info without auth", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info).To(HaveKey("ServerName")) + Expect(info).To(HaveKey("Version")) + }) + + It("rejects an authenticated endpoint without a token", func() { + w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("lists the seeded albums for an authenticated user", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) + Expect(q.TotalRecordCount).To(Equal(5)) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("MusicAlbum")) + names = append(names, it.Name) + } + Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("resolves a seeded album id round-trip (encoded in the URL)", func() { + id := albumID("Abbey Road") + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(id)), &item) + Expect(item.Id).To(Equal(enc(id))) + Expect(item.Name).To(Equal("Abbey Road")) + Expect(item.Type).To(Equal("MusicAlbum")) + }) +}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go new file mode 100644 index 000000000..c096d904a --- /dev/null +++ b/server/jellyfin/e2e/streaming_test.go @@ -0,0 +1,128 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Streaming", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /Audio/{id}/stream", func() { + It("streams the requested track", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/stream") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("fake audio data")) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("streams via the /universal endpoint", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("serves the stream.{container} path form", func() { + id := songID("Help!") + Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + + It("forces raw format when static=true", func() { + // With ffmpeg unavailable the decider direct-plays regardless, but static=true must + // never resolve to a transcode. + id := songID("Help!") + get("/Audio/" + enc(id) + "/stream?static=true") + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("returns 404 for an unknown track", func() { + Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { + It("returns a VOD playlist whose segment streams through the transcode pipeline", func() { + id := songID("Come Together") + w := get("/Audio/" + enc(id) + "/main.m3u8?audioCodec=aac&audioBitRate=320000") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + + // Fetch the advertised segment like an HLS player would. + var segment string + for _, line := range strings.Split(body, "\n") { + if line != "" && !strings.HasPrefix(line, "#") { + segment = line + } + } + Expect(segment).To(HavePrefix("stream.aac?")) + Expect(get("/Audio/" + enc(id) + "/" + segment).Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + Expect(streamerSpy.LastRequest.Format).To(Equal("aac")) + Expect(streamerSpy.LastRequest.BitRate).To(Equal(320)) + }) + + It("is reachable with Jellyfin's case-insensitive routing", func() { + id := songID("Come Together") + Expect(get("/audio/" + enc(id) + "/Main.m3u8").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("direct-file endpoints", func() { + It("serves /Items/{id}/File as direct play (raw)", func() { + id := songID("Something") + w := get("/Items/" + enc(id) + "/File") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) + }) + + It("serves /Items/{id}/Download", func() { + id := songID("Something") + Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("PlaybackInfo", func() { + It("returns a single direct-play MediaSource via GET", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) + Expect(info.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns a MediaSource via POST", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) + Expect(info.MediaSources).To(HaveLen(1)) + }) + + It("embeds a self-authenticating TranscodingUrl (for native players that omit auth headers)", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + streamURL := info.MediaSources[0].TranscodingUrl + // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute + // host path hits the mounted router. + Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + Expect(streamURL).To(ContainSubstring("api_key=")) + // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e + // router is mounted at the root, so strip the /jellyfin prefix before replaying. + replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) + w := rawReq("GET", replayURL, "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) + }) + }) +}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go new file mode 100644 index 000000000..d4d2f2777 --- /dev/null +++ b/server/jellyfin/e2e/system_test.go @@ -0,0 +1,55 @@ +package e2e + +import ( + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /System/Info/Public", func() { + It("returns public server info without authentication", func() { + w := rawReq("GET", "/System/Info/Public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + var info map[string]any + parseInto(w, &info) + Expect(info["ServerName"]).To(HavePrefix("Navidrome")) + Expect(info["ProductName"]).To(Equal("Jellyfin Server")) + Expect(info["StartupWizardCompleted"]).To(BeTrue()) + Expect(info["Id"]).ToNot(BeEmpty()) + Expect(info["Version"]).ToNot(BeEmpty()) + }) + + It("routes case-insensitively (lowercase path)", func() { + w := rawReq("GET", "/system/info/public", "") + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("GET/POST /System/Ping", func() { + It("answers GET with a plain-text server name", func() { + w := rawReq("GET", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("answers POST identically", func() { + w := rawReq("POST", "/System/Ping", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) + }) + }) + + Describe("GET /QuickConnect/Enabled", func() { + It("reports QuickConnect disabled", func() { + w := rawReq("GET", "/QuickConnect/Enabled", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("false")) + }) + }) +}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go new file mode 100644 index 000000000..722e828c5 --- /dev/null +++ b/server/jellyfin/images.go @@ -0,0 +1,170 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "strconv" + + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + _ "golang.org/x/image/webp" +) + +func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { + // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs + // under an elevated context to bypass the persistence visibility filter; playlist access is + // gated inside resolveArtworkID. + ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) + itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + + artID := api.resolveArtworkID(ctx, r, itemId) + reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + switch { + case errors.Is(err, context.Canceled): + return + case err != nil: + log.Warn(ctx, "Error retrieving artwork", "id", itemId, err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer reader.Close() + // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). + _, _ = io.Copy(w, reader) +} + +// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing +// album -> artist -> media file -> playlist. +func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { + if al, err := api.ds.Album(ctx).Get(itemId); err == nil { + return al.CoverArtID().String() + } + if ar, err := api.ds.Artist(ctx).Get(itemId); err == nil { + return ar.CoverArtID().String() + } + if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil { + return mf.CoverArtID().String() + } + if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { + // Playlist covers are user-scoped: serve a private one only for a public playlist or a + // token identifying its owner/an admin, so this public route can't probe others' covers. + u, ok := api.userFromToken(r) + if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { + return pl.CoverArtID().String() + } + } + return (model.ArtworkID{}).String() +} + +// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come +// from scanning. The body is always drained first (even on the not-implemented path) because +// Finamp writes it synchronously and sees a broken pipe if we respond before reading it. +func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + // Honor the same artwork-upload gate and size cap as the native endpoint. + u, _ := request.UserFrom(ctx) + if !conf.Server.EnableArtworkUpload && !u.IsAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode + // the wire body (4/3 bigger), so the read cap allows for inflation. + limit := core.MaxImageUploadSize() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", + "playlistId", id, "limit", humanize.Bytes(uint64(limit)), err) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + imgBytes, err := decodeImageBody(body) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + if int64(len(imgBytes)) > limit { + log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize", + "playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit))) + http.Error(w, "file too large", http.StatusBadRequest) + return + } + // Validate by decoding and derive the extension from the real format — clients lie in Content-Type. + _, format, err := image.DecodeConfig(bytes.NewReader(imgBytes)) + if err != nil { + log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + ext := "." + format + + if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. +func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + + if _, err := api.playlists.Get(ctx, id); err != nil { + http.Error(w, "Not Implemented", http.StatusNotImplemented) + return + } + + if err := api.playlists.RemoveImage(ctx, id); err != nil { + api.internalError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients +// send raw bytes, so input already starting with an image magic number is passed through as-is. +func decodeImageBody(body []byte) ([]byte, error) { + if isImageMagic(body) { + return body, nil + } + trimmed := bytes.TrimSpace(body) + return base64.StdEncoding.DecodeString(string(trimmed)) +} + +func isImageMagic(b []byte) bool { + switch { + case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG + return true + case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG + return true + case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a) + return true + case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP + return true + default: + return false + } +} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go new file mode 100644 index 000000000..8099fcf94 --- /dev/null +++ b/server/jellyfin/images_test.go @@ -0,0 +1,394 @@ +package jellyfin + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeArtwork struct { + artwork.Artwork + recvId string + recvCtx context.Context + data []byte +} + +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + f.recvId = id + f.recvCtx = ctx + data := f.data + if data == nil { + data = []byte("IMG") + } + return io.NopCloser(bytes.NewReader(data)), time.Now(), nil +} + +func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("itemId", itemId) + rctx.URLParams.Add("type", "Primary") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + return w, r +} + +var _ = Describe("Images", func() { + It("streams album artwork", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(fa.recvId).To(ContainSubstring("a1")) + }) + + It("sniffs the Content-Type instead of hardcoding it", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + + png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) + fa := &fakeArtwork{data: png} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) + }) + + It("resolves a public playlist id to its cover artwork", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).To(ContainSubstring("pl1")) + }) + + It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + ds := &tests.MockDataStore{} + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("pl1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + }) + + // This endpoint is public (no user in the request), so artwork must be resolved under an + // elevated context; otherwise a private playlist's cover fails its visibility filter and + // silently falls back to the placeholder. + It("resolves artwork under an elevated admin context", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + u, ok := request.UserFrom(fa.recvCtx) + Expect(ok).To(BeTrue()) + Expect(u.IsAdmin).To(BeTrue()) + }) +}) + +// Real image fixtures: postItemImage validates uploads by decoding them. +func pngBytes() []byte { + var b bytes.Buffer + Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) + return b.Bytes() +} + +func jpegBytes() []byte { + var b bytes.Buffer + Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +func gifBytes() []byte { + var b bytes.Buffer + Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) + return b.Bytes() +} + +// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). +func webpBytes() []byte { + b, err := base64.StdEncoding.DecodeString( + "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") + Expect(err).ToNot(HaveOccurred()) + return b +} + +var _ = Describe("postItemImage", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api = &Router{playlists: fp} + }) + + It("uploads a raw JPEG body and returns 204", func() { + body := jpegBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".jpeg")) + }) + + It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { + raw := pngBytes() + encoded := base64.StdEncoding.EncodeToString(raw) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded))) + r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(raw)) + Expect(fp.setImageExt).To(Equal(".png")) + }) + + It("returns 501 for a non-playlist item, draining the body first", func() { + fp.getByIDPls = nil + fp.getByIDErr = model.ErrNotFound + bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + Expect(bodyReader.Len()).To(Equal(0)) + }) + + It("returns 500 when the service fails", func() { + fp.setImageErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("accepts a raw WebP body", func() { + body := webpBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/webp") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".webp")) + }) + + It("accepts a raw GIF body", func() { + body := gifBytes() + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/gif") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(body)) + Expect(fp.setImageExt).To(Equal(".gif")) + }) + + It("rejects an oversized body with 400, like the native endpoint", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MaxImageUploadSize = "16" // 16 bytes + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") + }) + + It("applies the size limit to the decoded image, not the base64 body", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + // The raw image is exactly at the limit; its base64 form is 4/3 bigger. + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) + body := []byte(base64.StdEncoding.EncodeToString(img)) + Expect(len(body)).To(BeNumerically(">", len(img))) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.setImageBytes).To(Equal(img)) + }) + + It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { + DeferCleanup(configtest.SetupConfig()) + img := pngBytes() + conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) + body := []byte(base64.StdEncoding.EncodeToString(img)) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/png") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects a body that is neither an image nor base64 with 400", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", strings.NewReader("!!not base64!!")) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { + body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body)) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("forbids a non-admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "u1", IsAdmin: false})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(fp.setImagePlaylistID).To(BeEmpty()) + }) + + It("still allows an admin upload when artwork upload is disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableArtworkUpload = false + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(jpegBytes())) + r.Header.Set("Content-Type", "image/jpeg") + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin", IsAdmin: true})) + + api.postItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + }) +}) + +var _ = Describe("deleteItemImage", func() { + It("removes the playlist image and returns 204", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + }) + + It("returns 501 for a non-playlist item", func() { + fp := &fakePlaylists{getByIDErr: model.ErrNotFound} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotImplemented)) + }) + + It("returns 500 when the service fails", func() { + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + api := &Router{playlists: fp} + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + + api.deleteItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) +}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go new file mode 100644 index 000000000..f8158bca4 --- /dev/null +++ b/server/jellyfin/items.go @@ -0,0 +1,577 @@ +package jellyfin + +import ( + "context" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "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" +) + +// notMissing excludes items whose backing files are all gone ("missing" is a real column on +// album, artist and media_file). +var notMissing = squirrel.Eq{"missing": false} + +func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { + res, err := api.queryItems(r.Context(), r) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, res) +} + +// queryItems is the /Items dispatcher: it parses entity types from IncludeItemTypes (defaulting to +// MusicAlbum), queries each via the matching listXxx, and merges multi-type results into one +// paginated list (as Finamp's favorites screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) { + p := req.Params(r) + // Query keys are read lowercase because normalizeQueryKeys folded them (Jellyfin binds + // case-insensitively). /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch below. + fields := dto.ParseFields(p.StringOr("fields", "")) + if ids := decodedQueryIDs(r, "ids"); len(ids) > 0 { + return api.itemsByIDs(ctx, ids, fields), nil + } + parentId := dto.DecodeID(p.StringOr("parentid", "")) + search := p.StringOr("searchterm", "") + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly := strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false) + sortBy := p.StringOr("sortby", "") + sortOrder := p.StringOr("sortorder", "") + offset := p.IntOr("startindex", 0) + limit := p.IntOr("limit", 0) + rawTypes := p.StringOr("includeitemtypes", "") + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + if strings.Contains(rawTypes, "ManualPlaylistsFolder") { + return result([]dto.BaseItemDto{playlistsFolder()}, 1, 0), nil + } + types := parseTypes(rawTypes) + // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistIds/artistIds + // select the artist's own discography; contributingArtistIds alone means albums they merely appear + // on (Jellyfin's "Featured On"), which must exclude that discography. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + contributingOnly := albumArtistScope == "" && contributingScope != "" + // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. + genreIds := decodedQueryIDs(r, "genreids") + + scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) + // A playlist parent always resolves to its tracks, whatever IncludeItemTypes says. Jellify opens + // a playlist with ParentId=&IncludeItemTypes=Audio; routing that through listSongs would + // treat the playlist id as an album id and return nothing. + if parentId != "" && !isLibraryParent && parentId != playlistsFolderID { + if pls, err := api.playlists.GetWithTracks(ctx, parentId); err == nil { + // GetWithTracks enforces visibility (public or owned by the current user). + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + return result(paginate(items, offset, limit), len(items), offset), nil + } + } + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks + // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse + // its albums). + if rawTypes == "" && parentId != "" && !isLibraryParent { + if parentId == playlistsFolderID { + // Browsing into the synthetic playlists folder lists the user's playlists. + types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(parentId); err == nil { + types = []string{"Audio"} + } + } + entityParent := parentId + // ParentId-as-entity-id (artist for MusicAlbum, album for Audio) only makes sense for a single + // type; a multi-type query has no natural parent entity, so ParentId is only library scoping there. + if isLibraryParent || len(types) > 1 { + entityParent = "" + } + + if len(types) == 1 { + opts := model.QueryOptions{Offset: offset, Max: limit} + applySort(&opts, types[0], sortBy, sortOrder) + return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) + } + + var items []dto.BaseItemDto + total := 0 + for _, itemType := range types { + var opts model.QueryOptions + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window); without this cap each would fetch its whole table. + // Totals are unaffected — they come from CountAll. + if limit > 0 { + opts.Max = offset + limit + } + applySort(&opts, itemType, sortBy, sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) + if err != nil { + return dto.QueryResult{}, err + } + items = append(items, res.Items...) + total += res.TotalRecordCount + } + return result(paginate(items, offset, limit), total, offset), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, favOnly bool, fields dto.Fields) (dto.QueryResult, error) { + switch itemType { + case "Audio": + return api.listSongs(ctx, opts, entityParent, artistId, genreIds, scopeIDs, search, favOnly, fields) + case "MusicArtist": + // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. + return api.listArtists(ctx, opts, genreIds, scopeIDs, search, favOnly, model.RoleAlbumArtist) + case "MusicGenre": + return api.listGenres(ctx, opts) + case "Playlist": + return api.listPlaylists(ctx, opts, favOnly) + default: // MusicAlbum + return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly) + } +} + +// firstNonEmpty returns the first non-empty string, or "". +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. +func firstDecodedID(s string) string { + if s == "" { + return "" + } + first, _, _ := strings.Cut(s, ",") + return dto.DecodeID(strings.TrimSpace(first)) +} + +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs), decoding each id. +func decodedQueryIDs(r *http.Request, key string) []string { + return slice.Map(queryIDs(r, key), dto.DecodeID) +} + +// parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to +// {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). +func parseTypes(types string) []string { + var recognized []string + for t := range strings.SplitSeq(types, ",") { + t = strings.TrimSpace(t) + switch t { + case "Audio", "MusicArtist", "MusicAlbum", "MusicGenre", "Playlist": + recognized = append(recognized, t) + } + } + if len(recognized) == 0 { + return []string{"MusicAlbum"} + } + return recognized +} + +// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only +// (single-type queries push Offset/Max down to SQL instead). +func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { + if offset >= len(items) { + return []dto.BaseItemDto{} + } + items = items[offset:] + if limit > 0 && limit < len(items) { + items = items[:limit] + } + return items +} + +// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the +// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is +// exact once matches end (and a growing lower bound before), so paging terminates at the last match. +func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { + fetch := opts + if fetch.Max > 0 { + fetch.Max++ + } + rows, err := search(fetch) + if err != nil { + return nil, 0, err + } + total := opts.Offset + len(rows) + if opts.Max > 0 && len(rows) > opts.Max { + rows = rows[:opts.Max] + } + return rows, total, nil +} + +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { + repo := api.ds.Album(ctx) + filters := squirrel.And{} + // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's + // albums"; contributingArtistIds means "albums they only appear on" (Featured On). + switch { + case contributingOnly && artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) + case firstNonEmpty(artistId, parentId) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + default: + filters = append(filters, notMissing) + } + if len(genreIds) > 0 { + filters = append(filters, filter.ByGenreID(genreIds)) + } + if fav { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, scopeIDs) + + if search != "" { + albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset), nil + } + albums, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil +} + +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId, artistId string, genreIds []string, scopeIDs []int, search string, fav bool, fields dto.Fields) (dto.QueryResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, fields) } + repo := api.ds.MediaFile(ctx) + filters := squirrel.And{} + // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. + switch { + case artistId != "": + filters = append(filters, filter.SongsByArtistID(artistId).Filters) + case parentId != "": + filters = append(filters, filter.SongsByAlbum(parentId).Filters) + default: + filters = append(filters, notMissing) + } + if len(genreIds) > 0 { + filters = append(filters, filter.ByGenreID(genreIds)) + } + if fav { + filters = append(filters, filter.ByStarred().Filters) + } + opts.Filters = filters + opts = filter.ApplyLibraryFilter(opts, scopeIDs) + + if search != "" { + mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(mfs, toItem), total, opts.Offset), nil + } + // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an + // explicit client SortBy still wins, since applySort already set opts.Sort. + if artistId == "" && parentId != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(parentId).Sort + } + mfs, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(mfs, toItem), int(total), opts.Offset), nil +} + +// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, +// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. +// genreIds isn't applied to search — a name lookup, like role (see below). +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, genreIds []string, scopeIDs []int, search string, fav bool, role model.Role) (dto.QueryResult, error) { + repo := api.ds.Artist(ctx) + + // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a + // search scope (artists have no library_id column). A compound or join-based filter + // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build + // filters differently. Role isn't applied to search for the same reason — it's a name lookup. + if search != "" { + if len(scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": scopeIDs} + } + artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { + return repo.Search(search, o) + }) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset), nil + } + + if fav { + opts.Filters = filter.ArtistsByStarred().Filters + } else { + opts.Filters = notMissing + } + if len(genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(genreIds)} + } + opts = filter.ArtistsByRole(opts, role) + opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) + artists, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil +} + +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. Paging is +// in-memory (GenreRepository has no CountAll, lists are small) so TotalRecordCount is the real total. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) { + genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) + if err != nil { + return dto.QueryResult{}, err + } + items := slice.Map(genres, dto.GenreToBaseItem) + return result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset), nil +} + +// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is +// enforced by playlistRepository, not scopeIDs. +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) { + if favOnly { + starred := squirrel.Eq{"starred": true} + if opts.Filters == nil { + opts.Filters = starred + } else { + opts.Filters = squirrel.And{opts.Filters, starred} + } + } + repo := api.ds.Playlist(ctx) + playlists, err := repo.GetAll(opts) + if err != nil { + return dto.QueryResult{}, err + } + total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) + if err != nil { + return dto.QueryResult{}, err + } + return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil +} + +// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, +// artist, song and playlist in turn. Albums and songs report not-found when the user lacks access +// to their library, so an id can't probe content outside the user's libraries. +func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { + // The synthetic playlists folder must resolve by the id we advertised, not 404. + if id == playlistsFolderID { + return playlistsFolder(), true + } + u, _ := request.UserFrom(ctx) + // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this + // the home screen and library tabs 404. + if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { + for _, lib := range u.Libraries { + if lib.ID == libID { + return libraryView(lib), true + } + } + // Admin bypass: Libraries is empty but all access is granted, so fetch the real library. + if lib, err := api.ds.Library(ctx).Get(libID); err == nil { + return libraryView(*lib), true + } + } + if al, err := api.ds.Album(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(al.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.AlbumToBaseItem(*al), true + } + if ar, err := api.ds.Artist(ctx).Get(id); err == nil { + // TODO: an artist spans multiple libraries (library_artist), so there's no single + // LibraryID to gate here; artist access relies on list-time scoping and persistence. + return dto.ArtistToBaseItem(*ar), true + } + if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { + if !u.HasLibraryAccess(mf.LibraryID) { + return dto.BaseItemDto{}, false + } + return dto.SongToBaseItem(*mf, fields), true + } + // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. + if pl, err := api.playlists.Get(ctx, id); err == nil { + return dto.PlaylistToBaseItem(*pl), true + } + return dto.BaseItemDto{}, false +} + +// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id. +func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile { + songs := make(map[string]model.MediaFile, len(ids)) + // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. + for chunk := range slice.CollectChunks(slices.Values(ids), 500) { + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) + if err != nil { + log.Error(ctx, "Jellyfin API: error fetching songs by id", err) + continue + } + for _, mf := range mfs { + songs[mf.ID] = mf + } + } + return songs +} + +// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. +// A Finamp-truncated id is resolved by prefix but echoed as requested — Finamp matches restored +// queue items against its stored (truncated) ids. +func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { + u, _ := request.UserFrom(ctx) + fullIDs := api.resolveItemIDs(ctx, ids) + songs := api.songsByIDs(ctx, fullIDs) + var items []dto.BaseItemDto + for i, id := range fullIDs { + var item dto.BaseItemDto + if mf, ok := songs[id]; ok { + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + item = dto.SongToBaseItem(mf, fields) + } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { + continue + } + if id != ids[i] { + item.Id = dto.EncodeID(ids[i]) + } + items = append(items, item) + } + return result(items, len(items), 0) +} + +func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { + api.ok(w, r, item) + return + } + http.Error(w, "Not Found", http.StatusNotFound) +} + +// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from +// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. +func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := dto.DecodeID(chi.URLParam(r, "itemId")) + if err := api.playlists.Delete(ctx, id); err != nil { + api.playlistError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + opts := filter.AlbumsByNewest() + opts.Max = req.Params(r).IntOr("limit", 20) + opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + albums, err := api.ds.Album(ctx).GetAll(opts) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array +} + +func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { + if items == nil { + items = []dto.BaseItemDto{} + } + return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} +} + +// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the +// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); +// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), +// not passed through raw where it could produce an invalid ORDER BY. +func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + for key := range strings.SplitSeq(sortBy, ",") { + if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { + opts.Sort = col + break + } + } + if strings.EqualFold(order, "Descending") { + opts.Order = "desc" + } +} + +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type. Each repository maps +// logical fields to different real columns (e.g. media_file has "title" not "name"; artist has no +// "random"). +var sortColumnsByType = map[string]map[string]string{ + "Audio": { + "sortname": "title", "name": "title", + "album": "album", + // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's + // "album" sort key is disc+track order within an album, so map both to it. + "indexnumber": "album", + "parentindexnumber": "album", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "random": "random", + // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. + "premieredate": "year", + "productionyear": "year", + }, + "MusicArtist": { + "sortname": "name", "name": "name", + "albumcount": "album_count", + "songcount": "song_count", + "datecreated": "created_at", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + }, + "MusicAlbum": { + "sortname": "name", "name": "name", "album": "name", + "artist": "artist", + "albumartist": "album_artist", + "datecreated": "recently_added", + "random": "random", + "playcount": "play_count", + "dateplayed": "play_date", + "communityrating": "rating", + "premieredate": "max_year", "productionyear": "max_year", + }, + "MusicGenre": { + "sortname": "name", "name": "name", + }, + "Playlist": { + "sortname": "name", "name": "name", + "datecreated": "created_at", + }, +} + +// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for +// itemType, reporting false when it isn't recognized for that type. +func sortColumn(itemType, sortBy string) (string, bool) { + col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)] + return col, ok +} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go new file mode 100644 index 000000000..8bd1bf052 --- /dev/null +++ b/server/jellyfin/items_test.go @@ -0,0 +1,608 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// withChiURLParam simulates chi's routing having captured a path parameter, since these +// tests call handlers directly instead of going through the full router. +func withChiURLParam(r *http.Request, key, value string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add(key, value) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +var _ = Describe("Items", func() { + var api *Router + var ds *tests.MockDataStore + var fp *fakePlaylists + // alice has access to library 1 only; used by tests that don't care about scoping. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + ctxUserWithLibraries := func(libs model.Libraries) context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + } + // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. + ctxAdmin := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true, Libraries: nil}) + } + BeforeEach(func() { + ds = &tests.MockDataStore{} + fp = &fakePlaylists{} + api = &Router{ds: ds, playlists: fp} + }) + + Describe("getItems", func() { + It("lists albums when IncludeItemTypes=MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("lists an album's songs when ParentId is an album and type is Audio", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("json_tree")) + }) + + It("lists artists when IncludeItemTypes=MusicArtist", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicArtist")) + }) + + It("lists genres when IncludeItemTypes=MusicGenre", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).NotTo(BeNil()) + }) + + It("lists playlists when IncludeItemTypes=Playlist", func() { + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("Playlist")) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("p1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("merges results from every requested type in IncludeItemTypes", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("Audio", "MusicAlbum")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("merges favorite songs, albums, and playlists", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) + playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(3)) + types := []string{res.Items[0].Type, res.Items[1].Type} + types = append(types, res.Items[2].Type) + Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist")) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + playlistSQL, _, err := playlistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(playlistSQL).To(ContainSubstring("starred")) + }) + + It("applies StartIndex/Limit to the merged multi-type result set", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(4)) + Expect(res.StartIndex).To(Equal(1)) + }) + + It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}, {ID: "s2", Title: "Song2"}}) + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // The merged window is [1, 3): each type needs at most its first 3 rows, not the table. + Expect(mfRepo.Options.Max).To(Equal(3)) + Expect(albumRepo.Options.Max).To(Equal(3)) + }) + + It("applies a starred filter when Filters=IsFavorite", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + }) + + It("forwards SearchTerm to the repo's Search method", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("reports a search total beyond the fetched page instead of the page length", func() { + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ + {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.TotalRecordCount).To(Equal(3)) + }) + + It("forwards StartIndex/Limit as Offset/Max", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Offset).To(Equal(5)) + Expect(albumRepo.Options.Max).To(Equal(10)) + }) + + Describe("Ids batch-fetch", func() { + // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=; without + // this, queryItems ignored Ids and returned the default type-dispatched list instead. + It("returns exactly the requested item when Ids has a single id", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Name).To(Equal("Song")) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + + It("returns items of different types for a lowercase ids param with multiple ids", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + ids := []string{res.Items[0].Id, res.Items[1].Id} + Expect(ids).To(ConsistOf(dto.EncodeID("a1"), dto.EncodeID("s1"))) + types := []string{res.Items[0].Type, res.Items[1].Type} + Expect(types).To(ConsistOf("MusicAlbum", "Audio")) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("resolves song ids with one batched IN query, not a Get per id", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}, {ID: "s2", Title: "Song2", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.id IN")) + Expect(args).To(ConsistOf("s1", "s2")) + }) + + It("omits an id in a library the user can't access, without erroring the whole batch", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) // alice only has access to library 1 + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("a1"))) + Expect(res.TotalRecordCount).To(Equal(1)) + }) + }) + + Describe("sorting", func() { + It("maps SortBy=PlayCount to the play_count column", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("play_count")) + }) + + It("maps SortBy=DatePlayed to the play_date column", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("play_date")) + }) + + It("uses the first recognized key in a comma-separated SortBy list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("recently_added")) + }) + + It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=Unknown1,Unknown2,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("title")) + }) + + It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=ParentIndexNumber,IndexNumber,SortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(mfRepo.Options.Sort).To(Equal("album")) + }) + + It("leaves Sort at the repo default when no SortBy key is recognized", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Sort).To(Equal("")) + }) + }) + + Describe("library scoping", func() { + It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := mfRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("scopes a MusicArtist listing to the user's accessible libraries", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_artist.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("2")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("json_tree")) // not treated as an artist-parent filter + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(2)) + }) + + It("does not let ParentId= scope results to that library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}} // no access to library 99 + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("99")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + // Falls back to treating "99" as an (empty-matching) artist-parent id... + Expect(sql).To(ContainSubstring("json_tree")) + // ...while still scoping to the user's own accessible libraries. + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElement(1)) + Expect(args).NotTo(ContainElement(99)) + }) + + It("does not restrict a default MusicAlbum listing for an admin user", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}, {ID: "a2", Name: "Two", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // accessibleLibraryIDs is empty for an admin (Libraries is nil), so + // ApplyLibraryFilter([]) is a no-op: no library_id restriction is added. + if albumRepo.Options.Filters == nil { + return + } + sql, _, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("library_id")) + }) + }) + }) + + Describe("getItem", func() { + It("returns an album by id", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + Expect(item.Type).To(Equal("MusicAlbum")) + }) + + It("returns 404 when the id doesn't match any entity", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an album in a library the user can't access", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a song in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1"), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns an album to an admin even when it's outside their (empty) Libraries", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("a1"), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("a1"))) + }) + + // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the + // library node before it can load the home screen or any library tab. + It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1, Name: "Music Library"}} + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + Expect(item.CollectionType).To(Equal("music")) + Expect(item.IsFolder).To(BeTrue()) + }) + + It("does not resolve a library-view id the user has no access to", func() { + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it + // 404s with "Could not fetch BaseItemDto from server." + It("resolves a playlist id via the playlists service", func() { + fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("p1"))) + Expect(item.Name).To(Equal("My Mix")) + Expect(item.Type).To(Equal("Playlist")) + }) + + It("returns 404 for a non-owned or absent playlist id", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { + ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("1"), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeID("1")) + invoke(api.getItem, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var item dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) + Expect(item.Id).To(Equal(dto.EncodeID("1"))) + Expect(item.Name).To(Equal("Music Library")) + Expect(item.Type).To(Equal("CollectionFolder")) + }) + }) + + Describe("getLatest", func() { + It("returns a bare array of the newest albums", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var items []dto.BaseItemDto + Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) + Expect(items).To(HaveLen(1)) + Expect(items[0].Id).To(Equal(dto.EncodeID("a1"))) + }) + + It("scopes to the user's accessible libraries", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + libs := model.Libraries{{ID: 1}, {ID: 2}} + r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) + invoke(api.getLatest, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := albumRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + }) +}) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go new file mode 100644 index 000000000..aab9628a0 --- /dev/null +++ b/server/jellyfin/jellyfin_suite_test.go @@ -0,0 +1,25 @@ +package jellyfin + +import ( + "net/http" + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJellyfinApi(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Jellyfin API Suite") +} + +// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call +// handlers directly (with withChiURLParam for path params) instead of routing, so without this the +// case-insensitive query folding real requests get would be skipped and PascalCase params dropped. +func invoke(h http.HandlerFunc, w http.ResponseWriter, r *http.Request) { + normalizeQueryKeys(h).ServeHTTP(w, r) +} diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go new file mode 100644 index 000000000..2e486c36f --- /dev/null +++ b/server/jellyfin/library.go @@ -0,0 +1,44 @@ +package jellyfin + +import ( + "context" + "strconv" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty +// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters. +func accessibleLibraryIDs(ctx context.Context) []int { + u, _ := request.UserFrom(ctx) + return u.Libraries.IDs() +} + +// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an +// entity id (artist/album). It's treated as a library only when the user has access; otherwise +// isLibraryParent is false and callers fall through to entity-id handling. +func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) { + if parentId != "" { + if id, err := strconv.Atoi(parentId); err == nil { + if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) { + return []int{id}, true + } + } + } + return accessibleLibraryIDs(ctx), false +} + +// libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. +// Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. +func libraryView(lib model.Library) dto.BaseItemDto { + return dto.BaseItemDto{ + Id: dto.EncodeID(strconv.Itoa(lib.ID)), + Name: lib.Name, + Type: "CollectionFolder", + CollectionType: "music", + IsFolder: true, + BackdropImageTags: []string{}, + } +} diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go new file mode 100644 index 000000000..2941d3c32 --- /dev/null +++ b/server/jellyfin/middlewares.go @@ -0,0 +1,187 @@ +package jellyfin + +import ( + "net" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params +// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, +// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one +// client's filters, sort and paging. Only keys are folded — values keep their case. The original +// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing. +func normalizeQueryKeys(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + folded := make(url.Values, len(q)) + changed := false + for k, vs := range q { + lk := strings.ToLower(k) + // Append, don't assign: two casings of the same key must merge, not overwrite. + folded[lk] = append(folded[lk], vs...) + if lk != k { + changed = true + } + } + if changed { + r2 := *r + u := *r.URL + u.RawQuery = folded.Encode() + r2.URL = &u + r = &r2 + } + next.ServeHTTP(w, r) + }) +} + +type mediaBrowserAuth struct { + Client, Device, DeviceId, Version, Token string +} + +var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) + +// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g. +// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. +// The recommended Authorization header is preferred, but only when it actually carries +// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client +// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk) +// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField +// leaves a raw value untouched. +func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth { + if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok { + return a + } + a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization")) + return a +} + +// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the +// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts). +func parseAuthHeader(h string) (mediaBrowserAuth, bool) { + var a mediaBrowserAuth + scheme, params, found := strings.Cut(h, " ") + if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) { + return a, false + } + for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) { + switch m[1] { + case "Client": + a.Client = unescapeField(m[2]) + case "Device": + a.Device = unescapeField(m[2]) + case "DeviceId": + a.DeviceId = unescapeField(m[2]) + case "Version": + a.Version = unescapeField(m[2]) + case "Token": + a.Token = unescapeField(m[2]) + } + } + return a, true +} + +// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't +// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not +// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space. +func unescapeField(v string) string { + if decoded, err := url.PathUnescape(v); err == nil { + return decoded + } + return v +} + +// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings +// deprecated by Jellyfin but still sent by clients. +func tokenFromRequest(r *http.Request) string { + if t := parseMediaBrowserAuth(r).Token; t != "" { + return t + } + if t := r.Header.Get("X-Emby-Token"); t != "" { + return t + } + if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { + return t + } + // api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't + // merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=). + if t := r.URL.Query().Get("api_key"); t != "" { + return t + } + return r.URL.Query().Get("apikey") +} + +// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token +// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +func (api *Router) userFromToken(r *http.Request) (model.User, bool) { + token := tokenFromRequest(r) + if token == "" { + return model.User{}, false + } + claims, err := auth.Validate(token) + if err != nil || claims.Subject == "" { + return model.User{}, false + } + usr, err := api.ds.User(r.Context()).FindByUsername(claims.Subject) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) + return model.User{}, false + } + return *usr, true +} + +func (api *Router) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + usr, ok := api.userFromToken(r) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ctx := request.WithUser(r.Context(), usr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring +// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic), +// so it's used directly as the player id and reports from the same install share a player/scrobbling +// session. +func (api *Router) withPlayer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if api.players == nil { // fail open when players isn't wired (e.g. in unit tests) + next.ServeHTTP(w, r) + return + } + ctx := r.Context() + a := parseMediaBrowserAuth(r) + // Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g. + // the /socket handshake that authenticates via ?api_key= only). Otherwise Register would + // create a junk player with an empty name (" []"). + if a.Client == "" && a.DeviceId == "" { + next.ServeHTTP(w, r) + return + } + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) + if err != nil { + // Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers + // degrade gracefully. + log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) + next.ServeHTTP(w, r) + return + } + ctx = request.WithPlayer(ctx, *player) + // Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override. + if trc != nil { + ctx = request.WithTranscoding(ctx, *trc) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go new file mode 100644 index 000000000..2c0761834 --- /dev/null +++ b/server/jellyfin/middlewares_test.go @@ -0,0 +1,254 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("authenticate middleware", func() { + var api *Router + var ds *tests.MockDataStore + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + api = &Router{ds: ds} + }) + + tokenFor := func(name string) string { + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name}) + Expect(err).ToNot(HaveOccurred()) + return t + } + + It("passes with a valid X-Emby-Token and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", tokenFor("alice")) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() { + var gotUser model.User + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUser, _ = request.UserFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotUser.UserName).To(Equal("alice")) + }) + + It("rejects a missing token with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a garbage token with 401 and does not call next", func() { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", "not-a-jwt") + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(nextCalled).To(BeFalse()) + }) + + It("rejects a valid token whose subject user does not exist with 401", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", t) + api.authenticate(next).ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var players *fakePlayers + + BeforeEach(func() { + players = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: players} + }) + + callWith := func() (model.Player, model.Transcoding, bool) { + var gotPlayer model.Player + var gotTrc model.Transcoding + var hasTrc bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, _ = request.PlayerFrom(r.Context()) + gotTrc, hasTrc = request.TranscodingFrom(r.Context()) + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + api.withPlayer(next).ServeHTTP(w, r) + return gotPlayer, gotTrc, hasTrc + } + + It("injects the registered player into the context", func() { + player, _, hasTrc := callWith() + Expect(player.ID).To(Equal("dev1")) + Expect(hasTrc).To(BeFalse()) + }) + + It("injects the player's server-forced transcoding into the context", func() { + players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + _, trc, hasTrc := callWith() + Expect(hasTrc).To(BeTrue()) + Expect(trc.TargetFormat).To(Equal("opus")) + }) +}) + +var _ = Describe("tokenFromRequest", func() { + It("accepts the recommended Authorization: MediaBrowser scheme", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("prefers the Authorization scheme token over deprecated token headers", func() { + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`) + r.Header.Set("X-Emby-Token", "legacy-token") + Expect(tokenFromRequest(r)).To(Equal("scheme-token")) + }) + + It("accepts the lowercase api_key query param", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil) + Expect(tokenFromRequest(r)).To(Equal("tok123")) + }) + + It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() { + r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) + Expect(got).To(Equal("tok123")) + }) +}) + +var _ = Describe("parseMediaBrowserAuth", func() { + authFor := func(header string) mediaBrowserAuth { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-Emby-Authorization", header) + return parseMediaBrowserAuth(r) + } + + It("reads Finamp's raw (unencoded) field values", func() { + a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + Expect(a.DeviceId).To(Equal("dev1")) + }) + + It("percent-decodes Jellify's URL-encoded field values", func() { + a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`) + Expect(a.Client).To(Equal("Jellify")) + Expect(a.Device).To(Equal("Pixel 8 Pro")) + }) + + It("keeps a literal '%' that isn't valid percent-encoding", func() { + a := authFor(`MediaBrowser Client="100% Player", Device="d"`) + Expect(a.Client).To(Equal("100% Player")) + }) + + It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("New")) + Expect(a.DeviceId).To(Equal("dev-new")) + }) + + It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() { + // A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must + // still be honored. + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Digest username="proxy", realm="site"`) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`) + a := parseMediaBrowserAuth(r) + Expect(a.Client).To(Equal("Finamp")) + Expect(a.Token).To(Equal("tok")) + }) + + It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Authorization", `Custom Token="not-for-us"`) + Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty()) + }) + + It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() { + a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`) + Expect(a.Client).To(Equal("OldClient")) + Expect(a.Token).To(Equal("tok")) + }) + + It("matches the scheme case-insensitively (HTTP auth schemes are)", func() { + a := authFor(`mediabrowser Token="tok"`) + Expect(a.Token).To(Equal("tok")) + }) +}) + +var _ = Describe("normalizeQueryKeys", func() { + // keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for + // the given (lowercase) key — i.e. what a case-insensitive read would find. + keyFor := func(rawQuery, key string) string { + r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil) + var got string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r) + return got + } + + It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() { + Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc")) + Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc")) + }) + + It("leaves values untouched", func() { + Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio")) + }) + + It("passes already-lowercase keys through unchanged", func() { + Expect(keyFor("container=mp3", "container")).To(Equal("mp3")) + }) + + It("merges values when two keys fold to the same name instead of dropping one", func() { + r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil) + var got []string + invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r) + Expect(got).To(ConsistOf("aaa", "bbb")) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go new file mode 100644 index 000000000..ffc2c6543 --- /dev/null +++ b/server/jellyfin/playlists.go @@ -0,0 +1,257 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "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/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// playlistsFolderID is the reserved id of the synthetic "playlists library" folder. Clients resolve +// it via a ManualPlaylistsFolder query, then list playlists with ParentId set to it. The literal +// can't collide with real ids (those are hashes). +const playlistsFolderID = "playlists" + +// 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.EncodeID(playlistsFolderID), + Name: "Playlists", + Type: "ManualPlaylistsFolder", + CollectionType: "playlists", + IsFolder: true, + } +} + +// playlistError maps core/playlists write errors to HTTP status: ownership -> 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): + 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 + } + 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)}) +} + +// 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 := dto.DecodeID(chi.URLParam(r, "playlistId")) + 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 { + ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + 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) +} + +// 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.EncodeID(t.ID) + return item +} + +// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the +// edit screen). GetWithTracks enforces 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 := 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 GetWithTracks 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 := dto.DecodeID(chi.URLParam(r, "playlistId")) + pls, err := api.playlists.GetWithTracks(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) + api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)}) +} + +// 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) // 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 containers into tracks (see expandContainerIDs). +// AddTracks enforces ownership; 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(queryIDs(r, "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 — playlist-entry ids (PlaylistItemId), not media +// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces +// ownership; 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(queryIDs(r, "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 answer client probes (e.g. Finamp) made before allowing +// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still +// enforced by AddTracks/RemoveTracks. +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}) +} diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go new file mode 100644 index 000000000..804123003 --- /dev/null +++ b/server/jellyfin/playlists_test.go @@ -0,0 +1,424 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/playlists" + "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/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so +// unimplemented methods aren't needed here; only the ones this test exercises are overridden. +type fakePlaylists struct { + playlists.Playlists + + createdName string + createdIds []string + createErr error + + getPls *model.Playlist + getErr error + + getByIDPls *model.Playlist + getByIDErr error + + addPlaylistID string + addIds []string + addErr error + + removePlaylistID string + removeIds []string + removeErr error + + setImagePlaylistID string + setImageBytes []byte + setImageExt string + setImageErr error + + removeImagePlaylistID string + removeImageErr error + + deletePlaylistID string + deleteErr error +} + +func (f *fakePlaylists) Delete(_ context.Context, id string) error { + f.deletePlaylistID = id + return f.deleteErr +} + +func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) { + f.createdName = name + f.createdIds = ids + if f.createErr != nil { + return "", f.createErr + } + return "pl-new", nil +} + +// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real +// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't +// care about playlists leave it unconfigured. +func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) { + if f.getByIDErr != nil { + return nil, f.getByIDErr + } + if f.getByIDPls == nil { + return nil, model.ErrNotFound + } + return f.getByIDPls, nil +} + +func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound // mirror the real repo: never (nil, nil) + } + return f.getPls, nil +} + +func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { + f.addPlaylistID = playlistID + f.addIds = ids + return len(ids), f.addErr +} + +func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error { + f.removePlaylistID = playlistID + f.removeIds = trackIds + return f.removeErr +} + +func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error { + f.setImagePlaylistID = playlistID + f.setImageExt = ext + if reader != nil { + f.setImageBytes, _ = io.ReadAll(reader) + } + return f.setImageErr +} + +func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error { + f.removeImagePlaylistID = playlistID + return f.removeImageErr +} + +var _ = Describe("Playlists", func() { + var api *Router + var fp *fakePlaylists + + BeforeEach(func() { + fp = &fakePlaylists{} + api = &Router{ds: &tests.MockDataStore{}, playlists: fp} + }) + + Describe("createPlaylist", func() { + It("creates a playlist and returns its id", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["s1","s2"]}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res map[string]string + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res["Id"]).To(Equal(dto.EncodeID("pl-new"))) + Expect(fp.createdName).To(Equal("Mix")) + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 400 on an invalid JSON body", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 500 when the service fails", func() { + fp.createErr = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)). + WithContext(context.Background()) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("getPlaylistItems", func() { + It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1", Title: "Song One"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2", Title: "Song Two"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(2)) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].Type).To(Equal("Audio")) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("s2"))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + api.getPlaylistItems(w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("container id expansion", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + api = &Router{ds: ds, playlists: fp} + }) + + createWith := func(id string) { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + WithContext(ctx) + invoke(api.createPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + } + + It("passes a bare song id through unchanged", func() { + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}}) + createWith("s1") + Expect(fp.createdIds).To(Equal([]string{"s1"})) + }) + + It("expands an album id into its songs, filtered by album", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + }) + createWith("al1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum("al1").Filters)) + }) + + It("expands an artist id into its songs", func() { + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1"}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1"}, {ID: "s2"}}) + createWith("ar1") + Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID("ar1").Filters)) + }) + + It("expands a playlist id into its tracks' media file ids", func() { + fp.getPls = &model.Playlist{ID: "pl9", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s3"}, {ID: "2", MediaFileID: "s4"}, + }} + createWith("pl9") + Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + }) + }) + + Describe("getPlaylist", func() { + It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Public: true, + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistInfo + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.OpenAccess).To(BeTrue()) + Expect(res.Shares).To(BeEmpty()) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID("s1"), dto.EncodeID("s2")})) + }) + + It("returns 404 for a non-owned or absent playlist", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "missing") + invoke(api.getPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("deleteItem", func() { + deleteReq := func(id string) *http.Request { + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background()) + return withChiURLParam(r, "itemId", dto.EncodeID(id)) + } + + It("deletes the playlist and returns 204", func() { + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.deletePlaylistID).To(Equal("pl1")) + }) + + It("returns 403 when the user doesn't own the playlist", func() { + fp.deleteErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("returns 404 for a missing playlist or non-playlist id", func() { + fp.deleteErr = model.ErrNotFound + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("al1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 on an unexpected error", func() { + fp.deleteErr = errors.New("boom") + w := httptest.NewRecorder() + api.deleteItem(w, deleteReq("pl1")) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("addToPlaylist", func() { + It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?Ids=s1,s2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.addErr = model.ErrNotAuthorized + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items?ids=s1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.addToPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addIds).To(BeEmpty()) + }) + }) + + Describe("removeFromPlaylist", func() { + It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?EntryIds=1,2", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removeIds).To(Equal([]string{"1", "2"})) + }) + + It("returns 404 when the service rejects the request (not found/not owned)", func() { + fp.removeErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items?entryIds=1", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/pl1/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removeIds).To(BeEmpty()) + }) + }) + + Describe("getPlaylistUsers", func() { + It("returns the current user with CanEdit true", func() { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/pl1/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", "pl1") + api.getPlaylistUsers(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res []dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}})) + }) + }) + + Describe("getPlaylistUser", func() { + It("returns CanEdit true for the requested user", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Users/u1", nil).WithContext(context.Background()) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("playlistId", "pl1") + rctx.URLParams.Add("userId", "u1") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + api.getPlaylistUser(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: "u1", CanEdit: true})) + }) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go new file mode 100644 index 000000000..e3c9903a8 --- /dev/null +++ b/server/jellyfin/routing_test.go @@ -0,0 +1,56 @@ +package jellyfin + +import ( + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Real Jellyfin servers route path segments case-insensitively, but chi's default matching is +// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go +// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof +// that requests using non-canonical casing are still routed correctly, both when the router is used +// directly and when mounted under a parent (as it is in production via server.MountRouter). +var _ = Describe("Case-insensitive routing", func() { + var api *Router + + BeforeEach(func() { + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + }) + + It("serves a fully lowercase path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/system/info/public", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a mixed/weird-case path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/SYSTEM/Info/PUBLIC", nil) + api.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("serves a lowercase login path directly", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/users/authenticatebyname", nil) + api.ServeHTTP(w, r) + // MockDataStore has no users, so authentication itself may fail downstream, but the + // route must be found (not a 404) to prove case-insensitive matching worked. + Expect(w.Code).ToNot(Equal(http.StatusNotFound)) + }) + + It("serves a lowercase path when mounted under a parent router, replicating production", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", api) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/jellyfin/system/info/public", nil) + parent.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go new file mode 100644 index 000000000..f462f283a --- /dev/null +++ b/server/jellyfin/sessions.go @@ -0,0 +1,115 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo +// fields Navidrome needs to keep its playback/scrobbling state in sync. +type playbackReport struct { + ItemId string `json:"ItemId"` + PositionTicks int64 `json:"PositionTicks"` + IsPaused bool `json:"IsPaused"` +} + +// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send +// it there) and is decoded here since it flows straight into scrobbler lookups by media file id. +// Finamp reports restored-queue playback with truncated ids, hence resolveItemID. +func (api *Router) decodeReport(r *http.Request) playbackReport { + var body playbackReport + _ = json.NewDecoder(r.Body).Decode(&body) + if body.ItemId == "" { + body.ItemId = r.URL.Query().Get("itemid") + } + body.ItemId = api.resolveItemID(r.Context(), dto.DecodeID(body.ItemId)) + return body +} + +// clientIdentity returns the scrobbler cache key/display name for the caller's +// player. Both are zero values if withPlayer could not resolve a player. +func clientIdentity(ctx context.Context) (id, name string) { + player, _ := request.PlayerFrom(ctx) + return player.ID, player.Client +} + +// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client starts an item. +// +// These Sessions endpoints report only the caller's own playback and never expose content, so unlike +// browse/stream they are intentionally not library-access-gated. +func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically +// (and on pause/resume/seek) while a client keeps playing an item. +func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + state := scrobbler.StatePlaying + if body.IsPaused { + state = scrobbler.StatePaused + } + clientId, clientName := clientIdentity(ctx) + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: state, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) + } + w.WriteHeader(http.StatusNoContent) +} + +// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when playback ends. +// +// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch, +// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play +// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played. +func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + body := api.decodeReport(r) + clientId, clientName := clientIdentity(ctx) + + err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: body.ItemId, + PositionMs: body.PositionTicks / 10_000, + State: scrobbler.StateStopped, + ClientId: clientId, + ClientName: clientName, + }) + if err != nil { + log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) + } + 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) { + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go new file mode 100644 index 000000000..24f945efd --- /dev/null +++ b/server/jellyfin/sessions_test.go @@ -0,0 +1,216 @@ +package jellyfin + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring +// server/subsonic's fakePlayTracker. +type fakePlayTracker struct { + scrobbler.PlayTracker + reported []scrobbler.ReportPlaybackParams + submitted []scrobbler.Submission +} + +func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error { + f.reported = append(f.reported, p) + return nil +} + +func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error { + f.submitted = append(f.submitted, s...) + return nil +} + +// fakePlayers is a local double for core.Players, used to exercise withPlayer. +type fakePlayers struct { + core.Players + err error + registerCalls int + lastClient string + trc *model.Transcoding +} + +func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { + f.registerCalls++ + f.lastClient = client + if f.err != nil { + return nil, nil, f.err + } + return &model.Player{ID: id, Client: client}, f.trc, nil +} + +var _ = Describe("Sessions", func() { + var api *Router + var pt *fakePlayTracker + + authed := func(r *http.Request) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", Client: "Finamp"}) + return r.WithContext(ctx) + } + + BeforeEach(func() { + pt = &fakePlayTracker{} + api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt} + }) + + Describe("reportPlaybackStart", func() { + It("reports playback start with the item id and position", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"s1","PositionTicks":10000000}`))) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].ClientId).To(Equal("p1")) + Expect(pt.reported[0].ClientName).To(Equal("Finamp")) + }) + + It("falls back to the ItemId query param when the body has none", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId=s2", nil)) + + invoke(api.reportPlaybackStart, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s2")) + }) + }) + + Describe("reportPlaybackProgress", func() { + It("reports the playing state when not paused", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":false}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(2000))) + }) + + It("reports the paused state when IsPaused is true", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"s1","PositionTicks":20000000,"IsPaused":true}`))) + + invoke(api.reportPlaybackProgress, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused)) + }) + }) + + Describe("reportPlaybackStopped", func() { + It("reports the stopped state and lets the scrobbler apply its play threshold", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"s1","PositionTicks":600000000}`))) + + invoke(api.reportPlaybackStopped, w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + Expect(pt.reported).To(HaveLen(1)) + Expect(pt.reported[0].MediaId).To(Equal("s1")) + Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) + Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) + // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides + // whether the play counts; we no longer force a Submit that would bypass it. + Expect(pt.reported[0].IgnoreScrobble).To(BeFalse()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) + + Describe("postCapabilities", 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) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(pt.reported).To(BeEmpty()) + Expect(pt.submitted).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("withPlayer middleware", func() { + var api *Router + var fp *fakePlayers + + BeforeEach(func() { + fp = &fakePlayers{} + api = &Router{ds: &tests.MockDataStore{}, players: fp} + }) + + It("registers a player from the Emby device info and injects it into the context", func() { + var gotPlayer model.Player + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPlayer, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeTrue()) + Expect(gotPlayer.ID).To(Equal("dev1")) + Expect(gotPlayer.Client).To(Equal("Finamp")) + }) + + It("fails open (no player in context) when registration errors", func() { + fp.err = errors.New("boom") + var gotOk bool + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOk = request.PlayerFrom(r.Context()) + w.WriteHeader(http.StatusNoContent) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Sessions/Playing", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(gotOk).To(BeFalse()) + }) + + // The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it + // carries no client/device info; registering it would create a junk player named " []". + It("skips registration when the request has no client or device info", func() { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/socket?api_key=tok", nil) + + api.withPlayer(next).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(fp.registerCalls).To(Equal(0)) + }) +}) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go new file mode 100644 index 000000000..db3671fe4 --- /dev/null +++ b/server/jellyfin/similar.go @@ -0,0 +1,190 @@ +package jellyfin + +import ( + "context" + "fmt" + "net/http" + "time" + + "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/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real +// result beats an instant empty list, which clients cache as "no similar items exist". A var so +// tests can shorten it. +var similarWait = 10 * time.Second + +const maxSimilarLimit = 100 + +// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine +// indefinitely. +const similarFetchTimeout = time.Minute + +// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the +// request times out or the client disconnects), waiting up to similarWait then answering empty. +// Identical concurrent requests share one fetch via singleflight; the key includes the user since +// mapped items embed that user's annotations. +func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult { + u, _ := request.UserFrom(ctx) + key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit) + ch := api.similarFlight.DoChan(key, func() (any, error) { + bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout) + defer cancel() + return fetch(bgCtx), nil + }) + select { + case res := <-ch: + return res.Val.(dto.QueryResult) + case <-time.After(similarWait): + return result(nil, 0, 0) + } +} + +// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same +// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are +// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. +func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { + id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarArtists(ctx, id, limit) + })) +} + +// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar +// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty +// result (not 404) so the client stops retrying. +func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult { + switch entity.(type) { + case *model.Artist: + return api.similarArtists(ctx, id, limit) + case *model.Album: + return api.similarAlbums(ctx, id, limit) + default: // *model.MediaFile + return api.similarSongs(ctx, id, limit) + } + })) +} + +// getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so +// 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 := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + limit := clampLimit(req.Params(r).IntOr("limit", 20)) + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + api.ok(w, r, result(nil, 0, 0)) + return + } + mf, isSong := entity.(*model.MediaFile) + if isSong { + if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) { + api.ok(w, r, result(nil, 0, 0)) + return + } + } + // Prefixed key: a mix must not share the singleflight/cache slot with a Similar request. + tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarSongs(ctx, id, limit) + }) + if !isSong { + // Container seeds: the provider's similar songs already blend the seed's own tracks. + api.ok(w, r, tail) + return + } + // The seed leads the mix and must not depend on the provider: a slow or failing provider times + // the await out with an empty tail, but the tapped track still plays. + items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)} + for _, it := range tail.Items { + if len(items) >= limit { + break + } + if it.Id != items[0].Id { + items = append(items, it) + } + } + api.ok(w, r, result(items, len(items), 0)) +} + +func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult { + artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err) + return result(nil, 0, 0) + } + present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) + items := slice.Map(present, dto.ArtistToBaseItem) + return result(items, len(items), 0) +} + +// clampLimit bounds a client-supplied limit so it can't drive an oversized allocation or provider +// fetch (flagged by CodeQL as a user-controlled allocation size). +func clampLimit(limit int) int { + if limit <= 0 { + return 20 + } + return min(limit, maxSimilarLimit) +} + +func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err) + return result(nil, 0, 0) + } + // Filter to the caller's libraries; the provider can return songs from any library. + u, _ := request.UserFrom(ctx) + var items []dto.BaseItemDto + for _, mf := range songs { + if u.HasLibraryAccess(mf.LibraryID) { + items = append(items, dto.SongToBaseItem(mf, nil)) + } + } + return result(items, len(items), 0) +} + +// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct +// "similar albums" source), keeping each album once in first-seen order and resolving it to a full +// model.Album for cover art and metadata. +func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult { + songs, err := api.provider.SimilarSongs(ctx, id, limit*5) + if err != nil { + log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err) + return result(nil, 0, 0) + } + u, _ := request.UserFrom(ctx) + seen := make(map[string]bool, limit) + var items []dto.BaseItemDto + for _, s := range songs { + if s.AlbumID == "" || seen[s.AlbumID] { + continue + } + seen[s.AlbumID] = true + if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { + items = append(items, dto.AlbumToBaseItem(*al)) + if len(items) >= limit { + break + } + } + } + return result(items, len(items), 0) +} diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go new file mode 100644 index 000000000..cbb68e5cf --- /dev/null +++ b/server/jellyfin/similar_test.go @@ -0,0 +1,131 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("awaitSimilar", func() { + var api *Router + ctxFor := func(userID string) context.Context { + return request.WithUser(context.Background(), model.User{ID: userID}) + } + shortenWait := func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + } + + BeforeEach(func() { + api = &Router{} + }) + + It("returns the fetch result when it completes within the wait", func() { + res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) + }) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("fast")) + }) + + It("returns an empty result when the fetch exceeds the wait", func() { + shortenWait() + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + <-release // hung provider; would finish caching in the background + return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) + }) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + + It("dedupes requests into the in-flight fetch", func() { + shortenWait() + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + <-release + return result(nil, 0, 0) + } + // Both calls time out, but the flight can't complete before release closes, so the + // second call must join it rather than start a new fetch. + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + api.awaitSimilar(ctxFor("u1"), "id3", 20, fetch) + close(release) + Eventually(calls.Load).Should(Equal(int32(1))) + Consistently(calls.Load, "50ms").Should(Equal(int32(1))) + }) + + It("does not share fetches across users (items embed the user's annotations)", func() { + var calls atomic.Int32 + fetch := func(context.Context) dto.QueryResult { + calls.Add(1) + return result(nil, 0, 0) + } + api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) + api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + Expect(calls.Load()).To(Equal(int32(2))) + }) + + It("hands the fetch a deadline-bounded background context", func() { + var deadline time.Time + var hasDeadline bool + api.awaitSimilar(ctxFor("u1"), "id5", 20, func(ctx context.Context) dto.QueryResult { + deadline, hasDeadline = ctx.Deadline() + return result(nil, 0, 0) + }) + Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever") + Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout)) + }) +}) + +// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent. +type blockingProvider struct { + external.Provider + release chan struct{} +} + +func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + <-p.release + return nil, nil +} + +var _ = Describe("getInstantMix", func() { + It("returns the seed track even when the provider fetch exceeds the wait", func() { + old := similarWait + similarWait = 20 * time.Millisecond + DeferCleanup(func() { similarWait = old }) + + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Seed Song", LibraryID: 1}, + }) + release := make(chan struct{}) + DeferCleanup(func() { close(release) }) + api := &Router{ds: ds, provider: &blockingProvider{release: release}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Name).To(Equal("Seed Song")) + }) +}) diff --git a/server/jellyfin/socket.go b/server/jellyfin/socket.go new file mode 100644 index 000000000..80a49cd4c --- /dev/null +++ b/server/jellyfin/socket.go @@ -0,0 +1,55 @@ +package jellyfin + +import ( + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/log" +) + +// socketKeepAliveInterval (seconds) is sent in the initial ForceKeepAlive telling the client how +// often to send KeepAlive, and bounds the local read deadline. +const socketKeepAliveInterval = 60 + +// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed +// KeepAlive doesn't drop the connection. +const socketReadTimeout = 90 * time.Second + +var socketUpgrader = websocket.Upgrader{ + // Jellyfin clients aren't browsers, so there's no cross-origin risk; the connection is + // already authenticated via api_key. + CheckOrigin: func(*http.Request) bool { return true }, +} + +// handleSocket implements Jellyfin's /socket WebSocket endpoint. Finamp opens it right after login +// and 404-loop-reconnects without it. Minimal: keeps the connection alive and answers KeepAlive +// pings, with no session/playstate push. +func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) { + conn, err := socketUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err) + return + } + defer conn.Close() + + if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil { + log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err) + return + } + + for { + _ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout)) + var msg struct { + MessageType string `json:"MessageType"` + } + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.MessageType == "KeepAlive" { + if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil { + return + } + } + } +} diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go new file mode 100644 index 000000000..91542c111 --- /dev/null +++ b/server/jellyfin/socket_test.go @@ -0,0 +1,125 @@ +package jellyfin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("handleSocket", func() { + var api *Router + + BeforeEach(func() { + api = &Router{} + }) + + // Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without + // a working handshake here they 404-loop-reconnect instead of settling into a session. + It("upgrades the connection and sends ForceKeepAlive", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + Expect(msg["Data"]).To(BeNumerically("==", 60)) + }) + + It("replies to a KeepAlive message with a KeepAlive of its own", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive")) + + Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var reply map[string]any + Expect(conn.ReadJSON(&reply)).To(Succeed()) + Expect(reply["MessageType"]).To(Equal("KeepAlive")) + }) + + It("closes the connection when the client disconnects, without leaving the handler hanging", func() { + srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var handshake map[string]any + Expect(conn.ReadJSON(&handshake)).To(Succeed()) + + Expect(conn.Close()).To(Succeed()) + }) + + // End-to-end: proves /socket is reachable through the full router (case-insensitive + // wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp + // hits it in production with ?api_key=. + Context("mounted behind the full router and auth middleware", func() { + var ds *tests.MockDataStore + var token string + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed()) + + t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + Expect(err).ToNot(HaveOccurred()) + token = t + + api = New(ds, nil, nil, nil, nil, nil, nil, nil) + }) + + It("upgrades when authenticated via the api_key query parameter", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).ToNot(HaveOccurred()) + defer conn.Close() + + Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) + var msg map[string]any + Expect(conn.ReadJSON(&msg)).To(Succeed()) + Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) + }) + + It("rejects the upgrade with no api_key", func() { + srv := httptest.NewServer(api) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket" + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + Expect(err).To(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go new file mode 100644 index 000000000..28411f9d1 --- /dev/null +++ b/server/jellyfin/stream.go @@ -0,0 +1,164 @@ +package jellyfin + +import ( + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its +// library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. +// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. +func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { + ctx := r.Context() + id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + u, _ := request.UserFrom(ctx) + if !u.HasLibraryAccess(mf.LibraryID) { + http.Error(w, "Not Found", http.StatusNotFound) + return nil, false + } + return mf, true +} + +// getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct +// playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). +func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + src := dto.MediaSourceFromMediaFile(*mf) + // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl + // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients + // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so + // a client resolving it as an absolute host path still hits the mounted router. + if token := tokenFromRequest(r); token != "" { + src.TranscodingSubProtocol = "http" + src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + } + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: mf.ID}) +} + +// streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, +// reusing the same transcode-decision + streaming pipeline as the Subsonic /stream endpoint. +func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + p := req.Params(r) + + format := p.StringOr("container", "") + if format == "" { + // The /stream.{container} route form carries the format as a path segment, not a query param. + format = chi.URLParam(r, "container") + } + if format == "" { + // Jellyfin's audioCodec param names the target codec when no container is given. + format = p.StringOr("audiocodec", "") + } + if p.BoolOr("static", false) { + format = "raw" + } + + // Bitrate params are bits/sec by Jellyfin convention; ResolveRequest expects kbps. + bitRate := p.IntOr("audiobitrate", 0) / 1000 + if bitRate == 0 { + bitRate = p.IntOr("maxstreamingbitrate", 0) / 1000 + } + + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, format, bitRate, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} + +// streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD +// playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. +// Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. +func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + p := req.Params(r) + + // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced + // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. + codec := strings.ToLower(p.StringOr("audiocodec", "")) + if codec != "mp3" { + codec = "aac" + } + if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { + codec = strings.ToLower(trc.TargetFormat) + } + + // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. + segment := "stream." + codec + q := url.Values{} + if token := tokenFromRequest(r); token != "" { + q.Set("api_key", token) + } + if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { + q.Set("audioBitRate", strconv.Itoa(bitRate)) + } + if len(q) > 0 { + segment += "?" + q.Encode() + } + + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + //nolint:gosec // not HTML; the only tainted value is query-escaped + fmt.Fprintf(w, "#EXTM3U\n"+ + "#EXT-X-VERSION:3\n"+ + "#EXT-X-PLAYLIST-TYPE:VOD\n"+ + "#EXT-X-TARGETDURATION:%d\n"+ + "#EXT-X-MEDIA-SEQUENCE:0\n"+ + "#EXTINF:%.3f,\n"+ + "%s\n"+ + "#EXT-X-ENDLIST\n", + int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) +} + +// streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some +// clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so +// it must always resolve to direct play ("raw"), never a forced transcode. +func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + ctx := r.Context() + streamReq := api.transcodeDecider.ResolveRequest(ctx, mf, "raw", 0, 0) + s, err := api.streamer.NewStream(ctx, mf, streamReq) + if err != nil { + api.internalError(w, r, err) + return + } + defer s.Close() + if _, err := s.Serve(ctx, w, r); err != nil { + log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) + } +} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go new file mode 100644 index 000000000..7063d321a --- /dev/null +++ b/server/jellyfin/stream_test.go @@ -0,0 +1,316 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Stream", func() { + var api *Router + var ds *tests.MockDataStore + var streamer *fakeMediaStreamer + var decider *fakeTranscodeDecider + + // alice has access to library 1 only. + ctxUser := func() context.Context { + return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) + } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + streamer = &fakeMediaStreamer{} + decider = &fakeTranscodeDecider{} + api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + }) + + Describe("getPlaybackInfo", func() { + It("returns a media source for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.MediaSources).To(HaveLen(1)) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.MediaSources[0].Container).To(Equal("mp3")) + Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) + Expect(res.PlaySessionId).ToNot(BeEmpty()) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/s1/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.getPlaybackInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamAudio", func() { + It("invokes the transcode decider and streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("converts the bps audioBitRate param to kbps", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.BitRate).To(Equal(320)) + }) + + It("uses the audioCodec param as target format when no container is given", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "flac", LibraryID: 1}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(decider.req.Format).To(Equal("aac")) + }) + + It("returns 500 and logs when the streamer fails", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.err = errors.New("boom") + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamAudio, w, r) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("streamHls", func() { + BeforeEach(func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, + }) + }) + + hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Audio/s1/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.streamHls, w, r) + return w + } + + It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() { + w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser()) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) + body := w.Body.String() + Expect(body).To(HavePrefix("#EXTM3U\n")) + Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n")) + Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n")) + Expect(body).To(ContainSubstring("#EXTINF:100.500,\n")) + Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n")) + Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) + }) + + It("omits the bitrate param when the client doesn't send one", func() { + w := hls("?audiocodec=aac&api_key=tok", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n")) + }) + + It("falls back to aac for codecs HLS packed-audio can't carry", func() { + w := hls("?audiocodec=opus", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n")) + }) + + It("honors mp3 as segment codec", func() { + w := hls("?audiocodec=mp3", ctxUser()) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("prefers the server-forced transcoding format over the requested codec", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) + }) + + It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() { + ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"}) + w := hls("?audiocodec=aac", ctx) + Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n")) + }) + + It("returns 404 for a track in a library the user can't access", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "dsf", LibraryID: 2}, + }) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the id doesn't match any media file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) + Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("streamFile", func() { + It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + streamer.content = "audio-bytes" + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(decider.invoked).To(BeTrue()) + Expect(decider.req.Format).To(Equal("raw")) + Expect(streamer.invoked).To(BeTrue()) + Expect(w.Body.String()).To(Equal("audio-bytes")) + }) + + It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 2}, + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/s1/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", "s1") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + + It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "missing") + api.streamFile(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(decider.invoked).To(BeFalse()) + Expect(streamer.invoked).To(BeFalse()) + }) + }) +}) + +// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether +// (and how) ResolveRequest was invoked, so tests can assert it's never called on the +// access-denied path, without needing a real transcode decision pipeline. +type fakeTranscodeDecider struct { + invoked bool + req stream.Request +} + +func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { + return &stream.TranscodeDecision{}, nil +} + +func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) { + return "", nil +} + +func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) { + return stream.Request{}, nil +} + +func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { + f.invoked = true + f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset} + return f.req +} + +// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether +// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed +// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. +type fakeMediaStreamer struct { + invoked bool + content string + err error +} + +func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) { + f.invoked = true + if f.err != nil { + return nil, f.err + } + return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil +} diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go new file mode 100644 index 000000000..baa31fdeb --- /dev/null +++ b/server/jellyfin/system.go @@ -0,0 +1,96 @@ +package jellyfin + +import ( + "context" + "errors" + "fmt" + "net/http" + "path" + + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "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. +const jellyfinVersion = "10.8.13" + +func (api *Router) serverName() string { + if conf.Server.Jellyfin.ServerName != "" { + return conf.Server.Jellyfin.ServerName + } + return fmt.Sprintf("Navidrome %s", consts.Version) +} + +// serverID returns a stable Id that survives restarts, get-or-created in the Property table. +// Jellyfin clients cache ServerId across sessions, so a per-process value would break +// re-authentication. api.ds is nil only in unit tests; New() always sets it. +// +// The mutex serializes first-boot resolution so concurrent requests can't persist different +// UUIDs. Only a successful read or persisted id is cached; a transient failure yields a +// temporary id and retries on the next request rather than pinning a value. +func (api *Router) serverID(ctx context.Context) string { + api.serverIDMu.Lock() + defer api.serverIDMu.Unlock() + if api.serverIDVal != "" { + return api.serverIDVal + } + if api.ds == nil { + api.serverIDVal = uuid.NewString() + return api.serverIDVal + } + id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) + switch { + case errors.Is(err, model.ErrNotFound): + id = uuid.NewString() + if err := api.ds.Property(ctx).Put(consts.JellyfinServerIDKey, id); err != nil { + log.Error(ctx, "Jellyfin API: could not persist server id", err) + return id + } + case err != nil: + log.Error(ctx, "Jellyfin API: could not read server id", err) + return uuid.NewString() + } + api.serverIDVal = id + return api.serverIDVal +} + +func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { + return dto.PublicSystemInfo{ + LocalAddress: localAddress(r), + ServerName: api.serverName(), + Version: jellyfinVersion, + ProductName: "Jellyfin Server", + Id: api.serverID(r.Context()), + StartupWizardCompleted: true, + } +} + +// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring +// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as +// its server base URL; without it its SDK api instance is undefined and sign-in crashes. +func localAddress(r *http.Request) string { + scheme, host := server.ServerAddress(r) + return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI) +} + +func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, api.publicInfo(r)) +} + +// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's +// server does this and clients parse the raw body. +func (api *Router) ping(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(api.serverName())) +} + +func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, false) +} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go new file mode 100644 index 000000000..5ea525100 --- /dev/null +++ b/server/jellyfin/system_test.go @@ -0,0 +1,124 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("System", func() { + var api *Router + BeforeEach(func() { api = &Router{} }) + + It("returns public system info without auth", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + api.getPublicSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + Expect(info.Id).ToNot(BeEmpty()) + Expect(info.Version).To(Equal(jellyfinVersion)) + Expect(info.ProductName).To(Equal("Jellyfin Server")) + Expect(info.ServerName).To(HavePrefix("Navidrome")) + }) + + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { + DeferCleanup(configtest.SetupConfig()) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info/Public", nil) + r.Host = "music.example.com:4599" + api.getPublicSystemInfo(w, r) + + var info dto.PublicSystemInfo + Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) + // Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the + // SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path. + Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin")) + }) + + It("responds to ping with the server name as plain text", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Ping", nil) + api.ping(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain")) + // Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name. + Expect(w.Body.String()).To(HavePrefix("Navidrome")) + }) + + It("reports quick connect as disabled", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/QuickConnect/Enabled", nil) + api.quickConnectEnabled(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + var enabled bool + Expect(json.Unmarshal(w.Body.Bytes(), &enabled)).To(Succeed()) + Expect(enabled).To(BeFalse()) + }) + + Context("serverID with a real DataStore", func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + }) + + It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() { + first := &Router{ds: ds} + id := first.serverID(ctx) + Expect(id).ToNot(BeEmpty()) + + second := &Router{ds: ds} + Expect(second.serverID(ctx)).To(Equal(id)) + }) + + It("memoizes the id across repeated calls on the same Router", func() { + r := &Router{ds: ds} + id := r.serverID(ctx) + Expect(r.serverID(ctx)).To(Equal(id)) + Expect(r.serverID(ctx)).To(Equal(id)) + }) + + It("does not overwrite or pin over a stored id when the property read fails transiently", func() { + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "stable-id")).To(Succeed()) + + r := &Router{ds: ds} + props := ds.Property(ctx).(*tests.MockedPropertyRepo) + props.Error = errors.New("database is locked") + degraded := r.serverID(ctx) + Expect(degraded).ToNot(BeEmpty()) + Expect(degraded).ToNot(Equal("stable-id")) // temporary value, not the (unreadable) stored one + props.Error = nil + + // Once the DB recovers, the stored id is intact and served again. + Expect(r.serverID(ctx)).To(Equal("stable-id")) + stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal("stable-id")) + }) + }) +}) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go new file mode 100644 index 000000000..b58296391 --- /dev/null +++ b/server/jellyfin/truncated_ids.go @@ -0,0 +1,113 @@ +package jellyfin + +import ( + "context" + "slices" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +// truncatedIDLen is what Finamp's saved-queue persistence cuts item ids to (16 bytes, assuming +// Jellyfin GUIDs). No Navidrome id family is 16 chars (nanoid=22, legacy MD5=32, playlist +// UUID=36), so the length alone identifies a truncated id. See README. +// +// Handlers taking an item id resolve it via resolveItemID/resolveItemIDs; playlist-write handlers +// and ParentId scoping don't (a restored queue never edits playlists or browses by container id). +const truncatedIDLen = 16 + +// resolveItemID maps a truncated item id back to the full id via unique-prefix lookup. The id is +// returned unchanged when it isn't truncation-shaped, matches nothing, or is ambiguous. +func (api *Router) resolveItemID(ctx context.Context, id string) string { + if len(id) != truncatedIDLen { + return id + } + probes := []func() []string{ + func() []string { return idsMatching(api.ds.MediaFile(ctx).GetAll, "media_file.id", id, mediaFileID) }, + func() []string { return idsMatching(api.ds.Album(ctx).GetAll, "album.id", id, albumID) }, + func() []string { return idsMatching(api.ds.Artist(ctx).GetAll, "artist.id", id, artistID) }, + func() []string { return idsMatching(api.ds.Playlist(ctx).GetAll, "playlist.id", id, playlistID) }, + } + for _, probe := range probes { + switch ids := probe(); len(ids) { + case 0: + continue + case 1: + log.Trace(ctx, "Jellyfin API: resolved truncated item id", "truncated", id, "full", ids[0]) + return ids[0] + default: + log.Warn(ctx, "Jellyfin API: truncated item id is ambiguous", "truncated", id) + return id + } + } + return id +} + +// resolveItemIDs is the batch form of resolveItemID for id lists (queue restore sends hundreds of +// truncated ids): all media-file prefixes are resolved with one chunked range query, and only the +// leftovers (containers, unknowns) fall back to the per-id probes. +func (api *Router) resolveItemIDs(ctx context.Context, ids []string) []string { + var truncated []string + for _, id := range ids { + if len(id) == truncatedIDLen { + truncated = append(truncated, id) + } + } + if len(truncated) == 0 { + return ids + } + + byPrefix := make(map[string][]string, len(truncated)) + for chunk := range slice.CollectChunks(slices.Values(truncated), 100) { + ranges := make(squirrel.Or, len(chunk)) + for i, p := range chunk { + ranges[i] = squirrel.And{squirrel.GtOrEq{"media_file.id": p}, squirrel.Lt{"media_file.id": p + "\x7f"}} + } + mfs, err := api.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: ranges}) + if err != nil { + log.Error(ctx, "Jellyfin API: error batch-resolving truncated ids", err) + break + } + for _, mf := range mfs { + p := mf.ID[:truncatedIDLen] + byPrefix[p] = append(byPrefix[p], mf.ID) + } + } + + out := make([]string, len(ids)) + for i, id := range ids { + switch full := byPrefix[id]; { + case len(full) == 1: + out[i] = full[0] + case len(id) == truncatedIDLen: + out[i] = api.resolveItemID(ctx, id) // ambiguous or not a song: per-id probes decide + default: + out[i] = id + } + } + return out +} + +// idsMatching returns the ids of up to two rows whose id starts with prefix (two is enough to +// detect ambiguity). '\x7f' is above every character the id alphabets use. +func idsMatching[S ~[]T, T any](getAll func(...model.QueryOptions) (S, error), column, prefix string, id func(T) string) []string { + rows, err := getAll(model.QueryOptions{ + Filters: squirrel.And{squirrel.GtOrEq{column: prefix}, squirrel.Lt{column: prefix + "\x7f"}}, + Max: 2, + }) + if err != nil { + return nil + } + ids := make([]string, len(rows)) + for i, row := range rows { + ids[i] = id(row) + } + return ids +} + +func mediaFileID(mf model.MediaFile) string { return mf.ID } +func albumID(al model.Album) string { return al.ID } +func artistID(ar model.Artist) string { return ar.ID } +func playlistID(pl model.Playlist) string { return pl.ID } diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go new file mode 100644 index 000000000..5f231e0b5 --- /dev/null +++ b/server/jellyfin/users.go @@ -0,0 +1,61 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// getUserViews returns one CollectionFolder view per accessible library, so clients browse each +// library as its own top-level view rather than one aggregate. +func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + views := make([]dto.BaseItemDto, 0, len(u.Libraries)) + for _, lib := range u.Libraries { + views = append(views, libraryView(lib)) + } + api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) +} + +func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + u, _ := request.UserFrom(ctx) + api.ok(w, r, userToDto(&u, api.serverName(), api.serverID(ctx))) +} + +// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login +// picker. The route is unauthenticated, so it lists only the configured allowlist (never the full +// user table) and returns a minimal DTO — no Policy/Configuration, which would leak admin status. +func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + serverID := api.serverID(ctx) + seen := make(map[string]bool) + users := []dto.UserDto{} + for name := range strings.SplitSeq(conf.Server.Jellyfin.ExposedPublicUsers, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + key := strings.ToLower(name) + if seen[key] { + continue + } + seen[key] = true + usr, err := api.ds.User(ctx).FindByUsername(name) + if err != nil { + log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) + continue + } + users = append(users, dto.UserDto{ + Name: usr.UserName, + Id: usr.ID, + ServerId: serverID, + HasPassword: true, + }) + } + api.ok(w, r, users) +} diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go new file mode 100644 index 000000000..2b7177044 --- /dev/null +++ b/server/jellyfin/users_test.go @@ -0,0 +1,130 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Users", func() { + var api *Router + authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { + ctx := request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: libs}) + return r.WithContext(ctx) + } + BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) + + Describe("getUserViews", func() { + It("returns one view per accessible library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Podcasts"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.TotalRecordCount).To(Equal(2)) + + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + Expect(res.Items[0].Name).To(Equal("Music")) + Expect(res.Items[0].Type).To(Equal("CollectionFolder")) + Expect(res.Items[0].CollectionType).To(Equal("music")) + Expect(res.Items[0].IsFolder).To(BeTrue()) + + Expect(res.Items[1].Id).To(Equal(dto.EncodeID("2"))) + Expect(res.Items[1].Name).To(Equal("Podcasts")) + }) + + It("returns a single view for a user with one library", func() { + libs := model.Libraries{{ID: 1, Name: "Music"}} + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("1"))) + }) + + It("returns no views for a user with no library access", func() { + w := httptest.NewRecorder() + api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(0)) + Expect(res.TotalRecordCount).To(Equal(0)) + }) + }) + + It("returns the current user", func() { + w := httptest.NewRecorder() + api.getCurrentUser(w, authedWithLibraries(httptest.NewRequest("GET", "/Users/Me", nil), nil)) + var u dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed()) + Expect(u.Name).To(Equal("alice")) + Expect(u.Policy).ToNot(BeNil()) + Expect(u.Policy.IsAdministrator).To(BeFalse()) + Expect(u.Configuration).ToNot(BeNil()) + }) + + Describe("getPublicUsers", func() { + var ur *tests.MockedUserRepo + publicUsers := func() []dto.UserDto { + w := httptest.NewRecorder() + api.getPublicUsers(w, httptest.NewRequest("GET", "/Users/Public", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + var users []dto.UserDto + Expect(json.Unmarshal(w.Body.Bytes(), &users)).To(Succeed()) + return users + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ur = api.ds.User(context.Background()).(*tests.MockedUserRepo) + Expect(ur.Put(&model.User{ID: "u1", UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: "u2", UserName: "bob"})).To(Succeed()) + }) + + It("returns an empty list when the config is unset", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "" + Expect(publicUsers()).To(BeEmpty()) + }) + + It("lists the configured users in order, without leaking policy", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "bob, alice" + users := publicUsers() + Expect(users).To(HaveLen(2)) + Expect(users[0].Name).To(Equal("bob")) + Expect(users[0].Id).To(Equal("u2")) + Expect(users[1].Name).To(Equal("alice")) + // The public list must not expose Policy/Configuration to unauthenticated callers. + Expect(users[0].Policy).To(BeNil()) + Expect(users[0].Configuration).To(BeNil()) + }) + + It("skips a configured username that does not exist", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "alice,ghost" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + + It("matches usernames case-insensitively and de-duplicates", func() { + conf.Server.Jellyfin.ExposedPublicUsers = "ALICE, alice" + users := publicUsers() + Expect(users).To(HaveLen(1)) + Expect(users[0].Name).To(Equal("alice")) + }) + }) +}) diff --git a/server/middlewares.go b/server/middlewares.go index 5d6a1e59c..23e11eaa6 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -202,10 +202,10 @@ func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.H func serverAddressMiddleware(h http.Handler) http.Handler { // Define a new handler function that will be returned by this middleware function. fn := func(w http.ResponseWriter, r *http.Request) { - // Call the serverAddress function to get the scheme and host of the server + // Call the ServerAddress function to get the scheme and host of the server // handling the request. If a host is found, modify the request object to use // that host and scheme instead of the original ones. - if rScheme, rHost := serverAddress(r); rHost != "" { + if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme } @@ -225,10 +225,10 @@ var ( xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") ) -// serverAddress is a helper function that returns the scheme and host of the server +// ServerAddress is a helper function that returns the scheme and host of the server // handling the given request, as determined by the presence of X-Forwarded-* headers // or the scheme and host of the request URL. -func serverAddress(r *http.Request) (scheme, host string) { +func ServerAddress(r *http.Request) (scheme, host string) { // Save the original request host for later comparison. origHost := r.Host diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 5e2d29876..077eac35e 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -13,23 +13,14 @@ import ( "path/filepath" "strings" - "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" _ "golang.org/x/image/webp" ) -func maxImageUploadSize() int64 { - if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { - return int64(size) - } - size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) - return int64(size) -} - func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { user, _ := request.UserFrom(r.Context()) if !conf.Server.EnableArtworkUpload && !user.IsAdmin { @@ -40,7 +31,7 @@ func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { } func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { - maxImageSize := maxImageUploadSize() + maxImageSize := core.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/image_upload_test.go b/server/nativeapi/image_upload_test.go deleted file mode 100644 index 291912e67..000000000 --- a/server/nativeapi/image_upload_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package nativeapi - -import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("maxImageUploadSize", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("returns the configured size when valid", func() { - conf.Server.MaxImageUploadSize = "20MB" - Expect(maxImageUploadSize()).To(Equal(int64(20_000_000))) - }) - - It("returns the default size when config is empty", func() { - conf.Server.MaxImageUploadSize = "" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("returns the default size when config is invalid", func() { - conf.Server.MaxImageUploadSize = "not-a-size" - Expect(maxImageUploadSize()).To(Equal(int64(10_000_000))) - }) - - It("parses raw byte values", func() { - conf.Server.MaxImageUploadSize = "52428800" - Expect(maxImageUploadSize()).To(Equal(int64(52_428_800))) - }) -}) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 24bbca960..041a3b8f2 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/run" diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 817238aaf..f6a7047c4 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/filter" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" diff --git a/server/e2e/doc.go b/server/subsonic/e2e/doc.go similarity index 98% rename from server/e2e/doc.go rename to server/subsonic/e2e/doc.go index 51ee6f047..9435d1f60 100644 --- a/server/e2e/doc.go +++ b/server/subsonic/e2e/doc.go @@ -103,7 +103,7 @@ // // The e2e tests are included in the standard test suite and can be run with: // -// make test PKG=./server/e2e # Run only e2e tests +// make test PKG=./server/subsonic/e2e # Run only e2e tests // make test # Run all tests including e2e // make test-race # Run with race detector // diff --git a/server/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go similarity index 75% rename from server/e2e/e2e_suite_test.go rename to server/subsonic/e2e/e2e_suite_test.go index ac4aaa5f2..6875b6370 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -4,14 +4,12 @@ import ( "bytes" "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" - "strings" "testing" "testing/fstest" "time" @@ -22,7 +20,6 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" @@ -40,6 +37,7 @@ import ( "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,13 +87,10 @@ var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router - streamerSpy *spyStreamer + streamerSpy *harness.SpyStreamer + goldenDB *harness.DB lib model.Library - // Snapshot paths for fast DB restore - dbFilePath string - snapshotPath string - // Admin user used for most tests adminUser = model.User{ ID: "admin-1", @@ -113,13 +108,6 @@ var ( } ) -func createFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - // buildTestFS creates the full test filesystem matching the plan func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{ @@ -145,7 +133,7 @@ func buildTestFS() storagetest.FakeFS { // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) - return createFS(fstest.MapFS{ + return harness.CreateFS(fstest.MapFS{ // Rock / The Beatles / Abbey Road (with MBIDs) // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). @@ -331,61 +319,6 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// spyStreamer captures the Request passed to NewStream for test assertions, -// then returns a minimal fake Stream so the handler completes without error. -type spyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile - SimulateError error // When set, NewStream returns this error - SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) -} - -func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - s.LastRequest = req - s.LastMediaFile = mf - if s.SimulateError != nil { - return nil, s.SimulateError - } - format := req.Format - if format == "" || format == "raw" { - format = mf.Suffix - } - content := "fake audio data" - if s.SimulateEmptyStream { - content = "" - } - r := io.NopCloser(strings.NewReader(content)) - return stream.NewStream(mf, format, req.BitRate, r), nil -} - -// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. -type noopFFmpeg struct{} - -func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: transcode not supported") -} - -func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: extract image not supported") -} - -func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { - return "", nil -} - -func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - return nil, errors.New("noop ffmpeg: probe not supported") -} - -func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: convert animated image not supported") -} - -func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } -func (n noopFFmpeg) IsAvailable() bool { return false } -func (n noopFFmpeg) IsProbeAvailable() bool { return true } -func (n noopFFmpeg) Version() string { return "noop" } - // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -434,67 +367,22 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} ) var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) - tmpDir := GinkgoT().TempDir() - dbFilePath = filepath.Join(tmpDir, "test-e2e.db") - snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot") - conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" - db.Db().SetMaxOpenConns(1) - // Initial setup: schema, user, library, and full scan (runs once for the entire suite) conf.Server.MusicFolder = "fake:///music" conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" conf.Server.DevExternalScanner = false - db.Init(ctx) - - initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(initDS) - - adminUserWithPass := adminUser - adminUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed()) - - regularUserWithPass := regularUser - regularUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) - - lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - - Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) - Expect(err).ToNot(HaveOccurred()) - adminUser.Libraries = loadedUser.Libraries - - loadedRegular, err := initDS.User(ctx).FindByUsername(regularUser.UserName) - Expect(err).ToNot(HaveOccurred()) - regularUser.Libraries = loadedRegular.Libraries - - ctx = request.WithUser(GinkgoT().Context(), adminUser) - buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) - _, err = s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Checkpoint WAL and snapshot the golden DB state - _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") - Expect(err).ToNot(HaveOccurred()) - data, err := os.ReadFile(dbFilePath) - Expect(err).ToNot(HaveOccurred()) - Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) + goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) + lib = goldenDB.Library + ctx = request.WithUser(GinkgoT().Context(), adminUser) }) // Close the database before the suite's TempDir cleanup runs. Required on @@ -520,14 +408,14 @@ func setupTestDB() { conf.Server.DevEnableMediaFileProbe = false // Restore DB to golden state (no scan needed) - restoreDB() + goldenDB.Restore() ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) // Create the Subsonic Router with real DS, streamer spy, and real Decider - streamerSpy = &spyStreamer{} - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + streamerSpy = &harness.SpyStreamer{} + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( @@ -549,39 +437,3 @@ func setupTestDB() { nil, ) } - -// restoreDB restores all table data from the snapshot using ATTACH DATABASE. -// This is much faster than re-running the scanner for each test. -func restoreDB() { - sqlDB := db.Db() - - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) - Expect(err).ToNot(HaveOccurred()) - - rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") - Expect(err).ToNot(HaveOccurred()) - var tables []string - for rows.Next() { - var name string - Expect(rows.Scan(&name)).To(Succeed()) - tables = append(tables, name) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - rows.Close() - - for _, table := range tables { - // Table names come from sqlite_master, not user input, so concatenation is safe here - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - _, err = sqlDB.Exec("DETACH DATABASE snapshot") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} diff --git a/server/e2e/subsonic_album_lists_test.go b/server/subsonic/e2e/subsonic_album_lists_test.go similarity index 100% rename from server/e2e/subsonic_album_lists_test.go rename to server/subsonic/e2e/subsonic_album_lists_test.go diff --git a/server/e2e/subsonic_bookmarks_test.go b/server/subsonic/e2e/subsonic_bookmarks_test.go similarity index 100% rename from server/e2e/subsonic_bookmarks_test.go rename to server/subsonic/e2e/subsonic_bookmarks_test.go diff --git a/server/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go similarity index 100% rename from server/e2e/subsonic_browsing_test.go rename to server/subsonic/e2e/subsonic_browsing_test.go diff --git a/server/e2e/subsonic_lyrics_test.go b/server/subsonic/e2e/subsonic_lyrics_test.go similarity index 100% rename from server/e2e/subsonic_lyrics_test.go rename to server/subsonic/e2e/subsonic_lyrics_test.go diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go similarity index 100% rename from server/e2e/subsonic_media_annotation_test.go rename to server/subsonic/e2e/subsonic_media_annotation_test.go diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go similarity index 100% rename from server/e2e/subsonic_media_retrieval_test.go rename to server/subsonic/e2e/subsonic_media_retrieval_test.go diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go similarity index 100% rename from server/e2e/subsonic_multilibrary_test.go rename to server/subsonic/e2e/subsonic_multilibrary_test.go diff --git a/server/e2e/subsonic_multiuser_test.go b/server/subsonic/e2e/subsonic_multiuser_test.go similarity index 100% rename from server/e2e/subsonic_multiuser_test.go rename to server/subsonic/e2e/subsonic_multiuser_test.go diff --git a/server/e2e/subsonic_playlists_test.go b/server/subsonic/e2e/subsonic_playlists_test.go similarity index 100% rename from server/e2e/subsonic_playlists_test.go rename to server/subsonic/e2e/subsonic_playlists_test.go diff --git a/server/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go similarity index 100% rename from server/e2e/subsonic_radio_test.go rename to server/subsonic/e2e/subsonic_radio_test.go diff --git a/server/e2e/subsonic_scan_test.go b/server/subsonic/e2e/subsonic_scan_test.go similarity index 100% rename from server/e2e/subsonic_scan_test.go rename to server/subsonic/e2e/subsonic_scan_test.go diff --git a/server/e2e/subsonic_searching_test.go b/server/subsonic/e2e/subsonic_searching_test.go similarity index 100% rename from server/e2e/subsonic_searching_test.go rename to server/subsonic/e2e/subsonic_searching_test.go diff --git a/server/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go similarity index 100% rename from server/e2e/subsonic_sharing_test.go rename to server/subsonic/e2e/subsonic_sharing_test.go diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go similarity index 98% rename from server/e2e/subsonic_sonic_similarity_test.go rename to server/subsonic/e2e/subsonic_sonic_similarity_test.go index 1b8d34eb1..775fefe89 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -21,6 +21,7 @@ import ( "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/tests/harness" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -32,11 +33,11 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { loader := &mockSonicPluginLoader{provider: provider} m := matcher.New(ds) sonicSvc := sonic.New(ds, loader, m) - decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) return subsonic.New( ds, noopArtwork{}, - &spyStreamer{}, + &harness.SpyStreamer{}, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, diff --git a/server/e2e/subsonic_stream_test.go b/server/subsonic/e2e/subsonic_stream_test.go similarity index 100% rename from server/e2e/subsonic_stream_test.go rename to server/subsonic/e2e/subsonic_stream_test.go diff --git a/server/e2e/subsonic_system_test.go b/server/subsonic/e2e/subsonic_system_test.go similarity index 100% rename from server/e2e/subsonic_system_test.go rename to server/subsonic/e2e/subsonic_system_test.go diff --git a/server/e2e/subsonic_transcode_test.go b/server/subsonic/e2e/subsonic_transcode_test.go similarity index 100% rename from server/e2e/subsonic_transcode_test.go rename to server/subsonic/e2e/subsonic_transcode_test.go diff --git a/server/e2e/subsonic_users_test.go b/server/subsonic/e2e/subsonic_users_test.go similarity index 100% rename from server/e2e/subsonic_users_test.go rename to server/subsonic/e2e/subsonic_users_test.go diff --git a/tests/harness/harness.go b/tests/harness/harness.go new file mode 100644 index 000000000..ff5ce8919 --- /dev/null +++ b/tests/harness/harness.go @@ -0,0 +1,178 @@ +// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and +// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, +// and service doubles. Like core/storage/storagetest, it must only be imported from test code. +package harness + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" //nolint:staticcheck +) + +// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. +type DB struct { + FilePath string + SnapshotPath string + Library model.Library +} + +// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. +func CreateFS(files fstest.MapFS) storagetest.FakeFS { + fs := storagetest.FakeFS{} + fs.SetFiles(files) + storagetest.Register("fake", &fs) + return fs +} + +// SetupDB boots the golden database: a temp SQLite file, the given users (password "password", +// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a +// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS +// first; each user's Libraries field is populated in place. +func SetupDB(ctx context.Context, users ...*model.User) *DB { + tmpDir := ginkgo.GinkgoT().TempDir() + h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} + h.SnapshotPath = h.FilePath + ".snapshot" + conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + db.Init(ctx) + + ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&h.Library)).To(Succeed()) + + for _, u := range users { + seeded := *u + seeded.NewPassword = "password" + Expect(ds.User(ctx).Put(&seeded)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(u.ID, []int{h.Library.ID})).To(Succeed()) + loaded, err := ds.User(ctx).FindByUsername(u.UserName) + Expect(err).ToNot(HaveOccurred()) + u.Libraries = loaded.Libraries + } + + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(h.FilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir + return h +} + +// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a +// rescan. FTS shadow tables are skipped; they are kept in sync by their content tables' triggers. +func (h *DB) Restore() { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) + Expect(err).ToNot(HaveOccurred()) + + rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + var tables []string + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + tables = append(tables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + rows.Close() + + for _, table := range tables { + // Table names come from sqlite_master, not user input. + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("DETACH DATABASE snapshot") + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. +type SpyStreamer struct { + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // when set, NewStream returns this error + SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) +} + +func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { + s.LastRequest = req + s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } + format := req.Format + if format == "" || format == "raw" { + format = mf.Suffix + } + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil +} + +// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. +type NoopFFmpeg struct{} + +func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil } + +func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: convert animated image not supported") +} + +func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } +func (NoopFFmpeg) IsAvailable() bool { return false } +func (NoopFFmpeg) IsProbeAvailable() bool { return true } +func (NoopFFmpeg) Version() string { return "noop" } + +var ( + _ stream.MediaStreamer = &SpyStreamer{} + _ ffmpeg.FFmpeg = NoopFFmpeg{} +) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 3428813f6..85765abf8 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -174,6 +174,9 @@ func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { if m.Err { return errors.New("unexpected error") } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } return nil } @@ -182,6 +185,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { if m.Err { return errors.New("unexpected error") } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } return nil } diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b7a6fb811..748002882 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -73,6 +73,28 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockArtistRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockArtistRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] @@ -145,6 +167,13 @@ func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles . return result, nil } +func (m *MockArtistRepo) CountAll(...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model.Artists, error) { if len(options) > 0 { m.Options = options[0] diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f15ba1bc6..6ddd77f14 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -154,6 +154,28 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockMediaFileRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("error") + } + for _, id := range itemIDs { + if d, ok := m.Data[id]; ok { + d.Starred = starred + } + } + return nil +} + +func (m *MockMediaFileRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[itemID]; ok { + d.Rating = rating + } + return nil +} + func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { if m.Err { return nil, errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index b7df5361f..908d6aab5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -21,6 +21,7 @@ type MockPlaylistRepo struct { Data map[string]*model.Playlist // keyed by ID PathMap map[string]*model.Playlist // keyed by path All model.Playlists + Options model.QueryOptions Last *model.Playlist Deleted []string Starred map[string]bool // itemID -> starred @@ -33,14 +34,24 @@ func (m *MockPlaylistRepo) SetError(err bool) { m.Err = err } -func (m *MockPlaylistRepo) SetData(pls model.Playlists) { - m.Data = make(map[string]*model.Playlist, len(pls)) - m.All = pls +func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { + m.Data = make(map[string]*model.Playlist, len(playlists)) + m.All = playlists for i, p := range m.All { m.Data[p.ID] = &m.All[i] } } +func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + if len(options) > 0 { + m.Options = options[0] + } + if m.Err { + return nil, errors.New("error") + } + return m.All, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") @@ -57,13 +68,6 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) GetAll(_ ...model.QueryOptions) (model.Playlists, error) { - if m.Err { - return nil, errors.New("error") - } - return m.All, nil -} - func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") From 53d54baef04254c6243b5dd842db239fd0378cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 14:07:00 -0400 Subject: [PATCH 10/14] fix(jellyfin): stream collection responses to prevent OOM on large libraries (#5783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): stream /Items responses to prevent OOM on large libraries Finamp's library sync issues an unbounded GET /Items?IncludeItemTypes=Audio with Fields=MediaSources and no Limit. On a large library this built the whole result set — every MediaFile and every BaseItemDto, each fat with MediaSources — in memory, and json.Encoder then buffered the entire ~200MB response before writing a byte. Measured on a 96k-track library, one such request peaked near 1.6GB RSS; in a memory-limited container the resulting slowness made clients retry, stacking concurrent full-library builds until the process was OOM-killed. Stream the song listing straight from a DB cursor (MediaFileRepository.GetCursor), mapping and encoding one row at a time, so peak memory is bounded to about one item regardless of library size. The cursor is opened lazily, when streaming begins, so it doesn't hold a DB connection across the CountAll and ServerId lookups that run first (ServerId can write on first use and would deadlock against an open reader). Pagination still works — the cursor query carries LIMIT/OFFSET/ORDER BY from StartIndex/Limit/SortBy — and TotalRecordCount stays the full count. Other materialized responses stream their JSON too, avoiding the encoder's buffer-the-whole-output cost. Also bound artwork image concurrency with the same server.ThrottleBacklog that Subsonic's getCoverArt uses, so a burst of image requests during sync can't exhaust memory through concurrent decode/resize. Verified against a copy of a 96k-track production database: byte-for-byte identical response, peak RSS 1593MB -> 53MB, time-to-first-byte 8.1s -> 0.2s. * fix(jellyfin): fail loudly on /Items streaming errors instead of a truncated 200 Addresses code review: a streamed /Items response commits HTTP 200 before an error can occur, so failures were surfacing as misleadingly successful bodies. - A cursor-open failure (e.g. a busy DB under connection pressure) is now surfaced before the first byte is written: the cursor open is deferred into the item source and run in writeItems after the ServerId lookup but before the envelope, returning a clean 500 the client can retry — not a 200 with an empty item list. - A mid-stream (row-scan) error now aborts without closing the JSON envelope, leaving the body malformed. A truncated-but-valid response would let a sync client (Finamp) treat the short list as the whole library and prune local tracks; malformed JSON forces the client's parser to fail and retry. * refactor(jellyfin): stream every collection endpoint from a DB cursor Streaming was applied only to the song listing, which left two write paths, two ServerId stamping mechanisms and a listXxx return-type split ("songs is special") that made the code hard to follow. Add GetCursor to the album, artist, genre and playlist repositories, mirroring MediaFileRepository.GetCursor: same select builder as GetAll, so each cursor yields identical, fully-hydrated rows (hydration happens per-row in PostScan, and toModels is only a deref loop). The playlist cursor keeps GetAll's owner/public visibility filter. Each repository test asserts the cursor yields exactly what GetAll returns, including Max/Offset. With cursors everywhere, every listXxx returns itemsResult and every collection streams through one writer: - listAlbums, listArtists and listPlaylists now stream from their cursors; search paths stay materialized (Search returns a slice). - listGenres stays materialized: its total is the length of the full list and it paginates in memory, so there is nothing for a cursor to page over. - api.ok's QueryResult case delegates to writeItems, so the ~9 inline callers funnel into the same writer; streamQueryResult and wrapResult are gone. - /Items/Latest streams as a bare JSON array (its Jellyfin wire shape) via writeItemsArray, which shares the item loop and stamping. That also closes the same unbounded hole /Items had: limit=0 disabled its LIMIT. - ServerId is now stamped in exactly one place, so api.ok only handles single, non-collection payloads. Verified against a copy of a 96k-track production database: every endpoint byte-for-byte identical to master. Unbounded /Items peak RSS 1691MB -> 54MB; time-to-first-byte 6.3s -> 0.19s, /Artists 1.14s -> 0.13s. * refactor(jellyfin): route every response through api.ok Handlers were split between api.ok and api.writeItems with no clear rule for which to call. api.ok now accepts itemsResult too, so it is the single entry point: callers hand it whatever they have and it routes collections (cursor -backed or materialized) to the streaming writer. writeItems is reached only through api.ok now. The one exception is /Items/Latest, which returns a bare JSON array rather than a QueryResult envelope and so writes directly — noted in both doc comments. Also drop GenreRepository.GetCursor: listGenres derives its total from the length of the full list and paginates in memory, so there is nothing for a cursor to page over, leaving the method unused. * fix(jellyfin): stream the unbounded multi-type /Items merge The multi-type merge capped each per-type query at offset+limit only when a Limit was given. Without one (Finamp's favorites screen sends multi-type), every type ran an unbounded query and collect() drained each cursor into a slice — so /Items?IncludeItemTypes=MusicAlbum,Audio with no Limit materialized every album and every song, the same OOM class this branch set out to fix. The comment claiming the set was "capped at offset+limit per type" was wrong for limit=0. Without a limit the merged page is just each type's rows in order minus the first offset, which is exactly what chaining the per-type cursors yields, so stream that instead of merging in memory. The bounded path is unchanged: with a Limit each type holds at most offset+limit rows, so merging and paginating across the combined list is safe. Cursors are opened one at a time (each pins a DB connection until drained), with the first opened eagerly so the usual failure is still a clean error before any byte is written. Measured on a 96k-track library, /Items?IncludeItemTypes=MusicAlbum,Audio with no Limit (103,393 items): peak RSS 612MB -> 53MB, time-to-first-byte 4.9s -> 0.6s, response byte-for-byte identical. * refactor(jellyfin): parse /Items params into a struct The /Items dispatcher was a single 90-line block that parsed a dozen params and then threaded them positionally through three layers — queryItemsOfType took 11 arguments, listSongs and listAlbums 9 each — so reading any one of them meant decoding a long argument list. Parse once into an itemsQuery and pass that instead. queryItems now reads as the four things it actually does: parse, the id/playlists-folder/playlist-parent special cases, single-type dispatch, multi-type merge — with the playlist-parent and merge bodies moved to playlistTracks and mergeTypes. Every listXxx takes (ctx, opts, q). No behavior change: parsing, ordering and the entityParent rule are unchanged, and /Artists still passes favOnly=false explicitly by building the subset of the query it uses. * docs(jellyfin): trim comments on the streaming path Cut the comments back to the non-obvious why: the deferred cursor open (the ServerId write would deadlock against an open reader), the deliberate malformed JSON on a mid-stream error, why chained opens one cursor at a time, why listGenres stays materialized, and why the cursor helpers take the underlying func type. Dropped the rest — restatements of the code, doc comments that repeated a test's own name, and the GetCursor interface comments that the existing MediaFileRepository.GetCursor does without. * refactor(persistence): fold the cursor wrappers into a generic wrapCursor wrapAlbumCursor, wrapArtistCursor, wrapMediaFileCursor and wrapFolderCursor were the same twelve lines four times over, differing only in the type name, and the playlist cursor had its own inline copy of the loop. Add one generic wrapCursor. It takes an extractor func rather than a method on an interface: a type parameter can't reach an embedded field, and methods that only satisfy a generic constraint are reported by the unused linter. The extractor also lets both type parameters be inferred, so call sites need no explicit instantiation. Each entity keeps its named wrapper as a one-liner, since the model cursor types are defined types and need the conversion — and the existing wrapper tests call them directly. The nil-row error now names the model type via %T ("unexpected nil model.Album") instead of a hand-written per-entity string; the three tests asserting that message are updated. * fix(jellyfin): bound concurrent collection streams A streamed collection holds a DB cursor — and its pooled connection — for the whole client-paced response, where the old materialize-then-write path released it as soon as the query finished. The pool is shared with the scanner, Subsonic, the native API and the UI, so enough slow clients take every connection and everything else blocks waiting for one. Measured against a copy of a 96k-track production DB, 20 concurrent unbounded streams against a pool of 16 stalled a write for 16.2s (the other 14 writes in the run took ~2ms — the signature of connection starvation, not lock contention). Cap concurrent streams at half the pool. Excess requests queue rather than fail, so no client is rejected: the same 20 streams now all complete and the worst write is 2.5ms. - conf.MaxOpenConns() now owns the pool sizing (db calls it). It belongs in conf: the pool is a tunable, db already imports conf, and putting it in db would force server/jellyfin to import db just to size the cap against it. Expressing the cap as MaxOpenConns()/2 also keeps the two from drifting apart. - Uses chi's ThrottleBacklog, not server.ThrottleBacklog: the latter buffers the whole response to release its token early, which is right for artwork but would undo the streaming. chi's panics on a non-positive limit, so throttleStreams guards it — setting MaxConcurrentStreams=0 disables the cap instead of crashing the server at startup. * refactor(jellyfin): move throttleStreams to middlewares.go It's a middleware, so it belongs beside normalizeQueryKeys, authenticate and withPlayer rather than in api.go. Its tests move to middlewares_test.go with it, keeping one test file per production file. * fix(jellyfin): abandon the scan when the client stops reading encodeItems discarded its write errors and relied on the final Flush to report them, so once bufio's buffer filled and the flush failed, the loop still pulled every remaining row through the cursor and serialized it for a client that was gone. A test with a failing writer confirms it: all 20k items were drained. That wastes CPU, and holds the cursor's pooled DB connection and a stream slot (now a capped resource) for the length of a full scan nobody is reading. Check the per-item writes so the first failure ends the scan; the fixed envelope writes stay unchecked, since bufio latches for them anyway. --- conf/configuration.go | 18 + db/db.go | 3 +- model/album.go | 1 + model/artist.go | 4 + model/playlist.go | 4 + persistence/album_repository.go | 21 +- persistence/album_repository_test.go | 18 +- persistence/artist_repository.go | 14 + persistence/artist_repository_test.go | 16 + persistence/folder_repository.go | 12 +- persistence/folder_repository_test.go | 2 +- persistence/mediafile_repository.go | 12 +- persistence/mediafile_repository_test.go | 18 +- persistence/persistence_suite_test.go | 13 + persistence/playlist_repository.go | 16 + persistence/playlist_repository_test.go | 9 + persistence/sql_base_repository.go | 18 + server/jellyfin/api.go | 52 ++- server/jellyfin/browsing.go | 9 +- server/jellyfin/e2e/browsing_test.go | 28 ++ server/jellyfin/items.go | 537 ++++++++++++++++------- server/jellyfin/items_test.go | 8 + server/jellyfin/middlewares.go | 16 + server/jellyfin/middlewares_test.go | 69 +++ server/jellyfin/response.go | 87 ++++ server/jellyfin/response_test.go | 116 +++++ tests/mock_album_repo.go | 14 + tests/mock_artist_repo.go | 14 + tests/mock_mediafile_repo.go | 14 + tests/mock_playlist_repo.go | 14 + 30 files changed, 957 insertions(+), 220 deletions(-) create mode 100644 server/jellyfin/response.go create mode 100644 server/jellyfin/response_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 562f52465..83793bd43 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -225,6 +225,10 @@ type jellyfinOptions struct { // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. ExposedPublicUsers string + // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB + // cursor — and its pooled connection — for the whole client-paced response, so without a bound + // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. + MaxConcurrentStreams int } type httpHeaderOptions struct { @@ -889,6 +893,9 @@ func setViperDefaults() { viper.SetDefault("devuishowconfig", true) viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) + // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, + // scrobbles and the UI. See MaxOpenConns. + viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2)) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) @@ -959,3 +966,14 @@ func getConfigFile(cfgFile string) string { } return "" } + +// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner, +// Subsonic, Jellyfin, native API, UI). +// +// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so +// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a +// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the +// count is only loosely related to core count, and the floor is what matters on small machines. +func MaxOpenConns() int { + return max(4, runtime.NumCPU()) +} diff --git a/db/db.go b/db/db.go index 3f3f61d71..4ca996fe5 100644 --- a/db/db.go +++ b/db/db.go @@ -5,7 +5,6 @@ import ( "database/sql" "embed" "fmt" - "runtime" "time" "github.com/mattn/go-sqlite3" @@ -44,7 +43,7 @@ func Db() *sql.DB { } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) - db.SetMaxOpenConns(max(4, runtime.NumCPU())) + db.SetMaxOpenConns(conf.MaxOpenConns()) if err != nil { log.Fatal("Error opening database", err) } diff --git a/model/album.go b/model/album.go index 667f4695b..ade7f6ee0 100644 --- a/model/album.go +++ b/model/album.go @@ -141,6 +141,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetCursor(...QueryOptions) (AlbumCursor, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/artist.go b/model/artist.go index 2085f0051..f9c4bffd5 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "maps" "slices" "time" @@ -79,6 +80,8 @@ type ArtistIndex struct { } type ArtistIndexes []ArtistIndex +type ArtistCursor iter.Seq2[Artist, error] + type ArtistRepository interface { CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) @@ -86,6 +89,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) // The following methods are used exclusively by the scanner: diff --git a/model/playlist.go b/model/playlist.go index 262774aa7..f2586f52d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,6 +1,7 @@ package model import ( + "iter" "slices" "strconv" "time" @@ -121,6 +122,8 @@ func (pls Playlist) UploadedImagePath() string { type Playlists []Playlist +type PlaylistCursor iter.Seq2[Playlist, error] + type PlaylistRepository interface { ResourceRepository AnnotatedRepository @@ -130,6 +133,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 34845be15..6ebbd9202 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -247,6 +247,15 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e return res.toModels(), nil } +func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { + sq := r.selectAlbum(options...) + cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) + if err != nil { + return nil, err + } + return wrapAlbumCursor(cursor), nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) @@ -319,17 +328,7 @@ func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) } func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { - return func(yield func(model.Album, error) bool) { - for a, err := range cursor { - if a.Album == nil { - yield(model.Album{}, fmt.Errorf("unexpected nil album (%v): %w", a, err)) - return - } - if !yield(*a.Album, err) || err != nil { - return - } - } - } + return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album })) } // RefreshPlayCounts updates the play count and last play date annotations for all albums, based diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index f72f778db..64ff0095e 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -67,6 +67,22 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same albums as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(albumRepo.GetCursor(opts))).To(Equal([]model.Album(want))) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) @@ -854,7 +870,7 @@ var _ = Describe("AlbumRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil album")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index f84f410e9..b542dedb4 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "os" "slices" "strings" @@ -263,6 +264,19 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } +func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + sel := r.selectArtist(options...) + cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapArtistCursor(cursor), nil +} + +func wrapArtistCursor(cursor iter.Seq2[dbArtist, error]) model.ArtistCursor { + return model.ArtistCursor(wrapCursor(cursor, func(a dbArtist) *model.Artist { return a.Artist })) +} + func (r *artistRepository) getIndexKey(a model.Artist) string { source := a.OrderArtistName if conf.Server.PreferSortTags { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index d7b695ade..dc11ede36 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -268,6 +268,22 @@ var _ = Describe("ArtistRepository", func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same artists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Artist(want))) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index 8fb7f0296..5da395a74 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -263,17 +263,7 @@ func (r folderRepository) GetAllWithPlaylists() (model.FolderCursor, error) { } func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { - return func(yield func(model.Folder, error) bool) { - for f, err := range cursor { - if f.Folder == nil { - yield(model.Folder{}, fmt.Errorf("unexpected nil folder (%v): %w", f, err)) - return - } - if !yield(*f.Folder, err) || err != nil { - return - } - } - } + return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) } func (r folderRepository) purgeEmpty(libraryIDs ...int) error { diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index a8945dfee..8cd45f16b 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -297,7 +297,7 @@ var _ = Describe("FolderRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil folder")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index b4979ca77..ace61610c 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -420,17 +420,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC } func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { - return func(yield func(model.MediaFile, error) bool) { - for m, err := range cursor { - if m.MediaFile == nil { - yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile (%v): %w", m, err)) - return - } - if !yield(*m.MediaFile, err) || err != nil { - return - } - } - } + return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile })) } // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 80d440c41..f6a744d8d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -29,6 +29,22 @@ var _ = Describe("MediaRepository", func() { mr = NewMediaFileRepository(ctx, GetDBXBuilder()) }) + Describe("GetCursor", func() { + It("yields the same media files as GetAll", func() { + opts := model.QueryOptions{Sort: "title"} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + + It("honors Max/Offset like GetAll", func() { + opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} + want, err := mr.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(mr.GetCursor(opts))).To(Equal([]model.MediaFile(want))) + }) + }) + It("gets mediafile from the DB", func() { actual, err := mr.Get("1004") Expect(err).ToNot(HaveOccurred()) @@ -1012,7 +1028,7 @@ var _ = Describe("MediaRepository", func() { } }).ToNot(Panic()) Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile")) + Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile")) Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") }) diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 4f2fd7fe2..f146cb06b 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -329,3 +329,16 @@ var _ = BeforeSuite(func() { func GetDBXBuilder() *dbx.DB { return dbx.NewFromDB(db.Db(), db.Dialect) } + +// collectCursor takes the cursor's underlying func type so the named cursor types +// (model.AlbumCursor, ...) infer T. +func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { + GinkgoHelper() + Expect(err).ToNot(HaveOccurred()) + var out []T + for item, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + out = append(out, item) + } + return out +} diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 573f43c10..9626aad6a 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "iter" "slices" "time" @@ -186,6 +187,21 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + // Same userFilter as GetAll: a cursor must not widen visibility beyond public/owned playlists. + sel := r.selectPlaylist(options...).Where(r.userFilter()) + cursor, err := queryWithStableResults[dbPlaylist](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return wrapPlaylistCursor(cursor), nil +} + +// dbPlaylist embeds a value, not a pointer, so its model is never nil. +func wrapPlaylistCursor(cursor iter.Seq2[dbPlaylist, error]) model.PlaylistCursor { + return model.PlaylistCursor(wrapCursor(cursor, func(p dbPlaylist) *model.Playlist { return &p.Playlist })) +} + func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 831e24453..c51ff6222 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -27,6 +27,15 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetCursor", func() { + It("yields the same playlists as GetAll", func() { + opts := model.QueryOptions{Sort: "name"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.Playlist(want))) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ce5221d19..d0cbb2946 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -347,6 +347,24 @@ func (r sqlRepository) queryOne(sq Sqlizer, response any) error { return err } +// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's +// embedded model, which a type parameter can't reach on its own. +func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + for row, err := range cursor { + m := toModel(row) + if m == nil { + var zero T + yield(zero, fmt.Errorf("unexpected nil %T (%v): %w", zero, row, err)) + return + } + if !yield(*m, err) || err != nil { + return + } + } + } +} + // queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results // from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index fd523d154..d169a4c83 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -1,7 +1,6 @@ package jellyfin import ( - "context" "encoding/json" "net/http" "sync" @@ -19,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" ) @@ -71,8 +71,14 @@ func (api *Router) routes() http.Handler { inner.Get("/Users/Public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. - inner.Get("/Items/{itemId}/Images/{type}", api.getItemImage) - inner.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, + // and an unbounded burst (a client fetching artwork across a large library) can exhaust memory. + inner.Group(func(r chi.Router) { + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) + r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + }) inner.Group(func(r chi.Router) { r.Use(api.authenticate) @@ -85,12 +91,22 @@ func (api *Router) routes() http.Handler { r.Get("/Users/Me", api.getCurrentUser) r.Get("/Users/{userId}", api.getCurrentUser) - r.Get("/Items", api.getItems) - r.Get("/Users/{userId}/Items", api.getItems) + // Cursor-backed collections: each streams straight from the DB, holding a connection for the + // whole client-paced response, so enough slow clients would take the entire pool and stall the + // scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess + // requests queue rather than fail. + r.Group(func(r chi.Router) { + r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) + r.Get("/Items", api.getItems) + r.Get("/Users/{userId}/Items", api.getItems) + r.Get("/Users/{userId}/Items/Latest", api.getLatest) + r.Get("/Artists", api.getArtists) + r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + }) + r.Get("/Items/{itemId}", api.getItem) r.Get("/Users/{userId}/Items/{itemId}", api.getItem) r.Delete("/Items/{itemId}", api.deleteItem) - r.Get("/Users/{userId}/Items/Latest", api.getLatest) // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. @@ -106,8 +122,6 @@ func (api *Router) routes() http.Handler { r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) - r.Get("/Artists", api.getArtists) - r.Get("/Artists/AlbumArtists", api.getAlbumArtists) r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) r.Get("/Items/{itemId}/Similar", api.getSimilarItems) r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) @@ -158,14 +172,19 @@ func (api *Router) routes() http.Handler { return caseInsensitivePaths(inner) } -// ok writes payload as JSON, stamping ServerId on any item(s) in it — real Jellyfin always sets it, -// and it's the same value for every item, so it's applied here rather than threaded through mappers. +// ok writes payload as JSON — the single entry point for every handler. Collections are routed to +// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped +// on any item(s): real Jellyfin always sets it, and it's constant per request. +// +// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray). func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { switch p := payload.(type) { + case itemsResult: + api.writeItems(w, r, p) + return case dto.QueryResult: - api.stampServerID(r.Context(), p.Items) - case []dto.BaseItemDto: - api.stampServerID(r.Context(), p) + api.writeItems(w, r, materialized(p)) + return case dto.BaseItemDto: p.ServerId = api.serverID(r.Context()) payload = p @@ -176,13 +195,6 @@ func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { } } -func (api *Router) stampServerID(ctx context.Context, items []dto.BaseItemDto) { - sid := api.serverID(ctx) - for i := range items { - items[i].ServerId = sid - } -} - // notFound handles unmatched routes and unsupported methods, logging them so unimplemented // endpoints surface instead of returning chi's default plain-text 404/405. func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index bf77c4594..d5a00e492 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -28,10 +28,15 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. - genreIds := decodedQueryIDs(r, "genreids") + q := itemsQuery{ + scopeIDs: scopeIDs, + genreIds: decodedQueryIDs(r, "genreids"), + search: p.StringOr("searchterm", ""), + } - res, err := api.listArtists(ctx, opts, genreIds, scopeIDs, p.StringOr("searchterm", ""), false, role) + res, err := api.listArtists(ctx, opts, q, role) if err != nil { api.internalError(w, r, err) return diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index af639ed29..646de4497 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -300,6 +300,34 @@ var _ = Describe("Browsing", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs }) + + // Chaining the per-type cursors must preserve the merged order. + It("streams an unbounded multi-type merge, honoring StartIndex", func() { + all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) + Expect(all.Items).To(HaveLen(12)) + + skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2")) + Expect(skipped.Items).To(HaveLen(10)) + Expect(skipped.TotalRecordCount).To(Equal(12)) + Expect(skipped.StartIndex).To(Equal(2)) + Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:])) + }) + + // Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing. + It("pages songs via StartIndex/Limit while reporting the full total", func() { + all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName")) + Expect(all.TotalRecordCount).To(Equal(7)) + Expect(all.Items).To(HaveLen(7)) + + p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0")) + p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3")) + Expect(p1.Items).To(HaveLen(3)) + Expect(p2.Items).To(HaveLen(3)) + Expect(p1.TotalRecordCount).To(Equal(7)) + // The two pages are distinct and match the head of the unpaged, identically-sorted list. + Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) + Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6])) + }) }) Describe("GET /Items/{id}", func() { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index f8158bca4..0dca48938 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -2,6 +2,8 @@ package jellyfin import ( "context" + "io" + "iter" "net/http" "slices" "strconv" @@ -31,112 +33,328 @@ func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { api.ok(w, r, res) } -// queryItems is the /Items dispatcher: it parses entity types from IncludeItemTypes (defaulting to -// MusicAlbum), queries each via the matching listXxx, and merges multi-type results into one -// paginated list (as Finamp's favorites screen requests). -func (api *Router) queryItems(ctx context.Context, r *http.Request) (dto.QueryResult, error) { - p := req.Params(r) - // Query keys are read lowercase because normalizeQueryKeys folded them (Jellyfin binds - // case-insensitively). /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch below. - fields := dto.ParseFields(p.StringOr("fields", "")) - if ids := decodedQueryIDs(r, "ids"); len(ids) > 0 { - return api.itemsByIDs(ctx, ids, fields), nil - } - parentId := dto.DecodeID(p.StringOr("parentid", "")) - search := p.StringOr("searchterm", "") - // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone - // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly := strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false) - sortBy := p.StringOr("sortby", "") - sortOrder := p.StringOr("sortorder", "") - offset := p.IntOr("startindex", 0) - limit := p.IntOr("limit", 0) - rawTypes := p.StringOr("includeitemtypes", "") - // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - if strings.Contains(rawTypes, "ManualPlaylistsFolder") { - return result([]dto.BaseItemDto{playlistsFolder()}, 1, 0), nil - } - types := parseTypes(rawTypes) - // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping - // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistIds/artistIds - // select the artist's own discography; contributingArtistIds alone means albums they merely appear - // on (Jellyfin's "Featured On"), which must exclude that discography. - albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) - contributingScope := p.StringOr("contributingartistids", "") - artistId := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) - contributingOnly := albumArtistScope == "" && contributingScope != "" - // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. - genreIds := decodedQueryIDs(r, "genreids") +// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a +// full-library response never builds every DTO at once. Exactly one of items/openCursor is set. +// +// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which +// writes to the DB on first use and would deadlock against an open reader, but before the first +// response byte, so a failed open is still a clean error rather than a truncated 200. +type itemsResult struct { + items []dto.BaseItemDto + openCursor func() (iter.Seq2[dto.BaseItemDto, error], error) + total int + start int +} - scopeIDs, isLibraryParent := resolveLibraryScope(ctx, parentId) - // A playlist parent always resolves to its tracks, whatever IncludeItemTypes says. Jellify opens - // a playlist with ParentId=&IncludeItemTypes=Audio; routing that through listSongs would - // treat the playlist id as an album id and return nothing. - if parentId != "" && !isLibraryParent && parentId != playlistsFolderID { - if pls, err := api.playlists.GetWithTracks(ctx, parentId); err == nil { - // GetWithTracks enforces visibility (public or owned by the current user). - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - return result(paginate(items, offset, limit), len(items), offset), nil +func materialized(q dto.QueryResult) itemsResult { + return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex} +} + +func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult { + return itemsResult{openCursor: open, total: total, start: start} +} + +// chained streams several results back to back, skipping the first skip items — the unbounded +// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head. +func chained(results []itemsResult, total, skip int) itemsResult { + open := func() (iter.Seq2[dto.BaseItemDto, error], error) { + if len(results) == 0 { + return sliceItems(nil), nil + } + // Only the first opens eagerly (so the usual failure is still a clean error); the rest open as + // the stream reaches them, so only one cursor pins a DB connection at a time. + first, err := results[0].seq() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + n := 0 + emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return false + } + if n < skip { + n++ + continue + } + if !yield(it, nil) { + return false + } + } + return true + } + if !emit(first) { + return + } + for _, res := range results[1:] { + seq, err := res.seq() + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !emit(seq) { + return + } + } + }, nil + } + return streamed(open, total, skip) +} + +// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's +// underlying func type, so callers wrap repo.GetCursor for the named type to infer T. +func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) { + return func() (iter.Seq2[dto.BaseItemDto, error], error) { + cursor, err := openCursor() + if err != nil { + return nil, err + } + return func(yield func(dto.BaseItemDto, error) bool) { + for row, err := range cursor { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + if !yield(toItem(row), nil) { + return + } + } + }, nil + } +} + +// seq returns the items as one sequence, opening the cursor if there is one. +func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) { + if ir.openCursor != nil { + return ir.openCursor() + } + return sliceItems(ir.items), nil +} + +// collect drains the result into a slice, for the merge that combines types before paginating. +func (ir itemsResult) collect() ([]dto.BaseItemDto, error) { + if ir.openCursor == nil { + return ir.items, nil + } + seq, err := ir.openCursor() + if err != nil { + return nil, err + } + var out []dto.BaseItemDto + for it, err := range seq { + if err != nil { + return nil, err + } + out = append(out, it) + } + return out, nil +} + +func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + return streamItemsEnvelope(w, items, res.total, res.start) + }) +} + +// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope. +func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) { + api.streamResult(w, r, res, streamItemsArray) +} + +// streamResult stamps every item's ServerId (constant per request, so it's set here rather than in +// each mapper). The cursor opens before the first byte, so a failed open is still a clean 500. +func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, + write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { + sid := api.serverID(r.Context()) + seq, err := res.seq() + if err != nil { + api.internalError(w, r, err) + return + } + stamped := func(yield func(dto.BaseItemDto, error) bool) { + for it, err := range seq { + if err != nil { + yield(dto.BaseItemDto{}, err) + return + } + it.ServerId = sid + if !yield(it, nil) { + return + } } } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := write(w, stamped); err != nil { + log.Error(r.Context(), "Jellyfin API: error streaming response", err) + } +} + +// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead +// of a long positional parameter list. +type itemsQuery struct { + fields dto.Fields + ids []string + rawTypes string + types []string + search string + sortBy string + sortOrder string + offset int + limit int + favOnly bool + // parentId scopes the query. entityParent is the same id only when it names an entity (an artist + // for MusicAlbum, an album for Audio) rather than a library. + parentId string + entityParent string + isLibraryParent bool + scopeIDs []int + // artistId selects that artist's own discography; contributingOnly means albums they merely + // appear on (Jellyfin's "Featured On"), which must exclude that discography. + artistId string + contributingOnly bool + genreIds []string +} + +// parseItemsQuery also resolves the entity types (inferring them from the parent when +// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { + p := req.Params(r) + q := itemsQuery{ + fields: dto.ParseFields(p.StringOr("fields", "")), + ids: decodedQueryIDs(r, "ids"), + rawTypes: p.StringOr("includeitemtypes", ""), + search: p.StringOr("searchterm", ""), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone + // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: dto.DecodeID(p.StringOr("parentid", "")), + // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. + genreIds: decodedQueryIDs(r, "genreids"), + } + // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping + // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. + albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) + contributingScope := p.StringOr("contributingartistids", "") + q.artistId = firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + q.contributingOnly = albumArtistScope == "" && contributingScope != "" + + q.types = parseTypes(q.rawTypes) + q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). - if rawTypes == "" && parentId != "" && !isLibraryParent { - if parentId == playlistsFolderID { + if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { + if q.parentId == playlistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. - types = []string{"Playlist"} - } else if _, err := api.ds.Album(ctx).Get(parentId); err == nil { - types = []string{"Audio"} + q.types = []string{"Playlist"} + } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { + q.types = []string{"Audio"} } } - entityParent := parentId - // ParentId-as-entity-id (artist for MusicAlbum, album for Audio) only makes sense for a single - // type; a multi-type query has no natural parent entity, so ParentId is only library scoping there. - if isLibraryParent || len(types) > 1 { - entityParent = "" + // ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural + // parent entity, so there ParentId is only library scoping. + q.entityParent = q.parentId + if q.isLibraryParent || len(q.types) > 1 { + q.entityParent = "" } - - if len(types) == 1 { - opts := model.QueryOptions{Offset: offset, Max: limit} - applySort(&opts, types[0], sortBy, sortOrder) - return api.queryItemsOfType(ctx, types[0], opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) - } - - var items []dto.BaseItemDto - total := 0 - for _, itemType := range types { - var opts model.QueryOptions - // Each per-type query needs at most offset+limit rows (the worst case where one type fills the - // whole [offset, offset+limit) window); without this cap each would fetch its whole table. - // Totals are unaffected — they come from CountAll. - if limit > 0 { - opts.Max = offset + limit - } - applySort(&opts, itemType, sortBy, sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly, fields) - if err != nil { - return dto.QueryResult{}, err - } - items = append(items, res.Items...) - total += res.TotalRecordCount - } - return result(paginate(items, offset, limit), total, offset), nil + return q } -func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, entityParent, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, favOnly bool, fields dto.Fields) (dto.QueryResult, error) { +// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via +// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites +// screen requests). +func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { + q := api.parseItemsQuery(ctx, r) + switch { + // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. + case len(q.ids) > 0: + return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil + // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. + case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): + return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil + } + if res, ok := api.playlistTracks(ctx, q); ok { + return res, nil + } + if len(q.types) == 1 { + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, q.types[0], q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, q.types[0], opts, q) + } + return api.mergeTypes(ctx, q) +} + +// playlistTracks resolves a playlist parent to its tracks, whatever IncludeItemTypes says: Jellify +// opens a playlist with ParentId=&IncludeItemTypes=Audio, and routing that through +// listSongs would treat the playlist id as an album id and return nothing. +func (api *Router) playlistTracks(ctx context.Context, q itemsQuery) (itemsResult, bool) { + if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { + return itemsResult{}, false + } + pls, err := api.playlists.GetWithTracks(ctx, q.parentId) + if err != nil { + return itemsResult{}, false + } + // GetWithTracks enforces visibility (public or owned by the current user). + items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, q.fields) }) + return materialized(result(paginate(items, q.offset, q.limit), len(items), q.offset)), true +} + +func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (the worst case where one type fills the + // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + var results []itemsResult + total := 0 + for _, itemType := range q.types { + var opts model.QueryOptions + if q.limit > 0 { + opts.Max = q.offset + q.limit + } + applySort(&opts, itemType, q.sortBy, q.sortOrder) + res, err := api.queryItemsOfType(ctx, itemType, opts, q) + if err != nil { + return itemsResult{}, err + } + results = append(results, res) + total += res.total + } + if q.limit == 0 { + // No cap above, so merging in memory would pull every row of every type. The merged page is + // just their rows in order minus the first offset — what chaining the cursors yields. + return chained(results, total, q.offset), nil + } + var items []dto.BaseItemDto + for _, res := range results { + typeItems, err := res.collect() + if err != nil { + return itemsResult{}, err + } + items = append(items, typeItems...) + } + return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil +} + +func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { switch itemType { case "Audio": - return api.listSongs(ctx, opts, entityParent, artistId, genreIds, scopeIDs, search, favOnly, fields) + return api.listSongs(ctx, opts, q) case "MusicArtist": // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. - return api.listArtists(ctx, opts, genreIds, scopeIDs, search, favOnly, model.RoleAlbumArtist) + return api.listArtists(ctx, opts, q, model.RoleAlbumArtist) case "MusicGenre": return api.listGenres(ctx, opts) case "Playlist": - return api.listPlaylists(ctx, opts, favOnly) + return api.listPlaylists(ctx, opts, q) default: // MusicAlbum - return api.listAlbums(ctx, opts, entityParent, artistId, contributingOnly, genreIds, scopeIDs, search, favOnly) + return api.listAlbums(ctx, opts, q) } } @@ -213,145 +431,144 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO return rows, total, nil } -func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, parentId, artistId string, contributingOnly bool, genreIds []string, scopeIDs []int, search string, fav bool) (dto.QueryResult, error) { +func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's // albums"; contributingArtistIds means "albums they only appear on" (Featured On). switch { - case contributingOnly && artistId != "": - filters = append(filters, filter.AlbumsByContributingArtistID(artistId).Filters) - case firstNonEmpty(artistId, parentId) != "": - filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(artistId, parentId)).Filters) + case q.contributingOnly && q.artistId != "": + filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) + case firstNonEmpty(q.artistId, q.entityParent) != "": + filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) default: filters = append(filters, notMissing) } - if len(genreIds) > 0 { - filters = append(filters, filter.ByGenreID(genreIds)) + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) } - if fav { + if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, scopeIDs) + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - if search != "" { + if q.search != "" { albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset), nil - } - albums, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err + return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(albums, dto.AlbumToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + return streamed(open, int(total), opts.Offset), nil } -func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, parentId, artistId string, genreIds []string, scopeIDs []int, search string, fav bool, fields dto.Fields) (dto.QueryResult, error) { - toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, fields) } +func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } repo := api.ds.MediaFile(ctx) filters := squirrel.And{} // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. switch { - case artistId != "": - filters = append(filters, filter.SongsByArtistID(artistId).Filters) - case parentId != "": - filters = append(filters, filter.SongsByAlbum(parentId).Filters) + case q.artistId != "": + filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) + case q.entityParent != "": + filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) default: filters = append(filters, notMissing) } - if len(genreIds) > 0 { - filters = append(filters, filter.ByGenreID(genreIds)) + if len(q.genreIds) > 0 { + filters = append(filters, filter.ByGenreID(q.genreIds)) } - if fav { + if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, scopeIDs) + opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - if search != "" { + if q.search != "" { mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(mfs, toItem), total, opts.Offset), nil + return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil } // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an // explicit client SortBy still wins, since applySort already set opts.Sort. - if artistId == "" && parentId != "" && opts.Sort == "" { - opts.Sort = filter.SongsByAlbum(parentId).Sort - } - mfs, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err + if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { + opts.Sort = filter.SongsByAlbum(q.entityParent).Sort } + // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(mfs, toItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { + return repo.GetCursor(opts) + }, toItem) + return streamed(open, int(total), opts.Offset), nil } // listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, // RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. // genreIds isn't applied to search — a name lookup, like role (see below). -func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, genreIds []string, scopeIDs []int, search string, fav bool, role model.Role) (dto.QueryResult, error) { +func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { repo := api.ds.Artist(ctx) // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a // search scope (artists have no library_id column). A compound or join-based filter // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build // filters differently. Role isn't applied to search for the same reason — it's a name lookup. - if search != "" { - if len(scopeIDs) > 0 { - opts.Filters = squirrel.Eq{"library_id": scopeIDs} + if q.search != "" { + if len(q.scopeIDs) > 0 { + opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} } artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { - return repo.Search(search, o) + return repo.Search(q.search, o) }) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset), nil + return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil } - if fav { + if q.favOnly { opts.Filters = filter.ArtistsByStarred().Filters } else { opts.Filters = notMissing } - if len(genreIds) > 0 { - opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(genreIds)} + if len(q.genreIds) > 0 { + opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} } opts = filter.ArtistsByRole(opts, role) - opts = filter.ApplyArtistLibraryFilter(opts, scopeIDs) - artists, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err - } + opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) - return result(slice.Map(artists, dto.ArtistToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Artist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.ArtistToBaseItem) + return streamed(open, int(total), opts.Offset), nil } -// listGenres is intentionally unscoped: genres are global tags, not per-library entities. Paging is -// in-memory (GenreRepository has no CountAll, lists are small) so TotalRecordCount is the real total. -func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (dto.QueryResult, error) { +// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also +// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the +// length of the full list and paging is in-memory — nothing for a cursor to page over. +func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } items := slice.Map(genres, dto.GenreToBaseItem) - return result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset), nil + return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil } // listPlaylists lists playlists visible to the current user. Visibility (public or owned) is // enforced by playlistRepository, not scopeIDs. -func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, favOnly bool) (dto.QueryResult, error) { - if favOnly { +func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + if q.favOnly { starred := squirrel.Eq{"starred": true} if opts.Filters == nil { opts.Filters = starred @@ -360,15 +577,14 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, f } } repo := api.ds.Playlist(ctx) - playlists, err := repo.GetAll(opts) - if err != nil { - return dto.QueryResult{}, err - } total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) if err != nil { - return dto.QueryResult{}, err + return itemsResult{}, err } - return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil + open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { + return repo.GetCursor(opts) + }, dto.PlaylistToBaseItem) + return streamed(open, int(total), opts.Offset), nil } // resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, @@ -482,17 +698,18 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for +// /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() opts := filter.AlbumsByNewest() opts.Max = req.Params(r).IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) - albums, err := api.ds.Album(ctx).GetAll(opts) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, slice.Map(albums, dto.AlbumToBaseItem)) // /Latest returns a bare array + repo := api.ds.Album(ctx) + open := streamCursor(func() (func(func(model.Album, error) bool), error) { + return repo.GetCursor(opts) + }, dto.AlbumToBaseItem) + api.writeItemsArray(w, r, streamed(open, 0, 0)) } func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 8bd1bf052..801db9e3a 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -71,6 +71,14 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) }) + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) w := httptest.NewRecorder() diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 2941d3c32..d9e41d252 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -7,12 +7,28 @@ import ( "regexp" "strings" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) +// throttleStreams bounds how many collection responses stream concurrently, so they can't take every +// connection in the shared DB pool: each holds a cursor, and its connection, for the whole +// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it. +// +// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire +// response to release its token early, which is right for artwork but would undo the streaming here. +// chi's panics on a non-positive limit, hence the guard. +func throttleStreams(limit int) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) +} + // normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params // case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, // Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index 2c0761834..c766bff70 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -4,6 +4,9 @@ import ( "context" "net/http" "net/http/httptest" + "sync" + "sync/atomic" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -252,3 +255,69 @@ var _ = Describe("normalizeQueryKeys", func() { Expect(got).To(ConsistOf("aaa", "bbb")) }) }) + +var _ = Describe("throttleStreams", func() { + // serve fires n concurrent requests through the middleware and reports the highest number that + // were ever inside the handler at once. + serve := func(limit, n int) int32 { + var inFlight, peak int32 + release := make(chan struct{}) + h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cur := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&peak) + if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) { + break + } + } + <-release // hold the slot until every request has had a chance to enter + atomic.AddInt32(&inFlight, -1) + })) + + var wg sync.WaitGroup + for range n { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + // Give the admitted requests time to pile up before letting them finish. + time.Sleep(100 * time.Millisecond) + close(release) + wg.Wait() + return atomic.LoadInt32(&peak) + } + + It("admits no more than the limit at once", func() { + Expect(serve(2, 8)).To(Equal(int32(2))) + }) + + It("queues the excess rather than rejecting it", func() { + // All 8 still complete — they wait for a slot instead of getting a 429. + var served int32 + release := make(chan struct{}) + h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + atomic.AddInt32(&served, 1) + })) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) + }() + } + close(release) + wg.Wait() + Expect(served).To(Equal(int32(8))) + }) + + // chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not + // crash the server at startup. + It("is disabled, not panicking, when the limit is zero", func() { + Expect(func() { serve(0, 4) }).ToNot(Panic()) + Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) + }) +}) diff --git a/server/jellyfin/response.go b/server/jellyfin/response.go new file mode 100644 index 000000000..f4b96eda3 --- /dev/null +++ b/server/jellyfin/response.go @@ -0,0 +1,87 @@ +package jellyfin + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "iter" + "strconv" + + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// streamItemsEnvelope writes a QueryResult, byte-identical to json.NewEncoder(w).Encode(q). +// +// A mid-stream error aborts without closing the envelope: the 200 is already committed, so a +// truncated-but-valid body would let a sync client treat the short list as the whole library and +// prune local tracks. Malformed JSON forces its parser to fail instead. Callers open the cursor +// before the first byte, so this only fires on a rare mid-iteration failure. +func streamItemsEnvelope(w io.Writer, items iter.Seq2[dto.BaseItemDto, error], total, start int) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString(`{"Items":[`) + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString(`],"TotalRecordCount":`) + _, _ = bw.WriteString(strconv.Itoa(total)) + _, _ = bw.WriteString(`,"StartIndex":`) + _, _ = bw.WriteString(strconv.Itoa(start)) + _, _ = bw.WriteString("}\n") + return bw.Flush() +} + +// streamItemsArray writes a bare JSON array — the shape /Items/Latest returns, with no envelope. +func streamItemsArray(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + bw := bufio.NewWriterSize(w, 64*1024) + _, _ = bw.WriteString("[") + if err := encodeItems(bw, items); err != nil { + _ = bw.Flush() + return err + } + _, _ = bw.WriteString("]\n") + return bw.Flush() +} + +// encodeItems writes items comma-separated. Unlike the fixed envelope writes, these are checked: +// bufio surfaces a latched write error here once a flush fails, and a client that has gone away must +// abandon the scan rather than pull the rest of the library through the cursor — which would hold its +// pooled DB connection and stream slot for a response nobody is reading. +func encodeItems(bw *bufio.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { + // One reused buffer+encoder, so per-item JSON doesn't allocate. Encode HTML-escapes like + // json.Marshal, and appends a newline that's dropped below. + var itemBuf bytes.Buffer + enc := json.NewEncoder(&itemBuf) + first := true + for item, err := range items { + if err != nil { + return err + } + if !first { + if _, err := bw.WriteString(","); err != nil { + return err + } + } + first = false + itemBuf.Reset() + if err := enc.Encode(item); err != nil { + return err + } + b := itemBuf.Bytes() + if _, err := bw.Write(b[:len(b)-1]); err != nil { + return err + } + } + return nil +} + +func sliceItems(items []dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for i := range items { + if !yield(items[i], nil) { + return + } + } + } +} diff --git a/server/jellyfin/response_test.go b/server/jellyfin/response_test.go new file mode 100644 index 000000000..c32c566fc --- /dev/null +++ b/server/jellyfin/response_test.go @@ -0,0 +1,116 @@ +package jellyfin + +import ( + "bytes" + "encoding/json" + "errors" + "iter" + "strings" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// deadWriter stands in for a client that went away mid-response. +type deadWriter struct{} + +func (deadWriter) Write([]byte) (int, error) { return 0, errors.New("connection reset by peer") } + +var _ = Describe("streaming a materialized QueryResult", func() { + // The materialized path (api.ok -> writeItems -> sliceItems) must stay byte-for-byte identical to + // what json.Encoder.Encode produced before, so no client sees a different response. + assertIdenticalToEncoder := func(q dto.QueryResult) { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, sliceItems(q.Items), q.TotalRecordCount, q.StartIndex)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(q)).To(Succeed()) + + Expect(got.String()).To(Equal(want.String())) + } + + It("encodes an empty item list", func() { + assertIdenticalToEncoder(dto.QueryResult{Items: []dto.BaseItemDto{}}) + }) + + It("encodes a single item", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{{Id: "a", Name: "One"}}, + TotalRecordCount: 1, + }) + }) + + It("encodes multiple items, honoring HTML escaping and StartIndex", func() { + assertIdenticalToEncoder(dto.QueryResult{ + Items: []dto.BaseItemDto{ + {Id: "a", Name: "One"}, + {Id: "b", Name: "Two & "}, + }, + TotalRecordCount: 500, + StartIndex: 100, + }) + }) +}) + +var _ = Describe("streamItemsEnvelope", func() { + seqOf := func(items ...dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { + return func(yield func(dto.BaseItemDto, error) bool) { + for _, it := range items { + if !yield(it, nil) { + return + } + } + } + } + + It("produces the same bytes as encoding an equivalent QueryResult", func() { + items := []dto.BaseItemDto{{Id: "a", Name: "One"}, {Id: "b", Name: "Two & "}} + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(items...), 500, 100)).To(Succeed()) + + var want bytes.Buffer + Expect(json.NewEncoder(&want).Encode(dto.QueryResult{Items: items, TotalRecordCount: 500, StartIndex: 100})).To(Succeed()) + Expect(got.String()).To(Equal(want.String())) + }) + + It("emits an empty array (not null) for a sequence that yields nothing", func() { + var got bytes.Buffer + Expect(streamItemsEnvelope(&got, seqOf(), 0, 0)).To(Succeed()) + Expect(got.String()).To(Equal("{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}\n")) + }) + + // A client that goes away must not keep the source (a DB cursor, holding its pooled connection + // and a stream slot) running to the end of the library. + It("stops pulling from the source once writing fails", func() { + const total = 20000 + pulled := 0 + seq := func(yield func(dto.BaseItemDto, error) bool) { + for range total { + pulled++ + if !yield(dto.BaseItemDto{Id: "a", Name: strings.Repeat("x", 200)}, nil) { + return + } + } + } + err := streamItemsEnvelope(deadWriter{}, seq, total, 0) + Expect(err).To(HaveOccurred()) + Expect(pulled).To(BeNumerically("<", total), "should abandon the scan, not drain it") + }) + + It("aborts on a mid-stream error, leaving the envelope open (malformed) so the client fails loudly", func() { + boom := errors.New("scan failed") + first := dto.BaseItemDto{Id: "a", Name: "One"} + seq := func(yield func(dto.BaseItemDto, error) bool) { + if !yield(first, nil) { + return + } + yield(dto.BaseItemDto{}, boom) + } + var got bytes.Buffer + err := streamItemsEnvelope(&got, seq, 7, 0) + Expect(err).To(MatchError(boom)) + firstJSON, _ := json.Marshal(first) + Expect(got.String()).To(Equal("{\"Items\":[" + string(firstJSON))) + }) +}) diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 85765abf8..6635881b7 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -75,6 +75,20 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.Album, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { if m.Err { return errors.New("unexpected error") diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 748002882..e6ea7aea4 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -113,6 +113,20 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Artist, error) bool) { + for _, a := range res { + if !yield(a, nil) { + return + } + } + }, nil +} + func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { if m.Err { return errors.New("mock repo error") diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 6ddd77f14..990b91d7c 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -109,6 +109,20 @@ func (m *MockMediaFileRepo) GetRandom(qo ...model.QueryOptions) (model.MediaFile return res, nil } +func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + res, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return func(yield func(model.MediaFile, error) bool) { + for _, mf := range res { + if !yield(mf, nil) { + return + } + } + }, nil +} + func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { if m.Err { return errors.New("error") diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 908d6aab5..8f8842c8e 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -52,6 +52,20 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { + res, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return func(yield func(model.Playlist, error) bool) { + for _, p := range res { + if !yield(p, nil) { + return + } + } + }, nil +} + func (m *MockPlaylistRepo) Get(id string) (*model.Playlist, error) { if m.Err { return nil, errors.New("error") From 3d438b08efc705928a187b51042ca0778feeb900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 18:09:59 -0400 Subject: [PATCH 11/14] fix(jellyfin): close the unbounded playlist and search paths left by #5783 (#5784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): stream playlist tracks instead of loading every one PR #5783 left the playlist paths materializing: all three loaded every track of a playlist, whatever the client asked for. A playlist can be the whole library — a smart playlist matching everything — so this is the same OOM class that PR fixed for the other collections. Measured on a 96k-track smart playlist, /Items?ParentId=&Limit=10 peaked at 1.3GB to return ten tracks. Playlist tracks now stream from a cursor, like every other collection: - PlaylistTrackRepository gains GetCursor and CountAll, sharing the select builder with loadTracks so cursor rows hydrate identically, plus GetMediaFileIDs for callers that need every id but no track data. - playlists.Tracks(ctx, id) exposes that repo to the HTTP layer with visibility enforced, returning ErrNotFound rather than the repo's nil-and-log-a-warning (which /Items would hit on every album browse, since ParentId is usually not a playlist). - /Playlists/{id}/Items now honors StartIndex/Limit, which it silently ignored before — it always returned the whole playlist. Real Jellyfin pages it. - getPlaylist runs an id-only query: PlaylistInfo carries every track id so it can't be paged, but it no longer hydrates rows it discards. - The route joins the throttled group, as it's now cursor-backed. Measured against a copy of a 96k-track production DB, peak RSS over idle, with byte-for-byte identical responses on every endpoint: /Items?ParentId=&Limit=10 1275MB -> 1MB 8.8s -> 3.6s (0.27s warm) /Items?ParentId= unbounded 1353MB -> 8MB 8.7s -> 3.4s /Playlists//Items 1217MB -> 7MB 8.8s -> 3.4s /Playlists/ 1178MB -> 33MB 9.1s -> 3.8s TotalRecordCount now costs a count query where the old path got it from len(tracks): 6ms on the largest real playlist in that library (2638 tracks), 288ms on the synthetic all-96k one. The old path paid 1.3GB and 4s+ instead. * fix(jellyfin): bound unbounded /Items searches The other collections stream, so an unbounded one costs about one item of memory. Search can't: the repositories' Search returns a slice, so it materializes every match. PR #5783 left two ways to reach that. A whitespace-only SearchTerm was the first. " " != "", so it took the search path, where doSearch trims it back to empty and hits its "empty query, return everything in natural order" branch — with no LIMIT, since executeTwoPhase only applies one when Max > 0. The whole library, materialized. Trimming at the two parse sites makes the `search != ""` checks mean what they look like they mean: a blank term is not a search, so it takes the unfiltered streaming path, which still returns everything, exactly as real Jellyfin does for an empty term. A real search with no Limit was the second, and needs an actual bound. Search gets a default of 100 when the client sends no Limit — matching the DefaultSearchLimit in Jellyfin's unreleased SqlSearchProvider — plus a ceiling, without which Limit=999999 would still materialize the library. The default alone wouldn't have closed the hole. An explicit Limit under the ceiling is honored unclamped, as upstream does; truncating a search is safe in a way truncating /Items is not, since nothing syncs a library through searchTerm. Note this is upstream's own bug: v10.11's /Search/Hints returns the entire library for a whitespace-only term, which master fixed by switching to ThrowIfNullOrWhiteSpace. * fix(jellyfin): cap the search Limit the client asked for, not the merge window searchPage sees two different things in opts.Max: the client's Limit for a single-type query, and mergeTypes' internal offset+limit window for a multi-type one. Clamping there hit both, so a multi-type search paging past the ceiling fetched only `ceiling` rows of the first type and the merged page skipped into the next one — IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=2000 &Limit=1 returned an album instead of the 2001st song. The ceiling now applies where the client's Limit is read: queryItems (after the playlist branch, so a playlist parent's page isn't capped by a stray SearchTerm) and getArtists, which reads its own. A limit of 0 stays 0, keeping searchPage's default. What a deep page materializes is then bounded by the client's StartIndex, as it already was for any multi-type query, search or not. Also from review: the playlist-track mock reused the previously stored Options when called without any, so a later no-args call inherited stale paging. * fix(jellyfin): apply the search default to the client's Limit, not per type The default lived in searchPage, which runs per type and after mergeTypes has already picked its branch. So an unbounded multi-type search left q.limit at 0, mergeTypes took its chained branch, and StartIndex was applied to a list each type had already truncated to the default — dropping matches rather than paging them. With 200 matching songs, IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song &StartIndex=150 returned nothing at all, and without StartIndex it returned the default per type instead of in total. clampSearchLimit now applies the default and the ceiling together, to the client's Limit, at the two places it's read (queryItems and getArtists). A search therefore always gives mergeTypes a real window, so it pages the merged result and cuts it once, at the end. * fix(jellyfin): bound the multi-type search window against StartIndex mergeTypes asks each type for offset+limit rows before paginating the merged list, so a search still materialized whatever StartIndex asked for: IncludeItemTypes=Audio,MusicAlbum&SearchTerm=x&StartIndex=500000&Limit=1 pulled ~500001 matches per type. Bounding the window alone isn't enough — below it the merged rows are the client's page, but at it a truncated type is followed by the next one's rows, which is what made a clamped window serve an album where the 2001st song belonged. So the window is capped at maxSearchLimit and the page is clipped to it: pages below the ceiling are served in full and unchanged, a page straddling it is cut at it, and past it the result is empty rather than another type's rows. The total reports what can actually be paged to, so a client stops instead of asking for pages that no longer exist. This is the merge's own limit, not the client's: a single-type search still pages as deep as it likes, since its offset goes to SQL. Non-search multi-type queries keep the unbounded offset+limit window, which predates this and wants the same treatment via CountAll (exact per-type totals let whole types be skipped) rather than a cap. * fix(jellyfin): advertise the pageable search total, not the page window Clipping the multi-type search total to `window` clipped it to StartIndex+Limit on an ordinary page, so a first page of Limit=10 reported TotalRecordCount 10 however many matches there were, and a client paging on the total stopped after one page. The cap belongs at the ceiling — what can be paged to overall — not at the current page. The tests missed it because the only one asserting a total used StartIndex=maxSearchLimit, where the window happens to equal the ceiling. * refactor(jellyfin): fold the search clamp into clampLimit and simplify mergeTypes Cleanup pass over the branch, no behaviour change: - clampSearchLimit was clampLimit (similar.go) with different constants, so the latter takes the default and ceiling as arguments and both call it. Similar's default moves out of three IntOr calls into defaultSimilarLimit. - mergeTypes derived a second `limit` and needed an early return for the empty page, only because paginate reads 0 as "unbounded". Clipping the merged slice to the window instead lets q.limit be passed straight through: window is min(offset+limit, ceiling), so clipping there is the same cut. - playlistTracks returned (result, handled, error) where handled=false always meant error=nil. Splitting the lookup out gives playlistTracksRepo returning (repo, ok), and the nilerr suppression goes with it. - The comment on playlists.Tracks blamed the log warning for its extra Get; the reason is that PlaylistRepository.Tracks discards the error behind a nil. Also drops a stale reference to a renamed variable. --- core/playlists/playlists.go | 16 ++ core/playlists/playlists_test.go | 22 ++ model/playlist.go | 5 + persistence/playlist_repository.go | 12 +- persistence/playlist_track_repository.go | 32 +++ persistence/playlist_track_repository_test.go | 61 +++++ server/jellyfin/api.go | 2 +- server/jellyfin/browsing.go | 5 +- server/jellyfin/browsing_test.go | 17 ++ server/jellyfin/items.go | 84 +++++-- server/jellyfin/items_test.go | 208 ++++++++++++++++++ server/jellyfin/playlists.go | 49 ++++- server/jellyfin/playlists_test.go | 46 +++- server/jellyfin/similar.go | 20 +- tests/mock_album_repo.go | 2 + tests/mock_playlist_track_repo.go | 64 +++++- 16 files changed, 591 insertions(+), 54 deletions(-) create mode 100644 persistence/playlist_track_repository_test.go diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 3da24706c..1ef083bbb 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -22,6 +22,7 @@ type Playlists interface { GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) Get(ctx context.Context, id string) (*model.Playlist, error) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) + Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) // Mutations @@ -98,6 +99,21 @@ func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model return s.ds.Playlist(ctx).GetPlaylists(mediaFileId) } +// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather +// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards +// its error behind a nil (and warns), and this is probed with ids that are usually not playlists. +func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { + repo := s.ds.Playlist(ctx) + if _, err := repo.Get(id); err != nil { + return nil, err + } + tracks := repo.Tracks(id, true) + if tracks == nil { + return nil, model.ErrNotFound + } + return tracks, nil +} + // --- Mutation operations --- // Create creates a new playlist (when name is provided) or replaces tracks on an existing diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index f849a0a21..0c9674bed 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -73,6 +73,28 @@ var _ = Describe("Playlists", func() { }) }) + Describe("Tracks", func() { + var mockTracks *tests.MockPlaylistTrackRepo + + BeforeEach(func() { + mockTracks = &tests.MockPlaylistTrackRepo{} + mockPlsRepo.Data = map[string]*model.Playlist{ + "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, + } + mockPlsRepo.TracksRepo = mockTracks + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + }) + + It("returns the playlist's track repository", func() { + Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks)) + }) + + It("returns ErrNotFound for an unknown or invisible playlist", func() { + _, err := ps.Tracks(ctx, "nonexistent") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Describe("Create", func() { BeforeEach(func() { mockPlsRepo.Data = map[string]*model.Playlist{ diff --git a/model/playlist.go b/model/playlist.go index f2586f52d..40adb8d0a 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -157,10 +157,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { return mfs } +type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] + type PlaylistTrackRepository interface { ResourceRepository + CountAll(options ...QueryOptions) (int64, error) GetAll(options ...QueryOptions) (PlaylistTracks, error) + GetCursor(options ...QueryOptions) (PlaylistTrackCursor, error) GetAlbumIDs(options ...QueryOptions) ([]string, error) + GetMediaFileIDs(options ...QueryOptions) ([]string, error) Add(mediaFileIds []string) (int, error) AddAlbums(albumIds []string) (int, error) AddArtists(artistIds []string) (int, error) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9626aad6a..e39f0bbd3 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -298,10 +298,11 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { return nil } -func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { - sel = r.applyLibraryFilter(sel, "f") +// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. +func (r *playlistRepository) tracksQuery(query SelectBuilder, id string) SelectBuilder { + query = r.applyLibraryFilter(query, "f") userID := loggedUser(r.ctx).ID - tracksQuery := sel. + return query. Columns( "coalesce(starred, 0) as starred", "starred_at", @@ -321,8 +322,11 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). Where(Eq{"playlist_id": id}) +} + +func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.PlaylistTracks, error) { tracks := dbPlaylistTracks{} - err := r.queryAll(tracksQuery, &tracks) + err := r.queryAll(r.tracksQuery(query, id), &tracks) if err != nil { return nil, err } diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a7062cc2..e51ff8ea6 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -77,6 +77,14 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool return p } +func (r *playlistTrackRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := Select(). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + return r.count(query, options...) +} + func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { query := Select(). LeftJoin("media_file f on f.id = media_file_id"). @@ -116,6 +124,30 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P return tracks, err } +func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + sel := r.playlistRepo.tracksQuery(r.newSelect(options...), r.playlistId) + cursor, err := queryWithStableResults[dbPlaylistTrack](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { + return t.PlaylistTrack + })), nil +} + +// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. +func (r *playlistTrackRepository) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(options...).Columns("media_file_id"). + Join("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + query = r.applyLibraryFilter(query, "f") + var ids []string + if err := r.queryAllSlice(query, &ids); err != nil { + return nil, err + } + return ids, nil +} + func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { query := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go new file mode 100644 index 000000000..36f9ae4a9 --- /dev/null +++ b/persistence/playlist_track_repository_test.go @@ -0,0 +1,61 @@ +package persistence + +import ( + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PlaylistTrackRepository", func() { + var repo model.PlaylistTrackRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()).Tracks(plsBest.ID, true) + }) + + Describe("GetCursor", func() { + It("yields the same tracks as GetAll", func() { + opts := model.QueryOptions{Sort: "id"} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + + It("honors Max and Offset", func() { + opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} + want, err := repo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(1)) + + Expect(collectCursor(repo.GetCursor(opts))).To(Equal([]model.PlaylistTrack(want))) + }) + }) + + Describe("CountAll", func() { + It("returns the number of tracks in the playlist", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("ignores Max and Offset", func() { + Expect(repo.CountAll(model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) + }) + }) + + Describe("GetMediaFileIDs", func() { + It("returns the song ids in playlist order", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id"})). + To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) + }) + + It("honors Max and Offset", func() { + Expect(repo.GetMediaFileIDs(model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). + To(Equal([]string{songRadioactivity.ID})) + }) + }) +}) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d169a4c83..e94d64e85 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -102,6 +102,7 @@ func (api *Router) routes() http.Handler { r.Get("/Users/{userId}/Items/Latest", api.getLatest) r.Get("/Artists", api.getArtists) r.Get("/Artists/AlbumArtists", api.getAlbumArtists) + r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) }) r.Get("/Items/{itemId}", api.getItem) @@ -131,7 +132,6 @@ func (api *Router) routes() http.Handler { r.Post("/Playlists", api.createPlaylist) r.Get("/Playlists/{playlistId}", api.getPlaylist) r.Post("/Playlists/{playlistId}", api.updatePlaylist) - r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index d5a00e492..fae21fc0b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -33,7 +33,10 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol q := itemsQuery{ scopeIDs: scopeIDs, genreIds: decodedQueryIDs(r, "genreids"), - search: p.StringOr("searchterm", ""), + search: searchTerm(p), + } + if q.search != "" { + opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) } res, err := api.listArtists(ctx, opts, q, role) diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 7f50355e1..660e5d293 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -111,6 +111,23 @@ var _ = Describe("Browsing", func() { Expect(res.Items).To(HaveLen(1)) }) + It("bounds a search the client left unbounded, and clamps an oversized one", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + It("forwards StartIndex/Limit as Offset/Max", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 0dca48938..ff38b6491 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -24,6 +24,12 @@ import ( // album, artist and media_file). var notMissing = squirrel.Eq{"missing": false} +// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match +// everything" and materialize the library, where the unfiltered path streams. +func searchTerm(p *req.Values) string { + return strings.TrimSpace(p.StringOr("searchterm", "")) +} + func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { @@ -226,7 +232,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu fields: dto.ParseFields(p.StringOr("fields", "")), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), - search: p.StringOr("searchterm", ""), + search: searchTerm(p), sortBy: p.StringOr("sortby", ""), sortOrder: p.StringOr("sortorder", ""), offset: p.IntOr("startindex", 0), @@ -281,8 +287,11 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult case strings.Contains(q.rawTypes, "ManualPlaylistsFolder"): return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil } - if res, ok := api.playlistTracks(ctx, q); ok { - return res, nil + if repo, ok := api.playlistTracksRepo(ctx, q); ok { + return api.playlistTrackPage(repo, q.fields, q.offset, q.limit) + } + if q.search != "" { + q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) } if len(q.types) == 1 { opts := model.QueryOptions{Offset: q.offset, Max: q.limit} @@ -292,32 +301,39 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult return api.mergeTypes(ctx, q) } -// playlistTracks resolves a playlist parent to its tracks, whatever IncludeItemTypes says: Jellify -// opens a playlist with ParentId=&IncludeItemTypes=Audio, and routing that through -// listSongs would treat the playlist id as an album id and return nothing. -func (api *Router) playlistTracks(ctx context.Context, q itemsQuery) (itemsResult, bool) { +// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a +// playlist with ParentId=&IncludeItemTypes=Audio, and routing that through listSongs would +// treat the playlist id as an album id and return nothing. +// +// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type +// dispatch: ParentId is usually an album or artist. +func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { if q.parentId == "" || q.isLibraryParent || q.parentId == playlistsFolderID { - return itemsResult{}, false + return nil, false } - pls, err := api.playlists.GetWithTracks(ctx, q.parentId) - if err != nil { - return itemsResult{}, false - } - // GetWithTracks enforces visibility (public or owned by the current user). - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, q.fields) }) - return materialized(result(paginate(items, q.offset, q.limit), len(items), q.offset)), true + // Tracks enforces visibility. + repo, err := api.playlists.Tracks(ctx, q.parentId) + return repo, err == nil } func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { // Each per-type query needs at most offset+limit rows (the worst case where one type fills the // whole [offset, offset+limit) window). Totals are unaffected — they come from CountAll. + window := 0 + if q.limit > 0 { + window = q.offset + q.limit + } + // A search can't stream, so the window is what each type materializes and StartIndex would drive + // it without bound. Only below the window are the merged rows the true order, hence the clip + // below too. Non-search stays unbounded in StartIndex: a known gap, fixable with per-type counts. + if q.search != "" { + window = min(window, maxSearchLimit) + } var results []itemsResult total := 0 for _, itemType := range q.types { var opts model.QueryOptions - if q.limit > 0 { - opts.Max = q.offset + q.limit - } + opts.Max = window applySort(&opts, itemType, q.sortBy, q.sortOrder) res, err := api.queryItemsOfType(ctx, itemType, opts, q) if err != nil { @@ -339,6 +355,13 @@ func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, e } items = append(items, typeItems...) } + if q.search != "" { + // Past the window the merged order isn't the true one, so drop it rather than serve another + // type's rows. The total is what's pageable overall, not this page, or a client paging on it + // would stop after the first page. + items = items[:min(window, len(items))] + total = min(total, maxSearchLimit) + } return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil } @@ -412,20 +435,37 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without +// the ceiling, Limit=999999 still materializes every match. +const ( + defaultSearchLimit = 100 + maxSearchLimit = 2000 +) + +// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an +// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size). +// +// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger +// offset+limit window: bounding that would truncate each type before the merged page is cut. +func clampLimit(limit, def, ceiling int) int { + if limit <= 0 { + return def + } + return min(limit, ceiling) +} + // searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the // Search API returns no match count and CountAll can't see the search term. offset+len(rows) is // exact once matches end (and a growing lower bound before), so paging terminates at the last match. func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { fetch := opts - if fetch.Max > 0 { - fetch.Max++ - } + fetch.Max++ rows, err := search(fetch) if err != nil { return nil, 0, err } total := opts.Offset + len(rows) - if opts.Max > 0 && len(rows) > opts.Max { + if len(rows) > opts.Max { rows = rows[:opts.Max] } return rows, total, nil diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 801db9e3a..049151651 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -3,6 +3,7 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" @@ -71,6 +72,59 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) }) + It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodeID("1"))) + Expect(res.TotalRecordCount).To(Equal(2)) + }) + + It("pages a playlist parent's tracks in the query, not in memory", func() { + fp.getPls = &model.Playlist{ID: "pl1", Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + + It("falls through to the type dispatch when ParentId is not a playlist", func() { + fp.getErr = model.ErrNotFound + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) w := httptest.NewRecorder() @@ -220,6 +274,160 @@ var _ = Describe("Items", func() { Expect(res.Items).To(HaveLen(1)) }) + It("caps a search the client left unbounded", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) + }) + + It("honors an explicit search Limit up to the ceiling", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(501)) + }) + + It("clamps a search Limit that would materialize the library", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + Expect(albumRepo.SearchQuery).To(BeEmpty()) + }) + + It("reports a multi-type search total past the page, so clients keep paging", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(10)) + Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) + }) + + It("bounds the multi-type search window however large StartIndex is", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + // Without the bound this asks each type for ~500001 rows. + Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) + }) + + It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { + // Bounding the per-type window is what keeps StartIndex from driving it without limit, and + // past that window the merged order is no longer the true one. + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(Equal(maxSearchLimit)) + }) + + It("serves the last page below the ceiling in full", func() { + songs := make(model.MediaFiles, maxSearchLimit+1) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + // Clipped to the window, and still the real row at that index — not the album behind it. + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + }) + + It("bounds an unbounded multi-type search to the default in total, not per type", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(defaultSearchLimit)) + }) + + It("pages an unbounded multi-type search past the default without dropping matches", func() { + songs := make(model.MediaFiles, defaultSearchLimit*2) + for i := range songs { + songs[i] = model.MediaFile{ID: fmt.Sprintf("s%05d", i), Title: "Song"} + } + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), + nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).ToNot(BeEmpty()) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+50].ID))) + }) + It("reports a search total beyond the fetched page instead of the page length", func() { ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{ {ID: "r1", Name: "Alpha"}, {ID: "r2", Name: "Beta"}, {ID: "r3", Name: "Gamma"}, diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index ffc2c6543..a157aae2e 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -125,6 +125,21 @@ func (api *Router) clearPlaylist(ctx context.Context, id string) error { 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 @@ -136,17 +151,28 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { } // getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the -// edit screen). GetWithTracks enforces visibility; any error maps to 404 so private playlists can't +// 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 := dto.DecodeID(chi.URLParam(r, "playlistId")) - pls, err := api.playlists.GetWithTracks(ctx, id) + pls, err := api.playlists.Get(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) }) + 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{}, @@ -154,19 +180,24 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { }) } -// getPlaylistItems relies on GetWithTracks to enforce visibility; any error maps to a generic 404 so -// a playlist id can't probe for private playlists. +// 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 := dto.DecodeID(chi.URLParam(r, "playlistId")) - pls, err := api.playlists.GetWithTracks(ctx, id) + repo, err := api.playlists.Tracks(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) return } - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) - items := slice.Map(pls.Tracks, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - api.ok(w, r, dto.QueryResult{Items: items, TotalRecordCount: len(items)}) + p := req.Params(r) + fields := dto.ParseFields(p.StringOr("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 diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 804123003..27264b280 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -29,8 +29,9 @@ type fakePlaylists struct { createdIds []string createErr error - getPls *model.Playlist - getErr error + getPls *model.Playlist + getErr error + tracksRepo *tests.MockPlaylistTrackRepo getByIDPls *model.Playlist getByIDErr error @@ -92,6 +93,20 @@ func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playl return f.getPls, nil } +// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert +// what was pushed down to the query. +func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getPls == nil { + return nil, model.ErrNotFound + } + f.tracksRepo = &tests.MockPlaylistTrackRepo{} + f.tracksRepo.SetData(f.getPls.Tracks) + return f.tracksRepo, nil +} + func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { f.addPlaylistID = playlistID f.addIds = ids @@ -184,6 +199,30 @@ var _ = Describe("Playlists", func() { Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodeID("2"))) }) + It("pages with StartIndex/Limit, pushing them down to the query", func() { + fp.getPls = &model.Playlist{ + ID: "pl1", + Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}}, + {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, + {ID: "3", MediaFileID: "s3", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s3"}}, + }, + } + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", "pl1") + invoke(api.getPlaylistItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(3)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s2"))) + Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) + Expect(fp.tracksRepo.Options.Max).To(Equal(1)) + }) + It("returns 404 for a non-owned or absent playlist", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() @@ -247,7 +286,7 @@ var _ = Describe("Playlists", func() { Describe("getPlaylist", func() { It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { - fp.getPls = &model.Playlist{ + pls := &model.Playlist{ ID: "pl1", Public: true, Tracks: model.PlaylistTracks{ @@ -255,6 +294,7 @@ var _ = Describe("Playlists", func() { {ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}}, }, } + fp.getPls, fp.getByIDPls = pls, pls w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background()) r = withChiURLParam(r, "playlistId", "pl1") diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index db3671fe4..33cdc4c88 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -20,7 +20,10 @@ import ( // tests can shorten it. var similarWait = 10 * time.Second -const maxSimilarLimit = 100 +const ( + defaultSimilarLimit = 20 + maxSimilarLimit = 100 +) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine // indefinitely. @@ -51,7 +54,7 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch // returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { return api.similarArtists(ctx, id, limit) })) @@ -63,7 +66,7 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { @@ -88,7 +91,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 20)) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { @@ -136,15 +139,6 @@ func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto return result(items, len(items), 0) } -// clampLimit bounds a client-supplied limit so it can't drive an oversized allocation or provider -// fetch (flagged by CodeQL as a user-controlled allocation size). -func clampLimit(limit int) int { - if limit <= 0 { - return 20 - } - return min(limit, maxSimilarLimit) -} - func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { songs, err := api.provider.SimilarSongs(ctx, id, limit) if err != nil { diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 6635881b7..03dfed879 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -20,6 +20,7 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions + SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID } @@ -134,6 +135,7 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { + m.SearchQuery = q if len(options) > 0 { m.Options = options[0] } diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index c11b077d2..2835baadd 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -1,9 +1,14 @@ package tests -import "github.com/navidrome/navidrome/model" +import ( + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) type MockPlaylistTrackRepo struct { model.PlaylistTrackRepository + Data model.PlaylistTracks + Options model.QueryOptions AddedIds []string DeletedIds []string Reordered bool @@ -11,6 +16,63 @@ type MockPlaylistTrackRepo struct { Err error } +func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { + m.Data = tracks +} + +// page applies Max/Offset as the real repository's SQL would. +func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.PlaylistTracks { + var opts model.QueryOptions + if len(options) > 0 { + opts = options[0] + m.Options = opts + } + tracks := m.Data + if opts.Offset >= len(tracks) { + return nil + } + tracks = tracks[opts.Offset:] + if opts.Max > 0 && opts.Max < len(tracks) { + tracks = tracks[:opts.Max] + } + return tracks +} + +func (m *MockPlaylistTrackRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockPlaylistTrackRepo) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { + if m.Err != nil { + return nil, m.Err + } + return m.page(options...), nil +} + +func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { + if m.Err != nil { + return nil, m.Err + } + tracks := m.page(options...) + return func(yield func(model.PlaylistTrack, error) bool) { + for _, t := range tracks { + if !yield(t, nil) { + return + } + } + }, nil +} + +func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil +} + func (m *MockPlaylistTrackRepo) Add(ids []string) (int, error) { m.AddedIds = append(m.AddedIds, ids...) if m.Err != nil { From adeaa93e7e03b6b98dc4098812a0c8a9c58adaf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 19:02:12 -0400 Subject: [PATCH 12/14] fix(jellyfin): honor Recursive=false for library parents (#5788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): honor Recursive=false for library parents /Items ignored the Recursive parameter entirely — it appeared only in tests, never in production code. Finamp's download sync sends exactly one Recursive=false request per library (ParentId=&IncludeItemTypes=Audio) to pick up tracks outside any album, supplementing its recursive per-album fetch. We answered with every song in the library: 208MB and 21s per sync measured on a real library, against 20KB for the album queries. Finamp then required every track twice, once via its album and once under the library node. In Jellyfin 10.10 Recursive never reaches SQL. It selects between an in-memory walk of a folder's direct Children (false) and a DB ancestor query (true), with IncludeItemTypes applied as a post-filter over that direct-child list (ItemsController.cs:308, Folder.cs:949-994). The default is false, and neither IncludeItemTypes nor SearchTerm forces recursion, so Recursive=false with IncludeItemTypes=Audio on a music library returns an empty list — which is what Finamp expects and codes for. Filter the requested types to those nested directly under a library when the parent is a library and Recursive is not true; the hierarchy this API exposes is library -> album -> track, so no track is ever a library's direct child. Album and playlist parents are untouched, as their tracks are real direct children in Jellyfin (MusicAlbum.cs:86-89, Playlist.cs:140-167) and Jellify opens playlists with Recursive=false. A library parent is the only case handled: /Items with no ParentId still returns every song where Jellyfin returns the root's children, but no observed client sends that, and honoring it would surprise any client that simply omits Recursive. * refactor(jellyfin): narrow the Recursive=false filter to Audio Replace the libraryChildTypes allowlist with a direct Audio check. The two are behaviorally identical — parseTypes only ever yields Audio, MusicArtist, MusicAlbum, MusicGenre or Playlist, and the allowlist held the latter four, so it excluded exactly Audio and nothing else. The allowlist claimed those four are a library's direct children. That isn't true of MusicGenre or Playlist: listGenres is deliberately unscoped because genres are global tags, and listPlaylists ignores scopeIDs entirely, so neither is nested under a library at all. They were on the list only to leave their behavior untouched. The one verified invariant is that no track is a library's direct child, so state just that — it is also more conservative, as a type added later keeps its current behavior instead of being filtered by a stale list. Add a test pinning the omitted-Recursive default: ItemsController binds `bool? recursive` and reads it as `recursive ?? false`, so omitting the param is a non-recursive request and filters Audio for a library parent. --- server/jellyfin/e2e/browsing_test.go | 22 ++++++++ server/jellyfin/items.go | 6 +++ server/jellyfin/items_test.go | 75 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 646de4497..0b2769856 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -118,6 +118,28 @@ var _ = Describe("Browsing", func() { }) }) + // Finamp's download sync asks a library for the tracks outside any album this way; answering + // with every track would stream the whole library. + Describe("Recursive=false", func() { + lib1 := enc("1") + + It("returns no songs for a library parent", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(BeZero()) + }) + + It("still lists the library's albums", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("still lists an album's tracks", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false")) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + }) + }) + // Finamp's artist screen sends ParentId= (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=. Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index ff38b6491..43c8de12c 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -254,6 +254,12 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu q.types = parseTypes(q.rawTypes) q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) + // Recursive=false asks for direct children only, and no track is a library's direct child. + // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. + if q.isLibraryParent && !p.BoolOr("recursive", false) { + q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) + } + // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 049151651..401145461 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -133,6 +133,81 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + // Recursive=false asks for direct children only. Finamp's sync probes a library this way + // looking for tracks outside any album; answering with every track streams the whole library. + Describe("Recursive=false", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}}) + }) + + It("returns no songs for a library parent, as tracks are never its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + Expect(res.TotalRecordCount).To(BeZero()) + }) + + It("drops only Audio from a multi-type library query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Type).To(Equal("MusicAlbum")) + }) + + It("still lists albums for a library parent, as they are its direct children", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + It("still lists an album's tracks, as they are its direct children", func() { + fp.getErr = model.ErrNotFound + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1"))) + }) + + It("keeps returning every song when no parent scopes the query", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(1)) + }) + + // Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as + // `recursive ?? false`, so an omitted Recursive is a non-recursive request. + It("treats an omitted Recursive as false, like Jellyfin", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil). + WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + }) + It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}}) w := httptest.NewRecorder() From 09022b4bd29a722ca624e45214a78a0b97f87143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 15 Jul 2026 20:44:56 -0400 Subject: [PATCH 13/14] feat(jellyfin): AudioMuse-AI compatible sonic endpoints (#5782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(jellyfin): inject core/sonic into the Jellyfin Router * feat(jellyfin): add AudioMuse /info endpoint * feat(jellyfin): add AudioMuse /similar_tracks endpoint * feat(jellyfin): gate AudioMuse endpoints on sonic provider * feat(jellyfin): add AudioMuse /find_path endpoint * fix(jellyfin): fix case-insensitive route collision across positions canonicalRouteSegments keyed canonical case by lower-cased segment name alone, globally. Two unrelated routes sharing a segment name with different casing at different tree depths (e.g. "Info" in /System/Info/Public vs "info" in /AudioMuseAI/info) silently overwrote each other, 404-ing the loser even for exact-case requests. Replace the flat map with a position-aware trie mirroring the routing tree. * test(jellyfin): e2e tests for AudioMuse endpoints * docs(jellyfin): document AudioMuse compatibility endpoints * test(jellyfin): harden AudioMuse tests and doc note (final-review follow-ups) - Comment-lock the []string{} (not nil) contract for /AudioMuseAI/info's AvailableEndpoints so it keeps serializing as [] rather than null, and add a raw-body assertion to the existing empty-list test to catch a regression a struct-only unmarshal can't detect. - Cover the previously-untested engine-error branch in similar_tracks and find_path, both of which degrade to an empty result. - Document that find_path's path/total_distance only reflect hops through libraries the caller can access in multi-library setups. * refactor(jellyfin): dedup AudioMuse test request helper, presize dedup map * refactor(sonic): expose sonic.Engine interface; drop typed-nil guard in jellyfin.New The Jellyfin Router's sonic field was an interface but New() took the concrete *sonic.Sonic, so a nil arg became a non-nil typed-nil and needed a guard — the only injected dependency that did. Move the interface (sonic.Engine) beside its implementation, take it in New() like every other service, and bind it in wire. * refactor(jellyfin): case-insensitive routing via lowercased paths Replace the position-aware route trie with a trivial middleware that lowercases the request path, and register every route in lowercase. Simpler, and no segment name can collide across positions. caseInsensitivePaths moves into middlewares.go alongside normalizeQueryKeys. Relies on the invariant that no Jellyfin path segment carries case-sensitive data (all ids are lowercase hex via dto.EncodeID). * feat(jellyfin): add AudioMuse /health endpoint A liveness probe matching the reference plugin: 200 with an empty body when a SonicSimilarity provider is loaded, 404 otherwise. /AudioMuseAI/info now advertises it (list alphabetized like the plugin's OrderBy). Also trims the AudioMuse and case-insensitive-routing comments to their essential rationale. * fix(jellyfin): hex-encode user IDs so lowercased paths stay valid Address PR review: user IDs were the one id the Jellyfin API emitted raw (base62, uppercase-capable), so lowercasing request paths could alter a userId segment. Encode them via dto.EncodeID like every other id, making the 'all boundary ids are lowercase hex' invariant true — no routing special-casing needed. Also caps user-controlled n / max_steps, fixes the songAgent test comment, and adds leading slashes to the README endpoint list. --- cmd/wire_gen.go | 5 +- cmd/wire_injectors.go | 1 + core/sonic/sonic.go | 9 + server/jellyfin/README.md | 23 ++ server/jellyfin/api.go | 127 +++++---- server/jellyfin/api_test.go | 10 +- server/jellyfin/audiomuse.go | 161 +++++++++++ server/jellyfin/audiomuse_test.go | 250 ++++++++++++++++++ server/jellyfin/auth.go | 2 +- server/jellyfin/case_insensitive_routes.go | 68 ----- .../jellyfin/case_insensitive_routes_test.go | 90 ------- server/jellyfin/e2e/audiomuse_test.go | 113 ++++++++ server/jellyfin/e2e/auth_test.go | 6 +- server/jellyfin/e2e/e2e_suite_test.go | 67 ++++- server/jellyfin/e2e/playlists_test.go | 2 +- server/jellyfin/middlewares.go | 17 ++ server/jellyfin/middlewares_test.go | 59 +++++ server/jellyfin/playlists.go | 2 +- server/jellyfin/playlists_test.go | 2 +- server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/users.go | 2 +- server/jellyfin/users_test.go | 2 +- 23 files changed, 780 insertions(+), 242 deletions(-) create mode 100644 server/jellyfin/audiomuse.go create mode 100644 server/jellyfin/audiomuse_test.go delete mode 100644 server/jellyfin/case_insensitive_routes.go delete mode 100644 server/jellyfin/case_insensitive_routes_test.go create mode 100644 server/jellyfin/e2e/audiomuse_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4a2b46289..bd211cbfc 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -136,7 +136,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) return router } @@ -245,7 +246,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 0f6b73891..94faa5af3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -51,6 +51,7 @@ var allProviders = wire.NewSet( wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go index 19eb69c65..67f5cc7da 100644 --- a/core/sonic/sonic.go +++ b/core/sonic/sonic.go @@ -46,6 +46,15 @@ func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher } } +// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. +type Engine interface { + HasProvider() bool + GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) + FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) +} + +var _ Engine = (*Sonic)(nil) + func (s *Sonic) HasProvider() bool { return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 } diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index fb5c4a637..2e4c19950 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -215,6 +215,29 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are advertised and served but packed-audio players won't decode them. +## AudioMuse-AI compatible endpoints + +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no +external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` +plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. + +- `GET /AudioMuseAI/info` — returns `{"Version": , "AvailableEndpoints": [...]}` (200). + `AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty. +- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404. +- `GET /AudioMuseAI/similar_tracks?item_id=&n=10&eliminate_duplicates=true` — 404 when no provider is + loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match + or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist. +- `GET /AudioMuseAI/find_path?start_song_id=&end_song_id=&max_steps=25` — 404 when no provider is + loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": }` (200), or 400 + with `start_song_id and end_song_id are required.` when either id is missing. + +`item_id`/`start_song_id`/`end_song_id` are the hex-encoded ids Navidrome hands Jellyfin clients. +`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features +(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library +setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries +the caller can access, since hops through inaccessible libraries are filtered out of the result. + ## curl walkthrough This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index e94d64e85..0740901aa 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -32,6 +33,7 @@ type Router struct { scrobbler scrobbler.PlayTracker playlists playlists.Playlists provider external.Provider + sonic sonic.Engine similarFlight singleflight.Group serverIDMu sync.Mutex serverIDVal string @@ -39,10 +41,12 @@ type Router struct { func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, - scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider) *Router { + scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, + sonicSvc sonic.Engine) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, + sonic: sonicSvc, } r.Handler = r.routes() return r @@ -55,20 +59,22 @@ func (api *Router) routes() http.Handler { // handler and the api_key check see folded keys. inner.Use(normalizeQueryKeys) + // Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase. + // Public (no auth): handshake + login. - inner.Get("/System/Info/Public", api.getPublicSystemInfo) - inner.Get("/System/Ping", api.ping) - inner.Post("/System/Ping", api.ping) - inner.Get("/QuickConnect/Enabled", api.quickConnectEnabled) + inner.Get("/system/info/public", api.getPublicSystemInfo) + inner.Get("/system/ping", api.ping) + inner.Post("/system/ping", api.ping) + inner.Get("/quickconnect/enabled", api.quickConnectEnabled) // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated // brute-force surface, so it must share the same per-IP throttle when one is configured. if conf.Server.AuthRequestLimit > 0 { limiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) - inner.With(limiter).Post("/Users/AuthenticateByName", api.authenticateByName) + inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName) } else { - inner.Post("/Users/AuthenticateByName", api.authenticateByName) + inner.Post("/users/authenticatebyname", api.authenticateByName) } - inner.Get("/Users/Public", api.getPublicUsers) + inner.Get("/users/public", api.getPublicUsers) // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, @@ -76,8 +82,8 @@ func (api *Router) routes() http.Handler { inner.Group(func(r chi.Router) { r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, conf.Server.DevArtworkThrottleBacklogTimeout)) - r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) - r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}", api.getItemImage) + r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) }) inner.Group(func(r chi.Router) { @@ -86,10 +92,10 @@ func (api *Router) routes() http.Handler { // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a // player) even before the first playback report. r.Use(api.withPlayer) - r.Get("/UserViews", api.getUserViews) - r.Get("/Users/{userId}/Views", api.getUserViews) - r.Get("/Users/Me", api.getCurrentUser) - r.Get("/Users/{userId}", api.getCurrentUser) + r.Get("/userviews", api.getUserViews) + r.Get("/users/{userId}/views", api.getUserViews) + r.Get("/users/me", api.getCurrentUser) + r.Get("/users/{userId}", api.getCurrentUser) // Cursor-backed collections: each streams straight from the DB, holding a connection for the // whole client-paced response, so enough slow clients would take the entire pool and stall the @@ -97,70 +103,75 @@ func (api *Router) routes() http.Handler { // requests queue rather than fail. r.Group(func(r chi.Router) { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) - r.Get("/Items", api.getItems) - r.Get("/Users/{userId}/Items", api.getItems) - r.Get("/Users/{userId}/Items/Latest", api.getLatest) - r.Get("/Artists", api.getArtists) - r.Get("/Artists/AlbumArtists", api.getAlbumArtists) - r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems) + r.Get("/items", api.getItems) + r.Get("/users/{userId}/items", api.getItems) + r.Get("/users/{userId}/items/latest", api.getLatest) + r.Get("/artists", api.getArtists) + r.Get("/artists/albumartists", api.getAlbumArtists) + r.Get("/playlists/{playlistId}/items", api.getPlaylistItems) }) - r.Get("/Items/{itemId}", api.getItem) - r.Get("/Users/{userId}/Items/{itemId}", api.getItem) - r.Delete("/Items/{itemId}", api.deleteItem) + r.Get("/items/{itemId}", api.getItem) + r.Get("/users/{userId}/items/{itemId}", api.getItem) + r.Delete("/items/{itemId}", api.deleteItem) // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. - r.Post("/UserFavoriteItems/{itemId}", api.markFavorite) - r.Delete("/UserFavoriteItems/{itemId}", api.unmarkFavorite) - r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite) - r.Delete("/Users/{userId}/FavoriteItems/{itemId}", api.unmarkFavorite) - r.Post("/Users/{userId}/Items/{itemId}/Rating", api.setRating) - r.Delete("/Users/{userId}/Items/{itemId}/Rating", api.removeRating) + r.Post("/userfavoriteitems/{itemId}", api.markFavorite) + r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) + r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) + r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) + r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) // Per-item play/favorite/rating state. Jellify uses the /UserItems form; // /Users/{userId}/Items is the legacy spelling. - r.Get("/UserItems/{itemId}/UserData", api.getUserItemData) - r.Get("/Users/{userId}/Items/{itemId}/UserData", api.getUserItemData) + r.Get("/useritems/{itemId}/userdata", api.getUserItemData) + r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) - r.Get("/Artists/{itemId}/Similar", api.getSimilarArtists) - r.Get("/Items/{itemId}/Similar", api.getSimilarItems) - r.Get("/Items/{itemId}/InstantMix", api.getInstantMix) - r.Get("/Genres", api.getGenres) - r.Get("/MusicGenres", api.getGenres) + r.Get("/artists/{itemId}/similar", api.getSimilarArtists) + r.Get("/items/{itemId}/similar", api.getSimilarItems) + r.Get("/items/{itemId}/instantmix", api.getInstantMix) + r.Get("/genres", api.getGenres) + r.Get("/musicgenres", api.getGenres) - r.Post("/Playlists", api.createPlaylist) - r.Get("/Playlists/{playlistId}", api.getPlaylist) - r.Post("/Playlists/{playlistId}", api.updatePlaylist) - r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist) - r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist) - r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers) - r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser) + r.Post("/playlists", api.createPlaylist) + r.Get("/playlists/{playlistId}", api.getPlaylist) + r.Post("/playlists/{playlistId}", api.updatePlaylist) + r.Post("/playlists/{playlistId}/items", api.addToPlaylist) + r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) + r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) + r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes // above stay public. - r.Post("/Items/{itemId}/Images/{type}", api.postItemImage) - r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage) + r.Post("/items/{itemId}/images/{type}", api.postItemImage) + r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage) - r.Get("/Audio/{itemId}/stream", api.streamAudio) - r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/Audio/{itemId}/universal", api.streamAudio) - r.Get("/Audio/{itemId}/main.m3u8", api.streamHls) - r.Get("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) - r.Post("/Items/{itemId}/PlaybackInfo", api.getPlaybackInfo) + r.Get("/audio/{itemId}/stream", api.streamAudio) + r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) + r.Get("/audio/{itemId}/universal", api.streamAudio) + r.Get("/audio/{itemId}/main.m3u8", api.streamHls) + r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) + r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of // /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.Get("/items/{itemId}/file", api.streamFile) + r.Get("/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", 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) // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. r.Get("/socket", api.handleSocket) + + r.Get("/audiomuseai/info", api.audioMuseInfo) + r.Get("/audiomuseai/health", api.audioMuseHealth) + r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks) + r.Get("/audiomuseai/find_path", api.audioMuseFindPath) }) // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 23504c73e..1b390e75a 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go new file mode 100644 index 000000000..b01a4bf92 --- /dev/null +++ b/server/jellyfin/audiomuse.go @@ -0,0 +1,161 @@ +package jellyfin + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" +) + +// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin, +// and is sorted the same way (the plugin builds it with OrderBy). +var audioMuseEndpoints = []string{ + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", +} + +type audioMuseInfoResponse struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` +} + +func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) { + endpoints := []string{} // non-nil so an empty list serializes as [], not null + if api.sonic != nil && api.sonic.HasProvider() { + endpoints = audioMuseEndpoints + } + api.ok(w, r, audioMuseInfoResponse{ + Version: consts.Version, + AvailableEndpoints: endpoints, + }) +} + +// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else +// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable. +func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) { + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + w.WriteHeader(http.StatusOK) +} + +type audioMuseSimilarTrack struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` +} + +func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // 404 without a provider, like the Subsonic sonicSimilarity handlers. + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + tracks := []audioMuseSimilarTrack{} + + itemID := p.StringOr("item_id", "") + if itemID == "" { + api.ok(w, r, tracks) + return + } + + id := api.resolveItemID(ctx, dto.DecodeID(itemID)) + n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit + eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n) + if err != nil { + api.ok(w, r, tracks) + return + } + + u, _ := request.UserFrom(ctx) + seenArtists := make(map[string]bool, len(matches)) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + if eliminateDuplicates { + key := strings.ToLower(mf.Artist) + if seenArtists[key] { + continue + } + seenArtists[key] = true + } + tracks = append(tracks, audioMuseSimilarTrack{ + Author: mf.Artist, + Distance: m.Similarity, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + }) + } + api.ok(w, r, tracks) +} + +type audioMusePathTrack struct { + Author string `json:"author"` + ItemID string `json:"item_id"` + Title string `json:"title"` + Tempo *float64 `json:"tempo,omitempty"` +} + +type audioMusePathResponse struct { + Path []audioMusePathTrack `json:"path"` + TotalDistance float64 `json:"total_distance"` +} + +func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if api.sonic == nil || !api.sonic.HasProvider() { + api.notFound(w, r) + return + } + p := req.Params(r) + + startID := p.StringOr("start_song_id", "") + endID := p.StringOr("end_song_id", "") + if startID == "" || endID == "" { + http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest) + return + } + + resp := audioMusePathResponse{Path: []audioMusePathTrack{}} + maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count + matches, err := api.sonic.FindSonicPath(ctx, + api.resolveItemID(ctx, dto.DecodeID(startID)), + api.resolveItemID(ctx, dto.DecodeID(endID)), + maxSteps) + if err != nil { + api.ok(w, r, resp) + return + } + + u, _ := request.UserFrom(ctx) + for _, m := range matches { + mf := m.MediaFile + if !u.HasLibraryAccess(mf.LibraryID) { + continue + } + track := audioMusePathTrack{ + Author: mf.Artist, + ItemID: dto.EncodeID(mf.ID), + Title: mf.Title, + } + if mf.BPM != nil { + tempo := float64(*mf.BPM) + track.Tempo = &tempo + } + resp.Path = append(resp.Path, track) + resp.TotalDistance += m.Similarity + } + api.ok(w, r, resp) +} diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go new file mode 100644 index 000000000..e9d6d4e85 --- /dev/null +++ b/server/jellyfin/audiomuse_test.go @@ -0,0 +1,250 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse info", func() { + It("lists the sonic endpoints (excluding info) when a provider is present", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("returns an empty endpoint list when no provider is loaded", func() { + api := &Router{} + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) + + api.audioMuseInfo(w, r) + + Expect(w.Code).To(Equal(200)) + var body audioMuseInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.AvailableEndpoints).To(BeEmpty()) + Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`)) + }) +}) + +type fakeSonicEngine struct { + provider bool + similar []sonic.SimilarMatch + similarErr error + path []sonic.SimilarMatch + pathErr error + gotID string + gotStart string + gotEnd string + gotCount int +} + +func (f *fakeSonicEngine) HasProvider() bool { return f.provider } + +func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) { + f.gotID, f.gotCount = id, count + return f.similar, f.similarErr +} + +func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) { + f.gotStart, f.gotEnd, f.gotCount = startID, endID, count + return f.path, f.pathErr +} + +func mf(id, artist, title string, lib int) model.MediaFile { + return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib} +} + +var _ = Describe("AudioMuse health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + api := &Router{sonic: &fakeSonicEngine{provider: true}} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("returns 404 when no provider is loaded", func() { + api := &Router{} + w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) +}) + +// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request. +func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", path+"?"+query, nil) + r = r.WithContext(request.WithUser(r.Context(), user)) + invoke(handler, w, r) + return w +} + +var _ = Describe("AudioMuse similar_tracks", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotID).To(Equal("seed")) + Expect(fake.gotCount).To(Equal(5)) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + Expect(body[0]).To(Equal(audioMuseSimilarTrack{ + Author: "A", Distance: 0.3, ItemID: dto.EncodeID("mf1"), Title: "T1", + })) + }) + + It("collapses to one track per artist when eliminate_duplicates defaults on", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(1)) + }) + + It("keeps same-artist tracks when eliminate_duplicates=false", func() { + fake.similar = []sonic.SimilarMatch{ + {MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf("mf2", "A", "T2", 1), Similarity: 0.5}, + } + w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true}) + var body []audioMuseSimilarTrack + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body).To(HaveLen(2)) + }) + + It("filters out tracks in libraries the user cannot access", func() { + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 2), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{Libraries: model.Libraries{{ID: 1}}}) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("returns an empty array without calling the engine when item_id is missing", func() { + w := call("n=5", model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + Expect(fake.gotID).To(Equal("")) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty array when the engine errors", func() { + fake.similarErr = errors.New("boom") + fake.similar = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) +}) + +var _ = Describe("AudioMuse find_path", func() { + var fake *fakeSonicEngine + var api *Router + + call := func(query string, user model.User) *httptest.ResponseRecorder { + return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user) + } + + BeforeEach(func() { + fake = &fakeSonicEngine{provider: true} + api = &Router{sonic: fake} + }) + + It("returns 400 with the exact message when start_song_id is missing", func() { + w := call("end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns 400 when end_song_id is missing", func() { + w := call("start_song_id="+dto.EncodeID("s"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(400)) + }) + + It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { + bpm := 120 + withBPM := mf("mf1", "A", "T1", 1) + withBPM.BPM = &bpm + fake.path = []sonic.SimilarMatch{ + {MediaFile: withBPM, Similarity: 1.5}, + {MediaFile: mf("mf2", "B", "T2", 1), Similarity: 2.0}, + } + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e")+"&max_steps=10", model.User{IsAdmin: true}) + + Expect(w.Code).To(Equal(200)) + Expect(fake.gotStart).To(Equal("s")) + Expect(fake.gotEnd).To(Equal("e")) + Expect(fake.gotCount).To(Equal(10)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID("mf1"))) + Expect(*body.Path[0].Tempo).To(Equal(120.0)) + Expect(body.Path[1].Tempo).To(BeNil()) + }) + + It("returns 404 when no sonic provider is loaded", func() { + fake.provider = false + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(404)) + }) + + It("returns an empty path object when the engine errors", func() { + fake.pathErr = errors.New("boom") + fake.path = []sonic.SimilarMatch{{MediaFile: mf("mf1", "A", "T1", 1), Similarity: 1.0}} + w := call("start_song_id="+dto.EncodeID("s")+"&end_song_id="+dto.EncodeID("e"), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(200)) + var body audioMusePathResponse + Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) + Expect(body.Path).To(BeEmpty()) + Expect(body.TotalDistance).To(Equal(0.0)) + }) +}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index ecd129222..062ac6458 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -56,7 +56,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { return &dto.UserDto{ Name: u.UserName, - Id: u.ID, + Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid ServerId: serverID, ServerName: serverName, HasPassword: true, diff --git a/server/jellyfin/case_insensitive_routes.go b/server/jellyfin/case_insensitive_routes.go deleted file mode 100644 index cc95b526f..000000000 --- a/server/jellyfin/case_insensitive_routes.go +++ /dev/null @@ -1,68 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/go-chi/chi/v5" -) - -// caseInsensitivePaths normalizes each request path's literal segments to the case they were -// registered with before delegating to r, since Jellyfin clients route case-insensitively but -// chi matches case-sensitively. Param placeholders (e.g. "{itemId}") aren't literals, so id -// segments pass through untouched. -func caseInsensitivePaths(r chi.Router) http.Handler { - canon := canonicalRouteSegments(r) - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - normalizeRequestPath(req, canon) - r.ServeHTTP(w, req) - }) -} - -// canonicalRouteSegments walks every registered route and records, for each literal (non-param) -// "/"-separated segment, the case it was registered with, keyed by its lower-cased form (e.g. -// "audio" -> "Audio"). -func canonicalRouteSegments(router chi.Router) map[string]string { - canon := map[string]string{} - _ = chi.Walk(router, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { - for seg := range strings.SplitSeq(route, "/") { - if seg == "" || strings.Contains(seg, "{") { - continue - } - canon[strings.ToLower(seg)] = seg - } - return nil - }) - return canon -} - -// normalizeRequestPath rewrites literal path segments to the case routes were registered with. -// It must run before chi's matching. When the router is mounted under a parent, chi has already -// stripped the mount prefix and matches against RouteContext.RoutePath rather than r.URL.Path, so -// that's what must be normalized here. -func normalizeRequestPath(r *http.Request, canon map[string]string) { - if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" { - rctx.RoutePath = normalizeCase(rctx.RoutePath, canon) - return - } - r.URL.Path = normalizeCase(r.URL.Path, canon) -} - -// normalizeCase rewrites each "/"-separated literal segment of path to the case it was -// registered with in canon. Segments with no match (e.g. case-sensitive ids) are left untouched. -// A segment like "STREAM.mp3" comes from a mixed literal+param route (e.g. "stream.{container}"), -// whose literal prefix ("stream") is registered separately: normalize that prefix and lower-case -// the extension so chi's case-sensitive match still hits. -func normalizeCase(path string, canon map[string]string) string { - segs := strings.Split(path, "/") - for i, seg := range segs { - if canonical, ok := canon[strings.ToLower(seg)]; ok { - segs[i] = canonical - } else if prefix, suffix, found := strings.Cut(seg, "."); found { - if canonical, ok := canon[strings.ToLower(prefix)]; ok { - segs[i] = canonical + "." + strings.ToLower(suffix) - } - } - } - return strings.Join(segs, "/") -} diff --git a/server/jellyfin/case_insensitive_routes_test.go b/server/jellyfin/case_insensitive_routes_test.go deleted file mode 100644 index 4c9140f83..000000000 --- a/server/jellyfin/case_insensitive_routes_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/chi/v5" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("caseInsensitivePaths", func() { - var handler http.Handler - var gotID string - - var gotContainer string - - BeforeEach(func() { - gotID = "" - gotContainer = "" - r := chi.NewRouter() - r.Get("/Foo/{id}/Bar", func(w http.ResponseWriter, req *http.Request) { - gotID = chi.URLParam(req, "id") - w.WriteHeader(http.StatusOK) - }) - // A mixed literal+param segment (like Jellyfin's /Audio/{id}/stream.{container}): the "stream" - // literal prefix is registered separately via the bare /Foo/{id}/stream route below. - r.Get("/Foo/{id}/stream", func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - }) - r.Get("/Foo/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { - gotContainer = chi.URLParam(req, "container") - w.WriteHeader(http.StatusOK) - }) - handler = caseInsensitivePaths(r) - }) - - It("normalizes the literal prefix of a mixed literal.param segment", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/STREAM.mp3", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotContainer).To(Equal("mp3")) - }) - - It("matches a lower-cased request path against mixed-case registered literals", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/bar", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("preserves the id segment's original casing", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/ID/bar", nil) - handler.ServeHTTP(w, r) - Expect(gotID).To(Equal("ID")) - }) - - It("leaves a real mixed-case id untouched while still matching literals", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/foo/cjsFeXbNOaaSjASu3DM93g/bar", nil) - handler.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotID).To(Equal("cjsFeXbNOaaSjASu3DM93g")) - }) -}) - -var _ = Describe("normalizeCase", func() { - It("rewrites known literal segments to their canonical case", func() { - canon := map[string]string{ - "audio": "Audio", - "stream": "stream", - } - got := normalizeCase("/audio/XyZ123NotARoute/STREAM", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream")) - }) - - It("normalizes the literal prefix of a mixed literal.extension segment", func() { - canon := map[string]string{"audio": "Audio", "stream": "stream"} - got := normalizeCase("/audio/XyZ123NotARoute/STREAM.MP3", canon) - Expect(got).To(Equal("/Audio/XyZ123NotARoute/stream.mp3")) - }) - - It("leaves a dotted segment untouched when its prefix isn't a known literal", func() { - canon := map[string]string{"audio": "Audio"} - got := normalizeCase("/audio/some.file.id", canon) - Expect(got).To(Equal("/Audio/some.file.id")) - }) -}) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go new file mode 100644 index 000000000..383dbab83 --- /dev/null +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -0,0 +1,113 @@ +package e2e + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AudioMuse endpoints", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("GET /AudioMuseAI/info", func() { + It("returns version and available endpoints", func() { + var body struct { + Version string `json:"Version"` + AvailableEndpoints []string `json:"AvailableEndpoints"` + } + parseInto(get("/AudioMuseAI/info"), &body) + Expect(body.Version).To(Equal(consts.Version)) + Expect(body.AvailableEndpoints).To(ConsistOf( + "GET /AudioMuseAI/find_path", + "GET /AudioMuseAI/health", + "GET /AudioMuseAI/similar_tracks", + )) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/health", func() { + It("returns 200 with an empty body when a provider is loaded", func() { + w := get("/AudioMuseAI/health") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/similar_tracks", func() { + It("maps provider results to seeded tracks, encoding item ids", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("So What"), Similarity: 0.5}, + } + var body []struct { + Author string `json:"author"` + Distance float64 `json:"distance"` + ItemID string `json:"item_id"` + Title string `json:"title"` + } + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) + Expect(body).To(HaveLen(2)) + Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) + Expect(dto.DecodeID(body[0].ItemID)).To(Equal(songID(body[0].Title))) + }) + + It("collapses to one track per artist by default", func() { + sonicProviderFake.similar = []sonic.SimilarResult{ + {Song: songAgent("Something"), Similarity: 0.3}, + {Song: songAgent("Come Together"), Similarity: 0.5}, + } + var body []map[string]any + parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body) + Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles + }) + + It("returns an empty array (not null) when there are no results", func() { + sonicProviderFake.similar = nil + w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together"))) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) + }) + + It("requires authentication", func() { + Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("GET /AudioMuseAI/find_path", func() { + It("returns 400 with the exact message when a required id is missing", func() { + w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something"))) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) + }) + + It("returns the path and summed total_distance", func() { + sonicProviderFake.path = []sonic.SimilarResult{ + {Song: songAgent("Come Together"), Similarity: 1.5}, + {Song: songAgent("So What"), Similarity: 2.0}, + } + var body struct { + Path []struct { + ItemID string `json:"item_id"` + Title string `json:"title"` + } `json:"path"` + TotalDistance float64 `json:"total_distance"` + } + parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body) + Expect(body.Path).To(HaveLen(2)) + Expect(body.TotalDistance).To(Equal(3.5)) + }) + }) +}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index f66833af2..7128972ba 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -27,7 +27,7 @@ var _ = Describe("Authentication", func() { Expect(res.AccessToken).ToNot(BeEmpty()) Expect(res.User).ToNot(BeNil()) Expect(res.User.Name).To(Equal("admin")) - Expect(res.User.Id).To(Equal("admin-1")) + Expect(res.User.Id).To(Equal(enc("admin-1"))) Expect(res.User.Policy.IsAdministrator).To(BeTrue()) Expect(res.ServerId).ToNot(BeEmpty()) @@ -84,7 +84,7 @@ var _ = Describe("Authentication", func() { users := publicUsers() Expect(users).To(HaveLen(1)) Expect(users[0].Name).To(Equal("regular")) - Expect(users[0].Id).To(Equal("regular-1")) + Expect(users[0].Id).To(Equal(enc("regular-1"))) Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login }) }) @@ -94,7 +94,7 @@ var _ = Describe("Authentication", func() { var u dto.UserDto parseInto(getAs(regularUser, "/Users/Me"), &u) Expect(u.Name).To(Equal("regular")) - Expect(u.Id).To(Equal("regular-1")) + Expect(u.Id).To(Equal(enc("regular-1"))) }) It("returns the caller from GET /Users/{userId}", func() { diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 02c5183f8..ea47dcdaa 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -38,11 +38,14 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" @@ -77,14 +80,15 @@ var ( // Shared test state var ( - ctx context.Context - ds *tests.MockDataStore - router http.Handler - streamerSpy *harness.SpyStreamer - artworkSpy *spyArtwork - providerFake *fakeExternalProvider - goldenDB *harness.DB - dataFolder string + ctx context.Context + ds *tests.MockDataStore + router http.Handler + streamerSpy *harness.SpyStreamer + artworkSpy *spyArtwork + providerFake *fakeExternalProvider + sonicProviderFake *fakeSonicProvider + goldenDB *harness.DB + dataFolder string adminUser = model.User{ ID: "admin-1", @@ -308,6 +312,8 @@ func setupTestDB() { streamerSpy = &harness.SpyStreamer{} artworkSpy = &spyArtwork{} providerFake = &fakeExternalProvider{} + sonicProviderFake = &fakeSonicProvider{} + sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds)) decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) router = jellyfin.New( ds, @@ -318,6 +324,7 @@ func setupTestDB() { scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), playlists.NewPlaylists(ds, core.NewImageUploadService()), providerFake, + sonicSvc, ) } @@ -338,6 +345,50 @@ func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model return f.similarSongs, nil } +// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are +// active in e2e; the provider it hands back returns test-configured results. +type fakeSonicLoader struct{ provider sonic.Provider } + +func (f *fakeSonicLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return []string{"fake"} + } + return nil +} + +func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) { + return f.provider, true +} + +// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the +// agents.Song results; the real matcher resolves them back to seeded library tracks. +type fakeSonicProvider struct { + similar []sonic.SimilarResult + path []sonic.SimilarResult +} + +func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.similar, nil +} + +func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) { + return f.path, nil +} + +// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an +// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. +func songAgent(title string) agents.Song { + mfs, err := ds.MediaFile(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + for _, mf := range mfs { + if mf.Title == title { + return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}} + } + } + Fail("song not found: " + title) + return agents.Song{} +} + // --- Spy/noop dependencies (shared ones live in tests/harness) --- // spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 73b76e3d8..d2ff49db9 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { var perms []dto.PlaylistUserPermissions parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) Expect(perms).To(HaveLen(1)) - Expect(perms[0].UserId).To(Equal("admin-1")) + Expect(perms[0].UserId).To(Equal(enc("admin-1"))) Expect(perms[0].CanEdit).To(BeTrue()) }) }) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index d9e41d252..840143ac8 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -29,6 +30,22 @@ func throttleStreams(limit int) func(http.Handler) http.Handler { return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) } +// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the +// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param +// segments too, which is safe because every id the API emits — user ids included — is lowercase hex +// (dto.EncodeID). +func caseInsensitivePaths(r chi.Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path. + if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { + rctx.RoutePath = strings.ToLower(rctx.RoutePath) + } else { + req.URL.Path = strings.ToLower(req.URL.Path) + } + r.ServeHTTP(w, req) + }) +} + // normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params // case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, // Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index c766bff70..f3aa65d6f 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -321,3 +322,61 @@ var _ = Describe("throttleStreams", func() { Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) }) }) + +var _ = Describe("caseInsensitivePaths", func() { + var handler http.Handler + var gotID, gotContainer string + + BeforeEach(func() { + gotID, gotContainer = "", "" + r := chi.NewRouter() + // Routes are registered lowercase, mirroring the real router. + r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) { + gotID = chi.URLParam(req, "id") + w.WriteHeader(http.StatusOK) + }) + r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { + gotContainer = chi.URLParam(req, "container") + w.WriteHeader(http.StatusOK) + }) + // A second route reusing the "bar" segment name at a different position. + r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler = caseInsensitivePaths(r) + }) + + serve := func(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + return w + } + + It("routes a mixed-case request to its lowercase-registered route", func() { + Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK)) + }) + + It("routes both routes that share a segment name, regardless of casing", func() { + Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK)) + Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK)) + }) + + It("lowercases the mixed literal.extension segment so the route and container match", func() { + w := serve("/Audio/abc/STREAM.MP3") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(gotContainer).To(Equal("mp3")) + }) + + It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() { + serve("/foo/DEADBEEF/bar") + Expect(gotID).To(Equal("deadbeef")) + }) + + It("normalizes the RoutePath branch when mounted under a parent", func() { + parent := chi.NewRouter() + parent.Mount("/jellyfin", handler) + w := httptest.NewRecorder() + parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index a157aae2e..084ff2975 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -279,7 +279,7 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { // enforced by AddTracks/RemoveTracks. 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}}) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) } func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 27264b280..7770cb6db 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -442,7 +442,7 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusOK)) var res []dto.PlaylistUserPermissions Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: "u1", CanEdit: true}})) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID("u1"), CanEdit: true}})) }) }) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index e3c9903a8..e007dedf4 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 91542c111..d9d74bcb9 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index 5f231e0b5..bbc60c892 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -52,7 +52,7 @@ func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { } users = append(users, dto.UserDto{ Name: usr.UserName, - Id: usr.ID, + Id: dto.EncodeID(usr.ID), ServerId: serverID, HasPassword: true, }) diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go index 2b7177044..6a1597b70 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -106,7 +106,7 @@ var _ = Describe("Users", func() { users := publicUsers() Expect(users).To(HaveLen(2)) Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal("u2")) + Expect(users[0].Id).To(Equal(dto.EncodeID("u2"))) Expect(users[1].Name).To(Equal("alice")) // The public list must not expose Policy/Configuration to unauthenticated callers. Expect(users[0].Policy).To(BeNil()) From 1d5efdd5a0408cae8a31f80c11d78afd5d18a388 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 21:21:26 -0400 Subject: [PATCH 14/14] fix(jellyfin): don't truncate InstantMix to the Similar ceiling Finamp's Radio Mix requests /Items/{id}/InstantMix?limit=250, but getInstantMix clamped the limit to maxSimilarLimit (100), so the queue was cut to 100 tracks. A mix is a playback queue, not a "related items" list, so it gets its own ceiling instead of sharing the Similar one. The Similar handlers keep 100. Verified against a live library: the sonic provider supplies all 250 tracks for a seed that previously returned 100. --- server/jellyfin/similar.go | 5 ++++- server/jellyfin/similar_test.go | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 33cdc4c88..085080850 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -23,6 +23,9 @@ var similarWait = 10 * time.Second const ( defaultSimilarLimit = 20 maxSimilarLimit = 100 + // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the + // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. + maxInstantMixLimit = 500 ) // similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine @@ -91,7 +94,7 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) if err != nil { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index cbb68e5cf..302566195 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -3,7 +3,9 @@ package jellyfin import ( "context" "encoding/json" + "fmt" "net/http/httptest" + "strconv" "sync/atomic" "time" @@ -103,6 +105,16 @@ func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.Med return nil, nil } +// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. +type fakeSimilarProvider struct { + external.Provider + songs model.MediaFiles +} + +func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { + return p.songs[:min(count, len(p.songs))], nil +} + var _ = Describe("getInstantMix", func() { It("returns the seed track even when the provider fetch exceeds the wait", func() { old := similarWait @@ -128,4 +140,28 @@ var _ = Describe("getInstantMix", func() { Expect(res.Items).To(HaveLen(1)) Expect(res.Items[0].Name).To(Equal("Seed Song")) }) + + // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the + // queue, so InstantMix gets its own, higher ceiling. + It("honors a mix-sized limit above the Similar ceiling", func() { + const want = 250 + songs := model.MediaFiles{{ID: "s1", Title: "Seed Song", LibraryID: 1}} + for i := range want + 50 { // more than requested, so only the limit bounds the result + songs = append(songs, model.MediaFile{ID: fmt.Sprintf("t%d", i), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) + } + ds := &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("s1")+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + api.getInstantMix(w, r) + + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") + Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") + }) })